@sema-agent/core 7.12.0 → 7.14.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 (122) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/agents/subagent.d.ts +4 -9
  3. package/dist/agents/subagent.js +16 -12
  4. package/dist/agents/teacher.js +1 -1
  5. package/dist/brain/reasoning.d.ts +23 -0
  6. package/dist/brain/reasoning.js +35 -4
  7. package/dist/brain/stream-engine.js +35 -17
  8. package/dist/brain/timeout.d.ts +25 -4
  9. package/dist/brain/timeout.js +1 -1
  10. package/dist/core/ask-origin.d.ts +83 -9
  11. package/dist/core/ask-origin.js +31 -5
  12. package/dist/core/auto-mode.d.ts +3 -3
  13. package/dist/core/checkpoint-seat.d.ts +18 -0
  14. package/dist/core/checkpoint-seat.js +6 -0
  15. package/dist/core/checkpoint-store.d.ts +9 -16
  16. package/dist/core/checkpoint-store.js +7 -4
  17. package/dist/core/engine-notice.d.ts +21 -1
  18. package/dist/core/env-knob-announce.d.ts +16 -0
  19. package/dist/core/env-knob-announce.js +14 -0
  20. package/dist/core/gate-fold.js +2 -0
  21. package/dist/core/gate-lanes.d.ts +0 -40
  22. package/dist/core/gate-lanes.js +24 -29
  23. package/dist/core/gate-outcome.d.ts +42 -3
  24. package/dist/core/gate-outcome.js +22 -0
  25. package/dist/core/governance-codes.d.ts +1 -1
  26. package/dist/core/governance-codes.js +3 -1
  27. package/dist/core/hooks.d.ts +6 -17
  28. package/dist/core/hooks.js +7 -5
  29. package/dist/core/mcp.d.ts +3 -2
  30. package/dist/core/mcp.js +5 -11
  31. package/dist/core/memory-engine/engine.d.ts +2 -2
  32. package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
  33. package/dist/core/runner/advertised-writable-dirs.js +48 -0
  34. package/dist/core/runner/assemble-result.d.ts +50 -1
  35. package/dist/core/runner/assemble-result.js +4 -1
  36. package/dist/core/runner/compaction-seams.d.ts +42 -0
  37. package/dist/core/runner/compaction-seams.js +80 -0
  38. package/dist/core/runner/contracts.d.ts +44 -6
  39. package/dist/core/runner/denial-limit-arms.d.ts +7 -9
  40. package/dist/core/runner/denial-limit-arms.js +11 -12
  41. package/dist/core/runner/gate-exit.d.ts +15 -3
  42. package/dist/core/runner/gate-exit.js +6 -4
  43. package/dist/core/runner/permission-rule-lanes.d.ts +2 -1
  44. package/dist/core/runner/permission-rule-lanes.js +2 -1
  45. package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
  46. package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
  47. package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
  48. package/dist/core/runner/prepare-hands-readface.d.ts +10 -4
  49. package/dist/core/runner/prepare-hands-readface.js +5 -14
  50. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -3
  51. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  52. package/dist/core/runner/prepare-memory.d.ts +31 -34
  53. package/dist/core/runner/prepare-memory.js +73 -51
  54. package/dist/core/runner/prepare-policy-chain.js +8 -4
  55. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
  56. package/dist/core/runner/prepare-prompt-inputs.js +2 -2
  57. package/dist/core/runner/prepare-protocol-tools.js +5 -0
  58. package/dist/core/runner/prepare-question-face.d.ts +10 -8
  59. package/dist/core/runner/prepare-question-face.js +1 -3
  60. package/dist/core/runner/prepare-safety-scan.js +11 -16
  61. package/dist/core/runner/prepare-task.d.ts +9 -1
  62. package/dist/core/runner/prepare-task.js +63 -49
  63. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  64. package/dist/core/runner/prepare-wiring-manifest.js +1 -1
  65. package/dist/core/runner/resume-claim.d.ts +2 -2
  66. package/dist/core/runner/resume-preflight.d.ts +2 -2
  67. package/dist/core/runner/run-attachment-seats.d.ts +2 -2
  68. package/dist/core/runner/run-compaction-machinery.js +2 -0
  69. package/dist/core/runner/run-git-lane.d.ts +1 -1
  70. package/dist/core/runner/run-harness-handlers.js +5 -1
  71. package/dist/core/runner/run-identity-wiring.d.ts +7 -0
  72. package/dist/core/runner/run-identity-wiring.js +3 -1
  73. package/dist/core/runner/run-leg.d.ts +7 -5
  74. package/dist/core/runner/run-leg.js +256 -5
  75. package/dist/core/runner/run-notification-lane.d.ts +4 -3
  76. package/dist/core/runner/run-recovery-lanes.d.ts +4 -17
  77. package/dist/core/runner/run-recovery-lanes.js +5 -4
  78. package/dist/core/runner/run-settle-and-teardown.d.ts +14 -10
  79. package/dist/core/runner/run-settle-and-teardown.js +112 -4
  80. package/dist/core/runner/run-stop-and-final-verify.d.ts +2 -2
  81. package/dist/core/runner/run-terminal-adoption.d.ts +16 -16
  82. package/dist/core/runner/run-terminal-adoption.js +87 -7
  83. package/dist/core/runner/run-turn-boundary.js +5 -4
  84. package/dist/core/runner/runtask.d.ts +10 -90
  85. package/dist/core/runner/runtask.js +77 -596
  86. package/dist/core/runner/stream-lifecycle-verbs.js +10 -1
  87. package/dist/core/runner/stream-settle-backstop.js +1 -1
  88. package/dist/core/runtime-caps.d.ts +21 -0
  89. package/dist/core/runtime-caps.js +5 -1
  90. package/dist/core/task-event.d.ts +11 -2
  91. package/dist/core/task-registry-shared.js +8 -6
  92. package/dist/core/task-result.d.ts +15 -0
  93. package/dist/core/task-stream.d.ts +10 -1
  94. package/dist/core/terminal-cause.d.ts +6 -2
  95. package/dist/core/tool-face.d.ts +8 -0
  96. package/dist/core/tool-face.js +1 -0
  97. package/dist/core/tool-policy.d.ts +39 -31
  98. package/dist/core/tool-policy.js +29 -5
  99. package/dist/core/tool-registry.d.ts +11 -3
  100. package/dist/core/tool-registry.js +7 -1
  101. package/dist/core/tool-roster.d.ts +26 -0
  102. package/dist/core/tool-roster.js +40 -7
  103. package/dist/core/tool-spec.d.ts +13 -12
  104. package/dist/core/types.d.ts +2 -1
  105. package/dist/core/types.js +1 -0
  106. package/dist/core/wiring-manifest.d.ts +16 -9
  107. package/dist/core/wiring-manifest.js +8 -3
  108. package/dist/index.d.ts +8 -7
  109. package/dist/index.js +7 -6
  110. package/dist/orchestration/run-workflow-tool.d.ts +8 -8
  111. package/dist/orchestration/run-workflow-tool.js +1 -1
  112. package/dist/orchestration/workflow-primitives.d.ts +4 -3
  113. package/dist/orchestration/workflow-primitives.js +3 -3
  114. package/dist/orchestration/workflow-types.d.ts +14 -0
  115. package/dist/orchestration/workflow.d.ts +41 -4
  116. package/dist/orchestration/workflow.js +23 -5
  117. package/dist/server/http.js +2 -1
  118. package/dist/tools/fs/fs-shared.js +9 -3
  119. package/dist/tools/fs/read-deny.d.ts +24 -8
  120. package/dist/tools/fs/read-deny.js +20 -1
  121. package/package.json +2 -1
  122. package/test/export-surface.snapshot.json +35 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,129 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.14.0 — 2026-09-11
4
+
5
+ ### Narrowing — the agent's own key-bearing settings files are refused on the structured read faces by default (#685; P0; ruling B: the session record stays verbatim, the fix is on the READ face; sema form — CC 2.1.250 has no read-face deny table, the table itself is the registered divergence; @server @cli @test)
6
+ - **Before.** The built-in read deny table (`READ_FACE_BUILTIN_DENY_TABLE`, `src/tools/fs/read-deny.ts`) guarded credential directories (`.ssh`, `.aws`, `.netrc`, `.npmrc`, …) and named `.claude*`/`.mcp.json` as deliberately NOT listed ("agent config is routinely read for debugging"). Under default wiring a `Read ~/.sema/settings.json` (the shell's user settings — its `env` block carries the engine URL and provider API keys) returned the file verbatim: the key landed in the tool result, in the model context, and in the session record; a cli replay audit found it there in cleartext.
7
+ - **After (one tier, no new rule).** A fifth built-in tier **`agent-config`** joins the closed set `READ_DENY_BUILTIN_TIERS` (`credentials`, `shell-history`, `browser`, `wallet`, `agent-config`) and is default-ON through the existing derived default expression (every tier except `shell-history`). Its rows name the agent's own configuration FILES whose env / server blocks carry API keys — `.sema/settings.json`, `.sema/settings.local.json`, `.sema.*` (`.sema.json`), `.claude/settings.json`, `.claude/settings.local.json`, `.claude.*` (`.claude.json`), `.mcp.json`, `.ai-agent/.env` and `.sema/engine-data/.env` (the engine host's documented data-root key file under the default and the shell's layouts), `.codex/config.toml`, `.cursor/mcp.json`, `.continue/config.json`, `.continue/config.yaml`, `.aider.conf.yml`, `.gemini/settings.json` (every one a file whose `env` / server block takes literal values). Token STORES of the same tooling join the existing **`credentials`** tier, not the new one — `.credentials.json` (the shell's OAuth store under either brand's config home), `.codex/auth.json`, `.config/github-copilot`, `.gemini/oauth_creds.json` — so a deployment that switches `agent-config` off to debug its agent configuration does not expose the OAuth stores beside it. Rows are FILES, not the config directories: under the shell's layout `~/.sema/engine-data` is the engine data root and hosts the memory library the model reads and edits (Edit requires a prior Read), and `.sema/agents`, `.claude/skills`, `.cursor/rules`, `.codex/sessions` are working material — a directory-wide row would have needed a data-root passthrough (one more rule; the write face carries exactly that special case and this change does not copy it). The disposition is the credentials tier's existing one, unchanged in kind: a structured face whose own target is a listed file (Read; Grep/Glob/RepoMap given that path) refuses typed (`read_path_denied`, the pattern named, zero content bytes, no ask); a Grep/Glob TRAVERSAL over a scope containing one excludes it and says so in a trailing note (`entries matching the sensitive-path read deny list … were excluded`), no error, the file's bytes never in the result; the classify shell gate's reader arm (`grep needle .claude/settings.json`, `cat …`) demotes a listed operand to a MANDATED ask (B-057: the read boundary's question — neither a stored allow rule nor the read-only arm retires it; a person's yes releases the read). Rules: +0 (a tier row is data on the one compiled matcher every judgment point already consumes); the JSDoc "NOT listed" sentence for `.claude*`/`.mcp.json` is withdrawn.
8
+ - **User-visible path.** A default-wired run that reads `.claude/settings.json` / `.sema/settings.json` / `.mcp.json` now gets a typed refusal from Read (and a permission card from a `grep`/`cat` of it under `shellGate:"classify"`) where it used to get the file. **Deployment opt-out (existing knob, no new switch):** `RunnerDeps.readDenyBuiltinTiers` / `HandsToolkitOptions.readDenyBuiltinTiers` = `["credentials", "browser", "wallet"]` restores the previous face for the settings files while keeping the token stores refused; `readDenyBuiltinExclude` removes single rows by their pattern text (e.g. `[".mcp.json"]`). Additions (`readDenyPatterns`) are unchanged and remain add-only.
9
+ - **Fail-open reverse check — which reads of a key-bearing file still do NOT ask or refuse?** (a) a workspace `.env` / `.env.*` — deliberately unchanged (the standing ruling: workspace material; the write guard covers it; only the engine host's own data-root `.env` is listed); (b) a settings file under a relocated config home (`SEMA_CONFIG_DIR` / `CLAUDE_CONFIG_DIR`) or a relocated data root (`AGENT_DATA_DIR`) — the rows name the default spellings, a deployment that relocates adds its own row (the write guard has the same shape); (c) **the shell reader arm under the DEFAULT `shellGate`.** The fact, stated as the deployments will meet it: the read face's built-in `agent-config` tier blocks the STRUCTURED reads (Read; Grep/Glob/RepoMap targets and traversals); the Bash reader arm is covered under `shellGate:"classify"` (the probe mandates the ask for a listed operand, as above) and `"always"` (every shell call asks; a stored `Bash(cat:*)` allow rule does NOT clear it — the operator doctrine is a mandated ask); when `shellGate` is absent or `"off"` a Bash read goes through NO probe — `cat .claude/settings.json` runs with zero asks and the key reaches the session record — exactly as the `credentials` tier has always behaved on that arm (the module header's declared scope: a structured-face floor, the full shell is the approval chain's jurisdiction). Measured and pinned (`test/backlog685-agent-config-read-deny.test.ts`, the "declared scope" block). **This is the default deployment's shape today**: the server's `MANUAL_MODE_SHELL_GATE` is opt-in and unset by default, the cli mints no `shellGate` of its own, so `TaskSpec.shellGate` is absent ⇒ `"off"`. Not widened by this change (the arm is the existing design, same for every tier); follow-up **#696** (P1 design: whether the read-face floor should be decoupled from the shellGate tier and judge the shell reader arm unconditionally). @server @cli: this is the sentence to read for the P0's remaining arm; (d) `.sema/agents/*`, `.claude/skills/**`, `.cursor/rules/**`, `.codex/sessions/**` and `.claude/projects/**` transcripts — working material, or transcripts (a different class — the shell-history posture — not this change); (e) a `settings.json` outside an agent-config directory (`config/settings.json`, `.vscode/settings.json`) — reads normally (pinned as the control).
10
+ - **Export surface.** No public name added or removed. Closed-set member +1: `READ_DENY_BUILTIN_TIERS` (`ReadDenyBuiltinTier` widens by `"agent-config"`); `READ_FACE_BUILTIN_DENY_TABLE` +19 rows (4 `credentials`, 15 `agent-config`); `READ_FACE_DEFAULT_DENY_ENTRIES` grows by the same 19. A consumer that pinned the tier vocabulary or the default entry list as a closed set re-pins. `readDenyBuiltinTiers` configs listing the four old names keep working (exactly-these semantics: they now deliberately leave `agent-config` off).
11
+ - **Downstream.** `@server`: mirror the tier in any admin face that renders `READ_DENY_BUILTIN_TIERS` / the default table; a deployment that relocates `AGENT_DATA_DIR` should add its own `<root>/.env` row via `readDenyPatterns`; a persisted checkpoint's read-face section carries additions only (built-ins resolve at resume from the deployment), so no stored row changes shape. `@cli`: the Read refusal text names the pattern (`sensitive-path read deny list (pattern ".sema/settings.json")`) — the existing `read_path_denied` card applies; the classify card for `grep …/settings.json` is the existing B-057 mandated-ask card (no "don't ask again" offer, by design); `.mcp.json` as a basename also covers plugin-marketplace manifests (`…/plugins/marketplaces/*/external_plugins/*/.mcp.json` — they carry `env` blocks too), so a structured Read of one now refuses and a Grep over a plugin tree excludes them with the note. `@test`: G1–G8 in the release criteria (draft in the car receipt).
12
+ - Pins: `test/backlog685-agent-config-read-deny.test.ts` (table: tier membership, both brand spellings / case fold / both separator families, the readable working-material list incl. the memory library under `.sema/engine-data` and workspace `.env`, tier-off leaves the credentials rows; Read face under default wiring: three files refused with zero asks and zero key bytes in the session record, a covering stored `Read(//root/**)` rule does not reach the in-tool refusal, controls read normally and the key does flow from an unlisted file, tier-off reads the settings file while `.credentials.json` stays refused, Grep traversal excludes the rows; classify shell reader arm: no ⇒ one mandated ask and a clean record, yes ⇒ the read happens, a stored exact allow rule does not clear it, unlisted-file and tier-off controls at zero asks). Also pinned: the declared scope — `shellGate` absent ⇒ `cat .claude/settings.json` runs with zero asks and the key reaches the record; `"always"` ⇒ one ask, a stored `Bash(cat:*)` rule does not clear it. Mutation measured: dropping the 15 agent-config rows reds 9 of 19 (every refusal / mandate pin), the 10 controls / scope pins stay green. `test/backlog245-deny-builtin-config.test.ts` tier-vocabulary pin re-spelled for the fifth tier.
13
+ ### Behaviour — one rule for every prompt-advertised writable directory: the fence admits what the prompt teaches (#691; P1 组合病; cli [6900]; @server @cli @test)
14
+ - **Before.** The `# Memory` instruction taught "write to it directly with the Write tool" at `{{MEMORY_DIR}}` while the fs root fence knew nothing of that directory: on a default wiring (no `additionalDirectories`, memory root under `~/.sema/engine-data/memory`) every memory Write was refused `path_not_in_root` ("resolves outside the allowed root"). The scratchpad had the opposite half of the same rule hand-rolled in the hands phase (admit + materialize), and the instruction's supply condition checked only that a `Write` tool was mounted and reachable — two judgments of one fact.
15
+ - **After (the rule, one sentence).** A directory the prompt advertises as writable is a directory the root fence ADMITS, and the SAME admission decides the hole and the teaching — `AdvertisedWritableDirs` (`src/core/runner/advertised-writable-dirs.ts`), one seat, two advertisers: the memory phase admits its write plane's root (`MemorySessionHandle.writableRoot`, the very path the instruction names) iff the write channel's first conjunct holds (`Write` mounted, not excluded, and on the filesystem the store lives on — a remote env needs `memoryPersistenceCapable: true`) and a write face exists (`writeScope !== null`); the `# Memory` write instruction and the preference discipline are taught iff that admission held. The hands phase admits the scratchpad through the same seat (its exclusive-create marker materialization unchanged) and folds the seat's canonical roots into the write-capable containment allowlist before the toolkit compiles. Admission is fail-closed canonicalization through the fence's own ExecutionEnv; a refused admission is announced ONCE — `<advertiser> advertised writable directory skipped (cannot canonicalize): <path> — <code>: <message>` on `onError` (phase `config`) — and the advertiser then does not advertise (memory: no instruction; the scratchpad's silent narrowing is now loud on the same outlet, its env-block spelling unchanged).
16
+ - **Phase order (driver-internal).** The memory phase now runs BEFORE the hands phase in `prepareTask` (the engine owns the write root's coordinate; the toolkit compiles its allowlist once), so the memory write gate is installed before the band compiles. The two roster facts the memory phase needs from the not-yet-mounted band (`writeToolsMounted`, `rosterCanPersist`) read the band's catalog PLAN — the hands rows whose declared effect is not `read` (the file-write trio and the full shell), planned iff hands are enabled and not read-only — beside the assembled caller/MCP tools; the #181-F5 pins (handsReadOnly / excludeTools Write / hands-less caller Write) guard the plan against the compile. `PrepareMemoryInput` and `PrepareHandsReadFaceInput` gain `advertisedWritableDirs?` (phase-api floor 947 → 949).
17
+ - **Observable (pinned both ways, `test/backlog691-advertised-writable-dir.test.ts`, 9 pins; 6 targeted mutations each red ≥1).** Default wiring: the model's Write into the advertised memory dir lands, zero `path_not_in_root`, the `# Environment` additional-directories line now lists the memory root beside any scratchpad (the same disclosure the scratchpad already had); the engine's content gate still refuses a framing break-out inside the admitted root (the hole is not a content door). `writeScope: null` and `handsReadOnly`: no hole, no teaching, the read-only notice as before. Remote env undeclared: no admission attempt, no teaching, the notice once. Remote env declared but the sandbox cannot resolve the host path: skipped loudly, not taught. Remote env declared + resolvable: taught. Scratchpad ghost: skipped loudly; scratchpad + memory root on one allowlist.
18
+ - **Fail-open reverse check — which directory does the fence open that the prompt does not advertise?** None: `roots` is filled only through `admit`, and every `admit` caller decides its advertisement off the returned verdict; the memory phase asks only when it would teach (write face + first conjunct), so a read-only layering, a read-only band or an excluded `Write` opens no hole. Rules 2 → 1 (the scratchpad's inline admit and the instruction's roster-only supply condition fold into the seat).
19
+ - **Review round 1 (adversarial; four REAL findings adopted, one noted).** ① The write plane's root is REFUSED (loud `onError`, channel closed, not taught) when it physically contains another scope's directory: the root scope owns `memoryDir` and later scopes nest beneath it (layout.ts `registerScope`), and the file tools' fence is directory-granular — admitting the root would have exposed sibling scopes (another user's, an unadmitted org layer) to Read/Write. The check is `MemoryEngine.readonlyDirNamesUnderRoot` (made public), the same set the harvest already excludes. Consequence: a deployment that stacks several scopes in ONE memory dir (the pre-v2 form) gets no file-tool memory writes and hears why — the layout's non-disjoint scope roots are the design defect, ledgered. ② `AdvertisedWritableDir.expectCanonical`: the fence's canonical spelling must equal the coordinate the engine's write gate and harvest key on (`writableRoot`); a sandbox that exposes the host root under another name is a skip naming both spellings, never a hole the content gate cannot see. ③ The rule's other half for the scratchpad: a skipped scratchpad is NOT advertised — the `# Scratchpad Directory` section renders only when the seat did not skip it (`verdictOf("scratchpad")`, prepare-prompt-inputs). ④ The memory phase is split into MOUNT (before hands) and `composeInjection({ rosterCanPersist })` (after hands, at the phase's former position): the one destructive read — the announcement drain inside `inject()` — no longer precedes a hands-phase refusal, and the roster-wide persistence verdict reads the MOUNTED roster exactly as before (a StubExecutionEnv mounts no shell; a catalog plan counted one — pinned). `PrepareMemoryInput` loses `rosterCanPersist`; `PrepareMemoryResult` loses `memoryBlock`/`recallDisciplineSegment`/`seedFiles` and gains `composeInjection` (driver-internal phase contract; direct callers are the test rigs). Noted: the seat's judgment never throws on the env's Result answers; its skip outlet is the caller's `onError` sink and is deliberately unguarded, like every other use of that sink.
20
+ - **Review round 2 (adversarial; one adopted, two ledgered).** Adopted: a write root that contains the engine's CONTROL PLANE (a `FileMemoryEngineBackend` pinning `controlDir` inside the memory dir — the dual-root branch refused that overlap at mount, the single-plane branches never did) is refused by the same one-list refusal as sibling scopes ("what the root contains that is not the model's to touch"). Ledgered, not fixed: (a) the sibling-scope check is a prepare-time registry snapshot — a scope registered by ANOTHER session mid-run nests a new directory under an already-admitted root (the same non-disjoint layout defect; the design ticket below is the fix, not a per-write re-read); (b) on a case-insensitive volume a memory dir configured with case-variant spelling splits the engine's coordinate (`layout.canonicalize` = JS `realpathSync`, casing-preserving) from the fence's (`NodeExecutionEnv.canonicalPath` = libuv `realpath`, on-disk casing) — today that is a loud `expectCanonical` skip; before #691 the same configuration silently ran the memory write gate against a key it never matched (measured on this volume: `realpathSync` keeps `LOWER/DIR`, `fs.promises.realpath` returns `lower/dir`). One canonicalizer for both is the fix (engine-wide, its own ticket).
21
+ - **Review rounds 3–4.** Round 3 caught that the round-2 guard had been edited but not committed (the mutation harness restored the file while the edit was uncommitted); re-landed in its own commit. Round 4 (verification): approve, no material findings — the guard reaches all three mount branches with the paired engine/handle, both refusal pins match, and every earlier axis reads clean.
22
+ - Pins after round 2: `test/backlog691-advertised-writable-dir.test.ts` 15 (every adoption pinned; 11 targeted mutations, each red ≥1 — including push-before-compare on the seat and the dropped control-plane check); `memory-engine-runner.test.ts` declared-capable remote arm now models the shared mount (the sandbox resolves the host root).
23
+ - Not fixed, recorded: the memory layout's non-disjoint scope roots (root scope = `memoryDir` itself) — the reason ① refuses multi-scope single-dir deployments; the fix is a layout that gives every scope its own subdirectory (migration + registry adoption), a design ticket. The memory write-gate ref cell (`memoryWriteGateRef`) could now be a direct value (the memory phase runs first) — a pure cleanup with six test call sites.
24
+ ### Contract batch — every fact a downstream branches on is a closed-set structural position; prose only renders (#688 C1–C5, #692 C-a–C-d; per-key dispositions in `docs/wire-consumption/7.14.0.md`; @server @cli @client-core @test)
25
+ - **C1 · the classifier deny's FORM.** Before: the three ways an auto-mode classifier round ends (could not run / answered outside its contract / ruled a block) reached the wire as one layer word `deniedBy: "classifier"` plus prose, and the "could not run" half rode a separate `classifierUnavailable: { cause }` seat on the deny observer's payload (never on `tool_end.gate`). After: `GateDisposition.denied.cause?: "unavailable" | "parse_error"` (`CLASSIFIER_DENY_CAUSES`, `isClassifierDenyCause`; layer table `DENIED_BY_MAY_CARRY_CAUSE`, invariant I5 in `screenGateOutcome`) — attested by the two classifier stations on the deny they mint (call-bound, identity-keyed, transferred across the fold's rewrite clone), read once into `GatePass.denyCause`, minted onto the disposition at the exit; a ruling block carries none. An ancestor's frozen classifier reached through the inherited chain keeps its `policy` attribution (station row 14) and carries the cause beside it. The WHY (`error` / `timeout`) stays on the deny sentence and the `auto_mode.classified` trace frame. The `classifierUnavailable` seats are retired (below) — the ask-side ones had not been engine-written since #661, the deny-side one spelled three forms as one word.
26
+ - BREAKING · `PermissionDeniedPayload -classifierUnavailable` (read `payload.gate.disposition.cause === "unavailable"`).
27
+ - BREAKING · `PermissionResult -classifierUnavailable` (both arms; the fact is engine-attested, never a caller member).
28
+ - BREAKING · `PreToolUseResult -classifierUnavailable` (the alias face of the previous line).
29
+ - BREAKING · `AskRequest -classifierUnavailable` (the tool_approval card / inbox row key; dead since #661).
30
+ - BREAKING · `AskCarry -classifierUnavailable` (the park closure's structural seat).
31
+ - BREAKING · `PendingAction -tool_approval.classifierUnavailable` (the durable row; a stored key on an old row is ignored by the projection).
32
+ - BREAKING · `CheckpointSummary -classifierUnavailable` (the parked-ask summary projection).
33
+ - **C2 · `ASK_ORIGINS` member `unresolvable` → `ancestor_marked`.** Before: the word said "unresolvable" for an ask whose whole point is that an ancestor MARKED it for the park (its resolution is the park's business). After: the word names WHO raised it, like every other member; the ladder arm and both lane tables re-keyed; no alias, no mapping.
34
+ - BREAKING · `ASK_ORIGINS -unresolvable` (+`ancestor_marked`; a durable row parked under the old word reads as a non-member at the resume screens — `isAskOrigin` — while its `org` fact still governs it).
35
+ - **C3 · the mechanism behind `rule_store_unavailable`.** Before: one origin word for two mechanisms (the store could not be read / the CALL could not be read against the person's rows), told apart only by the ask's message text. After: `RULE_STORE_UNREADABLE_KINDS = ["store", "call"]` (`RuleStoreUnreadable`, `isRuleStoreUnreadable`); the gate reads the lane's answer ONCE into `AskOriginFacts.ruleStore` (the `"unavailable"` input word is gone — a type-level input, not a wire key), stamps `ruleStoreUnreadable` beside `origin` on the surviving ask (a policy's self-declared value is removed there) and carries it onto `AskRequest`, `AskCarry`, `PendingAction.tool_approval`, `CheckpointSummary`. Present ⇔ `origin === "rule_store_unavailable"`. Additive.
36
+ - **C4 · `WorkflowRun.errorCode?: string`.** Before: a run that failed — on a parked child in particular — carried `status: "failed"` and free text; only `WorkflowAgentRun.errorCode` was machine-readable. After: the run-level code in the same vocabulary, attributed ① from the thrown value's own `code` (a park ⇒ `workflow.agent_parked`, a refusal ⇒ its class code, a re-thrown typed error ⇒ its code), ② else the FIRST failed child's code (earliest `endedAt`, spawn order on a tie), ③ else absent (never `""`; absent on every completed run). Additive.
37
+ - **C5 · `blocked` is TERMINAL.** `TerminalCause.blocked` / `TaskStatus` JSDoc and `docs/INTEGRATION-CORE.md` §3.1's five-state table now say it in one sentence: the agent's own `report_blocked` self-report ends the leg and waits for nobody; the waiting-on-a-person words are `suspended` (pre-action approval) / `needs_review` (review pause), `parked` on the registry face. Docs only; pinned against the closed set.
38
+ - **C-a · `memory.consolidation_withheld` audience `user` → `operator` (value-domain).** Before: a `user` row minted where no user can be reached — a consolidation run is a host-driven maintenance run outside any session; `detail` carries scope / runId / counts and no `sessionId`, and no scope→session route exists (a memory scope is shared by many sessions). After: the operator's row, like its five siblings; the reader-facing half rides the receipt (`withheldFromCleanArm` / `withheldNeverSent`). Chosen over "add a routable position" because the position does not exist to add.
39
+ - **C-b · `TaskResult.stats.usageMissing?: true`.** Before: a leg whose provider reported no usage frame, and every host-minted zero result (a refused spawn's journal entry, a prepare-time refusal, a synthetic abort), wrote `tokens: 0` indistinguishably from "free". After: the key is present when at least one model round carried the brain's `usageMissing` mark (the `turn_end` frame's word, now on the leg's stats once) or the result never ran a measurable round; the numbers stay required and stay what was counted — a LOWER BOUND beside the key. Absent ⇔ every round reported usage; never `false`. Additive.
40
+ - **C-c · `reasoningEffortLevelsOf(model)` (export).** Before: the per-API effort acceptance set the clamp applies lived only inside `resolveEffort`'s callers; a host offering "supported tiers" read a global default it could not vouch for. After: the one table, two reads — anthropic effort lane = the declared `effortLevels`, budget lane = `undefined` (a token gradient, no tier set), Responses / completions = the sanitized `reasoningEffortLevels` or `DEFAULT_EFFORT_LEVELS`, binary formats and `supportsReasoningEffort:false` (openrouter excepted) = `undefined`. `sanitizeEffortLevels` is shared with `resolveEffort`, so `resolveReasoning(...).effective ∈ reasoningEffortLevelsOf(model)` wherever the set is defined. Per API, not per model id — `Model` grows no key.
41
+ - **C-d · which plane denied auto mode.** Before: `RuntimeCaps.autoMode: false` arrived already folded from the resolver (org / local / settings indistinguishable) and the manifest said `denied`. After: `RuntimeCaps.autoModeDenySource?: "org" | "local" | "settings"` (`AUTO_MODE_DENY_SOURCES`, `isAutoModeDenySource`) — the resolver's own word beside the bit, screened with it (a source beside a non-false bit, or outside the set ⇒ `onError` + deny coined + `resolver_fault`, never defaulted); `autoModeArmReasonOf` mints the whole `AutoModeArmFact { armed, reason, deniedSource? }` (exported) and `WiringManifest.autoMode.deniedSource` carries it only beside a STATED `denied` (`readAutoModeFact` refuses it elsewhere). Additive.
42
+ - **Fail-open reverse check — which fact still lives only in prose after this batch?** The WHY behind a classifier unavailability (`error` vs `timeout`) is deliberately prose + trace (not a branching fact); the lexer's reason behind `ruleStoreUnreadable: "call"` is prose (the mechanism is the fact; the sentence is the rendering); a workflow body's own codeless throw stays `error` text with no code (there is no fact to structure). Everything else the two integration audits named now has a position.
43
+ - Pins: `test/backlog616-classifier-unavailable.test.ts` (C1, rewritten), `test/backlog688-contract-batch.test.ts` (C3 / C4 / C5 / C-a / C-b / C-c / C-d, each double-sided), re-pinned: backlog661, design503, gate-outcome-stations S7, backlog564, gate-outcome-adversarial-r3, backlog625, backlog642 (park ⇒ `workflow.agent_parked`), design383 §1.4, governance-codes, prepare-wiring-manifest, backlog529 (unchanged, green). Export surface +12 (snapshot re-baselined). Ratchet raises: gate-lanes 1425→1445, assemble-result 620→630, run-harness-handlers 583→590, prepare-caps-and-workflow 787→800.
44
+
45
+
46
+ ### Behaviour — a child inherits the parent's RESOLVED checkpoint seat, both delegation lanes, one rule (#683; P1 design; test [6896] G-1/2/3/10; server B-060; @server @cli @test)
47
+ - **Before.** Both delegation lanes handed a child only the parent's OFF switch: the Task lane copied `checkpointStore: "disabled"` off `ctx.checkpointStoreDisabledForChildren`, the `run_workflow` lane off `RunWorkflowToolDeps.parentCheckpointStoreDisabled` / `buildWorkflowPrimitives(…, parentCheckpointStoreDisabled)`. A child whose seat was left unset re-resolved `RunnerDeps.checkpointStore` on its own — so a parent parking through a SPEC-level store (a deployment that wires the store per task, no store on the Runner deps) spawned children that found no store: the background child's and the workflow child's asks fell to the fail-closed deny (`delegation.ask_unresolvable`, `parkLaneExisted: false`) while the parent could park.
48
+ - **After (the rule, in one sentence).** The driver mints the run's checkpoint SEAT once, right after the config doors (`checkpointSeatOf(spec, resolveCheckpointStore(spec, deps))`, `src/core/checkpoint-seat.ts`): the store object the run parks in, the word `"disabled"` when the caller disarmed the run, or NO seat when the run has no store anywhere. A seat rides the trusted ctx seat `ToolExecuteContext.checkpointStoreForChildren` and the Workflow mount dep `RunWorkflowToolDeps.parentCheckpointStore` and lands on every child spec VERBATIM (Task sync / background / fork: `subagent.ts` one spread; workflow: `buildWorkflowPrimitives(…, parentCheckpointStore)`); no seat ⇒ no key, and the child's own door resolves its runner's deployment exactly as before (a split child-execution runner may carry a store the host has none of — `test/agent-park-jian4-durableapproval-forwarding.test.ts:643`, the design/153 件4 E2E chain, pins that topology and is unchanged). Rules 2 → 1: "deps baseline + only the OFF switch travels" is replaced by "a parent with a seat hands the whole seat down". Tighten-only in both directions: `"disabled"` lands UNCONDITIONALLY (a deployment baseline store does not re-arm a run the caller disarmed); a store lands only when the child's seat is unset — a baseline-pinned or trusted-script store is an explicit author and keeps winning (the baseline-principal precedent); a governed script's own `checkpointStore` is stripped and announced as before (`workflow.governance_key_stripped`, not on the whitelist). The child's own door re-reads an inherited store through the same `execution.outcome` predicate (idempotent on the object the parent already passed; no "inherited ⇒ skip" special case).
49
+ - **Review round (adversarial, two REAL findings, both fixed and pinned).** ① `workflowAgentCallKey` hashed the WHOLE child spec — a store object carries its own live state (an in-memory store's row map), so every park moved the journal key of the very call that parked, and an unchanged script's resume read its parked ordinal as a changed call. The checkpoint seat is deployment wiring, not behaviour: it is stripped from the identity like `signal`/`taskId`/`sessionId` (`workflow.ts`). ② The background lane's park family read `background.checkpointStore` (the tool option) for eligibility, TaskStop arbitration, compensation and claim rollback while the child now parks in the inherited seat — with two different instances a stop reported a phantom resume while the row stayed parked. The lane now addresses the store the child was HANDED (`ctx.checkpointStoreForChildren`, none for `"disabled"`); the option is read only when no host seat exists (a tool invoked outside a Runner task — its JSDoc says so). Round 2 (two more REAL, both fixed and pinned): ③ the observer sidecar (`observerSpec`, engine-built like every child) never carried the seat — a `"disabled"` parent's observer could park in the deployment store; it now carries the seat by the same spread. ④ the parked resume's config was rebuilt from the drive's ctx (the bare `/decide` ctx hands no seat), so with no Runner store the resume reported `no CheckpointStore wired` while the checkpoint stayed pending — the resume config now binds the store the lane arbitrates in (`checkpointStore: resumeRest.checkpointStore ?? childParkStore`; an explicit `"disabled"` still refuses). Consequence pinned in `agent-park-jian4-durableapproval-forwarding.test.ts` (RB-482 #31): a `background.checkpointStore` facade that KEEPS throwing now fails the drive loudly (rolled back, row still parked and redeemable) instead of proceeding through a second handle; the disclose-and-proceed shape is the transient (throw-once) form. Round 3 (one adopted, one refused): ⑤ a `background.checkpointStore` naming a DIFFERENT store than the host seat let a bare `/decide` drive resume against the option while the row lived in the seat — it is now refused at the first spawn (`config.invalid_checkpoint_store`, the checkpoint-seat refusal family; same instance or unset option both spawn). ⑥ REFUSED: "keep legacy journal keys" — `workflowAgentCallKey` documents that a spec-shape change moves every key and the resume of a pre-upgrade journal RE-RUNS live rather than replaying (design/164, declared); a compatibility key is the compat layer this codebase does not carry. **Upgrade note (@server):** a workflow journal written by ≤7.13.0 whose child specs carried `checkpointStore` (a `"disabled"` host, or a baseline-pinned store) misses on resume under 7.14.0 and its suffix re-runs live — finish or discard such parked workflow runs before upgrading if their children hold external writes. Not fixed, recorded: a caller tool whose non-string name is a `BigInt` AND whose `modelGate` class removes it for the selected model hits `JSON.stringify` in the model-gate disclosure before the roster-bound door (a second door would add a rule; the proper fix is to make the declaration door the FIRST reader).
50
+ - **Type keys (BREAKING on two exported types, no runtime seam).** `ToolExecuteContext.checkpointStoreDisabledForChildren?: true` → `checkpointStoreForChildren?: CheckpointStore | "disabled"` (Runner-filled whenever the run has a seat — an ordinary run on a deployment store now carries the store object where it carried no key; a run with no store anywhere carries no key, as before); `RunWorkflowToolDeps.parentCheckpointStoreDisabled?: boolean` → `parentCheckpointStore?: CheckpointStore | "disabled"`. `SubagentToolOptions.background.checkpointStore` keeps its key with a narrower meaning (read only without a host seat). Phase Inputs: `PrepareInheritedGateInput` / `PrepareCapsAndWorkflowInput` gain `checkpointSeat`, `PrepareQuestionFaceInput` gains `checkpointStore` (the question face no longer resolves its own; its `spec`/`deps` Picks drop `checkpointStore`).
51
+ - **Observable (pinned both ways).** Host store on the SPEC only: background child parks (pending row, `sourceTaskId` = the child session, zero notices); workflow child parks (wa* row `parked` + token, zero notices). Host `"disabled"` + deployment store: neither lane parks, ONE `delegation.ask_unresolvable` with `parkLaneExisted:false`. Host unset + deployment store: byte-for-byte the #642 six-grid (both lanes park; the ctx seat IS the deployment store object). Host seat = store A while `background.checkpointStore` = B: the child parks in A, TaskStop expires A's checkpoint (`killed`), B never sees a row.
52
+ - **Fail-open reverse check — which parent state lets a child be WIDER than its parent?** None: a `"disabled"` parent presses on the subtree; a parent with a store hands the child the same store; a parent with NO seat leaves the child exactly where it was before this change (its own door, its runner's deployment — the deployment's authored topology, pinned by the 件4 E2E chain).
53
+ - Pins: `test/backlog683-child-inherits-resolved-checkpoint-seat.test.ts` (①②③④ real-run through both lanes + the two review findings), `test/checkpoint-store-null.test.ts` (the three Task lanes + the workflow primitives + the Runner-filled seat, four seat states), `test/run-workflow-tool.test.ts`, `test/prepare-inherited-gate-transcript.test.ts`, `test/prepare-caps-and-workflow.test.ts`.
54
+ - **Not this car:** the teacher/student helper legs (`teacher.ts`) keep copying only the OFF switch — those legs cannot carry a pause (they read `r.result` and release the session), so a store must NOT reach them; their rule is stated there. Observer sidecar legs carry the seat since round 2 (see above).
55
+
56
+ ### Fix — a caller tool without a string `name` is refused by name, not crashed on (#684; test [6896] G-12)
57
+ - `rosterMemberBoundProblem` (the ONE roster-bound judge, `tool-roster.ts`) now judges the name's TYPE first: a member whose `name` is not a string (a class instance minted without an own `name`, `null`, a number) returns `config.tool_roster_bound` with `tool has no string name (got <typeof>)` — the declaration door (`prepare-safety-scan.ts`) and the row mint both read it, so the refusal is typed wherever the entry enters. Previously the door threw a bare `TypeError: Cannot read properties of undefined (reading 'length')`. The door's own frame is now `A caller tool cannot ride a roster row: <the judge's sentence>` (the judge's sentence already names the tool; the frame no longer re-reads `t.name`). Pin: `test/backlog684-tool-without-name-refused.test.ts` (+ positive control: the same class with an own `name` mounts).
58
+ ### Behaviour / wire — a stall watchdog is no longer reported as a dropped connection (#682 臂②; @cli @server @test)
59
+ - **`BrainRetryErrClass` +`stall` (closed set +1, additive).** `connect_refused | transport | stall | rate_limit | server | http | output_cap`. `stall` says NOTHING was observed on the link: this process cancelled its own request because one of its stall watchdogs reached its deadline. `transport` keeps its old meaning minus that case — something that happened TO the call. The split is about WHO GAVE UP, and claims nothing about the remedy: a blackholed link (no reset, no close) also produces silence, and silence is all a watchdog measures, so a `stall` may still turn out to be a network fault — what it always states truthfully is that the request was cancelled here, at a configured deadline, instead of lost out there.
60
+ - **The mid-stream retry announcement now belongs to the FAILURE, not to the tier.** Both re-sending tiers (B — only thinking streamed; C — nothing substantive) read one mapping, `midstreamRetryAnnouncement` (`src/brain/stream-engine.ts`). A watchdog cut announces `errClass:"stall"` with `detail` naming the watchdog, the deadline it fired at and the knob that arms it — `first-token watchdog fired after 120000ms (firstTokenTimeoutMs), retrying` / `stream idle watchdog fired after 90000ms (idleTimeoutMs), retrying`. A real mid-stream link failure still announces `errClass:"transport"` / `connection lost, reconnecting`. Before: BOTH tiers wrote the connection wording for both species, so a gateway whose prefill legitimately ran past the first-token deadline reported `transport` + "connection lost, reconnecting" and sent the operator looking for a network fault. The operator-facing `brain.retry` telemetry reads the same bucket as the user-facing status frame, as it did before.
61
+ - **Unchanged:** `phase` stays `reconnecting` for both species (the brain IS opening a fresh request); the `waiting_first_token` frame (`elapsedMs` / `timeoutMs`) is untouched; the tier-A partial-finalize warning keeps its CC-verbatim wording; the CONNECT-leg timeout keeps `errClass:"transport"` (a different watchdog and a different knob — not in this change). No watchdog default value moves *in this section* — the first-token default is raised by 臂① below, and nothing here depends on its value.
62
+ - **Downstream.** `@cli` `@server`: `errClass` gained a member — a renderer that switches on it must add a `stall` arm (the old wording arrives under a new class, so a `transport`-keyed branch stops firing for watchdog cuts); a consumer keying its "connection lost" prose off `detail` must re-read it. `@test`: the watchdog-cut retry frame's `detail` and `errClass` are both new text.
63
+ - Pins: `test/backlog682-stall-retry-announcement.test.ts` — ① first-token watchdog ⇒ tier C, `stall` + the watchdog/deadline/knob prose; ② idle watchdog ⇒ tier C (dangling tool call), same shape; ③ 反控 a real mid-stream link failure ⇒ `transport` + "connection lost, reconnecting"; ④ tier B (thinking-only) reads the SAME mapping, on tier B's own budget.
64
+
65
+ ### Behaviour — the first-token stall watchdog's default rises 120s → 600s (#682 臂①; ruling A; @server @cli @test)
66
+ - `STALL_FIRST_TOKEN_MS` (`src/brain/timeout.ts`), the ADVISORY per-call first-token bound the Runner supplies on every provider call, goes from `120_000` to `600_000`. The old value rested on a reason that is false for a real class of backend: a self-hosted server working through a long prefill emits literally nothing for 2–3 minutes and is perfectly healthy, so the watchdog cancelled real work and the re-send burned the same prefill again. 600s is the same order of magnitude as the reference implementation's own request timeout; the shape the bound exists for (a gateway that accepted the request and then died) is still caught, just later, and the task's own hard abort remains the outer bound.
67
+ - **Unchanged:** `STALL_CONNECT_MS` (30s — a different segment, the headers wait) and `STALL_IDLE_MS` (90s — silence after output started); the ADVISORY priority chain (a brain's construction-time `firstTokenTimeoutMs`, including an explicit `0`, still wins — a raised default is not a raised floor); the Runner's unconditional per-call supply.
68
+ - **The outer watchdog moved with it — `WORKFLOW_AGENT_STALL_MS` is now DERIVED (180_000 → `STALL_CONNECT_MS + STALL_FIRST_TOKEN_MS` = 630_000; #693).** `runWorkflow`'s per-agent progress watchdog is a COARSER instrument than the engine's re-send: it aborts the attempt and restarts the whole agent on a fresh session, so tool effects from before the stall can be repeated. At 120s the engine acted first in the ordinary case; raising the engine's bound past 180s inverted that systematically, and the same byte-silent provider call produced two agent runs on two sessions instead of one run with a re-sent call (measured). Deriving it means moving either engine bound moves this one too. A deployment that wants the old aggression passes `stallMs` explicitly (still honoured). CC divergence: `MTy` is 180000 upstream, and this is now a sema value — the alternative is an engine default whose own outer layer cancels it.
69
+ - **Read the sum as a floor, NOT as a proof of ordering (measured, adversarial round).** The two clocks do not start together and the outer one does not reset per inner retry, so it can still fire first: it starts before the Runner prepares the attempt (an execution-env factory, a hook, a store read all run inside it and outside the engine's window), while the first-token timer only starts once headers arrive — preparation plus connect time is exactly the margin by which the inner deadline can land later. Two silent provider attempts exceed the outer window by construction. The derivation removes the case that was GUARANTEED to invert; it does not make inversion impossible, and a deployment wanting a real guarantee sets `stallMs` from its own preparation budget. Also note the `run_workflow` TOOL's own whole-workflow cap (`totalTimeoutMs`, default 600_000) is now SMALLER than this window, so under that tool the total cap is what a long stall meets first.
70
+ - **A second meaning had to be split off it: `WORKFLOW_THROTTLE_SLOW_MS` (new, 90_000).** The throttle-shaped-degradation predicate read `stallMs * 0.5` — CC's own spelling, and exactly 90s only while the window was CC's 180_000. Deriving the window silently moved that threshold to 315s, so a 2-minute empty answer stopped earning its 45s-sleep-and-retry. The threshold is now its own constant at CC's value, floored by half the stall window (`min(stallMs / 2, 90_000)`) so a deployment that shortens `stallMs` still tightens it. Exported from `src/orchestration/workflow.ts`; NOT added to the package export surface (`handoff:diff` 0/0). The existing throttle pins derived their own durations from the stall constant and therefore moved with the regression — they now use a fixed 120_000.
71
+ - **What 600s still does NOT buy you (measured; disclosed, not fixed).** On Node's own `fetch` — the default transport for both adapters — undici destroys a body that has produced no bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt there and arrives as `errClass:"transport"` (correctly — the transport did cut it). A first token at 400s is therefore still unreachable on default Node fetch; the ruling's target case (a 2–3 minute prefill) sits inside 300s and is fully covered. **The seat to lift it exists:** `fetchImpl` on both `createOpenAIBrain` / `createAnthropicBrain` configs takes any `fetch`, so a deployment needing the full 600s supplies one built on an undici `Agent` with its own `bodyTimeout`. Filed as #694.
72
+ - **Downstream.** `@server`: mirror the catalog default for the settings key that steers this (`MODEL_FIRST_TOKEN_TIMEOUT_MS`) in 7.71.0. `@cli`: a deployment that set that key to work around the old bound can drop the override. `@test`: a run against a slow-prefill backend that used to fail `[network] first-token timeout after 120000ms` now waits.
73
+ - Pins: `test/backlog682-stall-retry-announcement.test.ts` 臂① (the supplied default is 600s, both sibling legs untouched; 反控 a construction-time `firstTokenTimeoutMs` still wins) and ① (the workflow window IS the derived sum; a byte-silent first provider call recovers in ONE agent run with TWO provider calls; 反控 an explicit `stallMs` below the engine's bound restarts the agent instead and is still honoured). Re-pinned: `test/walltime-stall-watchdog.test.ts` 量纲 sanity, `test/stream-options-forwarding.test.ts` (both per-call supply legs), `test/workflow-resilience.test.ts` (the window and the stall-exhaustion message read the constant instead of a literal 180000; the five throttle legs now spend a FIXED 120_000 so they stop moving with the threshold they test, plus two BOUNDARY legs either side of the exact threshold — without them the duration condition could be deleted outright and the suite stayed green).
74
+
75
+ ### Behaviour — the two output-length knobs say so when they discard a value (#690)
76
+ - `BASH_MAX_OUTPUT_LENGTH` (non-finite / ≤ 0) and `TASK_MAX_OUTPUT_LENGTH` (not a plain positive integer) fell back to their built-in defaults (30000 / 32000 characters) in SILENCE, with no written reason for the silence — while their sibling `MAX_MCP_OUTPUT_TOKENS`, same shape and same kind of resolver, already announced. Both now emit the sibling's line on the same process-level channel, once per deployment fact: `BASH_MAX_OUTPUT_LENGTH=25k was ignored — it is not a positive whole number of characters. Using 30000 instead.` For every value class the old resolvers already rejected this adds loudness and nothing else — the number in force does not move. One further class DOES move, and it is described in the next bullet.
77
+ - **One value class DOES change, and it is a bad-value class.** `BASH_MAX_OUTPUT_LENGTH` now requires a positive WHOLE number — the rule its own announcement states. Before, `Number.isFinite(raw) && raw > 0` let a fraction through to `Math.floor`, so `BASH_MAX_OUTPUT_LENGTH=0.5` resolved to a budget of ZERO and the shell's clip returned the empty string for a command that had already run and produced output — silently, and on the `TaskOutput` leg with no recovery file either. `0.5` / `.5` / `1e-3` / `1.5` now announce and take the 30000 default. Every whole-number value, the ceiling clamp, and the absence rule are untouched (adversarial round; found by the independent reviewer).
78
+ - Absence stays silent — unset, empty, or blank is not a value (the written exception the shell's timeout knobs already state).
79
+ - The announcer moved out of `src/core/mcp.ts` into `src/core/env-knob-announce.ts` (`announceEnvKnob`) so the outlet is a property of the shape — a process-level env knob whose resolver holds no structured notice seat — rather than of whichever domain built it first. No public export changes.
80
+ - The shared announcer is swallow-guarded (the posture every other advisory sink here already holds), which the MCP knobs inherit: a host that replaces `console.warn` with a logger that throws, or a `Console` built with `ignoreErrors:false` on a full stream, now costs the announcement and nothing else. Unguarded, it would have thrown out of the shell's output clip — which runs AFTER the command ran — destroying a completed command's result and inviting a re-run of work whose effects had already landed (adversarial round 2).
81
+ - Pins: `test/backlog690-output-length-knob-loud.test.ts` (per knob: four/five bad values ⇒ one line naming knob, raw text and the default in force + the second read stays silent; 反控 a legal value, a clamped-but-legal value, and absence ⇒ zero lines).
82
+
83
+ ## 7.13.0 — 2026-09-10
84
+
85
+ ### Narrowing — a run that has already given its answer is no longer cancellable (#677; ruling 「最后一次 `turn_end` 后 run 不可取消:三轴同律;窗内停机终态记 completed;`interrupt()` 改有明确回执」; sema form, no CC 2.1.250 mirror — DV-677-1; @server @cli @test)
86
+ - **The rule, in one sentence.** Once the run's last turn has ended `stopReason:"stop"` (a clean answer — not a `partialFinalized` salvage, not `length`, not a tool batch), a hard stop landing on the run's TAIL — `TaskStream.interrupt()`, the task's `signal`, the walltime limit, and the turns cap firing on that same answer turn — cuts no work, and the run says so: it ends `completed` with that answer as `result`; no `[Request interrupted by user]` marker is written; no `limits.max_walltime_exceeded` / `limits.max_turns_exceeded` is minted for the axis; the end-of-task compaction pass does not run (no second summary call after the stop, no hook seat to collapse — see below); and the stop's only trace is ONE `task.interrupt_unconsumed` notice. The stop still aborts what is left of the tail — a trailing-boundary compaction in flight is mooted (`compact()` resolves `"mooted"`; no second summary is burned for it), a slow `stop` hook's seat still collapses to the grace as every hook seat does on the task signal. Before: the same stop recorded `failed` / `"run aborted"` (walltime: `+ limits.max_walltime_exceeded`; the turns cap: `+ limits.max_turns_exceeded`), wrote the marker (the next run on the session then read a finished answer as an abandoned one), and collapsed the seats — for a window the bundled in-memory store closed (0–7 ms after `turn_end`, reachable only from the same microtask chain) but any slow await after the last turn opened wide: the whole final-boundary compaction call (seconds), a slow `stop` hook, a durable/remote session append.
87
+ - **One predicate, evaluated once, read by five sites, zero new branches.** `answerSettled(final, userInputLost)` := `final?.stopReason === "stop" && final.partialFinalized !== true && !userInputLost` (`src/core/runner/assemble-result.ts`; a module export, not a public name). The second fact is "a continuation was cut", from two owners: the harness's own agent_end account (`onUndrainedUserInputs`, which counts a frame drained at the final boundary but never served) — a steer / follow-up the person had already queued makes that "stop" work in progress; the loop's own trace steps (`Prepared.workCutRef.continuation`: a `continue` step then the `aborted_before_stream` terminal) — a thinking-only "stop" the loop had decided to nudge into a visible answer, a truncated-output or degenerate-output recovery, a drained frame — in both of these the loop exits on the abort BEFORE the next dispatch and `final` is still the previous clean answer, so the message alone cannot tell (adversarial r1 + r2, both reproduced: r2's thinking-only shape returned `completed` with `result:""`); and the `turn_end` handler's own reading (`Prepared.workCutRef.abortedBeforeTurnEnd`: the run's abort was already in force when the last turn ended, read before the turns cap fires) — the ruling's line is the last `turn_end`, so a brain that ignored its signal and delivered a full `stop` THROUGH the abort still answered a run somebody had stopped mid-work (`failed` / `"run aborted"`, the bytes in `result`; the existing rb506 cancel control pins it). The terminal-adoption lane evaluates it ONCE (`RunTerminalAdoptionResult.answerSettled`) and every reader takes the value: (a) the terminal arbitration's slot 9 via `ResultFlags.answerSettled` — the whole `abortedLive | stopReason aborted` disjunction stands down and the run takes the EXISTING completed floor (no terminal is newly minted; `TerminalCause` stays single-sourced); (b) the interruption-marker gate (the orphan reconcile and the held session-write drain still run on every live abort — only the marker is gated: with no open batch the reconcile is a session read, and the held writes have no other drain before the harness is discarded); (c) the end-of-task pass — skipped outright for a stop on the tail (below), so its `cancelled` option (the hook-seat grace, unchanged) only ever serves a stop on work; (d) the teardown's background-child attribution — a child of a run stopped on its tail is settled `stoppedBy:"parent"` as on any completion, never `"user"` under a parent that reports completed (adversarial r1); (e) the notice gate. And two passes that used to start a NEW model call after a live stop no longer do — they are part of the tail the stop aborted: the opt-in suggestion pass (`suggestNextPrompts`; it builds its own controller, so it used to start a fresh call after a person's interrupt — independent review) and the end-of-task compaction for a stop on the TAIL (`finish`'s `skipCompaction` now includes `abortedLive && answerSettled`: `maybeCompact` takes no cancellation signal there, so a trailing-boundary summary the stop had just mooted was burned a second time before `result()` resolved — adversarial r2, reproduced; the session's next run compacts at its own boundary). A stop on WORK keeps the documented between-task pass (design/64 §25 "after a max-turns abort the boundary does not compact — finish() does", pinned in `compaction-within-task-turn-boundaries`), with its hook seats on the post-abort grace as before — r2 proposed skipping it for every live abort; the documented contract wins. Rules: −2 (the sites used to answer "was this run stopped?" from `abortedLive` alone, each with its own reading of the tail; the answer question is asked once) +0 exceptions.
88
+ - **`interrupt()` answers, both sides of the loop's end.** After the loop's end (the teardown window: the reconcile, the ledger flush, the end-of-task compaction — or once the result is out) it REJECTS `steering.not_running` (`cannot interrupt: the task is no longer running` / `…has already finished` / `…is not running` on a prepare that never produced a handle) — the halt/steer liveness gate on the same two facts (`resultValue`, `loop.ended`); it used to resolve `void` and do nothing. Inside the window it is NOT refused (the run is alive) — it aborts the tail, and the run tail owns the account (the notice). **Consumers:** a fire-and-forget `void stream.interrupt()` after the result is now an unhandled rejection — attach a handler, as for `halt`/`steer`. The bundled HTTP server's disconnect handler (`src/server/http.ts`, the one in-tree fire-and-forget caller — adversarial r1 + independent review, both) handles it: a client that disconnects while the run is already tearing down is that race, not a fault.
89
+ - **Export surface (+1 closed-set member, additive).** `ENGINE_NOTICE_CODES` +`"task.interrupt_unconsumed"` (`NOTICE_AUDIENCE` row `user`; `detail: { sessionId, runId, taskId?, origin }`, `origin` ∈ `"interrupt"` | `"walltime"` | `"turns"` | `"signal"` — the last is every other run-level abort the leg cannot name (the task's own `signal`, a parent's cancel, a `destroy()`, a refused preempt); once per run, minted AFTER the terminal arbitration so it can never claim a completed ending the terminal did not give). No other public name added or removed (`handoff:diff` 0/0); internal lane seats: `RunSettleAndTeardownInput` +`userInterruptedLive` +`answerSettled`, `RunTerminalAdoptionInput` +`userInputLostAtEnd`, `RunIdentityWiringResult` +`userInputLostAtEnd` (read face), `RunTerminalAdoptionResult` +`answerSettled`; `gate:phase-api` floor 941 → 944.
90
+ - **Observable (double-sided; the probe car's readings, now pinned).** ⟨tail⟩ answer `"stop"` → trailing-boundary compaction 2.5 s → stop at +400 ms: `interrupt()` resolves, `result()` = `{ terminal: { kind: "completed" } }`, `result` = the answer, `errorCode` undefined, `compact()` → `"mooted"`, no new model turn, session markers 0, notices = exactly one `task.interrupt_unconsumed` (`origin:"interrupt"`); the task `signal` → same with `origin:"signal"`; `limits.maxWalltimeMs` firing inside the compaction → same with `origin:"walltime"` and NO `limits.max_walltime_exceeded`; `limits.maxTurns` = the answer's own turn → same with `origin:"turns"` and NO `limits.max_turns_exceeded`; with no widener at all, an `interrupt()` issued on the `turn_end` event lands inside the window → completed. End-of-task `preCompact` hook that never answers, `timeoutMs: 2500`, stop on the tail → `onError` says `did not answer within its 2500ms bound` (the declared deadline), run completed. `interrupt()` after `result()` → rejects `steering.not_running`; from inside the end-of-task `preCompact` hook (after the loop's end) → rejects the same; steer/halt/interrupt on a finished task → the same code, three verbs. ⟨work⟩ the same three stops landing while a tool is running → `failed` / `"run aborted"` (walltime `+ limits.max_walltime_exceeded`), the interruption marker written for `interrupt()`, no notice; the hanging `preCompact` seat under a mid-tool stop → `had not answered when the task was cancelled` (the grace) and settles under 2.4 s. A run with no stop at all → completed, compaction landed, zero notices.
91
+ - **Fail-open reverse check — which stop STILL records an answered run as failed (unchanged, pinned G6 / G1.e).** A steer or follow-up the person queued before the answer and the stop cut before it was served (`failed` / `"run aborted"` + the marker + `task.user_steer_undrained`); a budget ceiling crossed by the last turn's own spend (`limits.max_tokens_exceeded` / `limits.max_cost_exceeded` — slot 3, above the yield); a throw or a lifted platform / guardrail / gate-stop cause (slots 4–7, e.g. `usage.window_exhausted`); the suspend-loop cap (`suspend.loop`); an UNSETTLED final under any live abort — `length`, `toolUse`, a partial `"stop"`, an `aborted` cut, no final (`failed` / `"run aborted"`, the axis code intact). A durable pause is untouched (slot 8.5).
92
+ - **Downstream.** `@server`: a walltime / turns limit that fires after the answer no longer produces a `limits.*` terminal — a per-run count of `limits.max_walltime_exceeded` / `limits.max_turns_exceeded` drops by exactly the runs that had already answered; the `task.interrupt_unconsumed` notice (with `origin`) is the replacement signal — whitelist the code (closed-set addition), forward it on the wire as the other `task.*_unconsumed` rows are. `@cli`: `interrupt()` after the result now rejects `steering.not_running` — map it to the cancel prompt the way `halt`'s refusal is mapped (cli [6862] ③); a run cancelled after its answer renders as a completed run with its answer plus the notice text. `@client-core`: no shape change (`EngineNotice` is untyped on `code`). `@test`: G1–G6 in the release criteria.
93
+ - Pins: `test/backlog677-cancel-window.test.ts` — G1 (three axes + no-widener + the queued-continuation control G1.e + the thinking-only-nudge control G1.f with its unstopped twin G1.g + no-stop control; G1.a also pins that no second summary starts after the stop), G2 (marker both ways), G1.h (a brain that answers through the caller's abort ⇒ failed), G3 (no end-of-task pass after a stop on the tail — no summary call, no preCompact seat; a stop on work keeps the pass with its seat on the grace; the unstopped control runs the pass and its hanging seat expires on the declared bound; the tail arm asserts the notice so a timing slip cannot make it vacuous), G4 (refusal after the result / from the teardown window / three verbs one code / mid-tool positive control), G5 (work controls), G6 (the predicate + the arbitration as a unit matrix incl. the fail-open reverse list; the verdict as a value), G7 (background-shell child receipt `parent` on the tail / `user` on work), G8 (no suggestion pass after a stop / the pass runs on an unstopped completion). Re-pinned: `test/steer-interrupt-lifecycle-389.test.ts` "a LATE interrupt() … mints no marker" (the late stop is now refused typed — asserted — and still writes no marker); `test/steering.test.ts` "interrupt() after the task finished" (was the council #4 silent no-op; now the typed refusal).
94
+ - **Not this car (recorded for the ledger, not fixed):** a durable / remote `SessionStore` append widens the window the same way (inferred from the await class, not measured); a subagent / child run's tail (the same sites, not probed); a `preemptSignal` fourth axis (files under `origin:"signal"`); an abort that lands BEFORE an `interrupt()` in the same window records `failed` with no marker (the first-writer origin — inferred); a `spec.signal` abort that lands AFTER the leg's `abortedLive` snapshot (the teardown window) changes nothing and mints nothing — the caller fired its own signal and gets no receipt (independent review; no consumer known to care); a CC-style `stop` hook that answered "keep going" and was then cut by a stop before the nudged turn dispatched ends `completed` with the answer (the hook's continuation is not a person's queued input and is not folded in — same shape, different owner; independent review); the same-window boundary-bundle residue in `run-turn-boundary.ts`; the turns-cap arm of this rule (the cap firing on the answer turn ⇒ completed) is the whole-disjunction reading of the ruling's "three axes, one law" and is named here so it can be un-ruled if that reading is wrong.
95
+
96
+ ### Narrowing — a read-boundary MANDATE is never the auto-mode classifier's to answer (#681; B-059; cli [6870] U-2; B-057's third lane; @cli @server @test)
97
+ - **The hole.** `bashReversibilityProbe` mints `mandated` for a deny-listed / out-of-root / recursive read; the gate folds that ask under the origin word `shell_gate_tighten` — the classify tier's OWN hesitation — and `CLASSIFIER_MAY_ANSWER.shell_gate_tighten` is `true`. So with auto mode armed the classifier was asked, answered allow, and the read ran with ZERO asks (foreground and backgrounded alike; measured in-process: one classify call, `tool_end.gate` allowed, the sentinel on stdout). The rule lane (7.11.1/7.11.2, B-057) and the shell's sync-allow layer (cli B-058) had both been closed; this was the same boundary's third clearing lane.
98
+ - **The rule (one word-set, reused).** The live gate's classifier station and the inherited-lane station both now read the allow layer's mandated-family word (`persistedRuleMandateOf`) and exclude the PROBE half of it — `probe_mandate` / `probe_unanswered`, i.e. the deployment's own read boundary judged per call (`probeBoundaryMandate`). The other two words (`tool_marks`, the operator's "always" tier) keep binding the rule lane only: an egress / safety tighten is the classifier's question by the origin table's own design (`safety_tighten: true`, pinned since 7.4). No new origin word; the table keeps saying which QUESTIONS are the classifier's, the probe words say which asks are nobody's but a person's. CC 2.1.250 form: boundary-class decisions carry `classifierApprovable:false` and the classifier station skips them.
99
+ - **Observable.** auto armed + `readFace:"roots"` + `readDenyPatterns:["**/secrets/**"]` + `grep needle secrets/app.txt` (with or without `run_in_background:true`): the classifier is consulted ZERO times, the person is asked ONCE (`origin:"shell_gate_tighten"`), a refusal ⇒ denied and never read; a classify-tier write (`touch x`) with no boundary is still the classifier's question (one classify call, zero asks); an ancestor's `probeMandated` ask at the inherited station is resolved by the ancestor's chain, never classified.
100
+ - **The sandbox-admission legs (review round).** The same probe words are now read by the live gate's sandbox-admission leg and by the two ancestor sandbox-admission arms: containment holds EFFECTS, a read boundary is about what reaches the model, so an isolated-sandbox deployment (`sandbox_local` policy + `redecision.reopen` store + durable park) no longer auto-admits a probe-mandated read either — it parks for a person. `probeMandatedAsk(ask)` is the one spelling those three readers share.
101
+ - **Also observable.** A reversibility probe that THROWS or times out under auto mode now ASKS a person (`probe_unanswered` is the family's second probe word) — before, the classifier answered in its stead. A fail-closed change, disclosed so a flapping probe is not read as a regression.
102
+ - **Fail-open reverse check.** Which mandated ask can still reach an automatic yes? A blanket `onAsk:"allow"` deployment seat clears asks that carry no `requiresRealApproval` — the probe mandate does not mint that bit today (cli [6870] U-3); tracked as the next arm under #681, not folded into this narrowing.
103
+ - Pins: `test/backlog681-auto-mandate-not-classifiable.test.ts` (nine cells: five Runner cells, a probe_unanswered unit cell, the operator-always reverse control, the sandbox-leg unit cell, a lexical anchor over the two ancestor arms — their positive admission path is not unit-producible; mutations: drop either classifier conjunct ⇒ 2/1 red, drop the sandbox-leg conjunct ⇒ 2 red, drop the ancestor conjuncts ⇒ 1 red, narrow the family to one probe word ⇒ 1 red) + `test/f012-sandbox-admission-domain.test.ts` (probeMandated ⇒ not admitted, red before). Re-pinned with the A car's #626 ① door: `test/design378-mcp-content-class.test.ts` G11 keeps its hostile server name under the 128-char bound (the over-bound spelling is now refused at prepare, one new cell).
104
+
105
+ ### Narrowing — the roster row never truncates an identity member, and a containment fence refuses a bare request (#626 ①②)
106
+ - **① Identity members are bounded at the mint, never truncated.** `mintRosterEntry` used to `slice` four members onto the row (`contract.contractId` / `contract.implementationRevision` / `modelGate` to 64, `cardId` to 160) and carried an MCP/A2A `origin.peer` past the schema's own 128 bound. Now ONE judge — `rosterMemberBoundProblem` (exported) — is read by the declaration door (`spec.tools`, before session acquire) and by the row mint (every source: MCP/A2A peers included): a member over its bound refuses the row with the door's existing codes (`config.tool_name_too_long` for a name / alias / peer, `config.tool_contract_too_long` for a contract member) or the mint's own `config.tool_roster_bound` (`modelGate`, `cardId`). `TOOL_CARD_ID_MAX_CHARS` (exported) is now DERIVED from the member bounds (`mcp:` + 128 + `/` + 128 = 261) instead of the declared 160 — a row minted from in-bound members fits by construction; the `ToolRosterEntry` schema's `cardId.maxLength` reads the constant (a consumer validating against the exported schema sees 261).
107
+ - Observable: a `spec.tools` entry with `modelGate` over 64 characters now refuses prepare (`config.tool_roster_bound`) instead of mounting with a truncated class; an MCP server whose configured name is over 128 characters now refuses prepare (`config.tool_name_too_long`) instead of minting a row whose `origin.peer` violates the row schema and whose `cardId` was cut at 160; a definition that reaches the mint by a route the door does not cover (an `AgentTool` handed to `RosterBuilder` directly) with a contract member over 64 refuses (`config.tool_contract_too_long`) instead of minting a different identity.
108
+ - Pins: `test/backlog626-roster-identity-bounds.test.ts` (each member at the bound rides verbatim / one over refuses, per code; the derived `cardId` bound covers every source form; the schema reads the constants; every catalog row is inside the bounds); `test/tool-face-bounds.test.ts` unchanged (the door's codes are the same).
109
+ - Downstream: a consumer that validates roster rows with a private copy of the 160 `cardId` bound must read `TOOL_CARD_ID_MAX_CHARS` (or the exported schema); nothing else on the wire changes.
110
+ - The PEER's own door (adversarial round 1): an MCP server name over 128 characters refuses prepare BEFORE any server is connected (`config.tool_name_too_long`, `prepare-protocol-tools.ts`) — a server with an empty initial listing would otherwise be judged for the first time at a run-time refresh, where the harness is rebuilt before the roster republishes and a throw would leave the refreshed tools callable without a roster face. The mint's peer check is the backstop; this door is the one that fires. Pinned (a 129-character server name, zero tools, never started).
111
+ - `gate:tool-catalog` ②d (adversarial round 1): an INDIRECT reference to `defineTool` (a renamed import, a local alias, `.call`, a namespace property, a value passed along) is refused outright — the syntactic gate follows only direct calls, so what it cannot follow is red rather than silently missed (`typeof defineTool` in a type position stays green); round 2 extended it to a renamed RE-EXPORT (a barrel alias) and to string-key reaches (`mod["defineTool"]`, a quoted destructuring, `require(...)["defineTool"]`).
112
+ - **② A bare request is refused by a containment reader.** design/388 B6's migration seam — `declaredPathTargetOf(call)` answering a request with no `face` from the catalog's declaration for the NAME — is closed: it now throws a typed error, `policy.bare_request` (`BARE_REQUEST_CODE`, exported), and so does every fence behind it (`isDeclaredWrite`, the fs-write gate's accept/exempt dirs, the session `allowDirs` rule, the persisted path-rule lane's allow arm). A grant is a claim about the tool's OWN slot; only a prepared leg's roster can say which object a call dispatches to. The PROTECTIVE floor is unchanged: `protectivePathTargetOf` / `isProtectedWrite` / the sensitive-path guard / write protection keep reading the catalog for a bare request (tighten-only — a deny can only gain a slot).
113
+ - Observable: inside a Runner nothing changes — the gate entry, the three resume belts and the pre-CAS edit recheck stamp the leg's face on every request (audited: `hooks.ts` gate entry, `run-leg.ts` ×3, `resume-apply.ts`, `prepare-park-ask.ts`); the one engine producer that can still hand a containment fence a bare request is the pre-CAS resume-edit recheck of a park row written before 7.8.0 (no face members on the row) — that recheck already catches a throwing policy and DENIES the edit with the error text, so the outcome is a loud deny rather than a catalog reading. A policy driven OUTSIDE a Runner (a host's own `check()` call, a test fixture) on a covered write name (`Write` / `Edit` / `NotebookEdit` / a caller face) must now stamp `face` (`toolCallFaceOf(roster, name)`); a bare NON-covered name never reaches the door (the fs-write gate allows it as before; the session rule denies it as an unconfinable write as before).
114
+ - Pins: `test/backlog626-bare-request-hard-door.test.ts` (the two readers, the three fences and the lane refuse bare / answer faced; the protective floor still reads the catalog; a Runner-driven hands `Write` under the fs-write gate lands); `test/design388-write-fences.test.ts` re-pinned at the three bare readings.
115
+ - Downstream (@server): a self-written `ToolPolicy` that calls `declaredPathTargetOf` / `isDeclaredWrite`, or that drives `createFsWriteGatePolicy` / `createSessionRulePolicy` outside a Runner with a hand-built request, must stamp the face; a policy that only ever runs inside the Runner's gate needs nothing.
116
+ - **Rules: fewer.** ① two bound judges (door: name/contract; mint: four independent `slice`s) → one function read at both; ② the containment reader's bare arm (catalog fallback) is gone — one reading (the face) instead of two.
117
+
118
+ ### Internal — design/393 S7: the Runner's remaining private methods leave `runtask.ts`; the driver is one method (byte-invariant; #667 / #670 / #674 / #675 ruled with it)
119
+ - **What moved, verbatim.** `seamCCompactionOptions` / `compactionHookOptions` / `recordCompactionReuse` → `compaction-seams.ts` (layer 1: three lane callers reach DOWN; `runner: RunnerDepsSeat` reads the deps live where `this.deps` did). `finish` → `run-terminal-adoption.ts`; `suggestNextPrompts` (+ its four bounds) and `teardownOwnedEnv` → `run-settle-and-teardown.ts`; `applyResumeDecision` + `resolvePendingCall` (+ the org-disclosure cap) → `run-leg.ts` — each the lane's own module-private function, by the S6 legs' rule (one lane caller; a sibling file would be a lane-to-lane reach). `runtask.ts` 2 514 → 1 415 lines; `Runner` is its public API, the two façades (`runTaskStream`, `resumeStream` + ladder + handoff) and `runLocked`.
120
+ - **#675 — one contract for the orchestrator's entry.** The five delegate seats and the two (divergent) spellings of the compaction triple are gone; the one seat left (`RunNotificationLaneInput.prepareTask`) is typed by `PrepareTaskFn` (contracts.ts) and the orchestrator pins `typeof prepareTask` against it at its declaration (`PrepareTaskIsTheContract`, type-level).
121
+ - **#674 — the driver's terminal form.** `runLocked` alone: the S6 continuation methods (`runSeatLanes` / `runAssembliesAndLegs`) and the `RunSeatsHandoff` re-spelling are the `next` closures of the async lanes they follow; no method or closure is `async` (the S6 TICKS and rejection-depth pins hold unchanged).
122
+ - **#670 — the empty Results declare themselves.** `InstallingPhaseResult` (contracts.ts): the six lanes whose products are all installations on borrowed seats extend it (run-attachment-seats, run-stop-and-final-verify, run-recovery-lanes, run-settle-and-teardown, resume-preflight, resume-claim), and `gate:phase-api` reads the declaration (INSTALLING rule: empty ⇒ must extend it; members ⇒ must not; nothing else inherited).
123
+ - **#667 — `docs/INTEGRATION-CORE.md`** re-anchored: every `runtask.ts:<line>` coordinate now names today's file:line and the symbol (the resume resolver's arms are `run-leg.ts` coordinates).
124
+ - **Lane Inputs (fewer seats, one added).** `RunLegInput` −`applyResumeDecision`; `RunTerminalAdoptionInput` −`finish` +`sessions`; `RunSettleAndTeardownInput` −`suggestNextPrompts` −`teardownOwnedEnv`; `RunRecoveryLanesInput` −`compactionSeats`; `TurnBoundaryDeps` −3 compaction delegates +`runner` (the turn boundary spells the leg identity / seat bound the driver's adapter baked in, at its own call). `gate:phase-api` floor 945 → 941.
125
+ - **Export surface: unchanged** (no new public name; the lane modules and contracts additions are internal).
126
+
3
127
  ## 7.12.0 — 2026-09-10
4
128
 
5
129
  ### BREAKING — workflow journal stores refuse an oversize entry loudly (#672; server [6840]; @server SQL store result arm same rule; @test)
@@ -650,15 +650,10 @@ export interface SubagentToolOptions {
650
650
  * writes a durable row (restart-surviving TaskOutput reads; the S1b lifecycle flip keys off the
651
651
  * same wiring). Absent ⇒ pre-151 in-memory-only lifecycle, unchanged. */
652
652
  agentStore?: import("../core/background-agent-store.js").BackgroundAgentStore;
653
- /**
654
- * design/153 §7.3 the durable-approval checkpoint store, for the PARK lane's compensation and
655
- * arbitration (expire on a lost park CAS the no-orphans posture; the in-process TaskStop
656
- * arbitration closure). MUST be the SAME instance as `RunnerDeps.checkpointStore` (the child's
657
- * suspend mints its checkpoint there; a different instance could not see the token — the F-6
658
- * dual-wiring hazard, documented symptom: parks succeed but stops/reconciliation read "missing").
659
- * Absent ⇒ a durably-suspending bg child keeps the pre-153 lifecycle (settled `failed` with
660
- * `unexpected.suspended`), byte-for-byte.
661
- */
653
+ /** design/153 §7.3 — the park lane's checkpoint store where no host seat rides (a tool invoked outside a Runner task, and the
654
+ * bare /decide drive of a parked child). Inside a task the child parks in the seat it was HANDED (`ToolExecuteContext.
655
+ * checkpointStoreForChildren`); an option naming a different store is refused at spawn (`config.invalid_checkpoint_store`).
656
+ * Absent with no host seat a durably-suspending bg child keeps the pre-153 lifecycle. */
662
657
  checkpointStore?: import("../core/checkpoint-store.js").CheckpointStore;
663
658
  /**
664
659
  * design/153 §7.3 (r4 F-09) — the CAPABILITY protocol that attests the child's transcript
@@ -1897,6 +1897,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1897
1897
  ...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
1898
1898
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1899
1899
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
1900
+ ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
1900
1901
  tools: [reportTool],
1901
1902
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
1902
1903
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
@@ -2154,7 +2155,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2154
2155
  ...(def?.memory ? { memory: def.memory } : {}),
2155
2156
  ...(def?.skills?.length ? { skills: def.skills } : {}),
2156
2157
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
2157
- ...(ctx.checkpointStoreDisabledForChildren === true ? { checkpointStore: "disabled" } : {}),
2158
+ ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
2158
2159
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
2159
2160
  ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
2160
2161
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
@@ -2922,6 +2923,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2922
2923
  }
2923
2924
  if (wantsBackground) {
2924
2925
  const bg = opts.background;
2926
+ if (ctx.checkpointStoreForChildren !== undefined && ctx.checkpointStoreForChildren !== "disabled" && bg.checkpointStore !== undefined && bg.checkpointStore !== ctx.checkpointStoreForChildren)
2927
+ throw configError("background.checkpointStore is not the store this run's children park in (the host's resolved checkpoint seat, TaskSpec/RunnerDeps.checkpointStore) — wire the one store, or leave the option unset and let the seat ride.", "config.invalid_checkpoint_store");
2928
+ const childParkStore = ctx.checkpointStoreForChildren === undefined ? bg.checkpointStore : ctx.checkpointStoreForChildren === "disabled" ? undefined : ctx.checkpointStoreForChildren;
2925
2929
  const notify = ctx.onTaskNotification ?? bg.notify;
2926
2930
  const abort = new AbortController();
2927
2931
  const reviveRow = reviveClaim?.row;
@@ -3030,7 +3034,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3030
3034
  initialStatus: "parked",
3031
3035
  parkedCheckpointToken: parkedResume.ticket.token,
3032
3036
  resolveParkedStop: async () => {
3033
- const cpS = bg.checkpointStore;
3037
+ const cpS = childParkStore;
3034
3038
  if (cpS === undefined)
3035
3039
  return false;
3036
3040
  const cp = (await cpS.get(parkedResume.ticket.token));
@@ -3173,7 +3177,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3173
3177
  }
3174
3178
  const forwardDurableApproval = ctx.durableApprovalForChildren !== undefined &&
3175
3179
  bg.agentStore !== undefined &&
3176
- bg.checkpointStore !== undefined &&
3180
+ childParkStore !== undefined &&
3177
3181
  bg.ensureChildSessionDurable !== undefined;
3178
3182
  const bgSpecBase = {
3179
3183
  ...buildChildSpec(abort.signal),
@@ -3279,7 +3283,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3279
3283
  };
3280
3284
  const driveParkedResume = async () => {
3281
3285
  const pr = parkedResume;
3282
- const claimStores = { agentStore: bg.agentStore, checkpointStore: bg.checkpointStore };
3286
+ const claimStores = { agentStore: bg.agentStore, checkpointStore: childParkStore };
3283
3287
  const rollbackAndThrow = async (e) => {
3284
3288
  let disp = "retry";
3285
3289
  try {
@@ -3303,9 +3307,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3303
3307
  const code = e.code;
3304
3308
  if (code === "not_found") {
3305
3309
  try {
3306
- const cp = (await bg.checkpointStore.get(pr.ticket.token));
3310
+ const cp = (await childParkStore.get(pr.ticket.token));
3307
3311
  if (cp !== null)
3308
- await bg.checkpointStore.expire(pr.ticket.token, cp.scope);
3312
+ await childParkStore.expire(pr.ticket.token, cp.scope);
3309
3313
  }
3310
3314
  catch {
3311
3315
  }
@@ -3333,10 +3337,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3333
3337
  };
3334
3338
  let childResult;
3335
3339
  try {
3336
- const { objective: _o, sessionId: _s, ...resumeConfig } = bgSpec;
3340
+ const { objective: _o, sessionId: _s, ...resumeRest } = bgSpec, resumeConfig = { ...resumeRest, checkpointStore: resumeRest.checkpointStore ?? childParkStore };
3337
3341
  if (resumeConfig.durableApproval === undefined) {
3338
3342
  try {
3339
- const cp0 = (await bg.checkpointStore.get(pr.ticket.token));
3343
+ const cp0 = (await childParkStore.get(pr.ticket.token));
3340
3344
  const rec = cp0?.durableApproval;
3341
3345
  if (rec !== undefined && typeof rec.scope === "string" && rec.scope.length > 0) {
3342
3346
  resumeConfig.durableApproval = { scope: rec.scope, ...(typeof rec.ttlMs === "number" ? { ttlMs: rec.ttlMs } : {}) };
@@ -3369,7 +3373,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3369
3373
  if (childResult.terminal.kind === "failed") {
3370
3374
  let reopened = false;
3371
3375
  try {
3372
- const cp = (await bg.checkpointStore.get(pr.ticket.token));
3376
+ const cp = (await childParkStore.get(pr.ticket.token));
3373
3377
  reopened = cp !== null && cp.status === "pending";
3374
3378
  }
3375
3379
  catch {
@@ -3379,7 +3383,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3379
3383
  bg.registry.parkBackgroundAgent(taskId, {
3380
3384
  checkpointToken: pr.ticket.token,
3381
3385
  resolveStop: async () => {
3382
- const cpS = bg.checkpointStore;
3386
+ const cpS = childParkStore;
3383
3387
  const cp = (await cpS.get(pr.ticket.token));
3384
3388
  if (cp === null)
3385
3389
  return false;
@@ -3444,7 +3448,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3444
3448
  let unparkedPause;
3445
3449
  if (isDurablePause(child.terminal)) {
3446
3450
  const pausedToken = child.terminal.token;
3447
- const cpStore = bg.checkpointStore;
3451
+ const cpStore = childParkStore;
3448
3452
  const expireByStoreScope = async (token2) => {
3449
3453
  try {
3450
3454
  const cp0 = (await cpStore.get(token2));
@@ -3476,7 +3480,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3476
3480
  checkpointToken: token,
3477
3481
  seq: seqAtSettle ?? 1,
3478
3482
  resolveStop: async () => {
3479
- const cpS = bg.checkpointStore;
3483
+ const cpS = childParkStore;
3480
3484
  const cp = (await cpS.get(token));
3481
3485
  if (cp === null)
3482
3486
  return false;
@@ -409,7 +409,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
409
409
  sessionId: sid ?? "aborted",
410
410
  terminal: { kind: "failed", message: "student run did not settle promptly after stuck-abort" },
411
411
  result: "",
412
- stats: { turns: 0, tokens: 0 },
412
+ stats: { turns: 0, tokens: 0, usageMissing: true },
413
413
  });
414
414
  const abortFallback = (sid) => {
415
415
  let timer;
@@ -267,6 +267,29 @@ export declare function resolveReasoning(requested: ThinkingLevel, model: {
267
267
  * valid member); individually invalid MEMBERS (garbage strings, sparse holes, `"off"` — not a legal
268
268
  * member of the 6-tier compat effort sets) are dropped, so they can never become a clamp target.
269
269
  */
270
+ /**
271
+ * #692 C-c — the effort tiers that actually REACH THE WIRE for this model: the table {@link resolveEffort} clamps
272
+ * against, keyed by the model's API family exactly as {@link resolveReasoning} dispatches, gated by the request
273
+ * entry predicate (a `reasoning: false` model accepts no tier) and minus the tiers the model's `thinkingLevelMap`
274
+ * suppresses (`null` = no effort value on the wire) — the same arms, so a host reading this and the trace's
275
+ * `effective` can never disagree:
276
+ * - `anthropic-messages` with declared `compat.effortLevels` → that set (the `output_config.effort` lane);
277
+ * without it → `undefined` (the BUDGET lane is a token gradient, there is no tier set to clamp to);
278
+ * - the Responses family and the completions family → `compat.reasoningEffortLevels` sanitized
279
+ * (invalid members dropped, an empty/garbage declaration reads as undeclared) or the conservative
280
+ * {@link DEFAULT_EFFORT_LEVELS}; `supportsReasoningEffort: false` (openrouter excepted, whose unified
281
+ * knob ignores it) and the binary formats (`qwen` / `zai` / `qwen-chat-template`) → `undefined` (the tier
282
+ * is not honored as a gradient on that wire).
283
+ * `undefined` therefore means "this wire clamps no tier set", never "unknown": a host that offers a person the
284
+ * supported tiers reads THIS, per model, instead of a global default it cannot vouch for. Per API, not per
285
+ * model id: the acceptance set is a property of the endpoint's request schema; `Model` grows no key for it.
286
+ */
287
+ export declare function reasoningEffortLevelsOf(model: {
288
+ api?: string;
289
+ reasoning?: boolean;
290
+ compat?: unknown;
291
+ thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
292
+ }): readonly ThinkingLevel[] | undefined;
270
293
  export declare function resolveEffort(requested: ThinkingLevel, allowed?: readonly ThinkingLevel[]): ReasoningResolution;
271
294
  /**
272
295
  * Resolve a requested intensity for a binary enable-only endpoint (qwen / zai / qwen-chat-template): the
@@ -91,8 +91,7 @@ function dispatchReasoning(requested, model, facts) {
91
91
  if (BINARY_FORMATS.has(format)) {
92
92
  return { ...resolveBinary(requested), format, endpoint };
93
93
  }
94
- const supportsEffort = compat.supportsReasoningEffort ?? true;
95
- if (!supportsEffort && format !== "openrouter") {
94
+ if (!completionsEffortHonored(compat, format)) {
96
95
  return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
97
96
  }
98
97
  const mint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
@@ -101,9 +100,41 @@ function dispatchReasoning(requested, model, facts) {
101
100
  }
102
101
  return { ...mint.resolution, format, endpoint };
103
102
  }
104
- export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
103
+ export function reasoningEffortLevelsOf(model) {
104
+ if (!model.reasoning)
105
+ return undefined;
106
+ const compat = (model.compat ?? {});
107
+ if (model.api === "anthropic-messages") {
108
+ const declared = declaredEffortLevels(compat.effortLevels);
109
+ return declared === undefined ? undefined : sanitizeEffortLevels(declared);
110
+ }
111
+ let clampSet;
112
+ if (model.api !== undefined && RESPONSES_APIS.has(model.api)) {
113
+ if (compat.supportsReasoningEffort === false)
114
+ return undefined;
115
+ clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
116
+ }
117
+ else {
118
+ const format = compat.thinkingFormat ?? "openai";
119
+ if (BINARY_FORMATS.has(format))
120
+ return undefined;
121
+ if (!completionsEffortHonored(compat, format))
122
+ return undefined;
123
+ clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
124
+ }
125
+ const honored = clampSet.filter((lvl) => model.thinkingLevelMap?.[lvl] !== null);
126
+ return honored.length > 0 ? honored : undefined;
127
+ }
128
+ function completionsEffortHonored(compat, format) {
129
+ const supportsEffort = compat.supportsReasoningEffort ?? true;
130
+ return !!supportsEffort || format === "openrouter";
131
+ }
132
+ function sanitizeEffortLevels(allowed) {
105
133
  const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
106
- allowed = declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
134
+ return declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
135
+ }
136
+ export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
137
+ allowed = sanitizeEffortLevels(allowed);
107
138
  if (requested === "off" || allowed.includes(requested)) {
108
139
  return { requested, effective: requested, graded: true, clamped: false };
109
140
  }