@sema-agent/core 7.26.1 → 7.26.3

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 (33) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/agents/verify.d.ts +16 -6
  3. package/dist/agents/verify.js +12 -5
  4. package/dist/core/checkpoint-store.d.ts +13 -10
  5. package/dist/core/gate-lanes.js +2 -1
  6. package/dist/core/gate-outcome.d.ts +54 -5
  7. package/dist/core/gate-outcome.js +17 -0
  8. package/dist/core/runner/contracts.d.ts +48 -0
  9. package/dist/core/runner/decide-continuation.js +3 -1
  10. package/dist/core/runner/denial-limit-arms.d.ts +4 -3
  11. package/dist/core/runner/denial-limit-arms.js +2 -1
  12. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -0
  13. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  14. package/dist/core/runner/prepare-task.js +1 -1
  15. package/dist/core/runner/resume-apply.js +3 -1
  16. package/dist/core/runner/resume-policy-outcome.js +6 -6
  17. package/dist/core/runner/run-harness-handlers.js +2 -5
  18. package/dist/core/runner/run-identity-wiring.d.ts +6 -0
  19. package/dist/core/runner/run-identity-wiring.js +8 -3
  20. package/dist/core/runner/run-leg.js +9 -3
  21. package/dist/core/runner/runtask.js +2 -2
  22. package/dist/core/tool-policy.d.ts +36 -12
  23. package/dist/core/tool-policy.js +22 -9
  24. package/dist/tools/fs/dead-cwd.d.ts +48 -0
  25. package/dist/tools/fs/dead-cwd.js +56 -0
  26. package/dist/tools/fs/fs-bash.js +4 -32
  27. package/dist/tools/fs/shell-read-plan.js +16 -17
  28. package/dist/tools/monitor.d.ts +4 -0
  29. package/dist/tools/monitor.js +6 -0
  30. package/package.json +1 -1
  31. package/test/export-surface.snapshot.json +1 -1
  32. package/wire-changes/core@7.26.2.json +106 -0
  33. package/wire-changes/core@7.26.3.json +65 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.26.3 — 2026-09-21
4
+
5
+ ### Monitor probes the tracked working directory before it spawns (#1018; behaviour NARROWING; +0 exports; @server @cli @test)
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ ### 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)
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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".
22
+ - **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`.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+ - **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.
27
+ - **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.
28
+ - **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).
29
+
30
+ ## 7.26.2 — 2026-09-21
31
+
32
+ ### 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)
33
+
34
+ - **What changed.** `A=~/.env; true` was refused on every seat with `read_path_denied` and `target: "A=~/.env"` — the whole assignment WORD. Nothing in that command reads a file: bash sets a variable and runs `true`. The mint offered the word itself to the deny judge whenever the text could not settle the VALUE (a tilde bash expands at the start of a value and after each `:`, an append's earlier content), and a deny row whose leading double-star segment swallows the `A=~/` then read the rest as the guarded name. The refusal named a file that cannot exist, and the same row did not match `A=~/secret` unless spelled to — a barrier by coincidence of pattern shape.
35
+ - **One rule.** Every candidate such a word hands the judge is a fact about its value, and the question about the value is WHO MAY OPEN IT. At an operand slot that is the program's own grammar (`dd if=~/x` keeps its fail-closed answer, now accounted as `"~/x"` rather than `"if=~/x"`). For an environment word it is the program the segment STARTS: `A=~/.env cat safe.txt` hands `cat` a value bash expands and `cat` may open; `A=~/.env; true` starts no program; `A=~/'.env' true` starts one declared to open no path operand. Where a program may open it, an environment value that names ONE path is PLACED the way an operand is — a leading tilde against the declared home — so the deny judge and the refusal sentence name the real file.
36
+ - **NARROWING.** A value bash expands from a tilde is now judged against the declared home instead of being read as the literal word: `A=~/.env cat safe.txt`, `env A=~/.env cat safe.txt`, `A+=~/.env cat safe.txt`, `GIT_CONFIG_GLOBAL=~/.env git config --global --list` and `BASH_ENV=~/.env bash script.sh` refuse with `target: "<home>/.env"` on all three seats. The last two are the point: a program may open what its environment names (verified against the real `git`, which prints such a file's contents), and 7.26.1 refused those only when the deny row happened to swallow `NAME=~`. An absolute-path row never did.
37
+ - **RELAXING.** `A=~/.env; true` and `A=~/'.env' true` are vouched where they were refused: no program is started that could open the value, and the word itself is nobody's filename. The read boundary is not relaxed — those commands open nothing.
38
+ - **Who is hurt.** A deployment reading the refusal's `target` as the literal command word sees a path instead; that is the repair. A command whose environment value the text can settle is judged exactly as before. A value a `:` joins into two paths is placed by nobody (one spelling cannot carry two) and is no candidate — the remaining half of KL-993b.
39
+ - **Compensation.** The refusal's own judgement is pinned: a `read_path_denied` target is a path spelling, never a word carrying `=`, and the path quoted in the sentence is that target (`test/backlog1004-assignment-word-not-a-path.test.ts` ②). KL-993b records what is left.
40
+ ### Every leg of the verify gate offers its wiring manifest, and says which leg it is (#1005; additive; +0 exports; @server)
41
+
42
+ - **What was unreachable.** `runWithVerification` / `resumeWithVerification` / `runDeveloperTask` / `verifyCompleted` return a `TaskResult` and drain each leg's event stream internally, so the `wiring_manifest` frame every prepared leg pushes died inside the gate. A deployment recording what each attempt was wired with — its tool roster, its MCP rows, its governance section — saw "done" and nothing else, while the gate itself knew exactly which leg it had just dispatched.
43
+ - **What changes.** `RunInternals` gains `onWiringManifest?: (manifest, leg?) => void`, the fourth observation sink on the trusted core-caller channel beside `onActivity` / `onForwardEvent` / `onWorkspaceResolved`, and never a `TaskSpec` field. It is handed the very object that leg's frame carries, once per prepared leg, at the moment the frame is pushed — after prepare, before the leg's first model or tool interaction — on fresh and resumed legs alike.
44
+ - **The leg identity is minted by the composition, never by the engine.** The verify entries dispatch each leg with a per-dispatch copy of the internals object whose sink closes over that leg's `{kind: "impl" | "verifier" | "fix", round}`: round 0 for the entry implementation leg, the round a verifier verifies, the round whose findings a fix answers. The engine stamps none and infers none, so a leg nobody labelled — a plain `runTask`, a workflow agent call, a cascade rung — reaches the sink with `leg` absent, which is the honest reading of an unlabelled leg. Nothing a caller can set ON the object produces an identity, and the identity itself goes out FROZEN and fresh per dispatch — an observer that writes to what it was handed is refused, rather than rewriting what the next leg, or the next run, reports. This is a different axis from the manifest's own `leg` field (`root` / `child` / `resume`): a resumed fix leg carries both, and both are true.
45
+ - **Containment, and one notifier per run.** The sink is contained by the same run-scoped safe notifier as `onActivity`: a throw — or an `async` sink returning a rejecting promise — is swallowed, counted and disclosed once, never faulting the leg whose manifest it was handed. That notifier is now minted in the identity-wiring lane (the run's first internals-sink call) and shared with the harness handlers, where each lane used to mint its own; bounded disclosure is per notifier, so one account per run is what makes "reported once" true across the sinks on one object.
46
+ - **Who is hurt.** A caller that wires nothing is dispatched exactly as before (the per-dispatch copy is made only when the sink is present). A caller that DOES wire it gets one documented delta on top of the #246 forwarding shape: the gate's legs receive a fresh shallow copy carrying the bound sink instead of the caller's object by identity — every other field still rides by the caller's own reference, the caller's object is never mutated, and the `isolation` door still re-reads that same object before every leg.
47
+ ### A deployment's policy refusal stops speaking as the user (#1016; ADDITIVE closed-set word; +0 public exports; @server @cli @client-core)
48
+
49
+ - **What was wrong.** A shell running headless wires an `onAsk` approver that can show nobody a prompt, so it refuses on the deployment's behalf. The only way to spell that was `false` (plus a reason), and `false` means one thing to the engine: a person said no. The model was therefore told *"The user doesn't want to proceed with this tool use … treat it as the user's guidance"* and *"STOP what you are doing and wait for the user"* — about a machine's refusal, in a run where no user turn is ever coming. The deployment's own words about WHY rode a fence labelled as the user's.
50
+ - **The word.** `AskOutcome.settledBy` gains `"policy"` and the durable decide's `hostDecision.decidedBy` gains the same word — one fact, the same spelling on both legs. `SettlementKind` gains `policy_refused` (`who.party: "none"`, a refusal in `SETTLEMENT_IS_REFUSAL`), which is what `tool_end.gate.settlement.kind` and the durable row then carry.
51
+ - **The sentence.** One composer (`policyRefusalMessage`, module-internal — both legs call it, neither re-spells it) mints it for both legs: `approval required for "<tool>" but this run has no interactive approval channel: <ask prompt>` / `The deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and permission prompts are not available in this context.` plus a posture directive that tells the model to report what was blocked and get on with the work that needs no approval. A note rides a fence labelled `policy note` — relayed verbatim, never licensed as anyone's guidance. `humanRefusalMessage` is reached only by a person's refusal now.
52
+ - **The default does not move.** An omitted `settledBy` still reads as `"human"`, and `hostDecision.decidedBy` keeps both of its existing words: a deployment that never learns the new word is byte-unchanged on every face. Only a host that SAYS `"policy"` gets the policy sentence.
53
+ - **Who is hurt.** A consumer whose table over `SettlementKind` is exhaustive reds on the new member — that red is the notification, and it is what a closed set is for. A shell that adopts the word also changes its own turn shape: a BARE `human_refused` is the issuing batch's control-flow boundary (siblings settle un-executed, the turn ends awaiting the user) and `policy_refused` deliberately is not, because there is no user to await. The refusal still stands; the run continues.
54
+ - **Two screens widened, neither loosened.** `{allow: true}` beside any non-person word is a fail-closed `approver_contract` deny (was: the `timeout` word only), and a durable `decision:"allow"` is refused for every decider that is not `"person"` (was: `sla_timeout` only). One condition each, over the whole non-person family, so the next word is screened the day it is added.
55
+ - **The hole the word opened, found by the adversarial round.** "Was this deny made by nobody?" — the question the classifier's denial-limit fallback asks before stopping a run whose questions nothing can answer — was a hand-written two-word disjunction at the inherited chain's fold and a three-word one at the main gate. `policy_refused` answered it by falling out of both: a headless lane that hit the denial limit and refused BY POLICY no longer reached the stop that the same lane reached when it refused with a bare `false`. `SETTLEMENT_NOBODY_JUDGED` is now that question, once, with a compile-time fence over the whole set, and both sites read it. **Consequence for a shell adopting the word:** a run that hits the classifier denial limit on a policy-refusing SYNCHRONOUS lane ends `failed` with `classifier.denial_limit`, as a fully headless one already did. **Stated precisely, because the leg matters:** that stop seat exists on the synchronous gate and the inherited chain, and on NEITHER leg of a durable resume — a parked fallback ask answered through `/decide` re-parks instead of stopping, for every decider word alike (measured: `person` and `policy` produce identical runs). That gap predates this word and is not narrowed by it; it is recorded for its own fix.
56
+ - **Doc repairs found on the way.** `INTEGRATION-CORE` §1.3 still described a flat `tool_end.settledBy` and §4.4 a top-level `settledBy` on a decide; both were retired at 7.6.0 and a consumer following them was reading fields no release mints. §1.3 now documents the live `gate` record and §4.4 the `hostDecision` facts plus the refused keys. Same repair in the governed-agent example's `seats-2` row.
57
+ - Wire table: `docs/wire-consumption/unreleased-1016.md`.
58
+
3
59
  ## 7.26.1 — 2026-09-21
4
60
 
5
61
  ### One store, one answer about one file; one lineage judgment at every station (#1003 + #1000; narrowing; +0 exports; @server)
@@ -253,8 +253,8 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
253
253
  * WHY THE SEAT EXISTS: these entries return a `TaskResult` and DRAIN each leg's event stream
254
254
  * internally, so the host never holds a `TaskStream` for any of them. Without this parameter a
255
255
  * deployment can observe nothing a leg did — tool beats, forwarded child progress, the resolved
256
- * workspace — only the final result. Wiring an observation sink here (`onActivity`,
257
- * `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
256
+ * workspace, the leg's own wiring manifest — only the final result. Wiring an observation sink here
257
+ * (`onActivity`, `onForwardEvent`, `onWorkspaceResolved`, `onWiringManifest`) is the only way in.
258
258
  *
259
259
  * ATTRIBUTION — WEAK, and deliberately stated as such: the legs run SEQUENTIALLY (impl → verifier →
260
260
  * fix → verifier → …) and each is dispatched with the SAME object, so a leg's own tool beats are all
@@ -264,10 +264,20 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
264
264
  * no verifier marker either (an impl spec carrying its own `outputSchema` mounts it too).
265
265
  * {@link VerifyConfig.onRound} bounds a ROUND, not a leg — the segment before it holds the impl (or
266
266
  * fix) leg's beats AND that round's verifier beats together. A host that needs true per-leg
267
- * attribution must drive the legs itself (one `runner.runTask` per leg) rather than read it out of
268
- * this stream. Two further honesty notes: SUBAGENT events forwarded through `onForwardEvent` do
269
- * carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that spawned it — its
270
- * frames can arrive after that leg returned, i.e. interleaved into a later leg's segment.
267
+ * attribution for THOSE frames must drive the legs itself (one `runner.runTask` per leg) rather than
268
+ * read it out of this stream. Two further honesty notes: SUBAGENT events forwarded through
269
+ * `onForwardEvent` do carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that
270
+ * spawned it — its frames can arrive after that leg returned, i.e. interleaved into a later leg's
271
+ * segment.
272
+ *
273
+ * ONE SEAT IS EXEMPT, because its frame is per-leg by construction: `onWiringManifest` fires exactly
274
+ * once per leg, at that leg's start, and this gate hands it the leg's own IDENTITY beside the
275
+ * manifest (the axis, and the two readings a consumer cannot infer, are documented at the seat).
276
+ * That identity is minted HERE, in the dispatch that knows it — the engine stamps none and infers
277
+ * none — so a consumer of this seat reads per-leg attribution instead of reconstructing it from
278
+ * arrival order. This is also the one place the forwarding below is not by identity: a caller that
279
+ * wired that sink gets a fresh per-dispatch copy carrying the bound sink (see `withLegIdentity`),
280
+ * every other field still by its own reference.
271
281
  *
272
282
  * FAILURE-ISOLATED since #248, by the same primitive as {@link VerifyConfig.onRound} /
273
283
  * {@link VerifyConfig.onVerifierPause}: the fields on this object are invoked by the ENGINE on the
@@ -81,6 +81,13 @@ function refuseUnhonorableInternals(internals, phase) {
81
81
  `implementation leg keeps whatever it did (a consumed checkpoint stays consumed, its session stays the caller's to reclaim).`));
82
82
  }
83
83
  }
84
+ function withLegIdentity(internals, leg) {
85
+ if (internals?.onWiringManifest === undefined)
86
+ return internals;
87
+ const identity = Object.freeze({ ...leg });
88
+ return { ...internals, onWiringManifest: (manifest) => internals.onWiringManifest?.(manifest, identity) };
89
+ }
90
+ const IMPL_LEG = { kind: "impl", round: 0 };
84
91
  function fixObjective(findings) {
85
92
  const list = findings.length
86
93
  ? findings.map((f) => `- ${sanitizeUntrustedText(f)}`).join("\n")
@@ -173,7 +180,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
173
180
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
174
181
  signal: specBase.signal,
175
182
  };
176
- const v = await runner.runTask(verifierSpec, legInternals());
183
+ const v = await runner.runTask(verifierSpec, withLegIdentity(legInternals(), { kind: "verifier", round }));
177
184
  try {
178
185
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
179
186
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.terminal) ? { paused: { ...v, terminal: v.terminal } } : {}) };
@@ -259,7 +266,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
259
266
  readDenyPatterns: foldedReadDeny,
260
267
  }),
261
268
  };
262
- current = await runner.runTask(fixLegSpec, legInternals());
269
+ current = await runner.runTask(fixLegSpec, withLegIdentity(legInternals(), { kind: "fix", round }));
263
270
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
264
271
  implAccount.add(current.stats);
265
272
  if (isDurablePause(current.terminal)) {
@@ -272,7 +279,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
272
279
  }
273
280
  export async function runWithVerification(runner, implSpec, config = {}, internals) {
274
281
  refuseUnhonorableInternals(internals, "door");
275
- const result = await runner.runTask(implSpec, internals);
282
+ const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
276
283
  if (isDurablePause(result.terminal)) {
277
284
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
278
285
  }
@@ -281,7 +288,7 @@ export async function runWithVerification(runner, implSpec, config = {}, interna
281
288
  }
282
289
  export async function resumeWithVerification(runner, token, outcome, taskConfig, objective, config = {}, internals) {
283
290
  refuseUnhonorableInternals(internals, "door");
284
- const result = await runner.resume(token, outcome, taskConfig, internals);
291
+ const result = await runner.resume(token, outcome, taskConfig, withLegIdentity(internals, IMPL_LEG));
285
292
  if (isDurablePause(result.terminal)) {
286
293
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
287
294
  }
@@ -291,7 +298,7 @@ export async function resumeWithVerification(runner, token, outcome, taskConfig,
291
298
  export async function runDeveloperTask(runner, spec, config = {}, internals) {
292
299
  const implSpec = { systemPrompt: CODE_AGENT_PROMPT, ...spec };
293
300
  if (config.verify === false) {
294
- const result = await runner.runTask(implSpec, internals);
301
+ const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
295
302
  return { ...result, verification: { verdict: "unverified", unverifiedReason: "opted_out", rounds: 0, findings: [] } };
296
303
  }
297
304
  return runWithVerification(runner, implSpec, config, internals);
@@ -613,14 +613,17 @@ export type ResumeOutcome = {
613
613
  answer?: QuestionAnswer;
614
614
  /**
615
615
  * The host's DECISION FACTS — who ended this wait: a person (`"person"`, the decide an operator made
616
- * through the deployment's approval channel, with the attribution that channel reports), or the
616
+ * through the deployment's approval channel, with the attribution that channel reports), the
617
617
  * host's own SLA sweep at the row's deadline (`"sla_timeout"` — FACET A of the deadline contract; only
618
- * a sweep may say it, and a sweep cannot `allow`). REQUIRED: a decide that names nobody is not a
619
- * record of anything that happened, and core does not guess.
618
+ * a sweep may say it), or the deployment's OWN POLICY (`"policy"` — a machine refused: no interactive
619
+ * approval surface exists for this row, or a standing posture declined it; the durable spelling of the
620
+ * synchronous leg's `AskOutcome.settledBy: "policy"`, so ONE refusal reads the same on both faces).
621
+ * Only a person can `allow` — the other two words name a wait that ended with nobody judging.
622
+ * REQUIRED: a decide that names nobody is not a record of anything that happened, and core does not guess.
620
623
  *
621
624
  * The host supplies FACTS, never the settlement WORD: core mints the record
622
- * ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired`,
623
- * joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
625
+ * ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired`
626
+ * / `policy_refused`, joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
624
627
  * (`resolvedOutcome.gateOutcome`) and projects it onto the resolved call's `tool_end` frame. A caller
625
628
  * that supplies `gateOutcome` itself is refused pre-CAS.
626
629
  *
@@ -635,7 +638,7 @@ export type ResumeOutcome = {
635
638
  * authorization; a replay states its own facts.
636
639
  */
637
640
  hostDecision: {
638
- decidedBy: "person" | "sla_timeout";
641
+ decidedBy: "person" | "sla_timeout" | "policy";
639
642
  approver?: string;
640
643
  };
641
644
  /**
@@ -2175,8 +2178,8 @@ export declare class CheckpointError extends Error {
2175
2178
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2176
2179
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
2177
2180
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
2178
- * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`, pair
2179
- * an SLA timeout with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2181
+ * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
2182
+ * pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2180
2183
  * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
2181
2184
  * it). The throw
2182
2185
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
@@ -2354,8 +2357,8 @@ export declare class CheckpointError extends Error {
2354
2357
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2355
2358
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
2356
2359
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
2357
- * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`, pair
2358
- * an SLA timeout with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2360
+ * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
2361
+ * pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2359
2362
  * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
2360
2363
  * it). The throw
2361
2364
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
@@ -1,6 +1,7 @@
1
1
  import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
2
2
  import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, ruleClearanceOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
3
3
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
+ import { SETTLEMENT_NOBODY_JUDGED } from "./gate-outcome.js";
4
5
  import { inlineUntrusted } from "./untrusted-text.js";
5
6
  import { isRuleBehavior } from "./permission-rule-model.js";
6
7
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
@@ -539,7 +540,7 @@ export async function runGateLanes(pass) {
539
540
  input.autoMode?.denialTracking?.recordAllow();
540
541
  }
541
542
  else if ((resolved.action === "deny" &&
542
- (resolved.settlement.kind === "no_approver" || resolved.settlement.kind === "blanket_allow_refused" || resolved.settlement.kind === "approver_unavailable")) ||
543
+ (SETTLEMENT_NOBODY_JUDGED[resolved.settlement.kind] || resolved.settlement.kind === "approver_unavailable")) ||
543
544
  resolved.approverUnavailable === true) {
544
545
  try {
545
546
  input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: unarmedWindow(askBeforeResolve.denialLimitFallback) });
@@ -29,9 +29,17 @@ import { type AutoModeClassifierRound } from "./auto-mode.js";
29
29
  * unreadable member, a timeout-settled allow, a refused attribution…).
30
30
  * - `presentation_failed` — the args or the edit could not be safely presented/adopted (unclonable).
31
31
  * - `blanket_allow_refused` — a blanket `onAsk:"allow"` met a `requiresRealApproval` ask.
32
+ * - `policy_refused` — the DEPLOYMENT'S OWN POLICY refused: the approval channel reported that a machine,
33
+ * not a person, ended this wait (a headless lane with no interactive approval surface, a standing
34
+ * deployment posture). Distinct from `human_refused` because the party differs and the model is told a
35
+ * different thing: a person's "no" is the user's direction, a policy's is a capability of this run.
36
+ * Distinct from `no_approver` because an approval channel WAS wired and answered — core did not fall
37
+ * back to its own headless posture. The refusal's own note, when the channel attached one, reaches the
38
+ * model as the policy's words and NOT as a person's; it is not on this record (the note seat belongs to
39
+ * the two human words, whose ABSENCE is a control-flow fact this family does not have).
32
40
  * - `task_aborted` — the wait's abort signal ended it (the run's own end, or a turn-level interrupt).
33
41
  */
34
- export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "task_aborted"];
42
+ export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "policy_refused", "task_aborted"];
35
43
  export type SettlementKind = (typeof SETTLEMENT_KINDS)[number];
36
44
  /** Membership test for {@link SettlementKind} — the word crosses process boundaries on `tool_end`, so a
37
45
  * consumer validating one it received must not hand-roll the set. */
@@ -49,9 +57,9 @@ export declare function isSettlementKind(v: unknown): v is SettlementKind;
49
57
  *
50
58
  * @contract settlement.single_mint — every `Settlement` kind is composed by CORE at the site whose wait
51
59
  * ended (the ask resolver's arms, the approval factory's arms, the decide lane over a host's decision
52
- * FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"` on its outcome
53
- * and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`) — facts core turns
54
- * into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
60
+ * FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"`/`"policy"` on its
61
+ * outcome and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`/`"policy"`) — facts
62
+ * core turns into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
55
63
  * so a policy cannot self-declare one; the engine's own settlement sites that hand a settled decision
56
64
  * back through a policy fold attest it through {@link engineSettlementOf}.
57
65
  */
@@ -87,7 +95,7 @@ export type Settlement = {
87
95
  };
88
96
  when: number;
89
97
  } | {
90
- kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "task_aborted";
98
+ kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "policy_refused" | "task_aborted";
91
99
  who: {
92
100
  party: "none";
93
101
  };
@@ -132,10 +140,51 @@ export declare const SETTLEMENT_IS_REFUSAL: {
132
140
  readonly approver_contract: true;
133
141
  readonly presentation_failed: true;
134
142
  readonly blanket_allow_refused: true;
143
+ readonly policy_refused: true;
135
144
  readonly task_aborted: true;
136
145
  };
137
146
  /** Compile-time fence: `never` while every settlement kind has a refusal row. */
138
147
  export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_IS_REFUSAL>>;
148
+ /**
149
+ * Which settlement kinds are a refusal NOBODY JUDGED — the wait ended because this run has no one who
150
+ * could answer the question, not because someone considered it and said no. The classifier's
151
+ * denial-limit fallback reads it: that fallback exists to bound a classifier that would otherwise deny
152
+ * without end, and its last resort is to STOP the run rather than let the model keep re-asking a
153
+ * question nothing in this deployment can answer.
154
+ *
155
+ * It is a TABLE and not a condition for the reason the sibling above is: this question used to be a
156
+ * hand-written two-word disjunction at one site and a three-word one at another, so a settlement word
157
+ * added later answered it by falling out of both — which is exactly what `policy_refused` did on the
158
+ * commit that introduced it (a headless lane that refused by policy stopped reaching the stop seat that
159
+ * the same lane reached when it refused by returning a bare `false`).
160
+ *
161
+ * The rows that say `false` each say something:
162
+ * - the two human words and the window words — someone WAS asked; a person answered, or their window ran
163
+ * out. The run is not out of judges.
164
+ * - `approver_unavailable` — a ROUTING answer, not a posture: the main gate owns that arm (it may park
165
+ * the ask on a durable queue instead), and the two sites that treat it as headless anyway say so at
166
+ * their own call, because they are the ones with no park route left.
167
+ * - `approver_error` / `approver_contract` / `presentation_failed` — the approver was there and the
168
+ * engine could not use it. A defect to report, not a deployment with nobody home.
169
+ * - `task_aborted` — the run ended; nothing is escalating anywhere.
170
+ */
171
+ export declare const SETTLEMENT_NOBODY_JUDGED: {
172
+ readonly human_allowed: false;
173
+ readonly human_refused: false;
174
+ readonly approval_window_expired: false;
175
+ readonly denial_limit_window_expired: false;
176
+ readonly park_sla_expired: false;
177
+ readonly no_approver: true;
178
+ readonly approver_unavailable: false;
179
+ readonly approver_error: false;
180
+ readonly approver_contract: false;
181
+ readonly presentation_failed: false;
182
+ readonly blanket_allow_refused: true;
183
+ readonly policy_refused: true;
184
+ readonly task_aborted: false;
185
+ };
186
+ /** Compile-time fence: `never` while every settlement kind has a nobody-judged row. */
187
+ export type NobodyJudgedTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_NOBODY_JUDGED>>;
139
188
  /**
140
189
  * WHO REFUSED a call — the layer whose verdict is the deny. The other question a deny raises, "who ASKED",
141
190
  * is answered by {@link GateOutcome.origin} (an {@link import("./ask-origin.js").AskOrigin}); the two used
@@ -12,6 +12,7 @@ export const SETTLEMENT_KINDS = [
12
12
  "approver_contract",
13
13
  "presentation_failed",
14
14
  "blanket_allow_refused",
15
+ "policy_refused",
15
16
  "task_aborted",
16
17
  ];
17
18
  const SETTLEMENT_KIND_SET = new Set(SETTLEMENT_KINDS);
@@ -37,8 +38,24 @@ export const SETTLEMENT_IS_REFUSAL = {
37
38
  approver_contract: true,
38
39
  presentation_failed: true,
39
40
  blanket_allow_refused: true,
41
+ policy_refused: true,
40
42
  task_aborted: true,
41
43
  };
44
+ export const SETTLEMENT_NOBODY_JUDGED = {
45
+ human_allowed: false,
46
+ human_refused: false,
47
+ approval_window_expired: false,
48
+ denial_limit_window_expired: false,
49
+ park_sla_expired: false,
50
+ no_approver: true,
51
+ approver_unavailable: false,
52
+ approver_error: false,
53
+ approver_contract: false,
54
+ presentation_failed: false,
55
+ blanket_allow_refused: true,
56
+ policy_refused: true,
57
+ task_aborted: false,
58
+ };
42
59
  export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
43
60
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
44
61
  export function isDeniedBy(v) {
@@ -36,6 +36,7 @@ import type { MemoryEngine } from "../memory-engine/engine.js";
36
36
  import type { ResolvedSkillListingBudget } from "../skill-listing-budget.js";
37
37
  import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.js";
38
38
  import type { SessionPermissionRules } from "../session-policy-store.js";
39
+ import type { SafeNotifier } from "../safe-notify.js";
39
40
  import type { RecoveredOrphan } from "../session-reconcile.js";
40
41
  import type { SessionStore, StoredSession } from "../session.js";
41
42
  import type { TaskOutcome } from "../task-outcome.js";
@@ -2063,6 +2064,49 @@ export interface RunInternals {
2063
2064
  * threads it too) — a resumed leg's restore re-fires the observation with the settled root.
2064
2065
  */
2065
2066
  onWorkspaceResolved?: (workspace: ResolvedWorkspace) => void;
2067
+ /**
2068
+ * A TRUSTED run-scoped WIRING-MANIFEST sink, filled ONLY by a core caller and NEVER a {@link
2069
+ * TaskSpec} field — the posture of {@link onActivity} and {@link onWorkspaceResolved} beside it.
2070
+ * Handed the very object this leg's `wiring_manifest` frame carries, at the moment that frame is
2071
+ * pushed: ONCE per prepared leg, after prepare and before the leg's first model or tool
2072
+ * interaction. Rides `internals` on FRESH and RESUMED legs alike, because the mint sits in the
2073
+ * run body both entries share.
2074
+ *
2075
+ * WHY a seat, when the frame already rides the stream: a COMPOSITION entry (the verify gate, the
2076
+ * cascade) drains its inner legs' queues ITSELF, so the host holds no TaskStream for any of them
2077
+ * and an inner leg's manifest dies inside the gate — the hole {@link onStatusEvent} was opened
2078
+ * for, one frame family over. A direct `runTask` caller needs neither seat: its own stream
2079
+ * carries both frames.
2080
+ *
2081
+ * `leg` is the LEG IDENTITY, and the engine mints NONE of it. It is present only where a
2082
+ * composition labelled the leg it dispatched: the verify entries hand each leg a per-dispatch
2083
+ * copy of this object whose sink closes over that leg's identity, so a consumer can tell the
2084
+ * implementation leg from round 2's verifier without inferring it from arrival order (the
2085
+ * ordering is all the sibling sinks have — see the attribution paragraph on `verifyCompleted`).
2086
+ * ABSENT therefore means "nobody labelled this leg", never "implementation": a plain `runTask`,
2087
+ * a workflow agent call and a cascade rung all reach this sink with no identity, and reading one
2088
+ * in would be the engine fabricating a fact it does not hold. NOT the same axis as the manifest's
2089
+ * OWN `leg` field ({@link WiringManifest.leg}, `root` / `child` / `resume`), which is a fact about
2090
+ * how the ENGINE reached this run; this one is a fact about the composition that dispatched it, and
2091
+ * a resumed fix leg carries both at once — `manifest.leg.kind === "resume"` and `leg.kind === "fix"`
2092
+ * are both true of it. `round` is 1-based over the gate's verification rounds: a `verifier` leg
2093
+ * carries the one it verifies, a `fix` leg the one whose findings it answers, and the entry
2094
+ * implementation leg carries 0 — before the first. The axis is relative to the gate INVOCATION,
2095
+ * not to the session's history: a resume entry's work leg is THAT invocation's implementation leg
2096
+ * (`impl`, round 0) even when the checkpoint it resumes was minted inside a previous invocation's
2097
+ * fix round, because the invocation's own round counter (`VerificationResult.rounds`) restarts with
2098
+ * it — one axis for both fields, rather than a leg that reads "fix round 1" inside a result whose
2099
+ * rounds begin at 1 again.
2100
+ *
2101
+ * Observe-only and failure-isolated through the SAME per-run notifier as {@link onActivity} (one
2102
+ * isolation scope for every sink on this object, so a broken sink is counted and disclosed once,
2103
+ * not once per site family): a throw — or an `async` sink returning a rejecting promise — is
2104
+ * contained and never faults the leg whose manifest it was handed.
2105
+ */
2106
+ onWiringManifest?: (manifest: WiringManifest, leg?: {
2107
+ readonly kind: "impl" | "verifier" | "fix";
2108
+ readonly round: number;
2109
+ }) => void;
2066
2110
  }
2067
2111
  /**
2068
2112
  * The payload of {@link RunInternals.onWorkspaceResolved}: the working root this task actually
@@ -2541,6 +2585,10 @@ export interface HarnessHandlersDeps {
2541
2585
  runnerHooks: {
2542
2586
  onError: RunnerDeps["onError"];
2543
2587
  };
2588
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks, minted by the
2589
+ * identity-wiring lane (which calls the first of them) and shared with these handlers, so the
2590
+ * "first failure per site" disclosure is one account for the whole run rather than one per lane. */
2591
+ internalsNotifier: SafeNotifier;
2544
2592
  }
2545
2593
  /**
2546
2594
  * The Runner as the modules it drives and the compositions over it see it — the delegation tools
@@ -119,7 +119,9 @@ export function resumeContinuation(resume, mark) {
119
119
  }
120
120
  const decided = resume.outcome.decision === "allow"
121
121
  ? "was APPROVED and has now been executed — its result is in the tool results above"
122
- : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
122
+ : resume.outcome.hostDecision.decidedBy === "policy"
123
+ ? "was DENIED by this deployment's own policy, not by a person — the refusal and its reason are in the tool results above"
124
+ : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
123
125
  return formatHookFeedback(`The pending tool call "${pendingAction.toolName}" ${decided}. ` +
124
126
  `This is a RESUMED task — every step before this point is already done and its results are in the ` +
125
127
  `conversation above. Do NOT restart the task or re-run any tool you already ran; continue from this ` +
@@ -168,9 +168,10 @@ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
168
168
  }): Promise<InheritedClassifierJudgment<A>>;
169
169
  /** The resolver's answer as the settlement reads it. */
170
170
  export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
171
- /** A deny nobody made, at a wrapper arm: no approver wired, or a blanket `onAsk:"allow"` refused as
172
- * no approver. `approver_unavailable` is NOT headless here — it floats to the main gate, which owns
173
- * that arm (a park, or the fail-closed refusal). */
171
+ /** A deny nobody JUDGED, at a wrapper arm — read off the one table over the settlement words
172
+ * ({@link SETTLEMENT_NOBODY_JUDGED}) rather than listed here, so a word added to the set answers this
173
+ * question by getting a row and not by falling out of a disjunction. `approver_unavailable` is NOT
174
+ * headless here — it floats to the main gate, which owns that arm (a park, or the fail-closed refusal). */
174
175
  export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
175
176
  /** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
176
177
  * re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
@@ -1,6 +1,7 @@
1
1
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
2
2
  import { sanitizeAutoModeArmingRecipe, tightenDenialLimit } from "../auto-mode-arming.js";
3
3
  import { attestClassifierDenyCause, attestClassifierRound } from "../tool-policy.js";
4
+ import { SETTLEMENT_NOBODY_JUDGED } from "../gate-outcome.js";
4
5
  import { deliverEngineNotice } from "../types.js";
5
6
  import { inlineUntrusted } from "../untrusted-text.js";
6
7
  import { askOriginOf, classifierMayAnswer, probeMandatedAsk } from "../ask-origin.js";
@@ -109,7 +110,7 @@ export async function judgeInheritedClassifier(opts) {
109
110
  return { kind: "resolve", ask: minted, fallback, mintedHere: true, origin: askOriginOf(minted, INHERITED_STATION_FACTS) };
110
111
  }
111
112
  export function headlessDenyAtFold(r) {
112
- return r.settlement?.kind === "no_approver" || r.settlement?.kind === "blanket_allow_refused";
113
+ return r.settlement !== undefined && SETTLEMENT_NOBODY_JUDGED[r.settlement.kind];
113
114
  }
114
115
  export function headlessDenyAtRecheck(r) {
115
116
  return headlessDenyAtFold(r) || r.settlement?.kind === "approver_unavailable" || r.approverUnavailable === true;
@@ -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" });
@@ -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";
@@ -176,7 +176,9 @@ export async function resumeApply(input) {
176
176
  const facts = plainPolicyOutcome.hostDecision;
177
177
  const settlement = facts.decidedBy === "sla_timeout"
178
178
  ? { kind: "park_sla_expired", who: { party: "host", ...(facts.approver !== undefined ? { approver: facts.approver } : {}) }, when: Date.now() }
179
- : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
179
+ : facts.decidedBy === "policy"
180
+ ? { kind: "policy_refused", who: { party: "none" }, when: Date.now() }
181
+ : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
180
182
  plainPolicyOutcome = { ...plainPolicyOutcome, gateOutcome: mintGateOutcome({ ...(plainPolicyOutcome.decision === "deny" ? { deniedBy: "ask_resolution" } : {}), settled: { settlement, origin: parkedOrigin } }) };
181
183
  }
182
184
  const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
@@ -5,7 +5,7 @@ import { sanitizeUntrustedText } from "../untrusted-text.js";
5
5
  import { assertOutcomeText, describeSuppliedValue, pendingContentAskCallId, sameWinner } from "./decide-continuation.js";
6
6
  const REFUSED_DECIDE_KEYS = [
7
7
  ["gateOutcome", "the settlement record is minted by core from the host's decision facts and the row's origin, never supplied"],
8
- ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout", approver?} and core mints the settlement word from them'],
8
+ ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout" | "policy", approver?} and core mints the settlement word from them'],
9
9
  ["approver", "a retired key — the attribution rides the facts it belongs to, as hostDecision.approver"],
10
10
  ];
11
11
  export function resumePolicyOutcome(input) {
@@ -49,14 +49,14 @@ export function resumePolicyOutcome(input) {
49
49
  }
50
50
  const hostDecisionRaw = decide.hostDecision;
51
51
  if (typeof hostDecisionRaw !== "object" || hostDecisionRaw === null) {
52
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
52
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout" | "policy", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
53
53
  }
54
54
  const decidedBy = hostDecisionRaw.decidedBy;
55
- if (decidedBy !== "person" && decidedBy !== "sla_timeout") {
56
- throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person" or "sla_timeout"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
55
+ if (decidedBy !== "person" && decidedBy !== "sla_timeout" && decidedBy !== "policy") {
56
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person", "sla_timeout" or "policy"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
57
57
  }
58
- if (decidedBy === "sla_timeout" && decision === "allow") {
59
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "sla_timeout" — an elapsed SLA window cannot be what approved an action that EXECUTES; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
58
+ if (decidedBy !== "person" && decision === "allow") {
59
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "${decidedBy}" — only a person approves an action that EXECUTES, and that word names a decision nobody made; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
60
60
  }
61
61
  const attribution = screenApproverAttribution(hostDecisionRaw.approver);
62
62
  if (attribution.defect !== undefined) {