@sema-agent/core 7.21.1 → 7.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +290 -1
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +1 -0
  4. package/dist/agents/subagent.js +2 -1
  5. package/dist/core/ask-origin.d.ts +1 -0
  6. package/dist/core/ask-origin.js +3 -2
  7. package/dist/core/auto-compaction.d.ts +2 -2
  8. package/dist/core/auto-mode-rebuild.js +6 -2
  9. package/dist/core/auto-mode.d.ts +40 -18
  10. package/dist/core/auto-mode.js +63 -45
  11. package/dist/core/checkpoint-store.d.ts +9 -13
  12. package/dist/core/context-edit.js +1 -1
  13. package/dist/core/engine-notice.d.ts +62 -1
  14. package/dist/core/engine-notice.js +14 -1
  15. package/dist/core/gate-bounded-await.d.ts +13 -3
  16. package/dist/core/gate-fold.js +3 -3
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +6 -0
  19. package/dist/core/mcp.d.ts +65 -0
  20. package/dist/core/mcp.js +190 -41
  21. package/dist/core/memory-engine/consolidation.d.ts +5 -0
  22. package/dist/core/memory-engine/consolidation.js +7 -3
  23. package/dist/core/memory-engine/engine.d.ts +8 -0
  24. package/dist/core/memory-engine/engine.js +25 -19
  25. package/dist/core/memory-engine/layout.d.ts +47 -9
  26. package/dist/core/memory-engine/layout.js +157 -57
  27. package/dist/core/memory-engine/session-incarnation.d.ts +27 -0
  28. package/dist/core/memory-engine/session-incarnation.js +63 -0
  29. package/dist/core/parent-spec-seat.d.ts +15 -3
  30. package/dist/core/parent-spec-seat.js +5 -0
  31. package/dist/core/park-selfcheck.js +1 -1
  32. package/dist/core/path-identity.d.ts +11 -4
  33. package/dist/core/path-identity.js +5 -2
  34. package/dist/core/permission-rule-model.d.ts +1 -2
  35. package/dist/core/read-root-candidate.js +11 -1
  36. package/dist/core/retention.d.ts +8 -1
  37. package/dist/core/retention.js +2 -0
  38. package/dist/core/runner/assemble-result.d.ts +2 -7
  39. package/dist/core/runner/assemble-result.js +25 -8
  40. package/dist/core/runner/classifier-ladder.js +1 -0
  41. package/dist/core/runner/contracts.d.ts +26 -15
  42. package/dist/core/runner/inherited-ask-grants.d.ts +2 -7
  43. package/dist/core/runner/inherited-ask-grants.js +1 -0
  44. package/dist/core/runner/mcp-redial.d.ts +109 -0
  45. package/dist/core/runner/mcp-redial.js +60 -0
  46. package/dist/core/runner/memory-consolidation-driver.d.ts +2 -0
  47. package/dist/core/runner/prepare-ask-lane.js +1 -0
  48. package/dist/core/runner/prepare-caps-and-workflow.js +9 -12
  49. package/dist/core/runner/prepare-context-lane.d.ts +43 -1
  50. package/dist/core/runner/prepare-context-lane.js +25 -4
  51. package/dist/core/runner/prepare-hands-readface.d.ts +3 -0
  52. package/dist/core/runner/prepare-hands-readface.js +13 -3
  53. package/dist/core/runner/prepare-memory.js +3 -3
  54. package/dist/core/runner/prepare-protocol-tools.d.ts +6 -1
  55. package/dist/core/runner/prepare-protocol-tools.js +20 -38
  56. package/dist/core/runner/prepare-task.js +4 -4
  57. package/dist/core/runner/prepare-turn-wiring.js +2 -2
  58. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  59. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  60. package/dist/core/runner/run-harness-handlers.js +4 -2
  61. package/dist/core/runner/run-identity-wiring.d.ts +3 -1
  62. package/dist/core/runner/run-identity-wiring.js +4 -1
  63. package/dist/core/runner/run-recovery-lanes.js +6 -3
  64. package/dist/core/runner/run-settle-and-teardown.d.ts +3 -1
  65. package/dist/core/runner/run-settle-and-teardown.js +6 -2
  66. package/dist/core/runner/run-telemetry-and-budget-seats.js +11 -1
  67. package/dist/core/runner/run-terminal-adoption.d.ts +4 -1
  68. package/dist/core/runner/run-terminal-adoption.js +2 -6
  69. package/dist/core/runner/runtask.js +8 -5
  70. package/dist/core/runner/stream-lifecycle-verbs.d.ts +5 -1
  71. package/dist/core/runner/stream-lifecycle-verbs.js +23 -2
  72. package/dist/core/runner/write-protection-station.d.ts +7 -25
  73. package/dist/core/runner/write-protection-station.js +18 -0
  74. package/dist/core/runner-deps.d.ts +16 -7
  75. package/dist/core/skill-listing-budget.d.ts +5 -4
  76. package/dist/core/task-registry.d.ts +0 -6
  77. package/dist/core/task-result.d.ts +31 -12
  78. package/dist/core/task-spec.d.ts +2 -1
  79. package/dist/core/task-stream.d.ts +26 -0
  80. package/dist/core/tool-spec.d.ts +5 -1
  81. package/dist/core/trace.d.ts +5 -0
  82. package/dist/core/types.d.ts +3 -3
  83. package/dist/core/types.js +1 -1
  84. package/dist/core/wiring-manifest.d.ts +23 -0
  85. package/dist/core/wiring-manifest.js +3 -0
  86. package/dist/core/write-protect.d.ts +64 -12
  87. package/dist/core/write-protect.js +88 -34
  88. package/dist/engine/execution-env/node-execution-env.d.ts +19 -2
  89. package/dist/engine/execution-env/node-execution-env.js +5 -1
  90. package/dist/index.d.ts +5 -4
  91. package/dist/index.js +2 -2
  92. package/dist/orchestration/workflow-governance.js +1 -1
  93. package/dist/orchestration/workflow-primitives.d.ts +2 -2
  94. package/dist/tools/fs/bash-program-position.d.ts +26 -5
  95. package/dist/tools/fs/bash-program-position.js +63 -16
  96. package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -17
  97. package/dist/tools/fs/bash-readonly-classifier.js +134 -55
  98. package/dist/tools/fs/fs-bash.js +43 -18
  99. package/dist/tools/fs/fs-read.js +2 -1
  100. package/dist/tools/fs/fs-shared.d.ts +33 -7
  101. package/dist/tools/fs/fs-shared.js +12 -1
  102. package/dist/tools/fs/index.d.ts +4 -2
  103. package/dist/tools/fs/read-deny.d.ts +25 -11
  104. package/dist/tools/fs/read-deny.js +5 -1
  105. package/dist/tools/fs/safety.d.ts +38 -4
  106. package/dist/tools/fs/safety.js +31 -3
  107. package/package.json +2 -2
  108. package/test/export-surface.snapshot.json +11 -1
  109. package/wire-changes/core@7.22.0.json +637 -0
  110. package/wire-changes/core@7.23.0.json +316 -0
package/CHANGELOG.md CHANGED
@@ -1,12 +1,301 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.23.0 — 2026-09-18
4
+
5
+ ### Gates — every script's CLI entry guard folded into ONE helper; the roster runner no longer reads silence as green (#915; `gate:all` and its 21 guarded children; no product code)
6
+ - **Before.** Twenty-one scripts under `scripts/` each hand-rolled "am I the entry point?" in one of four forms, and each form was blind to one spelling of the same file: a hand-built `file://` string vs the percent-encoded module URL (non-ASCII checkout paths — `gate:memory-ga` and `gate:child-clamp-seats` printed 0 bytes and exited 0 on the main tree, so the local RC runs of 7.21.0 and 7.22.0 carried a PASS for two gates that never ran; test [7475] G6, sema-internal#48), a raw `argv[1]` vs the resolved module path (`/tmp` → `/private/tmp`, any symlinked checkout — **`gate:all` itself listed nothing and exited 0 through the alias**, and so did `verify-layering` / `verify-error-surface`; codex round 2 reproduced), and a one-sided realpath vs `--preserve-symlinks-main`. A false guard was never an error: the script exported, exited 0, and `gate:all` — which checked the exit status only — printed PASS.
7
+ - **After.** `scripts/cli-entry.mjs` `isCliEntry(import.meta.url)` is the one guard (both sides real-path'd, false on throw) and all 21 scripts import it. `gate:all` reads a child's exit 0 with nothing on either stream as **SILENT**, prints it as such and counts it as a failure (`childVerdict`): every gate speaks on green. Pins (`test/backlog915-gate-entry-guard.test.ts`): no script compares `import.meta.url` with `argv[1]` on its own; ≥21 users of the helper; both gates and `gate:all --list` speak through a symlink under a non-ASCII directory with and without `--preserve-symlinks-main`; importing a guarded script stays quiet; silence ≠ pass.
8
+ - **Reach.** No wire, no product behaviour. A gate that never ran can no longer show as green in `gate:all`; scripts invoked through a symlinked checkout now run their CLI bodies.
9
+ ### Fixed — `covers: "exact"` reaches the CARD (#913; user-visible half of #862b/#714; @cli @server @client-core re-pin; headless lanes: no change)
10
+ - **Before.** The default-tier read-boundary seat minted `readRootCandidate.covers: "exact"` for a single-file candidate (7.22.0, #714/#862b), and the card path rebuilt the offer key by key (`gate-fold` → `normalizeReadRootCandidate`), returning only `{ dir, clearsThisAsk }`. No card on the 7.22.0 artifact ever carried the member; a consumer that expanded the offer to `<dir>/**` granted nothing the boundary honours, so the ask kept coming back. The black-box round that judged this face PASS read the PROBE's return value, not the folded decision.
11
+ - **After.** The validator carries `covers` on its ONE spelling and refuses the WHOLE candidate on any other value — the same posture `clearsThisAsk !== true` already takes (a repaired offer is a different offer, never shown). The folded ask (`probeReadRootCandidate`) and the ask request (`readRootCandidate`) now carry `covers: "exact"` exactly where the seat minted it. Rules: 0 added (one spelling law now covers both members).
12
+ - **Reach.** @cli: the grant offer on a single-file card now says `covers: "exact"` — accept it by adding THAT exact spelling, not the directory (7.22.0 pins that expect the key absent flip). @server: 7.85.0's narrow reader already forwards the member; 7.84.0 swallowed it. @client-core: reader admits the optional member. Headless lanes: unchanged (the offer is card text; no ask is added or removed).
13
+ - **Stated.** On 7.22.0 the member was absent on every card: a deployment on that artifact cannot clear a single-file ask by directory grant. Pins: `test/backlog913-covers-on-the-card.test.ts` (validator, folded card, real seat end to end; mutation = the old return, red).
14
+
15
+ ### Fixed — assembling a failure terminal survives any thrown value (#886; behaviour-face text change on `terminal.message` for NON-Error rejections; @server @cli @client-core)
16
+ - **Before.** The failure terminal rendered a non-`Error` rejection with `String(value)`. `String` is a PARTIAL function: it raises on a value with a null prototype (nothing to call) and on one whose `toString` / `Symbol.toPrimitive` throws. So the one step whose job is to DISCLOSE the failure could destroy the disclosure — the run threw, the report of the throw threw, and no `TaskResult` was assembled at all. The sibling read, `errorCodeOf`'s `code`/`cause` walk, had the same hole through a throwing accessor.
17
+ - **After.** The non-`Error` arm renders through the engine's existing total describer (`describeRejectedValue`, reused — no second renderer), which never lets the value's own formatting hooks write the sentence and guards every read it makes. The code walk carries ONE guard over the whole walk: an unreadable link ends it and the answer is whatever was already read. An unreadable code is reported as NO code, never a fabricated one.
18
+ - **射程 / consumers.** `Error` rejections — essentially all production traffic — are byte-identical (`err.message` verbatim, code unchanged). For a NON-`Error` rejection `terminal.message` reads differently: an object now describes its shape and own keys instead of `[object Object]`, and a thrown string is quoted. It was always free text and never a discriminant; a consumer matching it exactly for a non-Error throw should read `terminal.code`. Sheet: `docs/wire-consumption/unreleased-886.md`.
19
+ - **headless.** No lane change: nothing is newly refused or newly allowed. A headless run that previously produced NO result for these two rejection classes now produces an ordinary `failed` terminal with a `limits.*` / `conflict` / carried code — strictly more reporting, never less.
20
+
21
+ ### Fixed — the repetition sample is scrubbed where it becomes a disclosure (#898; value change on `repetitionEvents[].segment` and the `repetition.detected` trace frame; @server @cli @client-core)
22
+ - **Before.** The degenerate-repetition detector keeps a ≤120-char verbatim slice of the looped text so the spare/cut allowance can be judged from real samples. It left the engine scrubbed by nobody, through TWO doors: the result face (`stats.mechanisms.repetitionEvents[].segment`) and the `repetition.detected` trace frame. A model that loops reproduces whatever it was looping ON — credentials included. A deployment scrubbing at its own mouth covered the result face only; the trace door had nothing in front of it.
23
+ - **After.** One `redactSecrets` call sits ABOVE both doors, so the two cannot disagree about what a reader may see and a third door added later inherits the scrub instead of re-deciding it. Only the text changes: `rule` / `period` / `reps` / `action` / `turn` and the cut/spared accounting are untouched, so the sample stays useful for the judgement it exists for.
24
+ - **CC anchor.** UNANCHORED, not a divergence: 2.1.259 has no output-repetition detector and no repetition-sample disclosure at all (its `degenerate` hits are MCP OAuth grant state and a job-label validator; its one `repetition` hit is a bug-report category string). This is a sema mechanism (design/39), so the posture is sema's to set — nothing is registered in the divergence book.
25
+ - **射程 / consumers.** Byte-unchanged for every sample with no credential shape. A host that already scrubs this at its mouth keeps working — the two become defence in depth. Sheet: `docs/wire-consumption/unreleased-898.md`.
26
+ - **headless.** No lane change: nothing is newly refused or allowed, and no ask/approval path is touched.
27
+
28
+ ### Additive — a human-review ledger row names the call it gated (#897; optional key `gates[].toolCallId`; @server S-394 @cli @client-core)
29
+ - **Before.** `stats.humanReview.gates[]` rows carried `kind` / `waitMs` / `decision` / `toolName` / `toolArg` but no identity, and a headless deployment's own permission-denial ledger is legitimately a DIFFERENT LENGTH from this one: a run pushes rows from FOUR mints (a synchronous ask, an inherited-grant reuse, a durable resume, the park probe) and only the first passes through the host's `onAsk`. Reconciling by count therefore cannot work, and there was no key to join on instead.
30
+ - **After.** Every mint carries `toolCallId` on the row it pushes, from its own view of the call — the synchronous ask from the request, the grant reuse from the grant's own key, the durable resume from the checkpoint's `pendingAction`. It is never re-derived downstream at the aggregator: a re-derivation would have to guess which of several rows a call produced, which is the ambiguity this closes.
31
+ - **Absence has one reading:** the mint had no call id to give — a durable pause that gates no tool call (`resource_limit` / `review` / `plan_review`), or a row from an engine older than this one. Never a placeholder. (The two review-gate rows in the timing suite are the standing negative control: they still carry no tool fields at all, correctly.)
32
+ - **Also closed:** the durable-resume row now carries `toolArg` too. The comment there recorded "the input isn't threaded onto the persisted gate — a documented follow-on"; it is threaded onto the row's PENDING ACTION, which is where the mint reads it from.
33
+ - **Shape.** The row had SIX structurally-identical inline copies (`task-result.ts`, `contracts.ts` ×2, `inherited-ask-grants.ts`, `assemble-result.ts`, `prepare-task.ts`) and a SEVENTH — the persisted checkpoint's — that had silently drifted NARROWER (`{kind, waitMs, decision?}`, no tool fields at all) while the three persist sites spread the full live rows into it: the type said storage loses the identity while the bytes carried it. All seven now reference one declaration, `HumanReviewGateRow`. No checkpoint-version bump (additive optional fields, the `reminderMark` / `hasBidiControls` precedent).
34
+ - **射程 / consumers.** Additive; `src/index.ts` is untouched (the row type is named internally, not exported — say the word if a downstream would rather import the name). A consumer reconciling its own ledger should join on `toolCallId` and read an absent one as "not joinable", never as "no call". Sheet: `docs/wire-consumption/unreleased-897.md`.
35
+ - **headless.** No lane change: nothing is newly asked, refused or allowed. The headless operator gains the key that makes its `permission_denials` ledger joinable against the engine's — the counts stay legitimately unequal, and that is now reconcilable instead of merely puzzling.
36
+
37
+ ### Fixed — a Read body taken off the wire retires the entry that dedups against it (#894; user-visible tool-face behaviour; @cli @server @client-core)
38
+ - **Before.** After the request-layer stale-tool-result pass replaced an old `Read` result's content with a cleared marker, the read-state entry survived — so re-reading the same unchanged file answered `[<path>: unchanged since you last read it …; content omitted to save context]` while the only copy on the wire was that marker. The model's escape was to shell out and `cat` the file. Reported from a deployment running a 40 000-token window.
39
+ - **What the evidence actually said.** The three COMPACTION lanes were never the problem: all three wire `onApplied` and clear the state correctly (measured — with compaction on, the re-read transmits the body). The uncovered mechanism is not a compaction lane at all; it is the micro-compact pass, which takes content off the wire with no summary, no compaction event and nothing changed in the session. The contract sentence that said "every compaction lane" was TRUE — its SCOPE was wrong, and it has been widened to say so explicitly rather than left as a true sentence that reads like a wider promise.
40
+ - **After.** ONE retirement function serves both mechanisms (`applyContextShrinkToReadFileState`, a discriminated shrink) rather than a second retirement written next to the second mechanism. Retirement is keyed by the TRANSMISSION, not by path: a `ReadEntry` records which tool call carried its body, so clearing an older transmission never retires an entry a later Read re-established. The retirement sits beside the clear's ledger write and shares its posture — an entry in that ledger means the marker is repainted on every later request build, so the content is gone for good.
41
+ - **射程 / consumers.** A host or probe that pinned "a second identical Read always dedups" over a long session will now see a full body where it saw a stub; the correct reading was always "dedups while the body is still visible". The structured sidecar is unchanged (`details.type === "file_unchanged"`) — it simply fires less often. An entry restored from a checkpoint written before this version carries no transmission id and is therefore never retired by a clear (it keeps the old behaviour; a real Read re-establishes it). Sheet: `docs/wire-consumption/unreleased-894.md`.
42
+ - **headless.** Strictly more content delivered, never less, and no ask/approval lane is touched: a headless run that previously had to spend a `Bash`/`cat` call (itself subject to the read boundary, and refused outright on a headless lane where the boundary asks) now gets the body from `Read` directly.
43
+
44
+ ### Test-only — the live honest-miss probe judges the ANSWER, not the whole reply (#899; no product code)
45
+ - **Before.** The `live-real-model` honest-miss leg asserted `not.toContain("ZEPHYR-9142")` over the model's entire reply. A model that answered `NOT-IN-MEMORY` correctly and then explained what memory DOES hold — naming the planted codename entry as a side note — was judged red for behaving correctly. Measured on the 7.22.0 release candidate: three chained-run reds against two isolated greens on the same build.
46
+ - **After.** The negative half reads the answer: an unrelated entry named on the line that carries the verdict token is being offered as the answer (red); named on any other line it is a side note (green). Same class as the other probes whose assertions judged a correct model reply red.
47
+ - **Where.** `test/backlog899-answer-segment.ts` holds the predicate so a deterministic pin (`test/backlog899-answer-segment.test.ts`, six cases, no model call) tests it; the live leg asserts with the same predicate.
48
+
49
+ ### Fix — the symlink chain anchors a relative target with ONE separator, so a link under a root stops reporting a doubled spelling (#893; `canonicalChain` seam; @server adapters)
50
+ - **Before.** The walk welded a relative link target onto its link's directory with a separator of its own: `` `${dirname(cur)}${sep}${target}` ``. A link that lives directly under a root has `/` for a directory and already carries that separator, so `/tmp -> private/tmp` was reported as `//private/tmp`. Two independent implementations of the same seam answered differently for the same filesystem — a remote adapter that anchors through its own platform join answered `/private/tmp` — and one seam with two answers is the defect, not the spelling.
51
+ - **After.** One helper states the anchor: exactly one separator between the link's directory and its target, none added when the directory already ends in the platform's own. It stays UNFOLDED — `path.resolve`/`path.join` would fix the separator and fold `..` with it, and folding `..` across a symlinked component is precisely what this anchor exists to prevent. A trailing backslash counts as a terminator only where the platform's separator IS one: on a POSIX tree `/a/b\` is a directory whose NAME ends in a backslash.
52
+ - **Reach (fewer asks, never more).** The judges that walk this chain — the write-protection judge and the read deny set — match each hop against guarded rows and containment roots that are written with single separators. A doubled hop matched **nothing**, so the correction can only make a hop match a row it should always have matched: on a host whose root holds relative symlinks (macOS `/tmp`, `/var`, `/etc`), a chain that passes through one now reaches the judge as an ordinary path. No hop stops matching. Nothing becomes silent.
53
+ - **Headless / no-approval lanes**: a chain hop through a root-relative symlink that lands on a protected row now refuses there instead of passing unnoticed — the same direction as every other hop, and confined to hosts whose root actually holds such a symlink.
54
+ - **@server**: any remote `ExecutionEnv` adapter that implements `canonicalChain` should answer the same way — one separator, `..` unfolded. Core's own answer was the outlier, not the adapters'. (How many adapters implement it is a fact about the server tree and is not counted from here.)
55
+ - Pins: `test/backlog893-chain-root-anchor.test.ts` (6 cases: the helper per family, `..` survival, the two separators, a real-filesystem walk, the live root-symlink reading where the host has one, and the structural pin that exists because that last one is host-conditional).
56
+
57
+ ### Fix — a trailing separator run stops minting a second key for one path (#872; three families, one fold point; @cli @server re-pin)
58
+ - **Before.** `<dir>/adir`, `<dir>/adir/` and `<dir>/adir//` name one path. The arm that keys an EXISTING path folded them already, as a side effect of asking `realpath`; the arm that keys a path that does not exist YET rejoined the caller's own tail verbatim, so one not-yet-created path came back under two and three keys (measured: three distinct keys). A byte-wise reader — the per-file read state, the write gate's per-key ledger — treats two keys as two files, so a `Read <dir>/f` followed by an `Edit <dir>/f/` was refused for want of a prior read. The POSIX, drive and UNC shapes had it alike.
59
+ - **After.** One statement of the rule, at the ONE place a key is minted for a path the probe just found absent — so the entry spelling and every symlink hop target reach it alike. The question "may this run come off?" is asked in the module's own vocabulary (is anything left BELOW the anchor), so the three families need no arm each.
60
+ - **The fold runs on the ANSWER, at the canonicalizer's single exit, and never on a spelling about to be probed.** Two things break if it moves earlier, and adversarial rounds found both. (i) A trailing separator ASSERTS a directory and the platform enforces it: `<existing regular file>/` answers `not_directory`, which this tree reports as a refusal — fold before the probe and that refusal is spent, so the slash-suffixed spelling of a file resolves to the file and overwrites it. (ii) The ancestor walk chooses which ancestor to canonicalize by SPLITTING the spelling it was handed: strip the leaf's separator first and the leaf is skipped as though it had been resolved, so `<root>/link/` — a link onto a directory OUTSIDE the root — comes back as the in-root spelling instead of the destination, which is a containment key naming a file that is not there. Both are pinned as negative controls.
61
+ - **An ANCHOR keeps the separator the platform writes on a root** (`/`, `C:\`, `C:/`, `\\host\share\`): that run is the root's own spelling, not a tail nobody wrote, and stripping it would rename the spelling to nothing, to a DRIVE-RELATIVE location, or to a share root written unlike every other root — the last of which the previous window deliberately made uniform.
62
+ - **Interior runs are untouched and stay a stated limit** (`<dir>/missing//new.txt`): the ancestor walk folds them by probing where it can, and a run in the middle of a name is the platform's to read. Unchanged from 7.21.0, on both the UNC and the drive shape.
63
+ - **⚠️ ONE WIDENING, stated.** For a path that does not exist, `<dir>/newfile/` now keys as `<dir>/newfile`, and a write under it creates a regular file — where the platform answered `ENOENT` for the slash-suffixed spelling (measured). **No guard is bypassed**: the key is judged by containment, the sensitive-path list and the deny set exactly as `<dir>/newfile` is, which is a spelling the caller may always write directly. What is lost is POSIX's "a trailing separator means a directory" for a target that does not exist yet. The alternative — refusing a directory-asserting spelling whose target would be a file — is a narrowing nobody has asked for and is left as a follow-up rather than decided here.
64
+ - **Otherwise strictly fewer keys.** A spelling that used to mint its own now mints the one its separator-free twin already did; no directory changes side (containment compares identities, which folded these already).
65
+ - **Headless / no-approval lanes**: no change of verdict on any existing path; the read-then-write pair spelled inconsistently stops being refused for want of a prior read, and the one widening above applies there too.
66
+ - **Cost.** Both matches on this path are scans now, not regexes. `/[\\/]+$/` against a spelling with a long separator run before a non-separator is quadratic (measured: ~5s for 32k separators, on the caller's own thread); and the anchor guard asks `pathIdentityOf`, whose UNC arm stripped each segment's trailing dot/space run with `/[. ]+$/` — a second quadratic match (~1.2s for 32k dots) that **every containment check already reached**, not only this fold. A path spelling is unbounded input; both are backward scans, pinned by shape at the helper AND at the call site.
67
+ - Pins: `test/backlog872-trailing-separator-one-key.test.ts` (9 cases: the three spellings of an absent path, the negative control that `a` and `a/b` still differ and that interior runs are untouched, the drive/UNC families on the helper, the anchor rule, the hop arm, the relative twin, the directory-assertion refusal, the scan's shape, and the skip-the-leaf negative control).
68
+
69
+ ### Narrowing — every symlink hop passes the Win32 namespace screen the entry spelling already passed (#871; @cli re-pin on POSIX trees)
70
+ - **Before.** The screen ran once, on the spelling the model wrote. A link TARGET is a spelling this function has never seen, and it goes on to be probed exactly like one the model wrote — but it was carried straight to the environment. So a link whose target read `\\.\pipe\p` had a raw device object probed (opening one can hang the process), and an extended-length target (`\\?\C:\x`) was carried forward verbatim instead of being folded to the DOS path it names. The verdict was already fail-closed — `isBlockedDevicePath` catches the device case on the terminal key — so what was open was the PROBE in between.
71
+ - **After.** Each hop target passes the same `win32NamespaceScreen`, with the same family and the same refusal word, before anything is joined or probed. The screen normalizes a spelling and never admits one, so an extended-length hop is FOLDED and followed rather than refused, and the refusal that does happen is lexical: measured, zero environment calls name the target.
72
+ - **⚠️ This is a NARROWING on POSIX trees, and it is the throat's own.** A backslash is an ordinary name byte on POSIX, so a file whose name literally begins `\\.\ ` or `\\host\` is a legal file there — and reached through a link target spelled bare, it is now refused (`device_namespace` / `unc_on_posix_tree`). That is exactly what the same spelling already got when the MODEL wrote it (measured, pinned as the consistency case): the entry screen has refused those two shapes on POSIX trees since 7.18.0. The screen is anchored at the start of the spelling, so a name with a directory in front of it (`<dir>/\\.\pipe\p`) is not screened and still resolves, on the entry side and on a hop alike. One spelling, one word, wherever it is read.
73
+ - **The screen may REFUSE on any tree; it may only REWRITE on the tree whose namespace it is.** On a POSIX tree `\\?\C:\real\x` is an ordinary (if strange) FILENAME — backslashes are name bytes there — and mapping it to `C:\real\x` would name a different file. A hop target is therefore joined as the caller wrote it on a POSIX tree, and as the screen mapped it on a win tree, where the mapping is the point. **Stated, not fixed here: the ENTRY throat does rewrite that spelling on a POSIX tree** (measured), which is a rename of the same kind; it predates this change, it is shared by every lexical site, and it is left as a follow-up rather than changed from a symlink-hop ticket.
74
+ - **Cost and SCOPE, stated.** One lexical screen per hop — no filesystem call, no device probe; the probe it removes is the expensive one. The scope: this walk is only reached when `canonicalPath` could not answer, so a chain the environment's own `realpath` resolves is never read hop by hop and its intermediate targets are not screened — that call follows the links itself, before anything lexical can look at them. Screening those would mean reading every link before every canonicalization: a different design with a different cost, left as a follow-up rather than claimed here.
75
+ - **Headless / no-approval lanes**: a chain whose hop is a namespace spelling now refuses instead of probing; on a lane with no approver a refusal is the outcome either way, so what changes is that the raw device object is not touched on the way there.
76
+ - **@cli / @server**: a pin that expects a symlink whose target is spelled `\\.\…` or `\\host\share\…` to resolve on a POSIX tree now expects a refusal carrying the namespace word. No win-family behaviour changes except the extended-length FOLD, which is a widening there.
77
+ - Pins: `test/backlog871-hop-namespace-screen.test.ts` (6 cases: the device hop with its probe count, the model-spelled twin as the consistency control, the extended-length fold, the POSIX word plus the anchored-screen asymmetry, the ordinary-hop negative control on both families, and the refuse-but-do-not-rewrite rule).
78
+
79
+ ### Test only — live evidence for the write-protection ask sentence (#842)
80
+
81
+ The table judge's ask message IS the `askMessage` an auto-mode classifier is handed, so the target/chain
82
+ sentences added when the judge gained its resolved-target view changed what a real model is asked — and
83
+ nobody had ever put the spellings in front of one. New `test/live-write-protect-ask.test.ts` (the four live
84
+ env vars, run through `scripts/run-live-gate.mjs`) classifies ONE fixed covered write under all three
85
+ minted sentences, read off the mint rather than re-spelled, and asserts (a) each yields a parseable ruling
86
+ and (b) the three agree. A disagreement is the disclosure #842 asked for, not a test bug, and the failure
87
+ message names which sentence moved. Measured 3/3 green on `deepseek-v4-flash`. No product change.
88
+
89
+ ### Additive — an operator notice for a write floor running on half a judgment (#869; notice code +1; @client-core mirror, @server @cli forwarders unchanged)
90
+
91
+ #868 turned "the environment could not resolve this target" from a refusal into an absent view. The refusal
92
+ was also the only way a deployment learned that its environment cannot answer path questions, so this code
93
+ replaces it as the signal. `EngineNotice.code = "config.write_protection_unresolved"`, audience `operator`,
94
+ minted at the leg's write-protection mount (`src/core/runner/write-protection-station.ts`, the same seat
95
+ that owns the sibling `config.write_protection_target_view_absent`). `detail: { reason, cause, sessionId,
96
+ runId }` with `reason` ∈ `"unresolved-symlink"` / `"unresolvable-target"` and `cause` the environment's own
97
+ sentence. ONCE PER LEG PER REASON, carrying no call identity — the fact is about the ENVIRONMENT, not about
98
+ a write, and one broken environment spells the same failure differently for every path it is asked about.
99
+ Announce, never narrow: no verdict moves because of the notice. `ENGINE_NOTICE_CODES` gains one member.
100
+ NOT covered by this code: a judgment the gate's bounded wait gave up on, which is still reported on the
101
+ hook-error lane (`onHookError`) — that one is the gate's fact, not the environment's.
102
+
103
+ ### Widening — a write-protection view that cannot answer no longer votes, on either arm (#868; @server headless range below, @cli, @client-core)
104
+
105
+ The always-on write-protection judge was one grade stricter than the reference implementation whenever a
106
+ target could not be RESOLVED: the allow arm refused the write outright, and the standing-ask arm marked the
107
+ ask with a word (`write_protection_unresolved`) no automatic lane may answer. The reference builds its
108
+ variant set by guarding every filesystem primitive of its walk and asking its table about the variants it
109
+ ended up with — a resolution that fails contributes nothing and the lexical variants still judge. Both arms
110
+ now follow one law: **a view that cannot answer does not vote**, and the judgment is what the views that did
111
+ answer said. The LEXICAL view always answers; the ENVIRONMENT views (the resolved real target, the leaf's
112
+ symlink chain) are independent contributions, and each of the ways one can fail — the canonicalizer refusing
113
+ the spelling, a link the tree cannot follow, a namespace it declines, an environment call that throws —
114
+ makes that view absent rather than refusing the write.
115
+
116
+ RANGE, as a conjunction of three conditions: the call is a covered write (`Write`/`Edit`/`NotebookEdit`
117
+ face), its SPELLING misses every row under every reading, AND the environment fails to resolve it. All three
118
+ ⇒ what was a hard refusal is now judged by the spelling alone. Any one absent ⇒ nothing moved: a spelling
119
+ that hits a row still asks; a resolvable environment is byte-for-byte unchanged. **Headless / no-approver
120
+ lanes (@server, deployment operators): a covered write that the environment cannot resolve now EXECUTES
121
+ where it was previously refused.** That is the reference's own posture for a default-ON friction floor —
122
+ refusing every write an environment cannot resolve refuses most writes on exactly the deployments whose
123
+ environments answer least — and it is deliberately NOT extended to the opt-in sensitive-path DENY set,
124
+ whose unresolvable target still refuses (`write_protection.judges_the_real_target` now states where the two
125
+ part and why).
126
+
127
+ What still refuses: the judgment that never happened at all. The gate's bounded wait elapsing or the task
128
+ ending mints `unresolvable`, which denies on both arms and keeps `write_protection_unresolved` reachable —
129
+ a wait that produced nothing has no lexical answer to stand on either.
130
+
131
+ New compile seat `WriteProtectionCompileOptions.onViewAbsent?: (absence: WriteProtectionViewAbsence) => void`
132
+ (additive; absent ⇒ the absence is silent), fired at most once per judgment on the hit arm as well as the
133
+ clear one. New exported type `WriteProtectionViewAbsence { cause, unresolvedSymlink }`.
134
+
135
+ ### No behaviour change — the write-protection arm's two asymmetries with its deny-set sibling, stated (#839)
136
+
137
+ Two places where the always-on write-protection table and the opt-in sensitive-path deny set answer
138
+ differently were carrying their difference implicitly. Neither moves a verdict; both now say what they are.
139
+ (a) WHAT THE ARM WRITES: the post-fold tighten was gated on an enumeration of the case it skipped ("not
140
+ over a standing ask") and now states the law positively and totally — it replaces a surviving `allow` and
141
+ nothing else, every other standing decision keeping its own action, message, provenance and disclosures
142
+ while the judge's finding is still RECORDED as the mandate the rule and classifier lanes read. The
143
+ alternative reading (compose under the gate's `deny > ask > allow` order, so the judge's refusal outranks a
144
+ standing ask, matching the sibling guard) was implemented, measured and refused: it retires the only
145
+ reachable home of the `write_protection_unresolved` mandate word, which exists for exactly that ask. The
146
+ asymmetry is intended — the sibling is a POLICY and composes under the fold; this is a post-fold TIGHTEN.
147
+ (b) WHO IS JUDGED: membership in this table is a predicate over the CALL (a face declaring a non-read path
148
+ target) with deliberately no name-list seat beside it, unlike `createSensitivePathPolicy({ tools })`. New
149
+ contract sentence `write_protection.membership_is_the_call` carries the argument: a name list on a
150
+ default-ON floor narrows that floor one tool name at a time and silently, while this layer's narrowing seat
151
+ is the whole-table replacement, which says what it removes. No seat was added.
152
+
153
+ ### Narrowing — the write-protection segment fold reads an INVISIBLE component and an NTFS data-stream suffix (#841)
154
+
155
+ The always-on write-protection table judged path components literally apart from its case fold, so two
156
+ spellings that name a protected component on a real filesystem were cleared: one carrying an invisible
157
+ format character (`.ss<U+200C>h/authorized_keys`, a right-to-left override in front of `.bashrc`) and one
158
+ hiding the component behind an NTFS data-stream suffix (`.bashrc:evil`, which names `.bashrc` on win32).
159
+ The reference implementation's own segment fold strips both; sema now does too, on the two axes they
160
+ actually belong to — the invisible strip is on the ONE segment fold and therefore applies to every reading
161
+ and to BOTH sides of the comparison (a deployment row carrying one folds onto the plain spelling, and two
162
+ rows differing only by an invisible are one row), while the data-stream strip belongs to the win32
163
+ NAMESPACE reading alone, because on POSIX `a:b` is a legal filename that keeps its own identity. A strip
164
+ that would EMPTY a component keeps the component. NARROWING, one extra question at most: these spellings
165
+ move from `allow` to the table's `ask` (headless: from executing to refused, as every table hit is there).
166
+ The data-root passthrough is deliberately untouched — its comparison is byte-exact, and there an added
167
+ match would be a guard bypassed rather than a spare question. `foldWriteProtectCase` is renamed
168
+ `foldWriteProtectSegment` (module-internal; not part of the package's public export surface).
169
+
170
+ ## 7.22.0 — 2026-09-18
171
+
172
+ ### Additive — an in-session RE-DIAL for one named MCP server, on the run handle (#857; no model face; @server S-381 @client-core CC-30 @cli L-276)
173
+ - **Before.** Core holds no MCP connection across task legs, and `MaterializedMcp.refresh` may not dial — its contract sentence is "never dials or re-dials". So once a server's transport closed, the leg was stuck with it for good: every one of that server's tools failed fast, `RefreshMcpTools` reported `not_connected` forever, and nothing a host could call would bring it back. The only recovery was to end the task and start another one.
174
+ - **After.** `TaskStream.reconnectMcpServer(server)` re-dials ONE named server on a live run: it closes whatever handle that server still has and dials it again through the SAME `connectServer` the materialization used (one intake pipeline, not two — the per-server commit that makes a connected server live is now shared with the merge stage rather than copied), then swaps the server's whole `mcp__<server>__` prefix domain on the live roster through the SAME applier `RefreshMcpTools` uses. `MaterializedMcp.reconnect(server)` is that verb's implementation and is reachable directly by a library caller that materialized MCP itself. `refresh` is untouched, contract sentence included — with the re-dial reachable only from the host, "never dials or re-dials" stays true of it, and the negative control (a dead server still refreshes `not_connected`) is pinned.
175
+ - **One named server, required.** There is no all-servers form: the reference CLI's control-channel request (2.1.259 `mcp_reconnect`) carries a required `serverName` and has none either, and an all-servers arm would CLOSE HEALTHY connections to re-dial them — a stdio server that owns a device or a port, which is the case this verb exists for, is exactly the one that cannot be re-dialed while its predecessor still holds the device.
176
+ - **⚠️ A FAILED RE-DIAL LEAVES THE SERVER DOWN — stated, not implied.** The old connection is closed BEFORE the new one is dialed (no other order works for a device-owning server), so a dial that fails has spent a working connection: the server stays disconnected, **with its tools withdrawn from the model's roster** (`tools: []`, the empty roster assertion), until another re-dial or the next leg's materialize. Calls in flight on the replaced connection fail with it. Both facts are inherent to re-dialing and are the reference CLI's behaviour too; a control that offers "Reconnect" on a HEALTHY server is offering that trade.
177
+ - **Three outcomes, mapped.** `reconnected` / `refused` (disposed materialization · revoked server, which is never contacted · a re-dial already in flight · the dial or first listing failed — `reason` says which, `status.errorCode` keys the next action) / `not_declared`. The verb THROWS rather than answering a fourth word: `mcp.reconnect_unavailable` when the run declared NO MCP servers (a host maps that to "unsupported", not "refused"), `steering.invalid_content` on a missing or non-string name, and the steer-family `steering.not_running` once the task has finished. `McpReconnectResult.tools` is present IF AND ONLY IF the result asserts a roster for its prefix domain — the one rule the applier reads, and the new contract sentence `mcp.redial.roster_assertion`.
178
+ - **No model face.** There is no `ReconnectMcpServer` tool and the `RefreshMcpTools` description is unchanged. The reference CLI has no such tool either (its reconnect lives on the SDK control channel and the interactive panel), and a verb that can leave a connection down is a host act. **Headless / no-approval lanes are unaffected: no model in any permission mode can reach this.** A deployment that relays the verb to a third party is granting that party the power to take an MCP server down mid-run — relay IS authorization, as with `steer`'s `priority`.
179
+ - **Existing faces.** `MaterializedMcp.statuses[name]` is REPLACED by a re-dial with the new connect verdict (the "never rewritten" sentence meant *a death does not rewrite a success*; a stale `transportClosed: true` on a server that is live again would be a lie). `instructionsDelta` is RECONCILED rather than diffed: a re-dial withdraws this server's unread entries and then states its own outcome — text is one add whose text supersedes, a FAILED re-dial of a server that was shown some is one removal, and a SUCCESSFUL re-dial of a server that now advertises none is **nothing at all**. Two reasons, both stated: whether the removal a transport death queued was already DELIVERED is not knowable inside the engine, and the removal lane's own frame says the servers *have disconnected* — false of a server that just came back. The costs of not guessing: a re-dial with byte-identical text costs one redundant re-announcement, and a server that returns advertising no instructions leaves the model holding text it no longer advertises (exactly what a refresh has always left — it never touches instructions at all). `refresh` keeps its contract, and gains ONE new `failed` reason: a refresh spanning several servers awaits each listing in turn, so a re-dial landing during one of those waits leaves an already-listed server's entry holding a closed client — that entry is downgraded to `failed` (no `tools`) so the roster keeps what the re-dial made. `dispose` does NOT wait for a dial in flight (the startup timeout bounds the initialize request, not the whole connect, and waiting would let one unreachable server hold a task's teardown open unbounded); the dial closes what it dialed instead — **a stdio child can outlive `dispose()`'s resolution by the length of that attempt, closed and never leaked**. The flat `tools` / `toolAxes` / `serverInstructions` arrays are the materialize-time roster and are NOT rewritten (nor were they by a refresh — documented rather than changed; the result carries the current roster and the runner's live roster is the authority). The global `ListMcpResourcesTool` / `ReadMcpResourceTool` now route through the LIVE server array instead of a build-time filtered copy, so a re-dialed server's resources follow the new client; absent a re-dial this is byte-identical. Stated residual: a server that GAINS the `resources` capability across a re-dial acquires no route this leg, and one that LOSES it keeps a route whose calls the server itself refuses.
180
+ - **Disclosure.** One new notice code, `mcp.server_redialed` (`ENGINE_NOTICE_CODES` +1, audience `operator`), `detail: { server, outcome, added, removed, reason?, sessionId, runId }` — `added`/`removed` are COUNTS, never the server-authored tool names. ONE code for both directions: the outcome is data about one event, not two kinds of event. Dedup unit: once per ATTEMPT, never folded per run (two attempts on one server are two different answers, and folding the second would hide the one that says the server is still down). This re-dial's own operator warnings (an incomplete listing walk, a kept-but-defective tool schema) ride the result and are forwarded to `onError(phase:"mcp")` — the array on the handle is drained once, at prepare.
181
+ - **Exports (additive, no removals).** `McpReconnectResult`. Pins: `test/backlog857-mcp-redial.test.ts` (9 cases over REAL stdio MCP subprocesses — a killed server re-dialed with a changed listing, the refresh negative control, the failed-dial withdrawal, the revoked and in-flight arms, the leg-local rule, and the verb over a real `Runner` observing the MODEL's own tool list before and after).
182
+
183
+ ### Widening (mandated ask → allow), the names-only half of the recursive-read arm — `find` / `ls -R` / `du` / `tree` no longer carry a card no rule can retire (#862 ①; both faces, both seats; @cli re-pin)
184
+ - **Before.** The demotion fired for every verb in the recursive-read form table under a wired deny judge, so `find . -name '*.ts'`, `ls -R` and `du -sh .` were MANDATED asks on all three (`tree` is not on the read-only allowlist and was never a card here: `run` on the default doctrine, a plain classifier ask on `classify`, unchanged by this change) (seat × face) combinations — a card with no "don't ask again" on it, refused outright on a headless lane — while the engine's own read-only proof of those same commands says they are read-only.
185
+ - **After.** The table gains a `discloses` column and the demotion reads contents-disclosing rows only. The hazard the arm answers is a walk that hands the CALLER the bytes it walked over, which the deny judge is structurally unable to pre-check; a walk that discloses names and sizes is a different disclosure with a different answer. The reference implementation draws the same line, as a verb set. The names rows stay in the table: a row is a decision, and a verb deleted from it reads as one nobody considered.
186
+ - **Reach.** Three everyday commands (`find`, `ls -R`, `du`) go from a mandated card to zero asks on every deployment; `tree` is unmoved. Headless lanes: three fewer refusals. @cli: a pin expecting a card for `find`/`ls -R`/`du`/`tree` under a wired deny judge is now the zero-ask spelling.
187
+ - **`tar`'s create mode is NOT among them, and the reason is worth stating**: it was filed as names-only on the ground that an archive is a file, and `tar -cf - .` writes the archive to STDOUT, which the tool hands straight back. Where the bytes go is not always a property of the verb, and reading `-f` per invocation is a per-program option model this zero-IO pass does not carry — so the row resolves demote-ward and `tar c` is a contents row. **Combined with the program-reading change below, `tar cf out.tar src` now asks on every deployment with a wired deny judge** (before: only where a deployment had allowlisted `tar`). @cli re-pin.
188
+
189
+ ### Narrowing (allow → mandated ask), the other half of the same arm — a recursive CONTENTS walk is judged by the program bash runs, not by whether the deployment allowlisted its name (#862 ①; @cli @server re-pin; headless lanes: one more refusal per spelling)
190
+ - **Before.** A traversal tool the deployment never allowlisted is judged under the synthetic scan head, so the recursive question was being asked about `cat`. `rg <pattern> .`, `ag <pattern> .` and `ack <pattern> .` therefore walked the very trees `grep -r <pattern> .` was stopped for and auto-allowed — one security property with two answers, decided by whether a name is on a list. `egrep -r` / `fgrep -r` had the same hole for the opposite reason: they are the grep program under two other names and the form table carried only the `grep` spelling.
191
+ - **After.** The arm reads the program the chain names (the same `slotVerb` the option-slot rows already travel with), and `egrep`/`fgrep` share grep's row. A deployment's allow list says which programs may RUN; it never said what a program does with its arguments, which is the only claim the form table makes. The program's own name under the synthetic head is argv[0] and is no longer offered as a traversal root.
192
+ - **Reach.** `rg <pat> <dir>`, `ag …`, `ack …`, `egrep -r …`, `fgrep -r …` and `tar c …` go from zero asks to a mandated ask **only where a deny judge is wired with entries** — a deployment that selected a built-in tier, or one that mounts its own `readDenyPatterns`. The shipped default selects no tier (see the built-in read-deny switch below), so an unconfigured deployment. Headless lanes: those spellings are refused where they used to run. The account travels too: the recursive-reach cause is now minted on the arm where the segment's own sentence is a name refusal, so the card no longer explains the mandate with the wrong fact. @cli: expect a card (and a `shell.recursive_read_unbounded` probe cause) on those spellings; @server: no wire key changes.
193
+
194
+ ### Additive seat (`BashReadonlyRootBoundary.denyEntries`) — the recursive arm asks whether the wired judge has anything to apply (#862 ①; absent ⇒ the reading every caller had before)
195
+ - The engine's own wiring feeds the seat from the SAME compiled matcher it feeds `denyMatch` from. **Empty array ⇒ the judge refuses nothing** (every built-in tier deselected and no additions — a legal posture), so a traversal under it walks past nothing and no finding is minted. **ABSENT ⇒ undeclared, which is not "none"**: the walk is reported, byte-identical to before. It is deliberately NOT the question the reference implementation asks at the same place — there a deny rule is ROOTED and "is the guarded place inside this traversal?" is answerable without touching the filesystem, while these patterns match a run of segments ANYWHERE in a judged path, so that question has no lexical answer here. What remains answerable is whether the set is empty. Registered as divergence candidate `recursive-read-demote-contents-only` (the two preconditions sema can implement, and the one it cannot).
196
+ - **Stated, unchanged reach:** `grep -r <pattern> .` still carries a mandated card on every deployment that keeps the built-in deny table, because the table IS that deployment's set of Read deny rules and the reference implementation asks in the equivalent configuration. The ways out are the deployment's: `readDenyBuiltinTiers` / `readDenyBuiltinExclude`, or the `ExecutionEnv` the command runs in (`shell.read_boundary.friction_floor`).
197
+
198
+ ### Widening (fewer phantom path candidates), `find`'s predicates — a single-dash WORD is not an option cluster (#862 ②)
199
+ - **Before.** `find . -name '*.ts'` offered `<cwd>/-name`, `<cwd>/ame`, `<cwd>/me`, `<cwd>/e` and `<cwd>/*.ts` as path candidates beside the one root it names: the verb-blind payload scan reads `-name` as the option letters `n a m e` and offers every suffix. Those candidates are not free — they are the input to the full shell's execute-leg canonical recheck, which is where a workspace holding a same-named escaping link turns one of them into a refusal.
200
+ - **After.** `VerbOptionSlots` gains `dashWordValue` / `dashWordPlain` (a token that is a dash followed by more than one character is read against the row's word maps instead of split into letters) and `optionsArePositionIndependent` (the both-readings rule past the first operand answers GETOPT's permute ambiguity; find's grammar is `find <paths…> <expression…>`, so every predicate stands past the first operand by construction and reading each as a filename manufactures a candidate per predicate). `find` gains a row whose value kinds follow the reference implementation's own find parser: its file-valued predicates (`-newer*`, `-samefile`, `-path`/`-wholename`/`-lname` and the `i` twins) are judged as paths; `-name`/`-iname`/`-regex` values are name patterns find matches against names it already walked to and never opens; `-maxdepth`/`-size`/`-type` values are consumed and are no longer `<cwd>/2`.
201
+ - **One row, two grammars, and the rule that covers the overlap.** `find`'s own manual carries a getopt option section (`-H`/`-L`/`-P`/`-f <path>`) AND an expression of single-dash predicates, and where a predicate's first letter is a value-taking short letter the token has two readings: `-fstype <x>` is a primary and `-f stype`, `-follow` is a primary and `-f ollow` — and one of those readings TRAVERSES `ollow`. Three adversarial rounds each found the next token in that overlap, so the rule is the table's own disputed-arity rule read one place further: **a dash-word whose first letter is a value-taking short letter of the same row declares nothing**, and the verb-blind scan reads it exactly as before. `-f` is the only such letter find declares, so the words affected are exactly the `-f…` ones; the cost is a few phantom suffix candidates on three predicates, all in-root.
202
+ - **Reach.** `checkedPaths` for a `find` call shrinks to the roots it names plus its file-valued predicate values — visible to anything reading that list (a disclosure face, the execute-leg recheck). A predicate no row declares still falls to the verb-blind scan; the containment answer for a real path behind one is unchanged.
203
+
204
+ ### Widening (mandated ask → allow), the everyday wrappers — a launcher row is one declaration with all its spellings (#862 ③; @cli re-pin)
205
+ - **Before.** The launcher table declared options for `time` alone, so `nice -n 5 npm test`, `env -u FOO npm test`, `env -i npm test`, `stdbuf -oL npm test`, `command -p npm test` and `timeout -k 5 300 npm test` were refused WHOLE and mandated — the program behind the wrapper was unreadable. `time --format=%e …` was refused by the table that declares `--format`: it knew only the exact spelling, so one program read as two.
206
+ - **After.** `pastLauncherOptions` honours the three spellings of an arity-1 declaration (separated, `--name=value`, `-Xvalue`) and the two of arity-0 (exact, a cluster of declared switches), and `nice` / `env` / `stdbuf` / `timeout` / `command` gain rows taken from the reference implementation's own wrapper-stripping table (plus `nice`'s traditional bare `-N`, declared per row). `command -v` / `-V` are deliberately absent: they make the call a LOOKUP that prints a name and runs nothing.
207
+ - **Reach.** Those spellings go from a mandated card to the verdict their unwrapped command has (zero asks for `npm test`); headless lanes: that many fewer refusals. A row makes the program READABLE and vouches for nothing — `nice -n 5 cat /etc/passwd` still mandates, with the operand's own reason. The fail-closed half is unchanged: a flag no row carries, a cluster with one undeclared letter, and an arity-1 option with nothing left to take all still refuse.
208
+ - **Fixed on the merged tree (two adversarial rounds).** A launcher's REQUIRED positional operand is its own word whatever it spells: `timeout DURATION`, `chrt PRIO`, `taskset MASK`, `flock FILE`, `chroot NEWROOT`, and the USER operand of `su` / `runuser` (which run a shell, not a program named after the user) now declare `requiredOperands: 1` on their row and the chain walk consumes it before reading the program; the spelling-based bare-number rule (integer durations only, then decimal fractions) is deleted. Before, a duration the rule did not recognise became the PROGRAM — `timeout 1.5 grep -r <pattern> <dir>` (and `1e1`, `+1`, `0x1p0`, `1e-1s`) under a deny judge walked a deny-listed file with zero asks, and `flock <file> grep -r …` read the lock file's name as the program (the out-of-root twins were still caught by containment). An option-shaped word at the operand slot still refuses as an undeclared flag. The same table now declares three more grammar dimensions the fourth round reproduced: `env`'s own assignment names (`env 1=x grep -r …` — `1=x` is env's operand, not the program), shell-TEXT launchers (`watch "cat /etc/passwd"`, `script …` hand their operands to a shell: no program word, refused as control syntax on both seats — before, the quoted text read as a program named after its last path segment and the read inside it was zero-ask), and implicit-shell launchers (`su USER`, `runuser USER`, `chroot NEWROOT`, `nsenter`, `unshare`, `pkexec` with nothing to run start a shell that piped text would reach — an empty program position now refuses instead of reading as none). A fifth round added `sudo`'s own assignment names, `su`/`runuser` as shell text in every form (a later `-c` included), and `firejail`'s implicit shell. Narrowing: `watch …`, `script …`, every `su`/`runuser` form and the bare implicit-shell forms take a card where they used to run. **Stated limit:** these rows are declared from the launchers' upstream manuals and were extended over five adversarial rounds on the merged tree, each finding another undeclared dimension; the table has no mechanical falsification leg against the real binaries yet (a registered design item), so a launcher not in the table, or a dimension not declared, still reads the word after it as the program — the failure direction is a zero-ask read behind that launcher under a deny judge, or of quoted shell text. Pinned at the seat for twelve duration spellings plus the three other rows, and once end-to-end on a real Runner. Stated over-fire: the walk judges a spelled absolute path wherever it stands, so `flock /tmp/lock …` still takes the containment card for the lock file's path (an in-root lock file is zero-ask) — the launcher's own semantics are not modelled, and that is the safe direction. A program word is matched to its row on its BASENAME, as the reference implementation matches its wrapper set, so a path-spelled local executable whose basename is a shell-text row (`./script`, `./watch`) takes that row's shell-text card — mandated, as `sh` itself is — while `./script.sh` does not; stated, measured on this tree. Found by the release-candidate run and fixed the same way: a row's declared options were read only at the HEAD of a chain, so `env timeout -k 5 300 npm test` / `sudo nice -n 5 npm test` refused the program as unreadable; the declaration now applies wherever the launcher stands. The falsification round on that fix then found the chain skipping an assignment-shaped word behind ANY launcher: behind a row with no assignment grammar of its own (`nice FOO=1 …`, `timeout 5 FOO=1 …`) that word is now the program word, as the binary execs it; `env` / `sudo` keep owning theirs, and the argv re-entry program ends that ownership too (`env xargs FOO=1 echo` execs `FOO=1`).
209
+
210
+ ### No behaviour change, one renamed reading — xargs targets stay refused, and the reference implementation's list is pinned as the thing NOT imported (#862 ④; zero reach, stated because the ticket asked for the opposite)
211
+ - **What the ticket asked for.** The reference implementation's read-only table accepts a bounded xargs target list (`echo printf wc grep egrep fgrep head tail`), so that `git ls-files | xargs grep -l TODO` and `find … | xargs wc -l` would stop carrying a card no rule can retire.
212
+ - **It was imported and reverted, with the counter-example pinned.** `echo <path outside the roots> | xargs head` reads that file at ZERO asks: the producer's COMMAND TEXT says nothing about the producer's OUTPUT — `echo` writes text, and the text becomes a filename nobody judged. `cat paths.txt | xargs head` is the same shape one indirection further away. That implementation ships no built-in read deny table and leaves out-of-root reads unblocked by default, so the list costs it nothing; here it hands back exactly the containment and deny coverage this boundary exists to provide.
213
+ - **After.** `argvReentryUnjudged` takes the set of ACCEPTED TARGETS from its caller rather than reading "programs taking no path operand" itself (one reading, the caller's declaration), and the classifier declares that set as the one property that answers the question: the target cannot open a file it is handed. **Behaviour is byte-identical to 7.21.0** — `| xargs echo|basename|dirname|tr` accepted, every file-opening target refused whole, `git ls-files | xargs grep` still carded. The everyday cost stands until the operands themselves can be established, which is the same unsolved half the recursive-read arm records.
214
+ - **One member DID move, and it is disclosed here rather than left to the section title.** The accepted stream-target set is the same identifier the unlisted-program change extended (`NO_PATH_OPERAND_COMMANDS`, which gained `printf`), so `… | xargs printf` goes from a mandated card to zero asks on the default doctrine (a plain classifier ask on `classify`). `printf` opens nothing it is handed, so the widening is safe; the title's "no behaviour change" holds for every other target.
215
+
216
+ ### Widening (silence → one disclosure) — the undrained-input account settles AFTER the reopen compensation, on what it DID (#808; @server @cli @client-core: one more `task.user_steer_undrained` on a class of resume legs)
217
+ - **Before.** A resumed leg carries the checkpoint row's parked steers until its continuation reaches the transcript; when the leg ends before that, whether a person LOST them depends on whether the settle lane reopened the consumed row (queue intact ⇒ the next resume delivers them). The account was closed one lane EARLIER, on a prediction of that outcome — "no new park and the approved action never started ⇒ the row comes back" — and the compensation excludes three endings the prediction calls a return: a DELIVERED negative decision (a deny carrying a reason), a store that wired no reopen, and a reopen that FAILED. In each, the row stays consumed, the frames are gone, and the account said nothing at all. (#554 fixed the other half of this with that predicate; this is the half that was ordering, not predicate.)
218
+ - **After.** The adoption lane CARRIES the account (`OwedUserInputAccount`: the held counts, what the park migration took, the continuation frames and their carried count) and the settle lane closes it — once — immediately after the reopen block, with the reopen's ACTUAL outcome. The counting rule is unchanged (`rowReturned` replaces `rowMayReturn` as its third input): a returned row excuses only the frames the STORE minted a `seq` for; the caller's own frames were never on a row and are owed either way. One notice per family per run, unchanged — now exactly-once by construction (the account object carries that fact) and never-throwing, so the lane's teardown closes it as a BACKSTOP when the lane threw before reaching its own settlement (adversarial rounds: result assembly can throw on a thrown value it cannot render, and the relocated disclosure would otherwise be lost with it). That backstop reads the row through the half of the downstream handler's own reopen eligibility it can see ("the approved call never started"), which leaves the exceptional path exactly as loud as it was before the settlement moved — in both directions, and with no new claim this station cannot support. KNOWN RESIDUAL (ticket): the end-of-task pass runs one lane earlier still, so a rejection there closes no account at all; the form that fixes it carries the account into the stream's own rejection handler and settles on the outcome that handler establishes. The #554 predicate that predicted the reopen is deleted — the fact it approximated is now read where it is known (regarding `suspendProgressRef.executedApproved`: it stays, its other reader is the suspend-chain base).
219
+ - **Reach.** A resume leg that ends before delivering its continuation now announces the row's frames unless the row really came back: `task.user_steer_undrained` (count ≥ 1) where the run was previously silent. Reachable shapes: a deny-with-reason resume interrupted mid-settlement, an approved call vetoed on resume by a session rule imported while it was parked, a store whose reopen throws. Consumers that COUNT these notices per run see one where they saw none; no new code, no detail-key change, no closed set touched. Headless / no-approver lanes: identical (this is a disclosure about a person's queued input, not a gate decision). Pinned in `test/backlog808-undrained-after-reopen.test.ts` (three run-level rows + the two arithmetic branches), with the ordering itself pinned in `test/backlog554-resume-batch-settlement.test.ts`.
220
+
221
+ ### Attribution (no behavior face on the verdict) — the classifier's QUESTION is built on its own seat, outside any rung's cap (#807; @server @cli read the `auto_mode.classified` frame)
222
+ - **Before.** One `classify` hook did both halves of a classification — build the transcript window, then dispatch it — and ONE cap (`RunnerDeps.autoMode.timeoutMs`, default 15 s) covered both. A slow session store therefore landed as the RUNG's `timeout`: a record stating that the classifier model did not answer in time for a classification in which no model had been asked anything, and — on a two-rung ladder — a `classifier.fallback` naming a move to the main model, with `cause: "timeout"` attributed to a first rung that was never dispatched to. The gap was stated in the code and is now closed.
223
+ - **After.** `AutoModeDeciderOptions.assemble` builds the question ONCE per classification, before the walk, on its own turn of the same `timeoutMs` (no new knob). A build that throws OR outruns the cap is the round-not-asked fact the `auto_mode.round_not_asked` marker already named: fail-closed `unavailable` / `cause: "error"`, NO rung dispatched, NO `fallback` on the record, attribution pinned to rung 0 — exactly what a build that threw already did. The rung cap now bounds a DISPATCH alone, which is what makes its `timeout` word true. The `AUTO_MODE_UNAVAILABLE_CAUSES` closed set is UNCHANGED (`error | timeout`): "the question did not assemble" and "the leg threw" are one fact for a consumer — nothing was dispatched — and splitting them into two cause words would be two answers to one question. WHICH way the build failed rides two additive optional members instead: `AutoModeClassified.notAsked` and the `auto_mode.classified` trace frame's `notAsked: "failed" | "timeout"` (absent ⇒ a model WAS asked). Both armings follow the same law — the live one (`prepare-caps-and-workflow`) and the cross-process rebuild (`rebuildAutoModeDecider`, whose `transcript()` read was inside the leg's cap the same way).
224
+ - **Reach.** Worst-case classification latency grows by at most one cap (assembly), and the per-rung dispatch budget is unchanged. Operators: a run of `unavailable` / `error` classifications now says whether to look at the transcript store (`notAsked`) or the provider (no `notAsked`). Headless / no-approver lanes: unchanged — every one of these classifications fail-closed to a deny before and after; what changed is the record's attribution, not the verdict. A deployment that wires no `assemble` seat keeps assembling inside `classify`, inside the rung cap, byte-identical. Pinned in `test/backlog807-classifier-build-cap.test.ts` (five decider rows + two through the arming the gate is actually handed), each with its mutation.
225
+
226
+ ### Additive export — the auto-consolidation arming resolver reaches the package root (#863 W1; @server @cli @client-core)
227
+ - **Before.** `autoRunOnRecommendationEffective` was module-local. A host rendering "auto-consolidation: armed / not armed" beside a session's memory state could only reach the SEAT (`RunnerDeps.autoRunOnRecommendation`), whose ABSENCE is not "off" — it reads `AUTO_RUN_ON_RECOMMENDATION_DEFAULT`, and only where `memoryConsolidation` AND a resolvable consolidation model seat are both wired. A seat-only reader therefore answered wrong in both directions: a default-armed deployment reported unarmed, and a deployment that wired no consolidation reported armed.
228
+ - **After.** `autoRunOnRecommendationEffective` is re-exported from the package root — the SAME function the prepare door's manifest fact and the settle-tail trigger call, never a second derivation (`memory.auto_consolidation.arming_has_one_resolver`). Exports +1/−0.
229
+ - **Reach.** Zero behaviour change (no call site moved). @server: this is the function to compose a memory-status `armed` cell from — its answer is a DEPLOYMENT fact, so it takes `RunnerDeps`, not a session. @cli / @client-core: render the arming from the composed cell, not from the seat.
230
+
231
+ ### Narrowing (a floor, not a default) — a task that declares memory OFF hands that fact to every child, grandchild and revived cycle (#863 W3; @server @client-core)
232
+ - **Before.** `TaskSpec.memory.enabled === false` governed the declaring task only. A child got its memory from an agent definition's own `memory` (`Agent`/`Task` lane) or from its own spec (workflow lane), so a task that had turned memory off could delegate to a definition that turned it back on — the child mounted the `# Memory` instruction, the three `memory_*` tools and the memory home as an advertised writable root, and its harvest committed notes the declaring task had asked not to have. The same hole on the revival lane: a run whose own task declared memory off could wake someone else's retained child into a memory-mounting cycle.
233
+ - **After.** The fact rides the parent SPEC seat as `ParentSpecSeat.memoryOff?: true` (minted ONLY from an explicit `enabled: false` — an absent `memory` is silence, not an off statement), and `applyParentSpecSeat` stamps it over the child's own declaration as the third unconditional REMOVAL beside `oneShot: true` and `memoryPersistenceCapable: false`. It REWRITES rather than deletes: the child keeps its own scopes and gets `enabled: false` (a child with no declaration gets the bare `{ enabled: false }`, which the normalizer reads exactly like the absent spec it replaces), because the next mint reads `spec.memory.enabled === false` and a deleted `memory` would end the floor one level down. The revival lane gains the matching clamp (`currentClamps.memoryOff`), filled on the cross-run `SendMessage` wake from the sender's own seat; the spawn snapshot's declaration already rides the snapshot.
234
+ - **Reach.** OFF means the memory FACE is not mounted — no `# Memory` section, no `memory_*` tools, no memory home among the run's advertised writable roots. It is NOT a filesystem prohibition: a memory-less run has no memory write gate at all, so a memory root inside an authorized workspace stays reachable by the ordinary file tools under the ordinary fence. A deployment that ALREADY spelled `memory: { …, enabled: false }` on a parent sees the narrowing today: its children's own `memory` declarations no longer mount. Type surface is additive (`ParentSpecSeat.memoryOff?`, `createSubagentResume` deps' `currentClamps.memoryOff?`) — no key is removed and an absent `memoryOff` reads exactly as 7.21.x did. **Stated residual:** the floor rides the spawn lanes, the retained resume and the cross-run wake; it does NOT ride a TIER-3 DURABLE revival, which rebuilds a settled background child from the durable row's lookup keys plus the WAKING run's own mount — that row carries no policy facts at all, so every tighten-only clamp (`handsReadOnly`, `oneShot`, the two containment faces, `memoryPersistenceCapable`) is already the waker's rather than the original spawner's on that leg, and `memoryOff` joins them unchanged. A waker that declared memory off does floor the revived cycle. @server: this is the core half of a request-level memory switch; @client-core: `memoryOff` is an additive typeKey on the resume-clamp shape. Headless lanes: a child of an off parent simply has no memory face — nothing new is asked and nothing is refused.
235
+
236
+ ### Narrowing (a recommendation arrives one session later) — the consolidation threshold excludes the session doing the asking (#863 G-17; @server @cli re-read)
237
+ - **Before.** The harvest-tail bookkeeping pushed the calling session into the dedup ring and then compared `row.sessions.count` — which by then INCLUDED that session — against `minSessionsBetweenRuns`. The snapshot door (`snapshotConsolidationCandidates`'s unforced session gate) compared its own copy of the same figure. The counted session is not material to a decision taken on its own behalf: this bookkeeping also runs on the SUSPEND path, so it may still be running, and at a terminal harvest its notes are the ones the harvest just wrote. The reference CLI filters the current session out before comparing.
238
+ - **After.** ONE reading, `sessionsSinceExcludingCurrent(row)` (`src/core/memory-engine/consolidation.ts`), is what both doors compare and what every sentence reports: the recommendation edge, the snapshot door's session gate, and that gate's refusal text. The ring and its `count` are UNCHANGED — they still include the calling session, which is exactly what makes a second harvest of it not a second session.
239
+ - **Reach.** With the shipped `minSessionsBetweenRuns: 5`, `memory.consolidation_recommended` is minted at the 6th distinct session's terminal harvest instead of the 5th (and, with the 7.21.1 default-on arming, the automatic run fires one session later too). The reported `detail.sessionsSince` narrows by one everywhere — but AT THE CROSSING it is unchanged, because the crossing is exactly the threshold: 5 before, 5 now. The unforced snapshot door moves in lockstep instead of opening a session early, and `force` bypasses it as before. @cli: a rendered "N session(s) since the last run" line is one lower away from the crossing; @server: any threshold text derived from this number. Headless lanes: unaffected — nothing here asks or refuses on a tool call.
240
+
241
+ ### Additive (a deployment seat, a durable record field, a retention contract clause) — a session id stops speaking for the wrong occupant (#514; @server @cli)
242
+ - **Before.** The three durable session record legs — the pollution marker, the capture opt-out record, and the memory lineage ledger — were keyed by session id alone. A session id is not a unique name for an occupant: the retention contract lets a deployment delete a session tree and hand the id out again, and when it did, a standing record answered the NEXT occupant with the PREVIOUS occupant's fact — a capture opt-out that silently suppressed memory for a new user and reported it "at the user's request", or a pollution sweep that challenged another occupant's committed entries. Deleting the record at purge was no fix either: the withdrawal was then forgotten while the contributions it withdrew stayed committed and re-entered distillation.
243
+ - **After.** A deployment may name the occupant behind an id through the new `RunnerDeps.memorySessionIncarnation` seat (opaque token, stable for one occupant's life, different for the next). Records mint stamped with it, and ONE predicate decides every leg: **only a proven mismatch — both sides known and different — separates a record from the identity reading it**. A record set aside is kept where it is, keeps binding the occupant it belongs to, and keeps its id on the roster faces the consolidation eligibility arm reads (incarnation-blind: an id's contributions stay excluded for every occupant it ever had — the conservative superset). A new occupant's own declaration now mints its own durable record instead of answering "already recorded" over a stranger's — and an occupant the resolver could not name during an outage writes one that binds every occupant, so its declaration survives the resolver's recovery. A standing record vouches for a later mint only when it will still bind that occupant after the resolver names it; a record this layer cannot attribute never vouches.
244
+ - **Absence.** The seat is absent by default: no incarnation is stamped, none is asked for, the record files' bytes are unchanged, and all three legs answer exactly as in 7.21.1. Records written before this version, records imported from another memory plane, synthesized fail-closed records for corrupt files, and any non-string value in the field all read as "occupant unknown", which BINDS — the pre-#514 reading. A resolver that throws falls back to the same reading.
245
+ - **Loudness.** No separation is silent: `memory.session_incarnation_mismatch` (once per session id per engine) states that a standing record was set aside and names both incarnations; `memory.session_incarnation_unreadable` (once per engine) states that the resolver threw and the run is binding by id. A deployment that wires the seat WRONG — a value that varies within one occupant's life would re-enable capture for a session that asked for privacy — is therefore visible from the first resume it breaks.
246
+ - **Retention contract (@server).** `ManagedRetentionCapability`'s three operations named checkpoint rows, session trees and orphan tool results and were SILENT about the memory control plane's governance legs. That silence is now spelled out and machine-readable: `RETENTION_CASCADE_EXEMPT` (`session-pollution`, `session-capture-optout`, `lineage.json`) is disjoint from `RETENTION_CASCADE_DOMAINS`, pinned identical to the layout coordinates it names, and a session purge must not delete those three — deleting them turns a one-way privacy record into a revocable one. With the incarnation seat wired, retaining them can no longer bind a later occupant, so there is no compliance reason left to.
247
+ - **Exports.** `RETENTION_CASCADE_DOMAINS` and `RETENTION_CASCADE_EXEMPT` added (+2, additive); `SessionCaptureRecordStore.read` takes an optional second parameter (existing one-parameter carriers still conform, no code change required). No BREAKING.
248
+ - **Also.** The session-pollution marker takes its twin's `lstat` arbitration for absence: a DANGLING entry at a marker's pathname is "present but unreadable" (fail-closed), never clean absence — the capture opt-out record has read it that way since 6.0.0-RC, and the two legs must agree about whether an entry that exists can read as nothing. A deployment whose control plane is healthy sees no difference.
249
+ - **Headless.** No ask surface is touched: there is no new question, no new refusal, and no gate reads the axis.
250
+
251
+ ### Widening (mandated ask → plain ask / allow), one family half — an interpreter's inline program and a verb's script slot DEMOTE the segment instead of refusing the whole command (#862 · #791 · #769; both faces; @cli @server re-pin; headless lanes: fewer refusals)
252
+ - **Before.** The ARGUMENT-AS-PROGRAM family had four members and one standing: shell re-entry (`sh -c …`), argv re-entry (`xargs cat`), a verb's SCRIPT SLOT (`sed -n '1r /etc/hosts' f`) and an INTERPRETER's inline program (`awk '{print}' f`, `perl -pe … f`, `jq -r .name pkg.json`) each refused the command WHOLE, which the read boundary reads as "nothing was judged" and both probe seats read as a MANDATE — no standing allow rule retires it, the auto-mode classifier may not answer it, and a headless lane refuses it. Measured on this tree: `jq -r .name package.json` was four-cell mandated while the engine's own read-only table (`#619`, the upstream transcription) judges it READ-ONLY; `awk '{print}' data.txt`, `perl -pe 's/a/b/' file.txt`, `sed -n '1r /etc/hosts' f` likewise.
253
+ - **After — one standing for the inline spelling and the FILE spelling.** The family's two TABLE-DRIVEN members demote the segment (never auto-allowed, the sentence unchanged character for character) and the walk goes on judging every path the command NAMES. The re-entry members are untouched: their unread text is a command line this walk MODELS, so nothing was judged and the mandate stands. The line between them is what the walk could have done about the text, and it is the line the friction-floor contract already drew for a program FILE — `awk -f prog.awk`, `jq -f filter.jq`, `python3 script.py` are judged as the in-root operands they are with the program inside them never parsed. An inline program is the same question at a different spelling; it now gets the same answer. The refused-whole word scan drops the same check for the same reason (`echo x > o; awk 'NR==1' f` is exempt where `awk -f prog.awk f` always was), and the `bash_readonly` face — which has no approval channel — still REFUSES, because its rule for every demotion is refusal.
254
+ - **Reach.** Classify doctrine (every permission mode but bypass): `awk '{print}' f`, `perl -pe 's/a/b/' f`, `node -e …`, `python3 -c …`, `sed -n '1r /x' f`, `sed 's/a/b/w out' f` ask ONCE and a standing `Bash(…)` rule now clears them. Default/off doctrine (the read-boundary seat alone, which bypass installs): they are VOUCHED — the same verdict that seat already gave `awk -f prog.awk`. `jq` reaches zero asks end to end, because the read-only leg it was blocked from now clears it. **Headless / no-approver lanes: these commands are executed rather than refused under the off doctrine, and are one clearable ask under classify.** **Erratum (2026-09-18, post-tag, docs only):** the inline spellings named here are the ones whose program text carries no `(` / `)` / `$` — `python3 -c 'print(1)'` and `node -e 'console.log(1)'` are UNCHANGED and still mandated, refused by the generic punctuation gate exactly as #791 stated since 7.17.7 (a lexical floor cannot tell `print(1)` from `open(...)`); `python3 -c 'x=1'`, `node -e 1`, `perl -e 'print 1'`, `awk 'BEGIN{print 1}'` are the demoted half, measured on the release tree. **Accepted, and stated rather than implied:** a file named INSIDE the unread program text is not judged by the boundary — `sed -n '1r /etc/hosts' f` reads `/etc/hosts` at zero asks under the off doctrine, and under classify after one approval or one stored rule. That is the floor's stated reach (`@contract shell.read_boundary.friction_floor`: it is not a defence against a command written to defeat it), it is what the same spelling in a SCRIPT FILE always did, and the containment that holds it is the `ExecutionEnv` the command runs in. **Unchanged:** an out-of-root OPERAND, a deny-listed operand, or an unresolvable word beside an inline program still mandates on both faces (pinned); a path-prefixed interpreter (`/usr/bin/perl -e …`) still mandates on the program's own out-of-root path.
255
+ - Pins: `test/backlog791-interpreter-inline-program.test.ts` (G-1/G-1c/G-2/G-3/G-6, the arm's sentence and the seat split pinned apart), `test/backlog769-sed-script-slot.test.ts` (G-1/G-2/G-3), `test/backlog696-*` and `test/backlog854-*` G6/G6b re-pinned to the new reading.
256
+
257
+ ### Widening (mandated ask → the segment's own plain ask), one word shape — a `NAME=value` ARGUMENT's value is judged only where the program's grammar says it opens one (#862 · #860; both faces and the refused-whole word scan; @cli @server re-pin; headless lanes: fewer refusals)
258
+ - **Before.** #860 made every `NAME=value` word offer its VALUE as a path candidate beside itself, for every program. On the roots face that put a MANDATE — an ask no standing allow rule retires, the auto-mode classifier may not answer, and a headless lane refuses — on the everyday build line: `make PREFIX=/usr/local`, `cmake -DCMAKE_INSTALL_PREFIX=/usr/local`, `docker run -e HOME=/root …`, and `make PREFIX=~/opt` (unresolved on BOTH faces). None of those programs opens the value; it is a build prefix or an environment string. The file's own text called the listed-reader case (`cat a=/etc/passwd`, whose reader opens the whole word) a stated over-fire; that one STAYS, because the exemption is an allow-list and no reader is on it — retiring it would mean naming every reader, a list whose omissions are exemptions.
259
+ - **After.** The value is a candidate unless the program receiving it is on a DECLARED ALLOW-LIST of programs whose `NAME=value` arguments are not paths (`make`, `gmake`, `bmake`, `cmake`, `ninja`, `meson`, `docker`, `podman`; a further one is a member, not a branch). **The polarity is the whole point and it cost four adversarial rounds to arrive at.** Listing the programs that DO open such a value means anything the walk fails to recognise is exempt — and what it fails to recognise is WHICH PROGRAM RUNS: an undeclared wrapper is read as the program (`caffeinate dd if=<path>`, `chpst dd …`, `envdir <dir> dd …`), and a launcher can carry the program inside an OPTION PAYLOAD with no word equal to it at all (`env -Sdd if=<path>`, `env --split-string=dd …`). Each was verified to execute the read, and each was found only because a differential battery was run against the previous tree rather than reasoned about. Inverted, every one of them is judged, and so is a fifth spelling nobody has thought of. The rest of the arm is untouched: the WHOLE word is still a candidate of its own (a deny row naming `secret=/work/in.txt` still gates), an OPTION word's payload still has its own arm, and the **LEADING environment assignments — the words bash strips before choosing the program, and the ones a launcher passes through (`FOO=/etc/passwd <unlisted>`, `env FOO=… <unlisted>`, the `NAME+=` spelling of either) — are judged whatever the program is**, because bash itself puts those values in the program's environment. That split is now a named seat (`HeadedSegment.argvFrom`, read by the boundary walk and the refused-whole word scan alike), so the two halves cannot disagree about one word.
260
+ - **Reach.** Roots face: `make PREFIX=/usr/local`, `cmake -D…`, `docker run -e HOME=/root …`, `make PREFIX=~/opt`, `make PREFIX+=…` and the same behind a resolvable launcher (`env make …`) go from a mandated ask to the segment's own plain, rule-clearable ask on the classify doctrine and to zero asks on the default/off doctrine. **Headless / no-approver lanes: one fewer refusal per such command.** Unchanged (i.e. still mandated): every program not on the list — `dd if=/etc/passwd`, `<unlisted> key=<outside>`, `cat a=/etc/passwd`, and any wrapper the reader cannot resolve — every deny-listed value, every leading environment word; and at zero asks as before, every path-less or in-roots value (`NODE_ENV=production`, `TZ=Asia/Shanghai`, `git -c core.editor=vim`). **Not a #860 case at all, measured and stated:** `cmake -D… ..` still mandates — on its `..` operand, which resolves above the root.
261
+ - Pins: `test/backlog860-assignment-word-payloads.test.ts` G1/G1b (the declared list and everything off it), G2/G3/G4 (the leading words and the word scan, unchanged); `test/backlog862b-*` ③ and the polarity pin (every wrapper spelling that was verified to execute the read).
262
+
263
+ ### Widening (a mandated ask gains a way out), the read-root grant offer — a card whose directory would hand out the home names the ONE path it read, and a per-segment NAME refusal no longer takes the offer with it (#862 · #714 · #758; the read-boundary seat; @cli @server: one new optional key, `ReadRootGrantCandidate.covers`)
264
+ - **Before.** The offer was refused outright whenever the containing directory would have handed out more than was read — the filesystem root, the declared home, or a directory containing a declared root — and it was dropped wherever ANY per-segment sentence took precedence over the walk's own. Measured: `cat ~/.gitconfig`, `cat ~/notes.txt`, `cd /etc && cat passwd` and `git -C /outside status` were mandated asks with NOTHING on them to accept — a card that cannot be cleared by a standing rule, cannot be answered by the classifier, is refused on a headless lane, and offered no directory either. `npm install /opt/pkg`, `curl -o /tmp/out.bin …`, `strings /etc/hosts`, `zzunlisted /etc/passwd` and `cat /etc/passwd | xxd` were the same, because "this program is not on the read-only table" was the sentence that won.
265
+ - **After — one fallback, and one word that says whose sentence stands.** (a) A directory that would hand out too much is still never offered; in its place the ONE out-of-root path the walk reported is offered VERBATIM, carrying `covers: "exact"`. The claim is the same MEASURED one (the walk is replayed with the value as a root and offered only if it comes back objection-free), and two out-of-root paths still offer nothing — the fallback names a single path, it never widens. (b) A per-segment NAME refusal keeps the offer, marked as such, and each SEAT reads that word: the read-boundary seat, whose only question is the boundary's, names the directory; the classify seat holds the call for the name itself — which no grant answers — and that arm spreads nothing, exactly as its backgrounded arm already does. A refused SHAPE (`tail -f <out-of-root>`) still takes the offer with it: it is refused after any grant, on every seat.
266
+ - **Also — a program DECLARED to read no path reads none whether or not the deployment listed it (#758's over-fire).** The no-path-operand declaration (`echo`, `printf` — the row this table was missing — `which`, `tr`, `basename`, `dirname`, the status printers) is a property of the PROGRAM, and the reference CLI reads it the same way: its own no-path set is keyed on the program basename and consulted before, and independently of, its path-restricted command table. `printf '/etc/hosts'` and `echo /etc/passwd` no longer raise a boundary objection on the format text and the string they print. The words BEFORE the program's own argv keep every judgement they had — a path-prefixed program (`/usr/bin/printf x` still mandates on `/usr/bin`) and the leading environment assignments (`FOO=/etc/passwd printf x` still mandates). The OTHER per-verb tables stay the deployment's to declare: a pattern slot and a base move would make the walk understand something on an undeclared program's behalf, which is the direction that grants silence, and both are pinned unchanged.
267
+ - **Reach.** @cli @server: `readRootCandidate` is present on cards that carried none before, and it may now carry `covers: "exact"`. **ABSENT `covers` = the ordinary directory offer, byte-unchanged. PRESENT = `dir` is the path itself, and a consumer must add THAT STRING — expanding it into a directory pattern (`<dir>/**`) grants nothing and leaves the ask standing.** Headless / no-approver lanes: unchanged for (a) and (b) — the mandate is the same, only the card's exit is new; `printf`/`echo` with an out-of-root argument goes from refused to executed on those lanes. Unchanged: a deny row offers nothing (no grant moves a pattern), a glob or a recursive walk offers nothing, a `..`-traversing spelling offers nothing, the classify seat's backgrounded arm offers nothing.
268
+ - **Two holes the adversarial round reproduced, closed here rather than filed** (both are the same root: a declaration ABOUT A PROGRAM was keyed on the segment's head word). `env dd if=<outside>` and `env dd if=<deny-listed>` lost dd's input-path judgement entirely — vouched where the previous tree mandated — because the synthetic head recorded `env` as the verb. And `A+=x env B=<outside> <program>` hid the launcher behind the append word, because the program-position reader reads `A+=x` as a command NAME. ONE reader (`argvProgramOf`) now answers both "which program" and "where its argv begins", stripping BOTH assignment spellings before it resolves the chain. The per-verb OPTION grammar deliberately does NOT follow the launcher (that row would make the walk understand something on an undeclared program's behalf — #758's line), and the asymmetry is pinned with its measured pair.
269
+ - **The `clearsThisAsk` claim is now CHECKED rather than argued.** Widening where an offer may ride broke the old soundness argument ("a face names one only beside its own containment sentence") for one shape: a poll loop over an UNLISTED program is refused whole by the compound face, whose unjudged-read mandate no root grant erases, while the loop face had walked the reads and named a directory — measured: the ask survived its own offer. The seat now withholds an offer that rides a name refusal when the grammar refused the command whole and the scan says it may read; the same loop over a LISTED reader still offers and still clears, exactly as before. Pinned as a battery that grants what each command offers and requires the seat to fall silent.
270
+ - Pins: `test/backlog803-read-root-candidate.test.ts` (③/③b the seat split, ③c the granted-offer battery, ⑤ the home fallback, the `cd`-out card), `test/backlog714-*` (the three answers side by side: deny row / home file / the home itself), `test/backlog758-*` G-6 (the declaration follows the program; the other per-verb tables do not), `test/backlog696-*`.
271
+
272
+ ### Not done, recorded with the measurement — two narrowing points handed over from the sibling car are HELD (#862 · #792 · #766)
273
+ - Both were implemented and then reverted, because each turned a refusal that was CARRYING a mandate into a demotion while the mandate was standing in for arms that then never ran. Neither is a judgement call this car could make alone.
274
+ - **An unreadable launcher option** (`nice -n 5 …`, `env -u X …`, a spelling no row carries) ⇒ a plain ask. Measured with the demotion applied: `env -u X sh -c "<command line>"` reached ZERO asks on the boundary seat — with no program identified, the shell-re-entry arm never runs and the inner command line is walked as an ordinary in-root filename — and `nice -n 5 grep -r <dir>` lost its traversal reading under a live deny judge. What the reader could not read is not one option's arity; it is WHICH PROGRAM RUNS, and every per-program judgement depends on that answer.
275
+ - **An `xargs` target outside the declared path-free set** ⇒ a plain ask. Measured with the demotion applied: under the default/off doctrine the boundary seat sits ALONE, so "not mandated" is VOUCHED — `echo <out-of-root> | xargs cat` and its deny-listed twin both reached zero asks, which are the exact spellings a previous P0 closed. Making this safe needs a demotion that means different things on the two seats, which is a ruling, not an implementation detail.
276
+ - Both measurements are pinned (`test/backlog862b-narrowing-cc-alignment.test.ts` ⑨) so they are not lost with the branch.
277
+
278
+ ### Narrowed reading of one persisted cell, plus one additive key — a shell-seat raise is the `shell` safety axis, not the `irreversible` one (#862 · #858 (i-a) · #713; `CheckpointGate.kind` unchanged; @server @cli: a resolver reading `safetyAxis.irreversible` flips for the read-boundary family)
279
+ - **Before.** `safetyAxisOf` folded every raise onto `irreversible`, so a card minted by the engine's SHELL SEAT — a read-boundary probe's verdict, a probe consulted that did not answer, the operator's `shellGate:"always"` doctrine, that doctrine's own tighten — persisted `safetyAxis: { irreversible: true }` and reached the person as "tool X performs an irreversible action". That seat's question is about TEXT (`@contract shell.read_boundary.friction_floor`: which files the words of a command may name), so the command behind the card may be entirely benign — a `grep -rn TODO src/` the boundary could not enumerate. The tree already knew: `buildRiskDescriptor`, minting at the SAME moment from the same inputs, graded it `axes: { shell: true }`, severity 3, "NOT `irreversible` (4): the command MAY be benign". Two derivations, opposite answers, one gate row — and the one the durable record kept was the wrong one.
280
+ - **After — one derivation, two axes, mutually exclusive.** `safetyAxisOf` still decides WHETHER a raise happened by exactly the same rule (so `CheckpointGate.kind` is byte-identical: `irreversible_ask` for every park that had it, `human` for every park that did not, and a plain policy ask on a shell-gated tool still raises nothing at all). What it now also decides is WHICH axis the raise lands on: the shell seat's raises are `shell: true` with `irreversible: false`; a tool's OWN `always`/`maybe` declaration keeps `irreversible`, whoever asked first. `buildRiskDescriptor` drops the `!shell &&` correction it carried — there is nothing left to correct once the axis is judged once.
281
+ - **Reach.** **@server @cli: a resolver that reads `safetyAxis.irreversible` to decide "does this need a real person?" flips for the whole read-boundary family** — read `irreversible || shell` to keep today's answer, or read them apart to treat a question about a command's text differently from an act that cannot be undone. **The budget face is unchanged** (`kind` is what a budget resolver keys on, and it did not move), `RiskDescriptor.axes` is unchanged, and `SAFETY_AXIS_VOCABULARY.safetyAxis` already listed `shell` — the key it named now exists. **Headless / no-approver lanes: unchanged** (no ask is gained or lost; only the word on the persisted row moved). The ask card's own sentence is NOT changed by this piece — it remains the auto-mode classifier's input, and changing it is a separate question with no live evidence behind it yet (`#842`).
282
+ - Pins: `test/backlog713-safety-axis-by-seat.test.ts` (the derivation table row by row, the mutual-exclusion pin, and the six Runner cells A–F unchanged in `kind`), `test/backlog862b-narrowing-cc-alignment.test.ts` ⑥ (the two derivations agreeing on one mint), `test/shell-gate.test.ts`.
283
+
284
+ ### Widening (refuse → read, ask → allow) — the built-in sensitive-path READ deny table now ships OFF; it is a deployment opt-in switch (#889; ruling: align with the reference CLI, which has no run-time deny table keyed on file names; @server @cli @client-core @test)
285
+ - **Before (≤ 7.21.1).** `READ_DENY_DEFAULT_TIERS` was "every tier except `shell-history`", so an unconfigured deployment refused reads of ~40 credential-class NAMES anywhere in a path — `.npmrc`, `id_rsa*`, `.ssh`, `.aws`, `.mcp.json`, `.claude/settings.json`, … — on the structured read faces AND on the shell read boundary, including a repo's own fixture spelled `fixtures/.ssh/key`. Because the recursive-read demotion mints on "a deny judge is wired", that table also made `grep -r`, `ls -R`, `du` and `diff -r` cost one mandated card in every project, on a deployment that had never asked for any of it.
286
+ - **After.** The default selection is EMPTY. An unconfigured deployment activates no built-in row: the structured faces serve an in-root `.npmrc`, the shell boundary runs `cat .npmrc` and the whole recursive family at zero cards, and nothing in that table participates in a verdict. The table itself is unchanged and fully intact — it is now the MENU. A deployment switches tiers on at the existing seat (`RunnerDeps.readDenyBuiltinTiers` / its `HandsToolkitOptions` twin, deployment-only as before), and the old posture is spelled `["credentials","browser","wallet","agent-config"]`. `readDenyBuiltinExclude` is unchanged. `[]` remains the explicit empty posture and `none`-style explicit spellings keep their meaning; absent and `[]` now resolve alike.
287
+ - **Independent of the switch.** Deployment and task `readDenyPatterns` are a SEPARATE set and are judged whether or not a built-in tier is active — a deployment that lists its own patterns is unaffected by this change, recursive-read card included. The CONTAINMENT boundary is untouched: an out-of-root read is still one mandated ask (bypass immunity unchanged), and the WRITE protection table (a different table, a different ruling) is untouched.
288
+ - **New disclosure.** `WiringManifest.readDeny?: { builtinTiers }` — effective half, PRESENT IFF the leg's configuration activates at least one built-in row, in `configFingerprint`. An empty array is never emitted, so absence is the unconfigured posture and reads as "no BUILT-IN row judges this leg" (not "no read is deny-judged": `readDenyPatterns` are deliberately not reported — pattern texts are deployment material, not an enum). The tiers named are the ones that MATERIALLY activate rows; a tier whose every row was excluded is not named. Closed set `ReadDenyBuiltinTier` is now registered in `docs/CLOSED-SETS.md` (members unchanged).
289
+ - **Second half, no wire key.** The shell read boundary carries its `denyMatch` seat present-iff the compiled set has entries. The recursive-read demotion reads that seat's PRESENCE as "the check a traversal would outflank is in place", so a judge compiled over an empty set must not be wired. Measured over an empty set: wiring the seat vs not changes only the recursive arm (8 of 32 probed commands, all recursive forms); every other verdict is identical. A deployment with any active row is byte-unchanged.
290
+ - **Reach — operators.** The switch is `readDenyBuiltinTiers` at the deployment seat; on a server that surfaces it as env, an UNSET env must now mean OFF while an explicit empty spelling keeps meaning the explicit empty posture. A deployment that relied on the shipped floor to keep a model from reading credential files in-root must now say so — and the honest floor was always the sandboxed `ExecutionEnv` plus the containment boundary, never this name table (the module said so; it is a structured-face floor, not a machine-wide boundary). Headless / no-approver lanes: OFF is strictly fewer refusals (an in-root credential-named read and every recursive read now complete instead of parking); out-of-root refusals are unchanged. A safety-configuration guide for switching tiers on is owed separately.
291
+ - **Reach — consumers.** `READ_FACE_DEFAULT_DENY_ENTRIES` is now `[]` (kept, still derived): a face rendering "what does an unconfigured deployment refuse" gets the honest empty answer, and a face rendering "what COULD be switched on" reads the unchanged tier-annotated `READ_FACE_BUILTIN_DENY_TABLE`. @test: the B-105 / B-107 / B-120 boundary criteria still hold verbatim WITH THE SWITCH ON — re-verify them under an explicit `readDenyBuiltinTiers`, and add an OFF-state control. Pinned in `test/backlog889-read-deny-default-off.test.ts` (the two-state matrix: OFF / ON / own-patterns × in-root / out-of-root × single / recursive × attended / headless); every other suite that judges built-in rows now names its selection at the seat.
292
+
3
293
  ## 7.21.1 — 2026-09-17
4
294
 
5
295
  ### Widening (default ON) — memory consolidation runs by itself once the recommendation threshold is crossed (#761 follow-up; ruling: align with the reference CLI's auto-dream; @server @cli @client-core)
6
296
  - **Before (7.21.0).** `AUTO_RUN_ON_RECOMMENDATION_DEFAULT` shipped `false`: an ABSENT `RunnerDeps.autoRunOnRecommendation` seat only minted the `memory.consolidation_recommended` notice and the host had to run consolidation itself.
7
297
  - **After.** The named default is `true`: on a deployment that wired `memoryConsolidation` AND a resolvable consolidation model seat, an absent seat ARMS the trigger — the terminal harvest that crosses the threshold (24h ∧ 5 sessions, the recommendation edge's own gate, unchanged) fires ONE host consolidation run at the post-task tail; the effective wiring manifest carries `autoConsolidation: { onRecommendation: true }` (in the fingerprint) on such deployments. An explicit `false` is the off switch and beats the default. A deployment without consolidation wired, or with no model seat, is NOT armed by the default (byte-unchanged).
8
298
  - **Reach.** Memory content crosses to the configured consolidation model automatically on every recommending task — an EGRESS decision now ON by default; multi-tenant deployments turn it off with an explicit `false` on the seat. Manifest `configFingerprint` drifts once on every deployment that becomes armed by this default. Pins: absent seat + both seats wired ⇒ armed, manifest cell present; absent seat + no consolidation ⇒ not armed; explicit `false` ⇒ off.
9
-
10
299
  ## 7.21.0 — 2026-09-17
11
300
 
12
301
  ### Widening (ask → allow), one spelling — the shell read boundary's word scan now resolves a home-prefixed operand (#856; completes #854 for `~/x`; deployments declaring `readFace: "open"` widen, `"roots"` widens only where the home lies inside the roots; @server @cli re-pin)
@@ -1004,6 +1004,7 @@ export function createSendMessageTool(opts) {
1004
1004
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1005
1005
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
1006
1006
  ...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
1007
+ ...(ctx.parentSpecSeat?.memoryOff === true ? { memoryOff: true } : {}),
1007
1008
  },
1008
1009
  ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
1009
1010
  });
@@ -467,6 +467,7 @@ export declare function createSubagentResume(deps: {
467
467
  interactiveTools?: false;
468
468
  oneShot?: true;
469
469
  memoryPersistenceCapable?: false;
470
+ memoryOff?: true;
470
471
  readFaceRoots?: true;
471
472
  writeFaceRoots?: true;
472
473
  readDenyPatterns?: readonly import("../tools/fs/read-deny.js").ReadDenyEntry[];
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
- import { applyParentSpecSeat, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
3
+ import { applyParentSpecSeat, memoryTurnedOff, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
4
4
  import { childClampSeats } from "../core/child-clamp-seats.js";
5
5
  import { stripSpawnTurnQuestionFace } from "../core/ask-question.js";
6
6
  import { withDelegationProvenance } from "../core/tool-policy.js";
@@ -569,6 +569,7 @@ export function createSubagentResume(deps) {
569
569
  ...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
570
570
  ? { memoryPersistenceCapable: false }
571
571
  : {}),
572
+ ...(deps.currentClamps?.memoryOff === true ? { memory: memoryTurnedOff(entry.specSnapshot.memory) } : {}),
572
573
  signal: abort.signal,
573
574
  };
574
575
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -390,6 +390,7 @@ export declare function safetyAxisOf(marks: MandateMarks & {
390
390
  }): {
391
391
  egress: boolean;
392
392
  irreversible: boolean;
393
+ shell?: boolean;
393
394
  } | undefined;
394
395
  /** An ASK that carries its probe's mandate — the per-call member the gate stamped (`probeMandated`), read
395
396
  * through the same family word and lane table, so its three consumers (the inherited classifier station,
@@ -129,9 +129,10 @@ function irreversibilityMandateOf(marks) {
129
129
  }
130
130
  export function safetyAxisOf(marks) {
131
131
  const egress = marks.egress === true;
132
- const irreversible = irreversibilityMandateOf(marks) !== undefined ||
132
+ const raised = irreversibilityMandateOf(marks) !== undefined ||
133
133
  (marks.shellGated === true && marks.irreversibility === "maybe" && marks.tightened === "shell_gate");
134
- return egress || irreversible ? { egress, irreversible } : undefined;
134
+ const shell = raised && marks.shellGated === true;
135
+ return egress || raised ? { egress, irreversible: raised && !shell, ...(shell ? { shell: true } : {}) } : undefined;
135
136
  }
136
137
  export function probeMandatedAsk(ask) {
137
138
  return bindsEveryAutomaticLane(persistedRuleMandateOf({ probeMandated: ask.probeMandated === true }));
@@ -330,7 +330,7 @@ export interface MaybeCompactOptions {
330
330
  /**
331
331
  * Fires ONCE per LANDED compaction (after `appendCompaction` committed the new baseline), with the
332
332
  * files that were attached WHOLE (untruncated) into the summary. The Runner wires this to the read-state
333
- * invalidation (`applyCompactionToReadFileState`): the summary just replaced the history the read-dedup
333
+ * invalidation (`applyContextShrinkToReadFileState`): the summary just replaced the history the read-dedup
334
334
  * stubs point at, so stale entries must drop and the re-attached content re-registers. Kept OUTSIDE
335
335
  * `workingFileAttachments` deliberately — the invalidation is owed even when attachments are disabled
336
336
  * (an empty array then). Best-effort: a throwing callback must not fail the landed compaction.
@@ -340,7 +340,7 @@ export interface MaybeCompactOptions {
340
340
  * survive the compaction's clear even though they are NOT in `attachedComplete` — every file
341
341
  * `maybeCompact` deliberately skipped re-attaching, for EITHER exclusion reason (kept-tail-visible,
342
342
  * or matches a declared instruction source). Without this the read-state clear (see
343
- * `applyCompactionToReadFileState`) would wipe them anyway, defeating both the read-before-edit gate
343
+ * `applyContextShrinkToReadFileState`) would wipe them anyway, defeating both the read-before-edit gate
344
344
  * and the next read's dedup for exactly the files this optimization was supposed to leave untouched.
345
345
  * (The instruction-source half of this was missing in an earlier revision: `instructionSources` and
346
346
  * `seededFiles` are independent `ProjectMemoryLoad` fields — a path can be declared in the former
@@ -31,9 +31,13 @@ export function rebuildAutoModeDecider(opts) {
31
31
  }
32
32
  const decider = createAutoModeDecider({
33
33
  ...(effective.timeoutMs !== undefined ? { timeoutMs: effective.timeoutMs } : {}),
34
- classify: async (input, signal) => {
34
+ assemble: async (input) => {
35
35
  const messages = await opts.transcript();
36
- const userPrompt = renderAutoModeWindow(messages, effective.window) + renderAutoModeAction(input);
36
+ return renderAutoModeWindow(messages, effective.window) + renderAutoModeAction(input);
37
+ },
38
+ classify: async (_input, signal, _rung, userPrompt) => {
39
+ if (userPrompt === undefined)
40
+ throw new Error("the rebuilt auto-mode classifier was asked with no assembled question");
37
41
  return opts.complete({ systemPrompt, userPrompt }, signal);
38
42
  },
39
43
  });