Master's docs-overhaul stack (#142-#144) rewrote AGENTS.md into the
slim budget-gated form and repointed the review skill's citations.
Resolutions:
- AGENTS.md: master's rewrite wins; the no-hardcoded-tunables
convention is re-added as one terse bullet in the new style, after
'Explicit > implicit at package seams'. Within the verify-doc-budgets
ceiling, so no displacement or raise needed.
- dsh-code-review SKILL.md: master's repointed citations win; the
hardcoded-tunables reviewer check and the Conventions keyword are
re-applied on top.
- packages/README.md: master replaced the hand-maintained dependency
list (which carried this branch's chars-per-token wording) with a
pointer to the generated module graph — master's side taken whole;
the estimator wording lives on in the compact package READMEs.
The vocabulary maps grow by declaration merging, and the admission policy
stated on TurnEndReasonMap is that a variant lands with its first emitter.
Three declared items had no producer and no consumer:
- CacheHint and the cache?: CacheHint fields on TextBlock/ToolResultBlock:
nothing constructs a block with cache:, and neither adapter reads .cache —
DeepSeek prompt caching is automatic (hints map OUT of responses, never IN).
- MessageSourceMap.agent: zero constructors; the subagent backends send the
parent prompt with no source (logs as user), and the envelope renderer
interpolates source.kind without routing on it.
- TurnTriggerMap.continuation: the loop structurally cannot emit it —
continuation is further steps within a turn, never a new turn — and its only
writer was an llm-replay test fixture that needed any non-message trigger
(now an injection trigger).
Each variant returns the day it gains a real producer, via the same
merge-extensible maps. Docs updated in the same change: the MessageSourceMap
paste in core.md, the TurnTriggerMap paste in session.md (manifest untouched —
both symbols survive), the content-block vocabulary RFC's cache-hints
consequence line, and the RFC moved to implemented/ and amended to shipped
reality (the image block's own cache field had already left with the
drop-image RFC).
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
ImageBlock had no production producer and every consumer dropped it:
the deepseek serializer skipped it, the pi-ai converter skipped it as
unrepresentable, the ACP bridge neither advertises image prompt
capability nor forwards image blocks, and compact-basic charged a flat
85-token estimate and rendered an [image] placeholder. A block
constructed today would silently vanish from the wire — the vocabulary
advertised a capability no path honors, the silent-data-loss shape the
defensive patterns warn against. The only constructors were tests
pinning the skip/estimate branches.
Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint
field leaves with it; CacheHint itself and the other two cache? fields
are out of scope). compact-basic loses its explicit image estimate and
placeholder arms (the merge-extensible default arms absorb the case);
the deepseek serializer, pi-ai converter, and ACP codec already handled
image in their default arms, so only their image-naming comments
change. The codec's inbound rejection of ACP-protocol image prompt
content stays — that guards wire content a client can send regardless
of our vocabulary.
Tests that constructed harness image blocks to pin the removed branches
are dropped (the 85-token estimate pin) or retargeted onto plugin-added
block types / other non-text blocks, which the surviving default arms
own. Docs, the type-equiv pastes, and the content-block vocabulary
RFC's block list and multimodal-home consequence are updated in the
same change; the RFC moves to implemented/ and the index is
regenerated. A real multimodal feature reintroduces image via
declaration merging together with the adapter mapping, ACP
advertisement, and compaction pricing that honor it.
The definitive sweep (audit every AGENTS.md mention in packages/, docs/,
examples/, scripts/) found thirteen more citations of relocated policy
and two citations of rules that never existed as quoted:
- with-key policy comments (web deepseek/perplexity e2e headers) ->
docs/testing.md; real-impl-over-mock comments (acp harness, load,
stream-update specs) -> docs/testing.md; defensive-pattern quotes
(acp index.ts x3, stream-update) -> docs/defensive-patterns.md.
- md-tier repoints: real-api-e2e RFC, tool-schema-catalog RFC,
postmortem 0001 guardrail row, adding-a-package cookbook,
drop-bash-output-spill-files RFC, acp-subagent-backend RFC phrasing.
- Two false attributions dropped in favor of self-contained reasoning:
tool-todo's 'don't validate scenarios that can't happen' and the
bash-stdin-env RFC's 'Don't add features beyond what the task
requires' (neither rule ever existed under those names).
- Citations of the two 'not golden truth' doctrines stay: those bullets
survive verbatim in the root conventions.
Note: packages/support/ui-stdio readline TTY spec flakes under full
coverage on a heavily loaded box (passes standalone and passed the
same tree's coverage run minutes earlier); untouched by this stack.
architecture.md is the behavior map: layering, service map, seam
pattern, and the loop — everything else defers to its owning tier.
- Seam narrations compress to two-to-four sentences plus links to the
RFC and type-catalog homes that carry the detail (turn-end variant
semantics -> session.md, derivation mapping -> session.md, StreamChunk
conventions -> llm-streaming.md + source).
- The MVP feature-to-mechanism checklist moves de-statused into the
extension cookbook as 'The feature -> mechanism map' — mechanisms
only, no implementation-status bolding to rot; the microkernel RFC's
proof-obligation pointer follows it.
- The layering diagram describes layers by family instead of
enumerating packages (the stale 'future plugins: hooks, compaction'
row is gone); the dependency rule defers to packages/README.md.
- The loop pseudocode, the three externally-cited anchors (the
vocabulary, event taxonomy, waterfall semantics), and the filename
are unchanged.
- Budget ratchet: docs/architecture.md 3897 -> 1800 (now 1,797 words);
the doc-tiers RFC's deferred list prunes the item this ships.
Applies the documentation standard to its biggest offender. Every rule
survives as one to three lines plus a link to its durable home; the
stories, duplicate statements, and re-narrations go:
- Situational clusters evict to new homes: docs/testing.md (tiers,
with-key policy, real-over-mock, world-verification, real-entry-path
guards), docs/defensive-patterns.md (the bug-class rules), and
docs/cookbook/responding-to-pr-review-on-a-stack.md (the stacked-PR
review procedure).
- Doc-authoring rules consolidate into docs/AGENTS.md § Writing rules
(current-state-never-history, md-wrap, ts-block compilation, @mode,
catalog same-change, pair same-change).
- packages/README.md drops to the group table + the extension-vs-bundle
dependency rule; the hand ASCII graph yields to the generated
module-graph.md; group READMEs are the canonical per-package map.
- packages/AGENTS.md keeps only its packages-specific rules (export
shape, ctx.get, real-Loader coverage); examples/AGENTS.md repoints
its with-key-policy link; rfc/README.md loses a narrated-history
aside; dsh-code-review / dsh-find-simplifications / verify-md-wrap
references follow the moved content.
- Budget manifest ratchets: AGENTS.md 8200 -> 1500 (now 1,495 words),
packages/README.md 1900 -> 600, packages/AGENTS.md 600 -> 450; the
two new eviction docs join the budget set (testing 800, defensive
550); docs/AGENTS.md raises 1000 -> 1250 for the absorbed writing
rules (the one justified increase). The doc-tiers RFC's deferred list
prunes the two items this change ships.
The result-time diff card sent view.title raw, so a completed edit/write
of an absolute in-workspace path flipped the card header back from the
relativized `Edit src/b.ts` to the absolute path — the pending card
relativizes, the result did not, and tool_call_update.title replaces the
header. Apply displayTitle to the result diff arm using the diff path,
mirroring the call-side card. Regression test proven red on the unfixed
arm.
Also record the overwrite diff-basis pre-read as a bounded follow-up
(TODO(overwrite-diff-bound) + RFC non-goal): overwriting a large file
reads the whole prior text into memory for a UI-only diff.
Codex's stale-prose pass found seven more spots still describing the
result diff as ALWAYS an applied contextual hunk, or a create/binary
overwrite as rendering "only the call-time card": the DiffCallView JSDoc
and the acp bridge diff-arm comment, the FsWriteOutcome.before and
readTextForDiff JSDoc, and three RFC lines. All now say: the result diff
is the applied change — a contextual hunk when there is a before-image,
else a whole-file diff (create / undiffable binary) — and a successful
mutation always returns the result diff so the model-facing text can't
clobber it. Regenerate the cordis catalog (source line shift).
A Write CREATE rendered its completed tool_call_update as the model-facing
result TEXT (`<path>…</path>…Created file`), which — because an ACP
tool_call_update.content REPLACES the call's content — clobbered the
new-file diff the pending call installed. So Zed showed the diff, then
replaced it with raw XML-ish text; only overwrite/edit looked right
(their result re-sends a diff).
write's presentResult now ALWAYS returns a diff card for a successful
write: the applied contextual hunk from `meta` when there is one
(overwrite), else an args-derived whole-file diff (`oldText: null`) for a
create or an unchanged-content overwrite. This matches claude-agent-acp,
where the create diff rides on the update and no result text replaces it.
An error still falls through to generic rendering so its message shows.
edit is unchanged (it always has a hunk; no whole-file fallback).
Re-recorded fs-write / fs-write-overwrite goldens; the create's completed
update is now a {type:'diff'} block, not the XML result text.
Address the applied-hunk-diffs review:
- CRLF write overwrite emitted bogus every-line-changed hunks: write's
`before` was LF-normalized but `after` kept the raw model content, so a
CRLF rewrite of an LF file diffed every line. Normalize write's `after`
to LF so both sides share the diff basis (edit already did). Regression
test proves it fails on the raw-after path.
- The tool-private `meta` payload is now typed `unknown` (opaque) at every
seam instead of `JsonValue`. This drops the `dsh-tools -> dsh-session`
package edge that existed only to name the type, and removes the
`FileDiff` index signature that had been widening the type solely for
JsonValue-assignability. Serializability is still enforced at runtime by
`Session.append`'s isJsonValue check, which was always the real guard.
- Sync the docs the new result/meta surface left stale: ToolResultView's
diff card + ToolExecutionResult.meta in tools.md/session.md type-equiv
blocks, the acp/tools READMEs, and the adding-a-tool cookbook; regenerate
the cordis catalog and module graph.
fs write/edit now emit a result-time contextual-diff tool_call_update
(the applied hunk with ±3 context lines, one hunk per replace_all site),
matching what claude-agent-acp sends and what makes an editor render the
change in place. The call-time snippet diff stays; the result hunk
supersedes it (ACP content-replace).
Mechanism:
- A persisted tool-private `meta` channel: execute may return
`{ content, meta }`; `meta` (JsonValue) rides on the tool/result event
and is handed back to presentResult, so the diff reproduces on replay
(event-sourced). JsonValue is now exported from dsh-session.
- The backend returns raw before/after text (storage facts) on
FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm
`diff` package's structuredPatch. A create has no before → no result
diff; a failed/aborted mutation carries no meta.
- ToolResultView gains a DiffResultView; the bridge's result-side switch
renders it as {type:'diff'} content blocks.
RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
(justifies the npm `diff` runtime dep over vendoring and the meta channel);
the render-intent-union RFC's Non-goal is updated to record this shipped.
All fs snapshot goldens re-recorded; edit/overwrite gain the contextual
result diff, create/read/policy-reject unchanged in structure.
Replace the "bag of optional fields" tool-presentation types
(ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a
card-tagged discriminated union — the standing FIXME(tool-presentation).
A tool declares one render intent per call/result and the ACP bridge
switches on `card`:
ToolCallView = generic | terminal | diff
ToolResultView = generic | terminal
The `diff` card is new: fs write/edit now emit an ACP {type:'diff'}
content block (an editor's inline diff), which the old shapes could not
express. The bridge also relativizes a file card's title against the
session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping
locations/diff paths raw, and derives the no-capability fenced console
fallback from a terminal result's output. read gains the window-in-title
(`Read foo.txt (5 - 8)`) and an always-set location line, matching the
reference adapter field-for-field.
Migrates all three producer families (tool-fs, tool-bash, tool-todo) and
the sole consumer (the ACP bridge) together — the source does not compile
piecewise. Adds snapshot coverage for the terminal _meta path (a new
capability-advertising scenario) and re-records the fs goldens to show the
diff cards. Applied-hunk (result-time, context-line) diffs need a new
result/event shape and are a follow-up.
RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
The loop recorded every model token delta as a durable `assistant/chunk`
session event AND emitted an identical live `agent/stream-chunk` Cordis event
one line later. Same StreamChunk, same turn/step; the emit added only the live
Agent handle, which the sole consumer discarded. This is the boundary-mirror
duplication the event-domain work removed for turn/step boundaries, applied to
the token stream — a follow-up the boundary RFC explicitly deferred.
The premise is settled: chunk persistence is authoritative (the proposal to
stop persisting chunks was rejected — replay/snapshots depend on it), so
`assistant/chunk` on `session/event` is the load-bearing token stream and
`agent/stream-chunk` is pure redundancy.
- Remove the `agent/stream-chunk` declaration + emit; drop the now-unused
StreamChunk import from dsh-agent's types.
- Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads
assistant/chunk off session/event) to render assistant/chunk in its existing
session/event listener. Consolidating to one listener also makes the
inReasoning dim-SGR flag deterministic across chunk/boundary events (they no
longer race across two listeners).
- Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the
session/event assistant/chunk feed.
- New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror);
amend the boundary RFC's retained-list entry to cross-link; update
architecture, cookbook, event-domain-semantics, the ACP proposal, and the
regenerated cordis catalog.
Snapshot goldens unchanged (ACP never used the mirror), confirming no
editor-facing transcript change.
Load dsh-fs-local + dsh-fs-policy + dsh-tool-fs after tool-todo (mirroring the
acp-agent wiring), and steer the system prompt to prefer read/write/edit for
file ops with bash for shell/tests/search. Update the welcome line and the
FIXME(config-comments) bash note.
Doc sweep now that both demos ship the fs tools and the seam resolves per-session
cwd: architecture.md and the event-gate RFC no longer say the demos do file ops
through bash / that no config wires the tools; the coding-agent + examples
READMEs and the AGENTS.md layout blurb list the fs tools; the acp-agent README
drops the launch-dir caveat (per-session cwd now works, so the server can launch
anywhere).
(stdio-agent is single-session, so fs-local's cwd = process.cwd() is the
workspace. Keyless boot smoke is blocked locally by an unrelated inotify
watcher-limit ENOSPC that also hits demo:echo; the config parses and the same fs
stack boots green in the acp-agent snapshot tier.)
The ACP bridge gives each session its own workspace (SessionHeader.cwd), and
dsh-tool-bash already resolves a bash workdir against it. But ctx.fs.resolve(path)
took no caller context and dsh-fs-local resolved every relative path against a
fixed config.cwd (process.cwd() at plugin load) — so in the ACP demo `write
foo.txt` and `bash cat foo.txt` hit different directories the moment an editor
opens any project other than the server's launch dir.
Thread the session cwd into resolution, mirroring dsh-tool-bash: widen
FileSystem.resolve to resolve(path, opts?: { cwd?: string }); dsh-fs-local bases
a relative path on opts.cwd ?? config.cwd (absolute paths ignore it); the
read/write/edit tools derive it via a shared sessionCwd(exec) helper
(exec.agent?.session.header.cwd). The provider stays free of dsh-agent/dsh-session
— the tool projects exec → cwd and hands over a plain string, per the
explicit-at-seams convention. Backward compatible (the arg is optional).
Tests: fs-local resolve(path,{cwd}) bases relative on the passed cwd / ignores it
for absolute; tool integration writes/reads/edits in a session cwd != config.cwd
and verifies the file on disk (proven to fail on the pre-fix no-cwd path). Fakes
that stood in a bare {session:{}} now carry a header so sessionCwd doesn't throw.
RFC in docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md.
Address review on the bash stdin/env seam PR: the seam spawned stdin as a
`'pipe'` for EVERY call, closing it empty when no stdin was supplied. That is
NOT observationally equivalent to the pre-seam `'ignore'` default — node's
spawn pipe is an AF_UNIX socket, so `test -c /dev/stdin` (and any fd-0 type
probe) flipped for every model-driven bash call, even though the code claimed
the no-stdin path was unchanged.
Spawn stdin as `'pipe'` only when the caller supplies bytes; otherwise
`'ignore'` (fd 0 → /dev/null), the exact prior default. A literal `stdio`
tuple per branch preserves the typed `spawn` overload's non-null stdout/stderr.
Regression test asserts fd 0 is a char device with no stdin and a socket when
supplied — proven red on the always-pipe code.
Bring the interception-seams branch onto current master (via A→B). The
substantive reconciliation is master's compaction `agent/pre-step` serial seam
meeting C's interception seams:
- types.ts: keep BOTH master's `agent/pre-step` AND C's new interception events
(`agent/prompt-submit`, `agent/session-start`, `agent/turn-continuation`→
`ContinuationDecision`); drop the turn-mirror declarations (removed on A).
- loop.ts: the merged per-turn order is `turn/start` → per queued msg
`agent/prompt-submit` (rewrite/inject/block) → (fully-blocked ⇒ zero-step
`rejected`) → per step: drain steering → assemble system prompt →
`agent/pre-step` (compaction, OUTSIDE the step) → `step/start` → single
`deriveMessages()` → model → tools/pre-execute·dispatch·post-execute. No
turn-mirror emits; `closeTurn()` is the A-simplified single-call form.
- Docs (architecture, core.md, agent/agent-loop READMEs, catalog) reconciled to
show C's interception seams alongside `agent/pre-step`, no turn/step mirrors.
- rfc/README: dropped the stale `proposed/` compaction row (master moved that RFC
to implemented/); kept C's new `pre-tool-input-rewrite` proposed row.
- interception.spec.ts: migrated its two `agent/turn-end` reason collectors to
the `turn/end` session event, and ADDED a cross-test proving a
`prompt-submit` rewrite + additionalContext is VISIBLE to an `agent/pre-step`
listener on the same turn — pinning the merged seam ordering (compaction sees
the post-prompt-submit surface, not stale history).
Address review: the "trusted-plugin surface" framing overstated the security
story. A model driving the `bash` tool already has equivalent power to set env
vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so
the `env`/`stdin` seam fields grant it no new capability — and they cannot
exfiltrate the harness's ambient credentials, because the credential SCRUB in
dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the
child sees it) is the actual control, and it works regardless of these fields
(tool-call args are static JSON, never shell-evaluated).
So drop the "dangerous / trusted-plugin boundary" language across the RFC, the
three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both
the type-equiv blocks — kept 1:1 with source — and the prose). The reality that
remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd
be redundant with shell syntax; the fields exist for in-process plugins (the
hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is
kept but reframed: it catches a future `...args` spread that would silently
forward model input into the post-scrub env merge, NOT a trust wall. No code or
behavior change.
Codex review of the turn-mirror removal found current-state docs/comments that
still claimed the removed `agent/turn-start`/`agent/turn-end` events exist:
- docs/architecture.md: the loop diagram's turn-start line still said "emit
agent/turn-start" (the turn-end line was already fixed).
- event-domain-semantics RFC: the `agent/*` domain description listed "the turn
boundaries" among the transient emits.
- docs/core-data-structures/core.md: the agent/* taxonomy blurb listed
"turn/step boundaries" as agent events.
- the proposed ACP RFC: the settle-signal rows named agent/turn-start /
agent/turn-end; retargeted to the durable `turn/end` session event + the
session/event owning-turn correlation.
- loop.ts outer-catch comment: said "closeTurn/failTurn are idempotent" — after
the emit-param removal closeTurn is called exactly once (mutually exclusive
normal/catch paths), so corrected to state that and to scope idempotency to
closeStep (which is still guarded by stepOpen).
Regenerated the cordis catalog. No behavior change.
Complete the boundary-mirror removal begun with the step mirrors: drop
`agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and
step boundaries are now read exclusively off the durable `session/event` feed
(`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror
for any boundary.
- loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and
its now-unreachable idempotency guard (it is called exactly once per turn, on
mutually exclusive normal/catch paths); `failTurn` loses the dead post-close
branch that only a throwing turn-end LISTENER could reach.
- ui-stdio: render turn boundaries from `session/event`, recovering the short
agent label from an `agent/created`→id map (the `turn/start` event carries only
the turn number, and the session id is not reliably the agent id). ui-stdio is
a disposable test REPL, so this migration retires the sole justification the
event-domain-semantics RFC gave for KEEPING the turn mirrors.
- Tests: reason/turn-number collectors and the boundary-ordering test now read
`session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that
code path no longer exists). A new test covers the outer-catch disposed branch
via a pre-step listener that disposes-then-throws (the surviving real path).
- Docs: promote the "remove agent boundary mirror events" RFC to implemented
(amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror);
update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the
cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog.
`agent/steering` and `agent/stream-chunk` are explicitly out of scope (not
durable-boundary mirrors). ACP is unaffected — it already settles from the log's
`turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
Rename per review naming decisions:
- package dsh-file-context → dsh-fs-policy (dir, package name, plugin name,
tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph)
- events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent
(fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation"
wording → "intent" throughout
- exported FileContextExec → FsPolicyExec
Make the implemented RFCs describe what shipped, not the superseded designs:
the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on
ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext
service prose is rewritten to the fs/* event-gate reality (freshness-based auth).
Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the
"new RFC" escape hatch is for macro decision reversals only, not renames.
Code fixes from review:
- fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a
file) into the structured FsError taxonomy instead of leaking a raw Node error;
resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to
fail on the unfixed code.
- tool-fs HMR test now asserts prompt sections (not just tool schemas) are
withdrawn on disposal.
- fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment,
filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains"
a throwing listener; a throw surfaces as the tool's isError result.
- drop the false "loaded by the default product config" claim (no config wires
the fs tools yet), the duplicate ctx.bash service-map row, the stale
FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line;
correct the dsh-fs package.json description.
Codex's PR-C review found two (A) blockers:
- tools/post-execute could corrupt the protected outcome. postExecute passed the
mutable `result` to listeners and then read result.callId / spread result on the
return paths, so a listener mutating the reference (flipping isError, rewriting
callId, injecting an error) escaped the decision channel. Now the authoritative
callId/isError/error are SNAPSHOT before the waterfall and the return value is
rebuilt from the snapshot + the typed PostToolDecision — the decision is the only
sanctioned way to change the outcome, and callId is always exec.callId. Added a
regression test that mutates the result reference and asserts it has no effect;
proven to fail red on the unfixed code.
- Public docs/JSDoc still advertised the removed `tools/execute` waterfall after the
split. Swept every current-state reference to tools/pre-execute + tools/post-execute:
the ToolRegistry class JSDoc (and the regenerated catalog), loop.ts's ASCII flow
(also added the prompt-submit/session-start steps it was missing), the package-map
READMEs (packages, core, agent-core), core-data-structures core.md/tools.md, the
bash + acp + invariants src/READMEs (the deferred permission gate is the
tools/pre-execute deny/ask seam now), the cookbook, and the implemented RFCs whose
factual seam catalog drifted. codec.ts's totality prose now lists `rejected`.
Proposed-RFC references are left as-is (frozen proposals, validated when built).
- The implemented RFC named stack positions (PR-A…PR-F, "the hooks bridges (PR-F)")
as shipped reality, violating the rule that an implemented RFC describes current
truth and docs never name a change unit the reader cannot see. Rephrased to
describe the hooks subsystem / a hook bridge as the standing motivating consumer,
without PR/stack references. The decision and rationale are unchanged.
- childEnv's comment pointed at dsh-tool-bash's "module doc" for the trusted-plugin
boundary, but that explanation lives in the package README (§ "Trusted-plugin
boundary"), not the module JSDoc. Fixed the reference.
The hooks subsystem runs external hook commands the Claude Code / Codex way:
JSON payload on stdin, context in CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT env.
Reusing the ctx.bash seam for that needs two new inputs — but stdin and arbitrary
env are exactly what dsh-bash-local's credential scrub exists to keep away from
model-driven commands. So this adds them as a TRUSTED-PLUGIN surface:
- BashExecRequest + BashExecSpec gain optional `stdin` and `env`. They are plain
optionals on the resolved spec (not required-but-nullable like `owner`): a
missing one means "none", the safe default, not a security footgun.
- dsh-bash-local threads them through resolve/run/start. `env` merges AFTER the
credential scrub, so a trusted caller's explicit entry wins even on a
credential-shaped name — the scrub guards the harness's OWN ambient creds from
model-driven commands, not a trusted plugin. stdin is always a pipe, closed
immediately (with bytes when supplied, empty otherwise — EOF as before); an
EPIPE from a child that exits without reading is swallowed.
- The model-facing dsh-tool-bash NEVER forwards model input into stdin/env (its
request is command/workdir/timeoutMs/signal/owner only). A regression guard
drives the real tool with adversarial args and asserts the request carries
neither field — proven to go red if the consumer ever forwards them.
Configurable scrub (in an earlier sketch) is dropped as speculative: the explicit
`env` field already gives a trusted caller full control, and no caller needs to
broaden the ambient scrub. Documented in a new architecture RFC, the bash.md
type-equiv blocks, and the three bash READMEs.
Second-round Codex review of the PR-A taxonomy change found four issues, all
verified against the code:
- The /goal regression guard asserted only that the steered content reached
requests[1], which passes even with the hasSteering override (loop.ts) disabled:
leftover steering is re-enqueued as a next-turn queued message and also lands in
requests[1], one turn later. The guard now asserts the same-turn shape — ONE
turn, TWO steps, a steering/message recorded before step 2 — which is the
mechanism the override drives. Proven to fail red with the override disabled.
- The event-domain-semantics RFC's consequence list still described the pre-fix
behavior (step marked open AFTER step/start, so no step/end owed). It now states
the shipped behavior: the loop marks the step open BEFORE the append, so a
throwing step/start listener gets a balancing step/end via closeStep().
- architecture.md's loop pseudocode said only continuation listeners force
continuation; step/end session-event listeners (the /goal pattern) do too.
- The agent/turn-end JSDoc listed a `rejected` TurnEndReason that does not exist on
this branch (it belongs to the later interception work). Removed it and
regenerated the cordis catalog; `interrupted` (a real variant) stays.
Pin the three-domain rule (session = durable fact log, agent = live runtime
surface, tools = registry/exec): a durable replayable fact is a SessionEvent; a
live interception or transient/live-object signal is an agent/tools Cordis
event. A boundary that is both is mirrored as an agent/* emit ONLY where a live
consumer needs the Agent handle.
Apply it to the boundary twins: drop agent/step-start and agent/step-end (no
production consumer needs the live Agent at a step boundary — consumers read the
durable step/start/step/end session events). Keep agent/turn-start/turn-end (the
stdio UI labels output by agent.id). Tests that observed step boundaries via the
removed emits now observe the durable session events; the pinned behavior is
unchanged.
Conservative subset of the proposed "remove boundary mirror events"
simplification; foundation for the Hooks subsystem's canonical event surface.
Two cohesion cleanups on the filesystem tool package:
- Fold window.ts + types.ts + formatReadOutput into one cordis-free
read-render.ts. Line windowing, the FileReadOutcome shape, and output
formatting are one concern (the read tool's rendering); splitting them across
three files added no value. read.ts is now just the tool (schema + I/O).
- Drop observe.ts and emit fs/observed with a plain ctx.emit in read/write/edit.
The event is contractually a synchronous, side-effect-only recorder
(file-context's listener is a WeakMap.set), so the per-call try/catch guarded
against a contract violation that cannot happen under the shipped listener —
defensive code for an impossible case. The event contract (dsh-fs JSDoc,
README, RFC) is updated to state the fire-and-forget semantics plainly.
Adapt the four fs packages to master's single-tsconfig build convention
(lib/types outDir + types path + files allowlist), brought in by the merge.
While doing so, drop dsh-tool-fs's /read//write//edit subpath plugins. They
were the only subpath-export package in the tree and forced bespoke tsdown,
tsconfig path, package.json files, and workspace-constraint handling that no
sibling tool package (e.g. dsh-tool-bash) carries, for a focused-deployment
use case no consumer needed. dsh-tool-fs is now a single root plugin that
registers read/write/edit, mirroring dsh-tool-bash; the per-tool registration
helpers stay internal modules the root composes. The file-context event-gate
RFC is amended to record the narrowed scope.