@sema-agent/core 7.26.2 → 7.27.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 (53) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/core/auto-mode-directive.d.ts +6 -4
  3. package/dist/core/auto-mode-directive.js +1 -5
  4. package/dist/core/engine-notice.d.ts +13 -6
  5. package/dist/core/runner/contracts.d.ts +31 -7
  6. package/dist/core/runner/denial-limit-arms.d.ts +17 -15
  7. package/dist/core/runner/denial-limit-arms.js +4 -1
  8. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -0
  9. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  10. package/dist/core/runner/prepare-gate-stations.d.ts +1 -2
  11. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -2
  12. package/dist/core/runner/prepare-policy-chain.d.ts +1 -2
  13. package/dist/core/runner/prepare-task.js +2 -2
  14. package/dist/core/runner/run-leg.js +5 -1
  15. package/dist/core/tool-policy.d.ts +23 -2
  16. package/dist/core/tool-policy.js +106 -103
  17. package/dist/index.d.ts +6 -1
  18. package/dist/stores/file/background-agent-store.d.ts +4 -0
  19. package/dist/stores/file/background-agent-store.js +10 -0
  20. package/dist/stores/file/checkpoint-store.d.ts +3 -0
  21. package/dist/stores/file/checkpoint-store.js +18 -0
  22. package/dist/stores/file/corrupt-read-sink.d.ts +20 -0
  23. package/dist/stores/file/corrupt-read-sink.js +15 -0
  24. package/dist/stores/file/file-history-store.d.ts +1 -2
  25. package/dist/stores/file/file-history-store.js +3 -8
  26. package/dist/stores/file/fs-atomic.d.ts +8 -3
  27. package/dist/stores/file/fs-atomic.js +19 -2
  28. package/dist/stores/file/index.js +4 -4
  29. package/dist/stores/file/mailbox-store.d.ts +3 -0
  30. package/dist/stores/file/mailbox-store.js +24 -18
  31. package/dist/stores/file/permission-rule-store.js +18 -1
  32. package/dist/stores/file/session-policy-store.d.ts +17 -1
  33. package/dist/stores/file/session-policy-store.js +3 -12
  34. package/dist/stores/file/session-store.d.ts +1 -5
  35. package/dist/stores/file/session-store.js +9 -14
  36. package/dist/stores/file/shared-ledger.d.ts +18 -0
  37. package/dist/stores/file/shared-ledger.js +16 -2
  38. package/dist/stores/file/tool-result-store.d.ts +9 -1
  39. package/dist/stores/file/tool-result-store.js +17 -2
  40. package/dist/stores/file/workflow-journal-store.d.ts +10 -1
  41. package/dist/stores/file/workflow-journal-store.js +8 -12
  42. package/dist/stores/file/workflow-run-store.d.ts +3 -0
  43. package/dist/stores/file/workflow-run-store.js +10 -0
  44. package/dist/tools/artifact/local-stub.js +16 -1
  45. package/dist/tools/fs/dead-cwd.d.ts +48 -0
  46. package/dist/tools/fs/dead-cwd.js +56 -0
  47. package/dist/tools/fs/fs-bash.js +4 -32
  48. package/dist/tools/monitor.d.ts +4 -0
  49. package/dist/tools/monitor.js +6 -0
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +3 -1
  52. package/wire-changes/core@7.26.3.json +65 -0
  53. package/wire-changes/core@7.27.0.json +152 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.27.0 — 2026-09-22
4
+
5
+ ### Every JSONL read-back is form-judged, and every store that tolerates damage names the loss (#1013; behaviour NARROWING; +0 exports; @server @cli @client-core @test)
6
+
7
+ - **What was wrong.** `readJsonlRecords` judged only whether a line PARSED. A line parsing to `123`, `"str"`, `true`, `null` or `[1]` was handed to a replay as a record, and the replays then read members off it: the session repo pushed `undefined` into a session's entry list, the memory replay's `ev.op` and the workflow journal's `typeof rec.callKey` faulted on `null`. Eight of the twelve store-contract kits said `unbound` — nobody had measured their read face against the cross-store sentence at all.
8
+ - **NARROWING (`readJsonlRecords`, a package-root export).** A line that parses to anything but a JSON OBJECT is now CORRUPT: disclosed once through `onCorrupt` and skipped, the same road an unparseable line already took. A downstream store that deliberately wrote scalars or arrays as JSONL records will read them back as absent, with a disclosure. Disclosure `reason` strings now lead with a word from `CORRUPT_BODY_FORMS` (`torn-json: unparseable jsonl record at line N …`, `not-an-object: …`); the historical `unparseable jsonl record` substring is unchanged.
9
+ - **One disclosure seat, seven stores.** The swallow-guard around a host's `onCorruptRead` was hand-written four times and had diverged — three copies contained an async sink's rejection, the mailbox's caught only a synchronous throw, so an `async` sink there became an unhandled rejection. `guardCorruptSink` is now the only implementation. `FileCheckpointStoreOptions`, `FileBackgroundAgentStoreOptions`, `FileWorkflowRunStoreOptions`, `FileWorkflowJournalStore`'s new third argument and `FileToolResultStore`'s new second argument all gained the seat (additive; absent = the old silent tolerance). `FileStorageBackend` forwarded its seat to three stores out of seven — it now forwards to all of them, so a host wiring ONE sink hears every store's losses.
10
+ - **A record is judged before anything applies it.** `LedgerModel` gained REQUIRED `judgeRow` / `judgeEvent` (the three bundled ledger models stopped compiling until they answered), and each model reuses ONE row judge for a snapshot row and for the row an event carries. The mailbox's `applyEvent` answers whether it applied, and each arm admits its own payload: `{"t":"ack"}` with no sequence number used to compare every message against `undefined` and empty the whole box in silence. An UNKNOWN checkpoint event kind is still admitted first, so the `checkpoint.unsupported_version` refusal keeps its full reach.
11
+ - **NARROWING (loud refusals).** A `LocalArtifactStub` ledger whose `attempts` map carries a body the store never wrote now refuses with `artifact stub ledger is corrupt (bad attempt record)`, in the words a bad artifact row already used — `null` used to fault the `in` operator and `{}` used to become a publish outcome with an undefined code. A permission-rule adoption marker is now judged as a WHOLE arm: a half-written one (`{"adopted":{}}`, `{"from":{}}`) used to read `rec.from.kind` off `undefined` or fall through to a WRITABLE partition over the retired bucket; it now refuses fail-closed, as that module's doc already promised.
12
+ - **The roster is bound.** All eight formerly `unbound` kits run `storeReadBackShapeContract` against their real bundled backend and really damaged files (`test/backlog1013-kit-roster-bound.test.ts`); `peer-directory` declares its in-memory exemption with a reason. `unbound` stays a legal census word so a kit added tomorrow can still say it.
13
+ - **Known limits.** `docs/KNOWN-LIMITS.md` KL-1013a (a skipped ledger row is erased at the next compaction) and KL-1013b (a row judge covers the members the replay and the queries read, not a store's whole persisted schema).
14
+ ### The classifier denial-limit's last resort has a DURABLE station too (#1019; behaviour NARROWING; +0 exports; @server @cli @test)
15
+
16
+ - **What was wrong.** The auto-mode classifier's denial limit falls back to a person. When nobody judges that fallback the run STOPS (`classifier.denial_limit`) rather than going back to a model the classifier denies without end. Only the synchronous leg had that station. A deployment that PARKS the fallback on a durable queue and then answers the park with its own policy (`hostDecision.decidedBy: "policy"`) got the deny delivered to the model, which tried again, was blocked again, parked again — forever, with zero `classifier.denial_limit` and zero notices. Measured on 7.26.3: the run re-parked on the next call and the terminal came back `paused`.
17
+ - **One rule, a second station.** The resume leg's deny arm asks the same two questions the synchronous settlement site asks: is the ask that just settled the classifier's FALLBACK (the member is minted only at the bound, so its presence IS "the limit was reached"), and did it end with NOBODY having judged it — read off the ONE table (`SETTLEMENT_NOBODY_JUDGED`), not a second word list. Rule count +0: one rule, two stations. The stop closure itself is now reached through `Prepared.stopForDenialLimit`, so both stations fill the same seat, compose the same sentence and mint the same notice.
18
+ - **NARROWING.** A durable deployment that answers a parked denial-limit fallback with `decidedBy:"policy"` now ends that leg: `failed`, `classifier.denial_limit`, one `EngineNotice`, the deny still delivered as the call's result. `"person"` is unchanged and stops nothing — the table's `human_refused` row says someone looked — and so is `"sla_timeout"`. An ordinary parked ask (no fallback member) is unchanged whatever the word.
19
+ - **The sentence says which lane, and states the ROW's counts.** The two lanes differ in one clause: the synchronous one says no approver is wired to fall back to, the durable one names the settlement word that ended the park. The counts come from the PARKED row, so a resume in a fresh process reports the streak that actually tripped the bound rather than the new leg's empty tracker.
20
+ - **Who is hurt.** A host whose durable approval channel refuses by policy and whose test asserts the run keeps going must re-pin: it now stops, which is the point — that loop was spending model turns on a question nothing in the deployment could answer. Nothing changes for a deployment that answers parks as a person, for one with no durable store, or for the synchronous leg, whose sentence is byte-identical (pinned).
21
+
22
+ ### The approval-posture directive is EXCLUSIVE, bypass first (#1002; prompt-face NARROWING; +0 exports; @cli @test)
23
+
24
+ - **What changed.** A run declaring BOTH `autoModeRequested` and `approverPosture:"bypass"` was told both paragraphs in its first-turn engine reminder. It is now told the bypass one and nothing else. The two facts stay independent SEATS — that is how they ARRIVE — but the statement they compose is the reference implementation's: its own conditional takes the bypass arm first and never reaches the auto-mode body, and showing both put a pair in front of the model that the reference implementation never shows together. Ruled 2026-09-21 (the registered divergence is withdrawn).
25
+ - **One rule, minus one.** The composition was two independent pushes and a join; it is a choice now. The DECOMPOSER keeps both segments in its vocabulary on purpose: a transcript minted before this version carries a joined body in one engine region, and a leg reading it must still suppress both halves rather than restate one beside it (pinned).
26
+ - **Who is hurt.** Single-seat runs are byte-unchanged — auto alone renders the auto body, bypass alone the bypass body, neither renders nothing. A shell that renders or counts the engine's first-message head for a run holding both facts sees one segment where it saw two. A leg that GAINS the bypass seat mid-session states the bypass segment alone and does not restate the auto paragraph standing in its transcript.
27
+
28
+ ### `createApprovalPolicy.approve` takes the outcome object, read by the one screen (#1020; additive; +1 export (type); @cli @server @client-core)
29
+
30
+ - **What was wrong.** The convenience approval door had one spelling for "no" — `false` — and `false` files a PERSON's record. A deployment whose approval channel is a MACHINE (a headless lane with no interactive prompt, a standing posture) could not say so on this door, while the same deployment on the `onAsk` seat says `{allow:false, settledBy:"policy"}` and gets the policy sentence and `policy_refused`. The object shape reached this door as an out-of-contract DEFECT that told the caller to return a boolean.
31
+ - **One reading, two doors.** `approve` now returns `boolean | { allow, settledBy?, reason?, approver?, updatedInput? }` — the same `AskOutcome` object the `onAsk` seat takes — and BOTH doors read it through one screen: the one-read member intake, the strict allow fold, the `settledBy` domain, the attribution screen, the "only a person approves" contradiction, the reason domain and the edit's alias isolation, together with the SETTLEMENT each outcome earns. A second screen here is exactly how one value came to be a policy refusal at one door and a defect at the other. Rule count: +1 arm, 0 special cases.
32
+ - **What the doors still say differently, and why.** A clean verdict's model-facing SENTENCE is each door's own: this seat holds a bare `ToolCallRequest`, so it has no ask message to quote and no delegated-child face, and its human refusal is the long-standing terse line. Contract DEFECTS are the other way round — their sentences come from the shared screen, so a malformed outcome is refused in byte-identical words whichever door it arrived at (pinned, seven shapes).
33
+ - **Two members of the object have no lane on this seat, and both are refused loudly.** `"unavailable"` is a ROUTING answer whose only consumer is the ask resolver's durable re-route. `updatedInput` is an OPERATOR EDIT, and this seat's answer is the deployment POLICY's verdict — an edit riding one leaves the gate as a policy REWRITE, which the restriction chain does not re-judge: only layers composed AFTER the rewriting one ever see the new args. Measured on this branch with a real Runner and `combinePolicies(guard, …)` where `guard` denies `/protected`: an edit from `/safe` to `/protected` EXECUTES through the policy-rewrite channel and is REFUSED through the ask resolution's edit re-adjudication. Carrying the factory's edit into the first channel would have given one value two enforcement rules depending on its door, so the seat declares it has no edit lane and the shared screen refuses; both members are outside the parameter's type as well (`updatedInput?: never`, the spelling a predeclared const or an annotated async return cannot dodge). The pre-existing asymmetry between the two channels is a separate question and is not touched here.
34
+ - **One direction is NOT source-compatible, and it is a TYPE change with no export moved.** Supplying a callback is unaffected: `approve: () => false` and `approve: async () => true` compile exactly as before (measured against v7.26.3 with the same compiler options). What breaks is FORWARDING the library's own callback type — a consumer that derives `type Approve = Parameters<typeof createApprovalPolicy>[0]['approve']` and then INVOKES it expecting a boolean (`async function invoke(a: Approve): Promise<boolean> { return await a(req); }`) now gets `Type 'ApprovalSeatOutcome' is not assignable to type 'boolean'` where v7.26.3 compiled. Measured, both sides, with a control on the supply direction. The repair is one annotation on the forwarder's own return; it is stated here rather than papered over with an overload, because an overload would tighten the SUPPLY side, which is the direction nearly every consumer is on. `npm run handoff:diff` reports +1 / −0 — the outcome type now has an exported NAME (`ApprovalSeatOutcome`, advanced tier), so a host can annotate with it instead of deriving it; that is the repair for the shape above, and an export count is still not what decides whether a release is additive.
35
+ - **Who is hurt at RUNTIME.** Nobody: `true` and `false` are byte-unchanged on both the verdict and the record. A deployment that was RELYING on the object arm being refused (it cannot be — the refusal named it out-of-contract) sees a verdict instead.
36
+
37
+ ## 7.26.3 — 2026-09-21
38
+
39
+ ### Monitor probes the tracked working directory before it spawns (#1018; behaviour NARROWING; +0 exports; @server @cli @test)
40
+
41
+ - **What was wrong.** `Monitor` handed the tracked cwd straight to the background seam. Node reports a bad spawn cwd on the async `'error'` event, so `spawnBackground` returns ok whatever the directory's state is: the watch registers, the model is handed a task id and told the watch is running, and no line ever arrives — indistinguishable, from the model's seat, from a quiet log. The shell's background leg has probed for exactly this since the dead-cwd diagnosis was written; this leg did not.
42
+ - **One probe, one diagnosis, two legs.** `isDeadCwd` and `diagnoseDeadCwd` moved to `src/tools/fs/dead-cwd.ts` and both tools call them (module-internal — no package-root export). "Is this directory usable?" has one answer and the refusal has one wording; the tool name is the only thing that differs between the two legs' sentences, and a pin holds them equal on every branch. Rule count: −1 (two copies became one), +0 special cases.
43
+ - **Monitor and Bash now share one root.** `MonitorToolOptions` gains the optional `rootCanonical` — the same canonical spelling `createBashTool` is built with, resolved once at the driver's root-receiving point and carried, never canonicalized a second time. A dead cwd with a live root therefore self-heals identically on both tools: the tracked cwd is reset to the root and the model is told a retry works.
44
+ - **NARROWING.** Deleting the working directory (or replacing it with a file, or leaving a dangling symlink there) and then starting a watch now refuses loudly and does not spawn, where it used to report a watch as running. Probe order is probe → diagnose → the leg's existing pre-spawn abort recheck → spawn, so the cancellation window the recheck closes is not reopened.
45
+ - **The probe itself got two shapes more truthful, on BOTH legs.** A probe error that says the path stands under a FILE (the ENOTDIR class — an ancestor replaced, where a stat on the cwd returns no kind at all) now counts as death; it used to pass through as healthy. And a symlink cwd retargeted at a file is now judged by what it LANDS on, not merely by whether it resolves. Every verdict that changed changed from healthy to dead, and each of those spawns genuinely fails — no shape the released probe called dead is called healthy now, and no live directory became dead. `docs/KNOWN-LIMITS.md` KL-1018d records the one classification the adapter's error vocabulary cannot yet express.
46
+ - **A probe that did not run says nothing.** On the resolution step a cancelled, refused (permissions) or unsupported `canonicalPath` reads as UNKNOWN — healthy — and a cancellation that lands during the probes never resets the tracked cwd; only a chain that will not resolve (gone, not a directory, or the adapter's catch-all where a loop lands) is dead. Before this release the shell's own leg read every resolution failure as death.
47
+ - **Cancellation reaches the probes.** Both shared helpers take the call's abort signal and hand it to every `fileInfo` / `canonicalPath`, so a watch or a command stopped while a remote probe is in flight is released by the stop instead of waiting the probe out. A cancelled probe never establishes death and never heals anything.
48
+ - **Honest wording when there is no root.** A direct mount that was never handed one gets a branch of its own: the cwd is named as gone and the tool says it has no reset target, rather than calling that directory "the task root" or declaring the tree unrecoverable — claims such a caller cannot back, since the root may well be alive one `cd` away. Nothing is reset on that branch.
49
+ - **Who is hurt.** A healthy cwd is byte-unchanged: one spawn, the same cwd and the same computed timeout, one extra `fileInfo` per watch start. A probe that FAILS for any reason other than "gone" (permissions, IO, an env that does not implement the face) is read as healthy — a watch tool that refused on a flaky stat would be worse than the bug. A deployment whose test asserts that a watch over a deleted directory registers must re-pin: that watch now refuses.
50
+ - **Consumers.** `MonitorToolOptions.rootCanonical` is an additive optional key on a type nobody downstream constructs directly (zero `createMonitorTool` / `MonitorToolOptions` sites in the server and the shell); a run prepared through `prepareTask` gets it wired automatically, so no downstream change is required to receive the self-heal.
51
+ ### The wire manifest reads union arms by their own word, and watches every type the package root reaches (#1012; release tooling only; ZERO runtime behaviour; +0 exports; @server @cli @client-core)
52
+
53
+ - **What changed.** Two holes in the `typeKeys` leg of `wire-changes/core@<ver>.json`, both of which hid changes from the same release. A union's arms were read TOGETHER unless the union discriminated on a property literally named `type` or `kind`, so a key added to one arm moved nothing whenever a sibling arm already spelled that name — `RemoveResult`'s `no-op` arm gained the read-back word its `removed` arm already carried, and 7.26.0's manifest reported neither. And the watched set was the frozen export surface's own roster, so `InheritedGate.approverPosture` — the same key, added in the same release to a type held only as `RunInternals.inheritedGate` — was outside the denominator by construction.
54
+ - **One rule each, not two readings bolted on.** The `type` / `kind` name list is gone: the discriminating property is the one every key-bearing arm declares with literal words no other arm shares. Measured over the export surface — of the 98 roster unions with two or more key-bearing arms, 26 were named by the old list and 82 are named by this rule; 25 of the 26 keep their exact words and the 26th is the one the old list labelled with a word two of its arms share (it named nothing, and is disclosed instead); no union had two properties qualify. The roster is now the SEED of the watched set rather than the set itself: its transitive closure over interface members, alias bodies, `extends` clauses and published signatures (parameters and index signatures included), 943 -> 1361 watched type declarations. MODULE-LEVEL EXPORTED declarations only: a declaration a public type exposes without exporting it is inlined into the emitted `.d.ts` and is genuinely visible to a consumer, but this model's type identity is the bare name, so admitting private declarations to it merged unrelated same-named ones and masked a real removal on the published one. That needs file-qualified declaration identity through the index, the closure and the inheritance fold — a design change, recorded as KL-1012e rather than guessed at here.
55
+ - **What a reader sees.** A key on a discriminated arm is spelled `<word>.<key>` (`RemoveResult` -> `no-op.stillLive`), one entry per word where an arm's word is itself a union. A row for a type the barrel does not publish by name carries `via` with the shortest path it is reached by (`RunInternals.inheritedGate`); a published type's row has no `via`, because absence is what says "published outright". A union whose arms carry no discriminating word is still read flat AND says so — `arms: "flattened"` on the row, and a new `typeKeysUnreadableArms` list naming the type, its declaration site and every reason at once. That list is drawn on the types THIS RANGE moved keys on (a census of every flat-read union in the tree is a wall nobody reads); the population it came from is `typeKeysFlattenedCount`, so an empty list never reads as "none".
56
+ - **Who is hurt.** Nobody at runtime — zero `src/` change, zero exports moved. A consumer that parses the manifest gains one optional list and two optional row keys; `manifestShapeProblems` accepts a manifest with or without them, so an older one still reads. The manifests already in the tree are NOT regenerated (a released manifest is a historical claim, and the packaged copy is what a pickup gate reads): the two keys 7.26.0 missed stay recorded by hand in `docs/wire-consumption/7.26.0.md`.
57
+ - **The alphabet is chosen once, for both refs.** Each ref reads its own syntax, so a union can change what its arms can be named by while staying additive — a third arm reusing an existing word, one more literal property on every arm, one arm extracted into an interface. Comparing `a.x` against `x` would report every key of every arm as REMOVED. Where the two refs do not name a type's arms the same way, the diff drops to the coordinate they share (the union's flat key set) and says so, rather than manufacturing a breaking line. An arm this reader cannot open (a reference, a generic) makes the whole union unnamable, for the same reason: naming the arms it CAN open publishes the others' keys as absent.
58
+ - **A key this reader can no longer see is REPORTED, with its doubt named.** It does not follow a reference out of a union arm or into an unresolvable base, so a key present at `from` and missing at `to` may have moved in there rather than left the type. It is reported REMOVED and breaking all the same, carrying `uncertain` naming the reference it could have gone into — a release document that goes quiet about a key it can no longer see costs a consumer its install, and one coordinate for the writer to check costs a minute. Measured over `7.25.1 -> 7.26.0`, `7.26.0 -> 7.26.1` and `7.26.1 -> 7.26.2`: zero uncertain removals in all three. Naming an arm and opening it stay two questions — the tree's largest union spells every arm as `({ type: "x"; … } & Identity)` and keeps all its arm names.
59
+ - **An OPTIONAL property discriminates nothing.** `settledBy?: "human" | "timeout" | "policy"` is one property of ONE shape carrying a three-word vocabulary; reading it as a discriminant split that single arm into three arms that do not exist, and 7.26.2's added word came out as five keys under a variant nobody can match on. A property a value may simply not carry cannot tell two shapes apart, so it is not a candidate, and a union whose only literal properties are optional is read flat. What that vocabulary change now moves in the manifest is nothing at all (KL-1012d): a word list living on a property is neither an exported alias nor an array constant, so no leg watches it — reading it through a wrong rule was an accident that happened to make noise, not coverage.
60
+ - **The two refs agree on an alphabet before they give up on one.** Each ref records every property that discriminates a union's arms, and the diff reads both sides under one that qualifies at BOTH — because a release adding a second qualifying property moves the sort-order winner, and two key sets written in different alphabets fall back to the flat one, where an arm losing a key a sibling still spells moves nothing at all. Where no property qualifies at both, the flat comparison stands and the row carries `uncertain` saying so: an additive-only verdict this reading cannot support is never made in silence.
61
+ - **Three more shapes that read as "nothing moved".** A declaration emptied to `{}` used to leave the model altogether, so deleting every key of a reachable alias reported nothing (it is a reading now, not a missing one). A row whose arms could not be aligned across the two refs now SURVIVES a zero delta, carrying `uncertain`: where the comparison is the flat key set, a key that left one arm while a sibling still spells it shows up as nothing at all, and vanishing is the one thing that row must not do. A nested intersection arm (`A & (B & C)`) was collected only to its outermost operands, so the same type written the other way round grew an arm and swapping between the spellings came out as removed keys. And the four real-library range pins now live in `test/backlog1012-wire-typekeys-real-ranges.test.ts`, which fails loudly rather than skipping where the release tags are absent.
62
+ - **Compensation.** `v7.26.1 -> v7.26.2` is pinned as the regression control: every segment of the manifest in the tree comes back word for word and `typeKeys` may only GROW (`test/backlog1012-wire-typekeys-union-arms-and-denominator.test.ts` ④). On that range the wider reading really does add — `AskOutcome`'s `policy` arm and `Settlement`'s `policy_refused` word, both invisible to the old reader. KL-1012a records that an arm-prefixed key's `criteriaCovered` is always false (criteria cite real property chains; that field feeds no gate).
63
+
3
64
  ## 7.26.2 — 2026-09-21
4
65
 
5
66
  ### An assignment word is not a path; its VALUE is, and the question about a value is who may open it (#1004; behaviour NARROWING and RELAXING, both; +0 exports; @cli @test)
@@ -29,16 +29,18 @@ export interface ApprovalPostureFacts {
29
29
  approverPosture?: ApproverPosture;
30
30
  }
31
31
  /**
32
- * The ONE composition, and the ONE invariant: **each segment the run's posture calls for is stated
32
+ * The ONE composition, and the ONE invariant: **the segment the run's posture calls for is stated
33
33
  * exactly once in the active model context**. `alreadyStated` is the transcript's ENGINE REGIONS (read
34
34
  * by metadata — a caller's own bytes are never in one, so a spliced copy cannot suppress the engine's);
35
35
  * a segment already standing there is not restated, and a segment that has left the context — compacted
36
36
  * away, or never delivered — is. Returns `undefined` when there is nothing left to say, which is both
37
37
  * "no posture declared" (zero bytes, byte-identical to the pre-seat shape) and "already said".
38
38
  *
39
- * Segment-wise rather than body-wise on purpose: a leg that gains the second fact must state the second
40
- * paragraph without restating the first. The caller wraps the result in the engine's marked reminder
41
- * shell; this module never wraps, so the mark stays owned by its own mint home.
39
+ * Segment-wise rather than body-wise on purpose: a leg whose posture CHANGES — it gains the bypass fact,
40
+ * so the segment it owes changes — must state the new segment without restating one already standing,
41
+ * and must be able to read a body an earlier leg minted under a composition that joined two. The caller
42
+ * wraps the result in the engine's marked reminder shell; this module never wraps, so the mark stays
43
+ * owned by its own mint home.
42
44
  */
43
45
  export declare function composeApprovalPostureDirective(facts: ApprovalPostureFacts, statedBodies?: readonly string[]): string | undefined;
44
46
  /** SHA-256 of a directive body — the pins' lock port, so the lock is computed the one way. */
@@ -15,11 +15,7 @@ export function refuseInvalidApproverPosture(spec) {
15
15
  return spec.approverPosture;
16
16
  }
17
17
  export function composeApprovalPostureDirective(facts, statedBodies = []) {
18
- const parts = [];
19
- if (facts.autoMode)
20
- parts.push(AUTO_MODE_DIRECTIVE);
21
- if (facts.approverPosture === "bypass")
22
- parts.push(BYPASS_POSTURE_DIRECTIVE);
18
+ const parts = facts.approverPosture === "bypass" ? [BYPASS_POSTURE_DIRECTIVE] : facts.autoMode ? [AUTO_MODE_DIRECTIVE] : [];
23
19
  if (parts.length === 0)
24
20
  return undefined;
25
21
  const stated = new Set();
@@ -392,12 +392,19 @@ export interface EngineNotice {
392
392
  *
393
393
  * - `"classifier.denial_limit"` (#548, CC 2.1.250 `FO`/"too many classifier denials in headless
394
394
  * mode") — the auto-mode classifier's DENIAL LIMIT was reached (3 consecutive blocks, or 20 in the
395
- * run; `RunnerDeps.autoMode.denialLimit`) and the fallback ask it turns into had NO approver to go
396
- * to: none wired, a blanket `onAsk:"allow"` (refused for a real-approval ask), or an approver that
397
- * reported unavailable with no durable park to take it. The run was STOPPED — `TaskResult.status`
398
- * `"failed"`, `errorCode` the same code, `errorMessage` the limit sentence — rather than kept
399
- * spending on a model the classifier denies without end; the triggering call's own result is the
400
- * deny that stood. Once per run (the first headless fallback owns the terminal);
395
+ * run; `RunnerDeps.autoMode.denialLimit`) and the fallback ask it turns into was never JUDGED by
396
+ * anyone. Two lanes reach it. SYNCHRONOUS: no approver to go to — none wired, a blanket
397
+ * `onAsk:"allow"` (refused for a real-approval ask), or an approver that reported unavailable with
398
+ * no durable park to take it. DURABLE: the fallback PARKED, and the decide that resolved the row
399
+ * reported a settlement the nobody-judged table reads as such (`hostDecision.decidedBy:"policy"` ⇒
400
+ * `policy_refused`) — a machine answered where the bound had asked for a person, so the last resort
401
+ * is the same one. A person's refusal (`"person"` ⇒ `human_refused`) is NOT this notice on either
402
+ * lane: someone looked. The run was STOPPED — a `failed` terminal, `code` the same code, `message`
403
+ * the limit sentence — rather than kept spending on a model the classifier denies without end; the
404
+ * triggering call's own result is the deny that stood. The two lanes' sentences differ in ONE
405
+ * clause (which of the two ways nobody answered), and the durable one states the counts the PARKED
406
+ * row carried, so a resume in a fresh process reports the streak that actually happened. Once per
407
+ * run leg (the first such fallback owns the terminal);
401
408
  * `detail: { sessionId, runId, toolName, toolCallId, consecutive, total, limit }` — `limit` is
402
409
  * `"consecutive"` | `"total"`, the bound that tripped. Audience `"user"` (the person whose run
403
410
  * ended is the one entitled to hear why, and to review the transcript the sentence points at).
@@ -25,7 +25,8 @@ import type { CwdRef, ContainmentFace } from "../../tools/fs/index.js";
25
25
  import type { MaterializedA2a } from "../a2a.js";
26
26
  import type { CompactionForkContext, CompactionPhaseDurations, MaybeCompactOptions, RapidRefillState } from "../auto-compaction.js";
27
27
  import type { AutoModeArmingRecipe } from "../auto-mode-arming.js";
28
- import type { AutoModeDecider, AutoModeDenialTracker } from "../auto-mode.js";
28
+ import type { AutoModeDecider, AutoModeDenialTracker, DenialLimitFallbackFace } from "../auto-mode.js";
29
+ import type { SettlementKind } from "../gate-outcome.js";
29
30
  import type { CacheBreakDetector, ToolFingerprintInput } from "../cache-break-detector.js";
30
31
  import type { ActorAssertion, Checkpoint, CheckpointState, CheckpointStore, CheckpointToken, PendingSteerEntry, PlatformLimitReason, ReopenReason, ResourceLedger, ResourceLimitReason, ResumeOutcome, SerializedCheckpointState } from "../checkpoint-store.js";
31
32
  import type { ExecutionOutcomeRecordWord } from "../checkpoint-execution-record.js";
@@ -204,6 +205,22 @@ export interface FrameReceiptLedger {
204
205
  pending: FrameReceipt[];
205
206
  listingSnapshotOwed: boolean;
206
207
  }
208
+ /**
209
+ * The one closure that stops a run leg for a classifier denial-limit fallback NOBODY JUDGED — the
210
+ * synchronous gate's headless arms and the durable resume station both call it, and both reach it
211
+ * through {@link Prepared.stopForDenialLimit}. Declared here, beside the seat it fills
212
+ * ({@link Prepared.gateStopRef}), so the two stations name one type rather than two.
213
+ *
214
+ * `settlement` is the kind that ENDED a parked fallback's wait, and is present only on the DURABLE
215
+ * station: the synchronous one has no settlement to report (no approver was reached at all), and that
216
+ * absence is what selects its clause of the shared sentence.
217
+ */
218
+ export type StopForDenialLimit = (info: {
219
+ toolName: string;
220
+ toolCallId: string;
221
+ fallback: DenialLimitFallbackFace;
222
+ settlement?: SettlementKind;
223
+ }) => void;
207
224
  export interface Prepared {
208
225
  harness: AgentHarness;
209
226
  /** The CONCRETE built-in session (engine-internal: prepare constructs/acquires `StoredSession` itself,
@@ -758,18 +775,25 @@ export interface Prepared {
758
775
  * without this the cause would reach the caller only as the generic `provider.error`. */
759
776
  brainCallGuardrailRef: BrainCallGuardrailRef;
760
777
  /**
761
- * The tool gate's own TYPED STOP: set (once) when the classifier denial limit was reached with
762
- * no approver to fall back to (headless), together with the run abort. The run loop adopts it as the
763
- * terminal `threw` (`TaskResult.errorCode` = the error's `code`, `errorMessage` = its sentence) the
764
- * same way it adopts the brain-call guardrail's — a loop that ended because THIS lane aborted it must
765
- * report the cause, not the consequence. The abort-result details seam reads it too, so the aborted
766
- * call's own `tool_end` carries the code. `undefined` ⇒ no gate stop happened.
778
+ * The tool gate's own TYPED STOP: set (once) when the classifier denial limit fell back to a person
779
+ * and nobody judged it — no approver to fall back to (headless), or a DURABLE park whose decide
780
+ * settled with a word the nobody-judged table reads as such — together with the run abort. The run
781
+ * loop adopts it as the terminal `threw` (`TaskResult.errorCode` = the error's `code`,
782
+ * `errorMessage` = its sentence) the same way it adopts the brain-call guardrail's — a loop that
783
+ * ended because THIS lane aborted it must report the cause, not the consequence. The abort-result
784
+ * details seam reads it too, so the aborted call's own `tool_end` carries the code. `undefined` ⇒ no
785
+ * gate stop happened.
767
786
  */
768
787
  gateStopRef: {
769
788
  terminal?: Error & {
770
789
  code: string;
771
790
  };
772
791
  };
792
+ /** The ONE closure that fills {@link Prepared.gateStopRef} (minted by `createDenialLimitStop`),
793
+ * carried on `Prepared` so the DURABLE resume station reaches the same seat, the same sentence
794
+ * composer and the same notice as the synchronous gate's arms — the second station of one rule, not
795
+ * a second rule. */
796
+ stopForDenialLimit: StopForDenialLimit;
773
797
  /** Set by a tool calling `ctx.requestReview()` (the first-party `present_plan` tool, CC
774
798
  * ExitPlanMode parity): the run loop honors it at the next CLEAN turn boundary by minting a `plan_review`
775
799
  * checkpoint. `{ pending }` is set (with an optional reason) the moment a tool requests review; the boundary
@@ -1,9 +1,9 @@
1
- import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type UnarmedDenialLimitFallback } from "../auto-mode.js";
2
2
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
3
  import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
5
5
  import { type AskOrigin, type RuleStoreUnreadable } from "../ask-origin.js";
6
- import type { Prepared } from "./contracts.js";
6
+ import type { Prepared, StopForDenialLimit } from "./contracts.js";
7
7
  /**
8
8
  * The classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
9
9
  * from `prepareTask` as a phase module (extract, don't accrete). The gate's OWN
@@ -53,20 +53,22 @@ export interface InheritedAutoMode {
53
53
  export declare function attachRebuiltDenialTrackers<T extends {
54
54
  autoMode?: InheritedAutoMode;
55
55
  }>(entries: ReadonlyArray<T> | undefined, denialLimit: AutoModeDenialLimitOptions | undefined): T[] | undefined;
56
- /** The one closure that stops a run for a headless denial-limit fallback (see {@link createDenialLimitStop}). */
57
- export type StopForDenialLimit = (info: {
58
- toolName: string;
59
- toolCallId: string;
60
- fallback: DenialLimitFallbackFace;
61
- }) => void;
62
56
  /**
63
- * The gate's typed stop seat (`Prepared.gateStopRef`) and the one closure that fills it — the headless
64
- * arm of the denial-limit fallback: no approver to fall back to ⇒ stop the run with
65
- * `classifier.denial_limit` (CC throws "Agent aborted: too many classifier denials in headless
66
- * mode"). Once per run (the first headless fallback owns the terminal); the deny that triggered it
67
- * stands as that call's result. Minted once so every mint site — the main gate's seat and the
68
- * inherited-lane arms — stops the SAME run through the SAME seat. `abort` is the run's own abort
69
- * (controller + harness), handed in because the harness is built after the seat is.
57
+ * The gate's typed stop seat (`Prepared.gateStopRef`) and the one closure that fills it — the
58
+ * nobody-judged arm of the denial-limit fallback: the fallback ask ended with no one having judged it
59
+ * ⇒ stop the run with `classifier.denial_limit` (CC throws "Agent aborted: too many classifier denials
60
+ * in headless mode"). Once per run leg (the first such fallback owns the terminal); the deny that
61
+ * triggered it stands as that call's result. Minted once so every mint site — the main gate's seat, the
62
+ * inherited-lane arms, and the durable resume station — stops the SAME leg through the SAME seat.
63
+ * `abort` is the run's own abort (controller + harness), handed in because the harness is built after
64
+ * the seat is.
65
+ *
66
+ * TWO LANES, ONE SENTENCE. The synchronous lane reached no approver at all; the durable lane parked the
67
+ * fallback, and the decide that came back named a settlement word the ONE table
68
+ * ({@link SETTLEMENT_NOBODY_JUDGED}) reads as "nobody judged this". Both are the same fact — the
69
+ * classifier's bound fell back to a person and no person answered — so both stop through here and say
70
+ * the same thing, differing in ONE clause: which of the two ways nobody answered. Composing the
71
+ * sentence in one place is what keeps them from drifting into two accounts of one event.
70
72
  */
71
73
  export declare function createDenialLimitStop(opts: {
72
74
  sessionId: string;
@@ -41,8 +41,11 @@ export function createDenialLimitStop(opts) {
41
41
  const stopForDenialLimit = (info) => {
42
42
  if (gateStopRef.terminal !== undefined)
43
43
  return;
44
+ const lane = info.settlement === undefined
45
+ ? `no approver is wired to fall back to`
46
+ : `the durable approval it parked was settled with nobody judging it ("${info.settlement}")`;
44
47
  const message = `too many classifier denials in headless mode — ${denialLimitSentence(info.fallback)} The run was stopped: ` +
45
- `the auto-mode classifier's denial limit falls back to a person, and no approver is wired to fall back to ` +
48
+ `the auto-mode classifier's denial limit falls back to a person, and ${lane} ` +
46
49
  `(latest blocked action: "${info.toolName}").`;
47
50
  const terminal = Object.assign(new Error(message), { code: "classifier.denial_limit" });
48
51
  gateStopRef.terminal = terminal;
@@ -30,6 +30,10 @@ export interface PrepareDelegationSurfaceInput {
30
30
  taskScope: string;
31
31
  /** borrowed-readonly — the SETTLED task root (the worktree tools' repoRoot). */
32
32
  taskRootFinal: string;
33
+ /** borrowed-readonly — the settled root in the CANONICAL spelling every fence judges with (the root-receiving
34
+ * point resolves it once). The Monitor mount's reset target: the shell tool is built with this same value, so
35
+ * a dead-cwd self-heal lands both tools on one directory rather than two spellings of it. */
36
+ taskRootCanonical: string;
33
37
  /** borrowed-readonly — the run's resolved env the Monitor and worktree tools operate. */
34
38
  executionEnv: ExecutionEnv;
35
39
  /** borrowed-readonly — the trusted Runner self-reference the SendMessage / AgentTranscript mounts execute through; undefined
@@ -30,7 +30,7 @@ function buildTier3ReviveSpawn(delegationForRevive, enrichSpecToolCtx) {
30
30
  };
31
31
  }
32
32
  export function prepareDelegationSurface(input) {
33
- const { spec, deps, internals, resume, sessionId, hostTaskId, taskScope, taskRootFinal, executionEnv, runnerSelf, backgroundTaskToolsActive, workflowToolsActive, peerLaneActive, peerSendMessageBuiltIn, peerLaneRefs, roster, toolEffects, axisExplicitNegatives, envHandToolNames, toolFaceSnapshot, firstPartyOffload, offloadStore, subagentRetain, enrichSpecToolCtx, handsCwdRef, wsSnapshot, rebaseWsPath, rebaseRestoredPath, workspaceStateSettle } = input;
33
+ const { spec, deps, internals, resume, sessionId, hostTaskId, taskScope, taskRootFinal, taskRootCanonical, executionEnv, runnerSelf, backgroundTaskToolsActive, workflowToolsActive, peerLaneActive, peerSendMessageBuiltIn, peerLaneRefs, roster, toolEffects, axisExplicitNegatives, envHandToolNames, toolFaceSnapshot, firstPartyOffload, offloadStore, subagentRetain, enrichSpecToolCtx, handsCwdRef, wsSnapshot, rebaseWsPath, rebaseRestoredPath, workspaceStateSettle } = input;
34
34
  let worktreeSessionRef;
35
35
  const delegationSurfaceActive = backgroundTaskToolsActive || workflowToolsActive;
36
36
  if (delegationSurfaceActive || internals?.parentNotify !== undefined || peerLaneActive) {
@@ -110,6 +110,7 @@ export function prepareDelegationSurface(input) {
110
110
  ...(sessionId !== undefined ? { sessionId } : {}),
111
111
  ...(internals?.onTaskNotification !== undefined ? { onTaskNotification: internals.onTaskNotification } : {}),
112
112
  ...(handsCwdRef !== undefined ? { cwdRef: handsCwdRef } : {}),
113
+ rootCanonical: taskRootCanonical,
113
114
  ...(offloadStore !== undefined ? { toolResultStore: offloadStore } : {}),
114
115
  ...(spec.retainBackgroundProcesses === true ? { retainBackgroundProcesses: true } : {}),
115
116
  })), { source: "builtin", mountedBy: "backgroundTasks" });
@@ -33,8 +33,7 @@ import type { RunnerDeps, TaskSpec, ToolEffect } from "../types.js";
33
33
  import type { WriteProtectionCheck } from "../write-protect.js";
34
34
  import type { ShellReadCheck } from "../../tools/fs/shell-read-check.js";
35
35
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
36
- import type { AskLane, IrreversibilityTier, ParkAsk, Prepared, ReversibilityProbes } from "./contracts.js";
37
- import type { StopForDenialLimit } from "./denial-limit-arms.js";
36
+ import type { AskLane, IrreversibilityTier, ParkAsk, Prepared, ReversibilityProbes, StopForDenialLimit } from "./contracts.js";
38
37
  import type { PermissionRuleLanes } from "./permission-rule-lanes.js";
39
38
  import { type ToolRosterDeltaSeat } from "../tool-roster.js";
40
39
  /**
@@ -46,8 +46,7 @@ import type { SessionStore } from "../session.js";
46
46
  import { type OnAsk } from "../tool-policy.js";
47
47
  import type { ApproverPosture, RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskEvent, TaskSpec, ToolExecuteContext } from "../types.js";
48
48
  import type { ActiveSkillScope } from "./active-skill-scope.js";
49
- import type { InheritedGate, Prepared, PrepareResume, RunInternals, ToolFaceSnapshot } from "./contracts.js";
50
- import { type StopForDenialLimit } from "./denial-limit-arms.js";
49
+ import type { InheritedGate, Prepared, PrepareResume, RunInternals, StopForDenialLimit, ToolFaceSnapshot } from "./contracts.js";
51
50
  export interface PrepareInheritedGateInput {
52
51
  /** borrowed-readonly — the REBOUND spec. Read: `shellGate` (the own doctrine the chain fold tightens), `durableApproval`
53
52
  * (the mandate tests + the children's copy), `onAsk`/`onQuestion` (the frozen seats), `handsReadOnly` / `interactiveTools`
@@ -31,8 +31,7 @@ import type { RunnerDeps, RuntimeCaps, TaskSpec, ToolEffect } from "../types.js"
31
31
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
32
32
  import type { ReadDenyMatcher } from "../../tools/fs/read-deny.js";
33
33
  import { type ActiveSkillScope } from "./active-skill-scope.js";
34
- import type { InheritedGate, IrreversibilityTier, PrepareResume, RunInternals } from "./contracts.js";
35
- import { type StopForDenialLimit } from "./denial-limit-arms.js";
34
+ import type { InheritedGate, IrreversibilityTier, PrepareResume, RunInternals, StopForDenialLimit } from "./contracts.js";
36
35
  import { createRuleOffersOf, type PermissionRuleLanes } from "./permission-rule-lanes.js";
37
36
  export interface PreparePolicyChainInput {
38
37
  /** borrowed-readonly — the REBOUND spec. Read: `sessionId` / `principal` (the session-rule read and the lanes' identity),
@@ -523,7 +523,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
523
523
  handsLessResolvedFace = handsReadFace.handsLessResolvedFace;
524
524
  handsLessResolvedWriteFace = handsReadFace.handsLessResolvedWriteFace;
525
525
  shellGatedTools = handsReadFace.shellGatedTools;
526
- const { worktreeSessionRef } = prepareDelegationSurface({ spec, deps, internals, resume, sessionId, hostTaskId, taskScope, taskRootFinal, executionEnv, runnerSelf, backgroundTaskToolsActive, shellReadAdmission, workflowToolsActive, peerLaneActive, peerSendMessageBuiltIn, peerLaneRefs, roster, toolEffects, axisExplicitNegatives, envHandToolNames, toolFaceSnapshot, firstPartyOffload, offloadStore, subagentRetain, enrichSpecToolCtx, handsCwdRef, wsSnapshot, rebaseWsPath, rebaseRestoredPath, workspaceStateSettle });
526
+ const { worktreeSessionRef } = prepareDelegationSurface({ spec, deps, internals, resume, sessionId, hostTaskId, taskScope, taskRootFinal, taskRootCanonical, executionEnv, runnerSelf, backgroundTaskToolsActive, shellReadAdmission, workflowToolsActive, peerLaneActive, peerSendMessageBuiltIn, peerLaneRefs, roster, toolEffects, axisExplicitNegatives, envHandToolNames, toolFaceSnapshot, firstPartyOffload, offloadStore, subagentRetain, enrichSpecToolCtx, handsCwdRef, wsSnapshot, rebaseWsPath, rebaseRestoredPath, workspaceStateSettle });
527
527
  const { liveQuestionFace, contentAskBindings, contentAskRoutable, lateStrandedAnswers, discloseStrandedAnswers, settleContentAskBindings, durableQuestionFace, questionToolMounted, mountedQuestionTool } = prepareQuestionFace({ frozenOnQuestion, spec, deps, checkpointStore, runtimeCaps, resolvedInteractionPosture, resume, sessionId, roster, inheritedUnavailableAsks: () => inheritedUnavailableAsks });
528
528
  if (spec.handsReadOnly !== true) {
529
529
  const sessionScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
@@ -711,7 +711,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
711
711
  const preparedHolder = {};
712
712
  const { cacheBreakDetector, cacheFingerprint, promptOverheadTokens, readTaskFile, normalizeAttachmentPath, isDedupStubResult, recentlyReadFiles, onCompactionApplied, detectExternalChanges, listBackgroundTasks, centerCompactionCandidate, effectiveReadFaceObserved, effectiveWriteFaceObserved, effectiveReadDenyObserved } = prepareTurnWiring({ deps, systemPromptSeat, harnessTools, fpRef, model, epochArtifactDigestForSnapshot, promptProfile, fableMitigations, systemBlocks, thinking, spec, sessionId, turnSnapshotRef, promptManifest, charsPerToken, handsEnabled, executionEnv, attachmentRootCanonical, additionalRootsCanonical, additionalReadRootsCanonical, readDenyMatcher, resolvedReadFace, handsCwdRef, readFileStateForCheckpoint, denyNarrowingPolicy, abortController, hostTaskId, taskScope, buildAssembleInputs, centerAdoptionRef, harnessRef, epochDeclaredSections, carrierReadFace, carrierWriteFace, readDenyAdditionsNormalized, preparedHolder });
713
713
  await onceLedger.settle(session, announcedListingsRef);
714
- const buildPrepared = () => ({ harness, session, sessionId, runId, reminderMark, reminderDisclosureCounts, approvalPosture: { autoMode: autoModeIntent, ...(approverPostureResolved !== undefined ? { approverPosture: approverPostureResolved } : {}) }, editedFilesSnapshot, taskRootPath: taskRootFinal, model, thinking, compModel: effectiveCompModel, mcp, ...(mcpRedial !== undefined ? { mcpRedial } : {}), ...(a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, gateOutcomes, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(fileHistoryBoundary !== undefined ? { fileHistoryBoundary } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved, effectiveWriteFace: effectiveWriteFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, toolProgressRef, ...(advertisedWritableDirs !== undefined ? { advertisedWritableDirs } : {}), ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), ...(sealReadStateSeat !== undefined ? { sealReadStateSeat } : {}), denyNarrowingPolicy, ...(permissionRuleLane !== undefined ? { persistedRuleLane: permissionRuleLane } : {}), ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, pausedRef, undeliveredContinuationRef, suspendProgressRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, workCutRef, brainCallGuardrailRef, gateStopRef, suspendForReview, ...(readFileStateForCheckpoint !== undefined ? { readFileState: readFileStateForCheckpoint } : {}), resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolRoster, toolRosterDeltas: rosterSeat, structuredProjector, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, microCompact, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, stopRequestedRef, announcedSnapshotRecovered: onceLedger.recovered, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(projectInstructionContent !== undefined ? { projectInstructionContent } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, frameReceipts: { pending: [], listingSnapshotOwed: false }, gitStatusRef, postureFrameRef, listBackgroundTasks, hookIdentity, ...(sessionBoxBound !== undefined ? { sessionBox: sessionBoxBound } : {}), hookTimeoutMs, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}), ...(shellReadCheck !== undefined ? { shellReadCheck } : {}), ...(toolDisclosureSeat !== undefined ? { toolDisclosure: toolDisclosureSeat } : {}) });
714
+ const buildPrepared = () => ({ harness, session, sessionId, runId, reminderMark, reminderDisclosureCounts, approvalPosture: { autoMode: autoModeIntent, ...(approverPostureResolved !== undefined ? { approverPosture: approverPostureResolved } : {}) }, editedFilesSnapshot, taskRootPath: taskRootFinal, model, thinking, compModel: effectiveCompModel, mcp, ...(mcpRedial !== undefined ? { mcpRedial } : {}), ...(a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, gateOutcomes, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(fileHistoryBoundary !== undefined ? { fileHistoryBoundary } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved, effectiveWriteFace: effectiveWriteFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, toolProgressRef, ...(advertisedWritableDirs !== undefined ? { advertisedWritableDirs } : {}), ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), ...(sealReadStateSeat !== undefined ? { sealReadStateSeat } : {}), denyNarrowingPolicy, ...(permissionRuleLane !== undefined ? { persistedRuleLane: permissionRuleLane } : {}), ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, pausedRef, undeliveredContinuationRef, suspendProgressRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, workCutRef, brainCallGuardrailRef, gateStopRef, stopForDenialLimit, suspendForReview, ...(readFileStateForCheckpoint !== undefined ? { readFileState: readFileStateForCheckpoint } : {}), resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolRoster, toolRosterDeltas: rosterSeat, structuredProjector, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, microCompact, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, stopRequestedRef, announcedSnapshotRecovered: onceLedger.recovered, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(projectInstructionContent !== undefined ? { projectInstructionContent } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, frameReceipts: { pending: [], listingSnapshotOwed: false }, gitStatusRef, postureFrameRef, listBackgroundTasks, hookIdentity, ...(sessionBoxBound !== undefined ? { sessionBox: sessionBoxBound } : {}), hookTimeoutMs, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}), ...(shellReadCheck !== undefined ? { shellReadCheck } : {}), ...(toolDisclosureSeat !== undefined ? { toolDisclosure: toolDisclosureSeat } : {}) });
715
715
  rollback.commit();
716
716
  const prepared = buildPrepared();
717
717
  preparedHolder.current = prepared;
@@ -3,7 +3,7 @@ import { redactThenCut } from "../../agents/subagent-steps.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { CheckpointError, readPendingSteerQueue, realApprovalOrgFact, remainingBudgetMicroUsd } from "../checkpoint-store.js";
5
5
  import { normalizePersistedRuleHit } from "../gate-lanes.js";
6
- import { decidersNote } from "../gate-outcome.js";
6
+ import { decidersNote, SETTLEMENT_NOBODY_JUDGED } from "../gate-outcome.js";
7
7
  import { formatHookFeedback, hookSeatExpiredError, runHookSeat } from "../hooks.js";
8
8
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
9
9
  import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permission-rule-org.js";
@@ -579,6 +579,10 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
579
579
  await settleEnd(true, { content: formatHookFeedback(reason, prepared.reminderMark) });
580
580
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, formatHookFeedback(reason, prepared.reminderMark), true));
581
581
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
582
+ const parkedFallback = pendingAction.denialLimitFallback;
583
+ if (parkedFallback !== undefined && settlementKind !== undefined && SETTLEMENT_NOBODY_JUDGED[settlementKind]) {
584
+ prepared.stopForDenialLimit({ toolName: pendingAction.toolName, toolCallId: pendingAction.toolCallId, fallback: parkedFallback, settlement: settlementKind });
585
+ }
582
586
  return;
583
587
  }
584
588
  if (outcome.decision !== "allow") {
@@ -775,6 +775,18 @@ export declare function createAllowDenyPolicy(opts: {
775
775
  message: string;
776
776
  }) => void;
777
777
  }): NamedToolPolicy;
778
+ /**
779
+ * What `createApprovalPolicy`'s `approve` may resolve to: a boolean, or the `AskOutcome` OBJECT minus the
780
+ * two members this seat has no lane for — `"unavailable"` (a ROUTING answer whose only consumer is the
781
+ * ask resolver's durable re-route) and `updatedInput` (an OPERATOR EDIT; see {@link ApproverEditLane}).
782
+ * The latter is spelled `?: never` rather than merely omitted, the way this file spells every
783
+ * prohibition a fresh object literal alone would not catch (a predeclared const, an annotated return).
784
+ */
785
+ export type ApprovalSeatOutcome = boolean | (Extract<AskOutcome, {
786
+ allow: boolean;
787
+ }> & {
788
+ updatedInput?: never;
789
+ });
778
790
  /**
779
791
  * Human-in-the-loop approval for selected tools. Tools in `requireApproval` call `approve(req)` and
780
792
  * are allowed only if it resolves true; tools in `deny` are always blocked; everything else is allowed
@@ -786,17 +798,26 @@ export declare function createAllowDenyPolicy(opts: {
786
798
  * here. A surface rendering the command text for a human on this seat must render through
787
799
  * {@link import("./permission-rule-model.js").renderUntrustedCommandText} (the display baseline)
788
800
  * or run its own screen — absence of the warning bit is "not on this surface", never "clean".
801
+ *
802
+ * WHAT `approve` MAY RETURN: a boolean, as always, or the `{allow, …}` OUTCOME OBJECT the `onAsk` seat
803
+ * takes, read by the SAME screen ({@link readApproverOutcomeObject}) and narrowed to what this seat has
804
+ * a lane for ({@link ApprovalSeatOutcome}) — so a deployment here can report WHO ended the wait
805
+ * (`settledBy:"policy"` / `"timeout"`), attach the decider's `reason` and name the `approver`. A bare
806
+ * `false` is unchanged: a person's refusal, with this factory's own terse sentence. Before this it was
807
+ * the only refusal spelling there was, so a machine's decision filed a PERSON's record.
789
808
  */
790
809
  export declare function createApprovalPolicy(opts: {
791
810
  /** Tools that need an approval decision. */
792
811
  requireApproval: string[];
793
812
  /**
794
- * The approval decision (e.g. await an operator). Resolve true to allow, false to deny. `signal`
813
+ * The approval decision (e.g. await an operator). Resolve true to allow, false to deny — or resolve
814
+ * the `{allow, settledBy?, reason?, approver?, updatedInput?}` outcome object for everything a bare
815
+ * boolean cannot say (see this factory's own doc for what each member means here). `signal`
795
816
  * fires when the task aborts — and, since design/384, when the asking turn is interrupted — race
796
817
  * your wait against it (e.g. an OA approval callback) so a never-answered request is released at
797
818
  * the deadline rather than holding the worker.
798
819
  */
799
- approve: (req: ToolCallRequest, signal?: AbortSignal) => boolean | Promise<boolean>;
820
+ approve: (req: ToolCallRequest, signal?: AbortSignal) => ApprovalSeatOutcome | Promise<ApprovalSeatOutcome>;
800
821
  /** Always-denied tools. */
801
822
  deny?: string[];
802
823
  /** Always-allowed tools (only meaningful with `denyByDefault`). */