@sema-agent/core 7.12.0 → 7.13.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 (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/core/ask-origin.d.ts +55 -0
  3. package/dist/core/ask-origin.js +21 -0
  4. package/dist/core/engine-notice.d.ts +18 -0
  5. package/dist/core/gate-lanes.d.ts +0 -40
  6. package/dist/core/gate-lanes.js +3 -17
  7. package/dist/core/governance-codes.d.ts +1 -1
  8. package/dist/core/governance-codes.js +2 -0
  9. package/dist/core/hooks.d.ts +1 -1
  10. package/dist/core/hooks.js +1 -1
  11. package/dist/core/runner/assemble-result.d.ts +45 -1
  12. package/dist/core/runner/assemble-result.js +4 -1
  13. package/dist/core/runner/compaction-seams.d.ts +42 -0
  14. package/dist/core/runner/compaction-seams.js +80 -0
  15. package/dist/core/runner/contracts.d.ts +44 -6
  16. package/dist/core/runner/denial-limit-arms.js +3 -2
  17. package/dist/core/runner/permission-rule-lanes.d.ts +2 -1
  18. package/dist/core/runner/permission-rule-lanes.js +2 -1
  19. package/dist/core/runner/prepare-policy-chain.js +3 -2
  20. package/dist/core/runner/prepare-protocol-tools.js +5 -0
  21. package/dist/core/runner/prepare-safety-scan.js +11 -16
  22. package/dist/core/runner/prepare-task.d.ts +9 -1
  23. package/dist/core/runner/prepare-task.js +6 -1
  24. package/dist/core/runner/resume-claim.d.ts +2 -2
  25. package/dist/core/runner/resume-preflight.d.ts +2 -2
  26. package/dist/core/runner/run-attachment-seats.d.ts +2 -2
  27. package/dist/core/runner/run-git-lane.d.ts +1 -1
  28. package/dist/core/runner/run-harness-handlers.js +2 -0
  29. package/dist/core/runner/run-identity-wiring.d.ts +7 -0
  30. package/dist/core/runner/run-identity-wiring.js +3 -1
  31. package/dist/core/runner/run-leg.d.ts +7 -5
  32. package/dist/core/runner/run-leg.js +256 -5
  33. package/dist/core/runner/run-notification-lane.d.ts +4 -3
  34. package/dist/core/runner/run-recovery-lanes.d.ts +4 -17
  35. package/dist/core/runner/run-recovery-lanes.js +5 -4
  36. package/dist/core/runner/run-settle-and-teardown.d.ts +14 -10
  37. package/dist/core/runner/run-settle-and-teardown.js +112 -4
  38. package/dist/core/runner/run-stop-and-final-verify.d.ts +2 -2
  39. package/dist/core/runner/run-terminal-adoption.d.ts +16 -16
  40. package/dist/core/runner/run-terminal-adoption.js +87 -7
  41. package/dist/core/runner/run-turn-boundary.js +5 -4
  42. package/dist/core/runner/runtask.d.ts +10 -90
  43. package/dist/core/runner/runtask.js +77 -596
  44. package/dist/core/runner/stream-lifecycle-verbs.js +10 -1
  45. package/dist/core/task-stream.d.ts +10 -1
  46. package/dist/core/tool-face.d.ts +8 -0
  47. package/dist/core/tool-face.js +1 -0
  48. package/dist/core/tool-policy.d.ts +5 -3
  49. package/dist/core/tool-registry.d.ts +11 -3
  50. package/dist/core/tool-registry.js +7 -1
  51. package/dist/core/tool-roster.d.ts +26 -0
  52. package/dist/core/tool-roster.js +38 -7
  53. package/dist/index.d.ts +3 -3
  54. package/dist/index.js +3 -3
  55. package/dist/server/http.js +2 -1
  56. package/package.json +1 -1
  57. package/test/export-surface.snapshot.json +11 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.13.0 — 2026-09-10
4
+
5
+ ### 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)
6
+ - **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.
7
+ - **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.
8
+ - **`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.
9
+ - **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.
10
+ - **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.
11
+ - **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).
12
+ - **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.
13
+ - 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).
14
+ - **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.
15
+
16
+ ### 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)
17
+ - **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.
18
+ - **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.
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - 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).
24
+
25
+ ### Narrowing — the roster row never truncates an identity member, and a containment fence refuses a bare request (#626 ①②)
26
+ - **① 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).
27
+ - 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.
28
+ - 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).
29
+ - 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.
30
+ - 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).
31
+ - `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"]`).
32
+ - **② 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).
33
+ - 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).
34
+ - 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.
35
+ - 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.
36
+ - **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.
37
+
38
+ ### 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)
39
+ - **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`.
40
+ - **#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).
41
+ - **#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).
42
+ - **#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).
43
+ - **#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).
44
+ - **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.
45
+ - **Export surface: unchanged** (no new public name; the lane modules and contracts additions are internal).
46
+
3
47
  ## 7.12.0 — 2026-09-10
4
48
 
5
49
  ### BREAKING — workflow journal stores refuse an oversize entry loudly (#672; server [6840]; @server SQL store result arm same rule; @test)
@@ -172,6 +172,11 @@ export type RealApprovalTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<As
172
172
  * deployment armed the classifier to resolve (the classify doctrine's whole point for the shell
173
173
  * tier; the safety marks make the ask NON-BUDGETABLE at the durable park, a different axis from who
174
174
  * may answer it live).
175
+ * A PROBE mandate riding the same `shell_gate_tighten` word (a read-boundary / out-of-root /
176
+ * recursive-read probe verdict, or a probe that did not answer) is NOT this table's to admit: the
177
+ * gate's `probeBoundaryMandate` excludes it at both classifier stations, the way the allow layer
178
+ * excludes it from a standing rule. This table says which QUESTIONS are the classifier's; the probe
179
+ * words say which asks are nobody's but a person's.
175
180
  * - `policy`: YES — including a safety policy's `requiresRealApproval` ask (a live verdict is
176
181
  * judgment, and arming auto mode is the deployment's explicit choice to let the classifier be the
177
182
  * explicit ask-resolution for those classes).
@@ -195,4 +200,54 @@ declare const CLASSIFIER_MAY_ANSWER: {
195
200
  export type ClassifierTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<AskOrigin, keyof typeof CLASSIFIER_MAY_ANSWER>>;
196
201
  /** Whether the auto-mode classifier may resolve an ask of this origin (see the table's contract). */
197
202
  export declare function classifierMayAnswer(origin: AskOrigin): boolean;
203
+ /**
204
+ * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
205
+ * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
206
+ * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
207
+ * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
208
+ * bit, which the org layer stamps):
209
+ * · `probeMandated` — this CALL's own reversibility probe declared its demotion STRUCTURAL
210
+ * (`"probe_mandate"`), judged FIRST because it is the only per-call member here: the three below
211
+ * are properties of the TOOL and are true of every call on the seat, so a mandate that is true of
212
+ * this one call must not be shadowed by the tier that happens to carry it (#502: the built-in
213
+ * shell probe raises it for a listed reader naming a path outside the session's roots — a boundary
214
+ * the deployment declared, which is exactly what the classify tier alone cannot say);
215
+ * · `egress` — the tool's own external-write mark, judged next: it is the tool's declaration even
216
+ * when the coarse doctrine also installed a shell tier on the same seat;
217
+ * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
218
+ * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
219
+ * lane's home turf (`undefined`) — that is the don't-ask-again main case, and the per-call member
220
+ * above is deliberately the ONLY thing that carves a mandate out of it;
221
+ * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
222
+ *
223
+ * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
224
+ * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
225
+ * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
226
+ * two would let a card offer a rule the lane then refuses to honor. The per-call member reaches both
227
+ * the same way every other per-call fact does: the gate stamps it on the surviving ask, the
228
+ * synchronous sites read it off the decision they spread, and the park leg threads its own parameter.
229
+ */
230
+ export declare function persistedRuleMandateOf(marks: {
231
+ egress?: boolean;
232
+ shellGated?: boolean;
233
+ irreversibility?: "never" | "maybe" | "always";
234
+ /** #502: the surviving ask's engine-stamped `probeMandated` — see
235
+ * {@link import("./types.js").ReversibilityVerdict.mandated}. */
236
+ probeMandated?: boolean;
237
+ /** The pass's own record that the probe was consulted and did not answer (`GatePass.probeUnanswered`):
238
+ * the boundary is unknown, which no standing yes may stand in for. The runner's suggestion factory does
239
+ * not see the pass and never passes it — a card may offer a rule the lane then discloses as shadowed
240
+ * (loud, never a silent override), the same drift window the `matchedAskRule` conjunct already has. */
241
+ probeUnanswered?: boolean;
242
+ }): "operator_always" | "tool_marks" | "probe_mandate" | "probe_unanswered" | undefined;
243
+ /** The PROBE half of the mandated family (the deployment's read boundary, judged per call): neither a standing rule
244
+ * nor the classifier clears these two words; `tool_marks`/`operator_always` bind the rule lane only (`safety_tighten`). */
245
+ export declare function probeBoundaryMandate(word: ReturnType<typeof persistedRuleMandateOf>): boolean;
246
+ /** An ASK that carries its probe's mandate — the per-call member the gate stamped (`probeMandated`), read
247
+ * through the same family word so every consumer (the inherited classifier station, the two ancestor
248
+ * sandbox-admission arms) is one predicate, not three spellings. Adding a probe word to the family
249
+ * changes all of them at once. */
250
+ export declare function probeMandatedAsk(ask: {
251
+ probeMandated?: boolean;
252
+ }): boolean;
198
253
  export {};
@@ -62,3 +62,24 @@ const CLASSIFIER_MAY_ANSWER = {
62
62
  export function classifierMayAnswer(origin) {
63
63
  return CLASSIFIER_MAY_ANSWER[origin];
64
64
  }
65
+ export function persistedRuleMandateOf(marks) {
66
+ return marks.probeMandated === true
67
+ ? "probe_mandate"
68
+ : marks.probeUnanswered === true
69
+ ? "probe_unanswered"
70
+ : marks.egress === true
71
+ ? "tool_marks"
72
+ : marks.shellGated === true
73
+ ? marks.irreversibility === "always"
74
+ ? "operator_always"
75
+ : undefined
76
+ : marks.irreversibility === "always" || marks.irreversibility === "maybe"
77
+ ? "tool_marks"
78
+ : undefined;
79
+ }
80
+ export function probeBoundaryMandate(word) {
81
+ return word === "probe_mandate" || word === "probe_unanswered";
82
+ }
83
+ export function probeMandatedAsk(ask) {
84
+ return probeBoundaryMandate(persistedRuleMandateOf({ probeMandated: ask.probeMandated === true }));
85
+ }
@@ -235,6 +235,24 @@ export interface EngineNotice {
235
235
  * (whoever answered the card is the one entitled to hear the answer ran nothing); a host may
236
236
  * forward it on its own wire.
237
237
  *
238
+ * - `"task.interrupt_unconsumed"` — a HARD stop ({@link TaskStream.interrupt}, the task's `signal`,
239
+ * the walltime or the turns limit) landed AFTER the run's last turn had already given its answer
240
+ * (`stopReason:"stop"`, not partial, and no steer / follow-up of the person's still pending — a
241
+ * queued continuation makes that "stop" work in progress, and a stop that cuts it stays the
242
+ * ordinary abort with its undrained-input account) and before the loop's end — what it aborted was the run's
243
+ * tail (a final-boundary compaction is mooted, a stop hook's wait is cut), never its work. The
244
+ * answer stands: the run ends `completed` with it as `result`, no `[Request interrupted by user]`
245
+ * marker is written and no `limits.*` terminal is minted for the axis that fired; this notice is
246
+ * the stop's only trace. Minted once per such run, after the terminal arbitration (so it never
247
+ * claims a completed ending the terminal did not give — a budget ceiling crossed by the last
248
+ * turn's own spend still fails the run and mints nothing here); `detail: { sessionId, runId,
249
+ * taskId?, origin }` with `origin` ∈ `"interrupt"` (the verb) | `"walltime"` | `"turns"` |
250
+ * `"signal"` (every other run-level abort: the task's own `signal`, a parent's cancel, a
251
+ * `destroy()`, a refused preempt — the leg names the three it can tell apart and files the rest
252
+ * here). Audience `"user"`. A stop that arrives
253
+ * AFTER the loop's end is refused typed (`steering.not_running`, every verb alike) and mints
254
+ * nothing: the refusal itself is the loud answer.
255
+ *
238
256
  * - `"checkpoint.execution_outcome_unrecorded"` — the resumed leg's pending-call resolver disposed
239
257
  * the decided action (executed it, delivered the refusal, or vetoed the person's yes) and filed
240
258
  * the frame's record through `CheckpointStore.recordExecutionOutcome`, and the store answered
@@ -48,46 +48,6 @@ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
48
48
  reason?: string;
49
49
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
50
50
  };
51
- /**
52
- * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
53
- * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
54
- * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
55
- * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
56
- * bit, which the org layer stamps):
57
- * · `probeMandated` — this CALL's own reversibility probe declared its demotion STRUCTURAL
58
- * (`"probe_mandate"`), judged FIRST because it is the only per-call member here: the three below
59
- * are properties of the TOOL and are true of every call on the seat, so a mandate that is true of
60
- * this one call must not be shadowed by the tier that happens to carry it (#502: the built-in
61
- * shell probe raises it for a listed reader naming a path outside the session's roots — a boundary
62
- * the deployment declared, which is exactly what the classify tier alone cannot say);
63
- * · `egress` — the tool's own external-write mark, judged next: it is the tool's declaration even
64
- * when the coarse doctrine also installed a shell tier on the same seat;
65
- * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
66
- * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
67
- * lane's home turf (`undefined`) — that is the don't-ask-again main case, and the per-call member
68
- * above is deliberately the ONLY thing that carves a mandate out of it;
69
- * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
70
- *
71
- * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
72
- * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
73
- * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
74
- * two would let a card offer a rule the lane then refuses to honor. The per-call member reaches both
75
- * the same way every other per-call fact does: the gate stamps it on the surviving ask, the
76
- * synchronous sites read it off the decision they spread, and the park leg threads its own parameter.
77
- */
78
- export declare function persistedRuleMandateOf(marks: {
79
- egress?: boolean;
80
- shellGated?: boolean;
81
- irreversibility?: "never" | "maybe" | "always";
82
- /** #502: the surviving ask's engine-stamped `probeMandated` — see
83
- * {@link import("./types.js").ReversibilityVerdict.mandated}. */
84
- probeMandated?: boolean;
85
- /** The pass's own record that the probe was consulted and did not answer (`GatePass.probeUnanswered`):
86
- * the boundary is unknown, which no standing yes may stand in for. The runner's suggestion factory does
87
- * not see the pass and never passes it — a card may offer a rule the lane then discloses as shadowed
88
- * (loud, never a silent override), the same drift window the `matchedAskRule` conjunct already has. */
89
- probeUnanswered?: boolean;
90
- }): "operator_always" | "tool_marks" | "probe_mandate" | "probe_unanswered" | undefined;
91
51
  /**
92
52
  * Run the lanes over the pass: the org layer over the fold's verdict, then — for a surviving ask — the rewrite
93
53
  * merge, the rule lane, the stamp, the classifier, the sandbox leg, the parks, the content lane, the resolution
@@ -1,5 +1,5 @@
1
1
  import { decisionText, describeThrown } from "./tool-policy.js";
2
- import { askOriginOf, classifierMayAnswer } from "./ask-origin.js";
2
+ import { askOriginOf, classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate } from "./ask-origin.js";
3
3
  import { CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
5
5
  import { isRuleBehavior } from "./permission-rule-model.js";
@@ -144,21 +144,6 @@ export function normalizePersistedRuleHit(hit) {
144
144
  return {};
145
145
  return { hit: { behavior, rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
146
146
  }
147
- export function persistedRuleMandateOf(marks) {
148
- return marks.probeMandated === true
149
- ? "probe_mandate"
150
- : marks.probeUnanswered === true
151
- ? "probe_unanswered"
152
- : marks.egress === true
153
- ? "tool_marks"
154
- : marks.shellGated === true
155
- ? marks.irreversibility === "always"
156
- ? "operator_always"
157
- : undefined
158
- : marks.irreversibility === "always" || marks.irreversibility === "maybe"
159
- ? "tool_marks"
160
- : undefined;
161
- }
162
147
  function parkWith(suspendAsk, parkArgs, carry) {
163
148
  return suspendAsk(...parkArgs, carry);
164
149
  }
@@ -375,7 +360,7 @@ export async function runGateLanes(pass) {
375
360
  }
376
361
  if (pass.decision.action === "ask")
377
362
  ledger.supersede();
378
- if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin)) {
363
+ if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin) && !probeBoundaryMandate(persistedRuleMandate)) {
379
364
  const verdict = await input.autoMode.decider
380
365
  .decide({ req: pass.req, askMessage: decisionText(pass.decision) }, input.abortSignal)
381
366
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -431,6 +416,7 @@ export async function runGateLanes(pass) {
431
416
  pass.hookAsk === undefined &&
432
417
  input.egress !== true &&
433
418
  input.peerMessage !== true &&
419
+ !probeBoundaryMandate(persistedRuleMandate) &&
434
420
  input.sandboxAdmission.boundaryCapable !== true) {
435
421
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
436
422
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -104,7 +104,7 @@ export type NoticeAudience = "user" | "operator";
104
104
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
105
105
  * no longer minted.
106
106
  */
107
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
108
108
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
109
109
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
110
110
  * sink is a supported shape, and narrowing that field would break it. */
@@ -141,6 +141,7 @@ export const ENGINE_NOTICE_CODES = [
141
141
  "task.turn_interrupted",
142
142
  "task.halt_unconsumed",
143
143
  "task.late_approval",
144
+ "task.interrupt_unconsumed",
144
145
  "memory.capture_opted_out",
145
146
  "memory.capture_optout_unpersisted",
146
147
  "tool_result.offload_put_failed",
@@ -159,6 +160,7 @@ const NOTICE_AUDIENCE_TABLE = {
159
160
  "steering.parked_input_blocked": "user",
160
161
  "task.halt_unconsumed": "user",
161
162
  "task.late_approval": "user",
163
+ "task.interrupt_unconsumed": "user",
162
164
  "config.durable_gate_unavailable": "user",
163
165
  "memory.capture_opted_out": "user",
164
166
  "memory.capture_optout_unpersisted": "user",
@@ -2,7 +2,7 @@ import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallFace, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import type { GateOutcome } from "./gate-outcome.js";
5
- export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
5
+ export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { type AskClass } from "./ask-class.js";
8
8
  import { type AutoModeDenialTracker } from "./auto-mode.js";
@@ -2,7 +2,7 @@ import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { createSettlementLedger, engineFailClosedExit, hookDenyExit, traceHookCrash } from "./runner/gate-exit.js";
3
3
  import { runGateFold } from "./gate-fold.js";
4
4
  import { runGateLanes } from "./gate-lanes.js";
5
- export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
5
+ export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { brandPolicyAskClass } from "./ask-class.js";
8
8
  import { isAskOrigin } from "./ask-origin.js";
@@ -178,6 +178,10 @@ export interface ResultFlags {
178
178
  * abort (no synthetic stopReason:"aborted" assistant is appended), `final` is the last REAL assistant
179
179
  * message — this flag is what keeps an interrupted run from being mistaken for a completed one. */
180
180
  abortedLive?: boolean;
181
+ /** The leg's ONE evaluation of {@link answerSettled} (a clean `"stop"` final with no queued continuation
182
+ * lost): a live abort that found this true landed on the run's tail, not on work — slot 9 stands down
183
+ * and the run takes the completed floor. Absent/false = the abort's ordinary reading. */
184
+ answerSettled?: boolean;
181
185
  budgetHit?: "exceeded" | "precall";
182
186
  /** design/164 — which budget ceiling the hit belongs to. Set together with {@link budgetHit} at every
183
187
  * site that records one; it picks the terminal code (`limits.max_tokens_exceeded` vs
@@ -225,6 +229,43 @@ export declare function errorCodeOf(err: unknown): string | undefined;
225
229
  * in-presence rule the usage-window wait hint states for itself), and it is not part of the cause's
226
230
  * public projection.
227
231
  */
232
+ /**
233
+ * Has the run's ANSWER settled — is `final` the model's own clean end of its work? A run whose last turn
234
+ * ended `stopReason:"stop"` has said everything it was going to say; whatever ran after that turn (the
235
+ * final boundary's compaction, a stop hook, the teardown) is the run's tail, not its work. A hard stop
236
+ * that lands in that tail — `TaskStream.interrupt()`, the task's `signal`, the walltime or turns limit —
237
+ * cuts nothing the person or the caller wanted, so the run is NOT cancellable there: it ends `completed`
238
+ * with the answer as `result` (slot 9 yields to the completed floor), no interruption marker is written
239
+ * for it, and the end-of-task hook seats keep their full deadline. One predicate, read by every site that
240
+ * used to decide the question for itself; there is no second spelling.
241
+ *
242
+ * What does NOT count as settled — each is "the model was still working" and stays cancellable:
243
+ * - `stopReason:"length"` — the output was truncated; the auto-continue chain owns what follows;
244
+ * - `stopReason:"toolUse"` — a batch was in flight (the orphan reconcile owns the abort);
245
+ * - `stopReason:"aborted"` / `"error"` — the turn itself was cut or failed;
246
+ * - a `partialFinalized` `"stop"` — a mid-stream loss promoted to a final; the loop's bounded
247
+ * continue nudge owns it, so the stop the caller sees is a salvage, not the model's own end;
248
+ * - no `final` at all;
249
+ * - `continuationCut` — the run was cut with a continuation still owed: a steer / follow-up the person
250
+ * had already queued was still pending (queued, or drained at the final boundary and never served —
251
+ * the harness's agent_end account, which also mints `task.user_steer_undrained` /
252
+ * `task.user_followup_undrained`), or the loop itself had decided to run another turn (a drained
253
+ * frame, a truncated-output / thinking-only / degenerate-output nudge) and the abort caught it before
254
+ * the dispatch (`Prepared.workCutRef.continuation`, from the loop's own trace steps) — in both, a
255
+ * stop landing between the decision and the next stream returns the PREVIOUS clean answer as
256
+ * `final` (the loop exits at its pre-dispatch guard), so `final` alone cannot tell, and a
257
+ * thinking-only "stop" the loop was about to nudge into a visible answer is an empty result, not
258
+ * an answer; or the abort was already in force when the last turn ENDED
259
+ * (`Prepared.workCutRef.abortedBeforeTurnEnd`) — a brain that ignored its signal and delivered a
260
+ * full "stop" through the abort still answered a run somebody had stopped mid-work.
261
+ * Three axes, one law: the `abortedLive` flag alone decides that a stop landed; this decides whether it
262
+ * landed on work or on the tail. Evaluated ONCE per leg (the terminal-adoption lane, where both facts
263
+ * are in hand) and handed on as a value — {@link ResultFlags.answerSettled} — so the arbitration, the
264
+ * marker gate, the hook-seat grace, the teardown attribution and the notice read the same verdict.
265
+ * (A limit that is NOT a stop — a budget ceiling crossed by the last turn's own spend — is arbitrated
266
+ * above slot 9 and is unaffected.)
267
+ */
268
+ export declare function answerSettled(final: AssistantMessage | undefined, continuationCut?: boolean): boolean;
228
269
  export declare function terminalCauseOf(final: AssistantMessage | undefined, stats: Pick<Stats, "humanReview">, flags: ResultFlags): {
229
270
  terminal: TerminalCause;
230
271
  apiFailure: TaskResult["apiFailure"];
@@ -252,7 +293,10 @@ export declare function terminalCauseOf(final: AssistantMessage | undefined, sta
252
293
  * 9. `abortedLive` | stopReason aborted → `"limits.max_walltime_exceeded"` | `"limits.max_turns_exceeded"` | undefined
253
294
  * (audit A-1: hoisted ABOVE no-final — the loop exits cleanly on abort, so `final` may be a
254
295
  * normal message or absent; a plain user interrupt carries NO errorCode — switch on `status`),
255
- * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`)
296
+ * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`),
297
+ * and EXCEPT when the answer had already settled (`answerSettled`: a clean `"stop"` final, not
298
+ * partial) — a stop landing on the run's tail cuts no work, so the run falls through to 12
299
+ * (`completed`, the answer as `result`, no `limits.*` code for the axis; three axes, one law)
256
300
  * 9.7 `userHalted` + (no `final` | the halt's own aborted `final`) → status `"completed"` (#504: a
257
301
  * bare user halt — the verb working, not a defect; below the aborts on purpose, a real
258
302
  * abort/limit that also fired owns the terminal and the halt rides as the pass-through seat)
@@ -41,6 +41,9 @@ function assistantText(msg) {
41
41
  .join("");
42
42
  return stripThinkTags(text);
43
43
  }
44
+ export function answerSettled(final, continuationCut = false) {
45
+ return final?.stopReason === "stop" && final.partialFinalized !== true && !continuationCut;
46
+ }
44
47
  export function terminalCauseOf(final, stats, flags) {
45
48
  let terminal;
46
49
  let apiFailure;
@@ -96,7 +99,7 @@ export function terminalCauseOf(final, stats, flags) {
96
99
  else if (flags.paused) {
97
100
  terminal = flags.paused;
98
101
  }
99
- else if ((flags.abortedLive || final?.stopReason === "aborted") && !haltOwnsAbortedFinal) {
102
+ else if ((flags.abortedLive || final?.stopReason === "aborted") && !haltOwnsAbortedFinal && flags.answerSettled !== true) {
100
103
  const code = flags.abortedForTimeout ? "limits.max_walltime_exceeded" : flags.abortedForTurns ? "limits.max_turns_exceeded" : undefined;
101
104
  terminal = { kind: "failed", ...(code !== undefined ? { code } : {}), message: final?.errorMessage ?? (flags.abortedForTurns ? "max turns exceeded" : "run aborted") };
102
105
  }
@@ -0,0 +1,42 @@
1
+ import type { MaybeCompactOptions } from "../auto-compaction.js";
2
+ import type { TaskSpec } from "../types.js";
3
+ import type { Prepared, RunnerDepsSeat } from "./contracts.js";
4
+ /**
5
+ * design/84 Seam C — the cost-optimization compaction options threaded into BOTH `maybeCompact` call
6
+ * sites (within-task turn boundary + `finish()`). All three fields come from `RunnerDeps` (a trusted
7
+ * FUNCTION seam — never `TaskSpec`, which is serializable/durable-resumable/untrusted-caller). The
8
+ * Runner OWNS the consecutive-reuse counter (`prepared.compactionReuseRef`) so the
9
+ * `maxConsecutiveProviderReuse` drift guard spans the whole task across both sites: it FEEDS the current
10
+ * count in as `consecutiveProviderReuse`, and {@link recordCompactionReuse} updates it from the result.
11
+ * Returns `undefined` when no provider is wired (so the call site spreads nothing → byte-identical to
12
+ * the pre-design/84 behavior).
13
+ */
14
+ export declare function seamCCompactionOptions(runner: RunnerDepsSeat, prepared: Prepared): Pick<MaybeCompactOptions, "summaryProvider" | "onCompaction" | "maxConsecutiveProviderReuse" | "consecutiveProviderReuse"> | undefined;
15
+ /** design/134 §3.2 — resolve the pre/postCompact lifecycle callbacks (whole-slot `spec.hooks ??
16
+ * deps.hooks`, same resolution as the stop hook) and wrap each in a SWALLOW+TRACE shell before
17
+ * threading them into maybeCompact. The wrapper owns the observability half of the R3 MED contract
18
+ * (maybeCompact swallows defensively too, but has no sink): a throwing callback is reported via
19
+ * `onError(phase:"hook")` and treated as absent; a `block` returned under a "forced" trigger is
20
+ * reported as ignored (maybeCompact enforces the ignore — blocking a compaction the provider/trim
21
+ * layer already demanded would kill the run). */
22
+ export declare function compactionHookOptions(runner: RunnerDepsSeat, spec: TaskSpec, sessionId: string, trigger: "auto" | "manual" | "forced",
23
+ /** #281 件A — the leg's identity envelope, spread onto both compaction contexts by the wrappers
24
+ * below (the compaction module builds its contexts below the runner and cannot know the leg;
25
+ * the wrapper is the runner's seam). Every runner call site supplies `prepared.hookIdentity`;
26
+ * optional only so a context built without a leg stays honestly identity-less. */
27
+ identity?: import("../hooks.js").HookInvocationIdentity,
28
+ /** The leg's already-validated seat bound (`Prepared.hookTimeoutMs`) and its abort signal. Optional
29
+ * for the same reason `identity` is — a wrapper built without a prepared leg still works, on the
30
+ * default bound and with no signal. The signal is what keeps a hung compaction hook from holding a
31
+ * CANCELLED run's turn boundary for the whole deadline (the seat's post-abort grace). */
32
+ seatBound?: {
33
+ timeoutMs: number;
34
+ signal?: AbortSignal;
35
+ }): Pick<MaybeCompactOptions, "trigger" | "preCompact" | "postCompact">;
36
+ /** design/84 Seam C — fold a finished compaction's `reused` flag into the run-scoped consecutive-reuse
37
+ * counter: a reused (provider) summary increments it, a real (LLM) summary resets it to 0. A no-op
38
+ * compaction (`compacted:false`) leaves the counter untouched. No-op when no provider is wired. */
39
+ export declare function recordCompactionReuse(runner: RunnerDepsSeat, prepared: Prepared, comp: {
40
+ compacted: boolean;
41
+ reused?: boolean;
42
+ }): void;
@@ -0,0 +1,80 @@
1
+ import { hookSeatExpiredError, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
2
+ export function seamCCompactionOptions(runner, prepared) {
3
+ if (!runner.deps.summaryProvider)
4
+ return undefined;
5
+ return {
6
+ summaryProvider: runner.deps.summaryProvider,
7
+ onCompaction: runner.deps.onCompaction,
8
+ maxConsecutiveProviderReuse: runner.deps.maxConsecutiveProviderReuse,
9
+ consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
10
+ };
11
+ }
12
+ export function compactionHookOptions(runner, spec, sessionId, trigger, identity, seatBound) {
13
+ const hooks = spec.hooks ?? runner.deps.hooks;
14
+ const pre = hooks?.preCompact;
15
+ const post = hooks?.postCompact;
16
+ const seatMs = seatBound?.timeoutMs ?? resolveHookTimeoutMs(hooks?.timeoutMs);
17
+ const seatSignal = seatBound?.signal;
18
+ const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
19
+ return {
20
+ trigger,
21
+ ...(pre
22
+ ? {
23
+ preCompact: async (rawCtx) => {
24
+ const ctx = withIdentity(rawCtx);
25
+ const report = (err) => {
26
+ try {
27
+ runner.deps.onError?.(err, { phase: "hook", sessionId });
28
+ }
29
+ catch {
30
+ }
31
+ };
32
+ try {
33
+ const seat = await runHookSeat("preCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => pre.call(hooks, { ...ctx, signal: sig }));
34
+ if (seat.expired) {
35
+ report(hookSeatExpiredError("preCompact", seatMs, seat.cause, `the "${ctx.trigger}" compaction PROCEEDED unblocked and with no additional instructions`));
36
+ return undefined;
37
+ }
38
+ const r = seat.value;
39
+ if (r?.block && ctx.trigger === "forced") {
40
+ report(new Error(`a preCompact callback blocked a "forced" compaction — ignored (PTL/trim-pressure compaction is not optional): ${r.block}`));
41
+ }
42
+ return r;
43
+ }
44
+ catch (err) {
45
+ report(err instanceof Error ? err : new Error(String(err)));
46
+ return undefined;
47
+ }
48
+ },
49
+ }
50
+ : {}),
51
+ ...(post
52
+ ? {
53
+ postCompact: async (rawCtx) => {
54
+ try {
55
+ const seat = await runHookSeat("postCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => post.call(hooks, { ...withIdentity(rawCtx), signal: sig }));
56
+ if (seat.expired) {
57
+ try {
58
+ runner.deps.onError?.(hookSeatExpiredError("postCompact", seatMs, seat.cause, "the observation was abandoned; the compaction that already landed is unchanged"), { phase: "hook", sessionId });
59
+ }
60
+ catch {
61
+ }
62
+ }
63
+ }
64
+ catch (err) {
65
+ try {
66
+ runner.deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
67
+ }
68
+ catch {
69
+ }
70
+ }
71
+ },
72
+ }
73
+ : {}),
74
+ };
75
+ }
76
+ export function recordCompactionReuse(runner, prepared, comp) {
77
+ if (!runner.deps.summaryProvider || !comp.compacted)
78
+ return;
79
+ prepared.compactionReuseRef.consecutive = comp.reused ? prepared.compactionReuseRef.consecutive + 1 : 0;
80
+ }