@sema-agent/core 7.19.0 → 7.20.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 (106) hide show
  1. package/CHANGELOG.md +183 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +3 -1
  6. package/dist/brain/open-responses.js +4 -2
  7. package/dist/brain/openai.js +3 -1
  8. package/dist/brain/stream-engine.d.ts +4 -0
  9. package/dist/brain/stream-engine.js +4 -0
  10. package/dist/core/auto-mode.d.ts +37 -18
  11. package/dist/core/auto-mode.js +1 -1
  12. package/dist/core/child-clamp-seats.d.ts +79 -0
  13. package/dist/core/child-clamp-seats.js +29 -0
  14. package/dist/core/engine-notice.d.ts +67 -49
  15. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  16. package/dist/core/fs-write-gate-policy.js +51 -4
  17. package/dist/core/gate-bounded-await.d.ts +38 -0
  18. package/dist/core/gate-bounded-await.js +53 -0
  19. package/dist/core/gate-fold.js +16 -48
  20. package/dist/core/gate-lanes.js +11 -3
  21. package/dist/core/gate-outcome.d.ts +9 -6
  22. package/dist/core/governance-codes.d.ts +16 -1
  23. package/dist/core/governance-codes.js +12 -0
  24. package/dist/core/hooks.d.ts +8 -8
  25. package/dist/core/memory-engine/engine.d.ts +14 -9
  26. package/dist/core/memory-engine/engine.js +21 -8
  27. package/dist/core/memory-engine/layout.d.ts +31 -0
  28. package/dist/core/memory-engine/layout.js +68 -3
  29. package/dist/core/memory-engine/types.d.ts +24 -0
  30. package/dist/core/memory.d.ts +19 -0
  31. package/dist/core/memory.js +12 -0
  32. package/dist/core/remote-env.d.ts +32 -0
  33. package/dist/core/remote-env.js +4 -1
  34. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  35. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  36. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  37. package/dist/core/runner/announce-once-ledger.js +8 -0
  38. package/dist/core/runner/classifier-ladder.d.ts +5 -0
  39. package/dist/core/runner/classifier-ladder.js +13 -2
  40. package/dist/core/runner/contracts.d.ts +44 -1
  41. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  42. package/dist/core/runner/denial-limit-arms.js +5 -4
  43. package/dist/core/runner/env-capability-census.d.ts +30 -0
  44. package/dist/core/runner/env-capability-census.js +38 -0
  45. package/dist/core/runner/forward-sink.d.ts +32 -0
  46. package/dist/core/runner/forward-sink.js +41 -0
  47. package/dist/core/runner/gate-exit.d.ts +4 -0
  48. package/dist/core/runner/gate-exit.js +4 -1
  49. package/dist/core/runner/git-status-frame.d.ts +0 -9
  50. package/dist/core/runner/listing-fold.d.ts +57 -0
  51. package/dist/core/runner/listing-fold.js +75 -0
  52. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  53. package/dist/core/runner/prepare-execution-env.js +2 -0
  54. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  55. package/dist/core/runner/prepare-gate-stations.js +6 -2
  56. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  57. package/dist/core/runner/prepare-hands-readface.js +3 -5
  58. package/dist/core/runner/prepare-listings.d.ts +3 -0
  59. package/dist/core/runner/prepare-listings.js +4 -2
  60. package/dist/core/runner/prepare-memory.js +7 -1
  61. package/dist/core/runner/prepare-policy-chain.js +5 -5
  62. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  63. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  64. package/dist/core/runner/prepare-run-refs.js +5 -4
  65. package/dist/core/runner/prepare-task.js +7 -5
  66. package/dist/core/runner/run-clock-and-content.js +3 -7
  67. package/dist/core/runner/run-harness-handlers.js +10 -10
  68. package/dist/core/runner/run-leg.js +28 -19
  69. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  70. package/dist/core/runner/run-turn-boundary.js +44 -33
  71. package/dist/core/runner/runtask.js +2 -1
  72. package/dist/core/runner/synthetic-tools.d.ts +13 -39
  73. package/dist/core/runner/synthetic-tools.js +15 -59
  74. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  75. package/dist/core/runner/task-root-canonical.js +18 -0
  76. package/dist/core/runner/turn-attachments.d.ts +125 -49
  77. package/dist/core/runner/turn-attachments.js +124 -66
  78. package/dist/core/safe-notify.d.ts +5 -0
  79. package/dist/core/safe-notify.js +24 -2
  80. package/dist/core/sensitive-path-policy.d.ts +53 -27
  81. package/dist/core/sensitive-path-policy.js +16 -26
  82. package/dist/core/skill-listing-budget.d.ts +34 -40
  83. package/dist/core/skill-listing-budget.js +25 -26
  84. package/dist/core/skills-directory.js +17 -10
  85. package/dist/core/task-registry.js +2 -1
  86. package/dist/core/task-spec.d.ts +8 -7
  87. package/dist/core/tool-policy.d.ts +12 -1
  88. package/dist/core/tool-policy.js +39 -25
  89. package/dist/core/write-protect.d.ts +98 -18
  90. package/dist/core/write-protect.js +88 -34
  91. package/dist/index.d.ts +6 -5
  92. package/dist/index.js +5 -4
  93. package/dist/orchestration/workflow-primitives.js +10 -20
  94. package/dist/stores/file/memory-store.d.ts +5 -2
  95. package/dist/stores/file/memory-store.js +8 -4
  96. package/dist/tools/fs/bash-readonly-classifier.d.ts +13 -2
  97. package/dist/tools/fs/bash-readonly-classifier.js +317 -35
  98. package/dist/tools/fs/fs-bash.d.ts +1 -1
  99. package/dist/tools/fs/fs-bash.js +12 -25
  100. package/dist/tools/fs/fs-shared.d.ts +22 -0
  101. package/dist/tools/fs/fs-shared.js +30 -0
  102. package/dist/tools/fs/index.d.ts +1 -1
  103. package/dist/tools/fs/index.js +1 -1
  104. package/package.json +2 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.20.0.json +568 -0
@@ -365,6 +365,7 @@ export function createAnthropicBrain(config = {}) {
365
365
  const blocks = new Map();
366
366
  const usageRaw = {};
367
367
  let stopReason = null;
368
+ const terminalFrameSeen = () => stopReason != null;
368
369
  let stopDetails;
369
370
  let nextContentIndex = 0;
370
371
  let malformedFrames = 0;
@@ -532,6 +533,7 @@ export function createAnthropicBrain(config = {}) {
532
533
  hasSubstantiveText,
533
534
  hasCompletedToolCall,
534
535
  hasOnlyThinking: hasThinking && !hasSubstantiveText && !hasCompletedToolCall,
536
+ terminalFrameSeen: terminalFrameSeen(),
535
537
  };
536
538
  },
537
539
  sealForRetry() {
@@ -666,7 +668,7 @@ export function createAnthropicBrain(config = {}) {
666
668
  emptyNoFinish ||
667
669
  (toolError !== undefined && noUsableContent) ||
668
670
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
669
- const tornWithContent = !errored && opts?.truncated !== true && stopReason == null && !noUsableContent && !degenerate;
671
+ const tornWithContent = !errored && opts?.truncated !== true && !terminalFrameSeen() && !noUsableContent && !degenerate;
670
672
  const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${STREAM_TORN_WARNING}` : STREAM_TORN_WARNING) : errorMessage;
671
673
  const errorKind = degenerate
672
674
  ? "degenerate"
@@ -303,6 +303,7 @@ export function createOpenResponsesBrain(config = {}) {
303
303
  let usageRaw;
304
304
  let responseId;
305
305
  let terminalStatus;
306
+ const terminalFrameSeen = () => terminalStatus !== undefined;
306
307
  let incompleteReason;
307
308
  let accumRefusal = "";
308
309
  const capabilityNotes = [];
@@ -573,6 +574,7 @@ export function createOpenResponsesBrain(config = {}) {
573
574
  hasSubstantiveText,
574
575
  hasCompletedToolCall,
575
576
  hasOnlyThinking: hasReasoning && !hasSubstantiveText && !hasCompletedToolCall,
577
+ terminalFrameSeen: terminalFrameSeen(),
576
578
  };
577
579
  },
578
580
  sealForRetry() {
@@ -690,7 +692,7 @@ export function createOpenResponsesBrain(config = {}) {
690
692
  const safetyCut = incompleteReason === INCOMPLETE_CONTENT_FILTER;
691
693
  const refused = accumRefusal.trim().length > 0;
692
694
  const incompleteUnmapped = terminalStatus === "incomplete" && incompleteReason !== INCOMPLETE_MAX_OUTPUT_TOKENS && incompleteReason !== INCOMPLETE_CONTENT_FILTER;
693
- const emptyNoFinish = noUsableContent && (terminalStatus === undefined || incompleteUnmapped) && !degenerate;
695
+ const emptyNoFinish = noUsableContent && (!terminalFrameSeen() || incompleteUnmapped) && !degenerate;
694
696
  const maxTokensNote = sentMaxTokens !== undefined
695
697
  ? `effective max_output_tokens=${sentMaxTokens} (from ${sentMaxTokensLane === "options"
696
698
  ? "options.maxTokens"
@@ -718,7 +720,7 @@ export function createOpenResponsesBrain(config = {}) {
718
720
  emptyNoFinish ||
719
721
  (toolError !== undefined && noUsableContent) ||
720
722
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
721
- const tornWithContent = !errored && opts?.truncated !== true && (terminalStatus === undefined || incompleteUnmapped) && !noUsableContent && !degenerate;
723
+ const tornWithContent = !errored && opts?.truncated !== true && (!terminalFrameSeen() || incompleteUnmapped) && !noUsableContent && !degenerate;
722
724
  const partialWarning = terminalStatus === undefined ? STREAM_TORN_WARNING : RESPONSE_INCOMPLETE_WARNING;
723
725
  const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${partialWarning}` : partialWarning) : errorMessage;
724
726
  const errorKind = degenerate
@@ -373,6 +373,7 @@ export function createOpenAIBrain(config = {}) {
373
373
  let textIndex = 0;
374
374
  const toolAccum = new Map();
375
375
  let finishReason = null;
376
+ const terminalFrameSeen = () => finishReason != null;
376
377
  let usageRaw;
377
378
  let malformedFrames = 0;
378
379
  let malformedSample;
@@ -438,6 +439,7 @@ export function createOpenAIBrain(config = {}) {
438
439
  hasSubstantiveText,
439
440
  hasCompletedToolCall,
440
441
  hasOnlyThinking: accumReasoning.length > 0 && !hasSubstantiveText && !hasCompletedToolCall,
442
+ terminalFrameSeen: terminalFrameSeen(),
441
443
  };
442
444
  },
443
445
  sealForRetry() {
@@ -619,7 +621,7 @@ export function createOpenAIBrain(config = {}) {
619
621
  emptyNoFinish ||
620
622
  (toolError !== undefined && noUsableContent) ||
621
623
  (truncatedEmpty && !dynamicCut);
622
- const tornWithContent = !errored && opts?.truncated !== true && finishReason == null && !noUsableContent && !degenerate;
624
+ const tornWithContent = !errored && opts?.truncated !== true && !terminalFrameSeen() && !noUsableContent && !degenerate;
623
625
  const errorMessageOut = tornWithContent ? (errorMessage ? `${errorMessage}\n${STREAM_TORN_WARNING}` : STREAM_TORN_WARNING) : errorMessage;
624
626
  const errorKind = degenerate
625
627
  ? "degenerate"
@@ -194,11 +194,15 @@ export interface StreamParser {
194
194
  * - `hasOnlyThinking` — thinking streamed but nothing substantive (no non-blank text, no completed
195
195
  * tool call) → tier B (seal + whole-turn retry). A dangling tool-call accumulation alongside the
196
196
  * thinking does not veto this (nothing was admitted; the retry's snapshot replacement drops it).
197
+ * - `terminalFrameSeen` — the provider TERMINATED this stream (openai `finish_reason`, anthropic
198
+ * `message_delta.stop_reason`, responses `response.completed`/`response.incomplete`). A FACT, not a tier:
199
+ * the parser reports it off the same state its own finalize reads, and the tiering owns what it means.
197
200
  */
198
201
  snapshot(): {
199
202
  hasSubstantiveText: boolean;
200
203
  hasCompletedToolCall: boolean;
201
204
  hasOnlyThinking: boolean;
205
+ terminalFrameSeen: boolean;
202
206
  };
203
207
  /**
204
208
  * design/124 §0.5-2 tier B: seal the UI stream before a whole-turn retry — emit `thinking_end` for
@@ -647,6 +647,10 @@ export function runStreamingBrain(args) {
647
647
  return;
648
648
  }
649
649
  const snap = parser.snapshot();
650
+ if (snap.terminalFrameSeen) {
651
+ parser.finalize();
652
+ return;
653
+ }
650
654
  if (snap.hasSubstantiveText || snap.hasCompletedToolCall) {
651
655
  midstreamWarning =
652
656
  failure.kind === "stalled"
@@ -13,10 +13,12 @@ export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUna
13
13
  /**
14
14
  * HOW a reply failed to carry a verdict — the closed set behind `parse_error.form`. `missing` = the reader found
15
15
  * NO answer (empty text, prose, an answer only inside a thinking block); `conflict` = it found TWO distinct
16
- * `<block>` answers (raw or thinking-stripped). The split exists for the classify leg's re-ask: a missing
17
- * answer is asked for again (CC 2.1.259 `Vrr` re-asks an unparseable reply), a conflict is not — it carries a
18
- * `yes`, and a second attempt that answered `no` would erase it. Not on any wire face; the trace frame and the
19
- * gate record keep the one word `parse_error`.
16
+ * `<block>` answers (raw or thinking-stripped). The split decides who gets a SECOND ATTEMPT at the same
17
+ * question, on both seats that offer one: a missing answer is asked for again (CC 2.1.259 `Vrr` re-asks an
18
+ * unparseable reply) and handed on to the next candidate ({@link rungDidNotRule}); a conflict gets neither —
19
+ * it carries a `yes`, and any second attempt that answered `no` would erase it. One rule, two seats: whatever
20
+ * a re-ask may not overwrite, a later rung may not overwrite either. Not on any wire face; the trace frame and
21
+ * the gate record keep the one word `parse_error`.
20
22
  */
21
23
  export declare const AUTO_MODE_PARSE_FAILURES: readonly ["missing", "conflict"];
22
24
  export type AutoModeParseFailure = (typeof AUTO_MODE_PARSE_FAILURES)[number];
@@ -26,8 +28,9 @@ export declare function isAutoModeParseFailure(v: unknown): v is AutoModeParseFa
26
28
  * WHY a classification moved DOWN the candidate ladder — the closed set behind
27
29
  * {@link AutoModeClassifierRound}'s `fallback.cause`, i.e. the ways a candidate produces NO RULING
28
30
  * ({@link rungDidNotRule}). Two words are the unavailable causes verbatim (the leg threw / rejected; the
29
- * round-trip cap elapsed); `parse_error` is the candidate that ANSWERED without a verdict. A ruling is not a
30
- * member: the ladder stops at a verdict either way.
31
+ * round-trip cap elapsed); `parse_error` is the candidate that ANSWERED with no answer in it at all (the
32
+ * `missing` form). A ruling is not a member, and neither is a CONTRADICTORY reply (`conflict`): the ladder
33
+ * stops at a rung that settled the classification, whichever way it settled it.
31
34
  */
32
35
  export declare const AUTO_MODE_FALLBACK_CAUSES: readonly ["error", "timeout", "parse_error"];
33
36
  export type AutoModeFallbackCause = (typeof AUTO_MODE_FALLBACK_CAUSES)[number];
@@ -65,8 +68,9 @@ export type AutoModeVerdict = ({
65
68
  } & AutoModeRoundAttributed)
66
69
  /** The model responded but not in the `<block>…` contract shape. Handled as a BLOCK with the CC
67
70
  * parse-failure sentence ({@link classifierParseFailureDenyMessage}), counted by the denial limit. `form`
68
- * says WHICH way the reply failed ({@link AutoModeParseFailure}): the classify leg re-asks a `missing`
69
- * reply once and never a `conflict`. */
71
+ * says WHICH way the reply failed ({@link AutoModeParseFailure}) and decides who may try the question a
72
+ * second time: the classify leg re-asks a `missing` reply once and the ladder hands it to the next rung;
73
+ * a `conflict` gets neither ({@link rungDidNotRule}). */
70
74
  | ({
71
75
  kind: "parse_error";
72
76
  raw: string;
@@ -100,20 +104,18 @@ export interface AutoModeClassifierRound {
100
104
  * the QUESTION (the transcript window every rung shares), not in a rung's answer.
101
105
  *
102
106
  * WHY IT EXISTS. The ladder advances on "the rung produced no ruling", and a hook that rejects looks exactly
103
- * like that. But a classification whose prompt could not be built fails IDENTICALLY on every rung (the build is one
104
- * shared promise, by contract — "the same prompt, re-asked" is literal), so the walk recorded a fallback
105
- * `{from: seat, to: main, cause: "error"}` and a deny sentence naming two models, for a classification in which NO
106
- * MODEL WAS EVER DISPATCHED: a transcript-store outage read, on every face a person can see, as a broken
107
- * classifier seat. The verdict is unchanged (fail-closed `unavailable`); what changes is that the record
108
- * stops attributing it to models that were never asked.
107
+ * like that. But a classification whose prompt could not be built fails IDENTICALLY on every rung (the build is
108
+ * one shared promise, by contract — "the same prompt, re-asked" is literal), so without this word the walk
109
+ * records a fallback and a deny sentence naming two models for a classification in which NO MODEL WAS EVER
110
+ * DISPATCHED. The verdict is unchanged (fail-closed `unavailable`); what this changes is that the record stops
111
+ * attributing it to models that were never asked.
109
112
  *
110
113
  * Carried as a `code` on the thrown error rather than a class so any classify hook — including one this
111
114
  * package never constructed — can raise it without importing a constructor.
112
115
  *
113
116
  * WHAT IT DOES NOT COVER, stated: a build that OUTRUNS a rung's cap is still recorded as that rung's
114
117
  * `timeout`. The cap bounds the whole classify call, assembly included, so at that point nothing distinguishes
115
- * a slow transcript store from a slow model. Moving the per-classification build outside the rung cap is a
116
- * seam change, filed for a later version.
118
+ * a slow transcript store from a slow model.
117
119
  */
118
120
  export declare const AUTO_MODE_ROUND_NOT_ASKED_CODE = "auto_mode.round_not_asked";
119
121
  /** Mint the marker above around whatever failed, keeping the original as `cause` for the host's own logs.
@@ -124,8 +126,25 @@ export declare const AUTO_MODE_ROUND_NOT_ASKED_CODE = "auto_mode.round_not_asked
124
126
  export declare function autoModeRoundNotAsked(reason: unknown): Error & {
125
127
  code: string;
126
128
  };
127
- /** Why a rung did not settle the classification, or `undefined` when it RULED and the ladder stops — the ONE
128
- * predicate behind the advance (there is no `if (fallback)` arm anywhere). */
129
+ /**
130
+ * Why a rung did not settle the classification, or `undefined` when it SETTLED it and the ladder stops — the
131
+ * ONE predicate behind the advance (there is no `if (fallback)` arm anywhere).
132
+ *
133
+ * A rung settles the classification two ways: it RULES (`allow` / `block`), or it answers with a
134
+ * CONTRADICTION (`parse_error` of form `conflict` — two distinct `<block>` answers in one reply). The second
135
+ * is not a ruling and stays the fail-closed deny it is; what it is NOT is an unanswered question. It holds a
136
+ * `yes`, so asking the SAME question again — of the same model as a re-ask, or of the next rung as a fallback
137
+ * — can only overwrite that `yes` with a `no` and allow a call the classifier already objected to. One rule
138
+ * for both second attempts: {@link AUTO_MODE_PARSE_FAILURES} names the form, the leg reads it for the re-ask
139
+ * and this predicate reads it for the walk. Only a reply with NO answer in it (`missing`) moves the ladder.
140
+ *
141
+ * @contract auto_mode.ladder.advances_on_no_evidence — the candidate ladder advances on exactly one
142
+ * condition, and it is the ABSENCE of evidence: the rung's leg threw or rejected, its round-trip cap elapsed,
143
+ * or its reply carried no answer at all. A rung that RULED and a rung whose reply CONTRADICTED itself both
144
+ * settle the classification where they stand — the contradiction as the fail-closed `parse_error` deny it
145
+ * already is, attributed to that rung and disclosing no fallback — so no later candidate is ever asked a
146
+ * question an earlier one has already answered `yes` to.
147
+ */
129
148
  export declare function rungDidNotRule(v: AutoModeVerdict): AutoModeFallbackCause | undefined;
130
149
  /**
131
150
  * The model-facing deny text for an `unavailable` verdict — CC 2.1.250 `x1t`, with the parenthetical `EIt`
@@ -56,7 +56,7 @@ export function rungDidNotRule(v) {
56
56
  if (v.kind === "unavailable")
57
57
  return v.cause;
58
58
  if (v.kind === "parse_error")
59
- return "parse_error";
59
+ return v.form === "conflict" ? undefined : "parse_error";
60
60
  return undefined;
61
61
  }
62
62
  export function classifierUnavailableDenyMessage(toolName, cause, attribution) {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The TIGHTEN-ONLY CLAMP SEATS an engine-built child spec carries from the run that builds it — and the
3
+ * ONE carrier every such constructor passes them through.
4
+ *
5
+ * Why a carrier and not a convention. The engine builds child specs in several places (the delegation
6
+ * lane, the observer sidecar, the wake/revival fold, the teacher's internal legs, the verify gate's two
7
+ * legs, the workflow lane), and each re-deriving per seat WHICH value is the tightening one lands a new
8
+ * seat one constructor at a time — the ones it does not reach staying invisible, since a `grep readFace`
9
+ * census cannot see a station that carries the face through a persisted seed or a synthesized fold.
10
+ *
11
+ * Two mechanical halves replace the census:
12
+ * · the COMPILER — {@link ChildClampSource} requires every seat as a PRESENT key (an `undefined` value
13
+ * is fine; a missing key is not), so adding a member to {@link CHILD_CLAMP_SEATS} reds `tsc` at every
14
+ * construction site until each one says what it carries there;
15
+ * · the SCAN — `scripts/verify-child-clamp-seats.mjs` reads {@link ENGINE_CHILD_SPEC_CONSTRUCTORS} and
16
+ * requires each registered constructor to go through this carrier, requires the carrier's own source
17
+ * to cover every seat, and requires every tighten-only seat `tightenTaskSpec` governs to be either
18
+ * carried here or listed in {@link CHILD_CLAMP_SEATS_NOT_CARRIED} with a reason.
19
+ *
20
+ * What "tighten-only" means for this seat list: the value that travels is the RESTRICTING one and only
21
+ * that one, so a parent that clamped nothing changes nothing about its children, and nothing a child can
22
+ * author lifts a clamp its parent ran under. That is why the carrier omits a key rather than writing a
23
+ * loosening value — a constructor may state its own stricter default and spread this after it.
24
+ */
25
+ import type { ReadDenyEntry } from "../tools/fs/read-deny.js";
26
+ import type { TaskSpec } from "./types.js";
27
+ /**
28
+ * The clamp seats, as string literals (the closed set the scan and the wire-manifest reader both parse —
29
+ * a spread or an identifier here reads as an empty set).
30
+ */
31
+ export declare const CHILD_CLAMP_SEATS: readonly ["handsReadOnly", "interactiveTools", "readFace", "writeFace", "readDenyPatterns"];
32
+ /** One clamp seat's name. */
33
+ export type ChildClampSeat = (typeof CHILD_CLAMP_SEATS)[number];
34
+ /**
35
+ * The seats `tightenTaskSpec` governs that this carrier deliberately does NOT carry, each with the reason
36
+ * — the "forgot" / "decided" distinction, kept where a new reader will trip over it. The scan requires
37
+ * every seat named in `tightenTaskSpec` to be in this table or in {@link CHILD_CLAMP_SEATS}.
38
+ */
39
+ export declare const CHILD_CLAMP_SEATS_NOT_CARRIED: Readonly<Record<string, string>>;
40
+ /**
41
+ * The engine-built child-spec constructors, as `<repo-relative file>#<name>` — the closed registry the
42
+ * scan enumerates. A constructor that builds a spec for a child RUN belongs here; the scan requires each
43
+ * row to resolve to exactly one declaration IN ITS OWN FILE that calls {@link childClampSeats}.
44
+ *
45
+ * Why the file is half the key, and why a shape the scan cannot read is a RED rather than an absence: a
46
+ * registry keyed on a bare NAME asks a syntactic scan to enumerate every shape that binds a callable, and
47
+ * that list has no end — every shape it misses leaves the real constructor invisible while a namesake
48
+ * answers for it.
49
+ */
50
+ export declare const ENGINE_CHILD_SPEC_CONSTRUCTORS: readonly ["src/agents/subagent.ts#buildChildSpec", "src/agents/subagent.ts#observerSpec", "src/agents/subagent.ts#resumeSpec", "src/agents/teacher.ts#inheritedDeploymentConfig", "src/agents/verify.ts#verifierSpec", "src/agents/verify.ts#fixLegSpec", "src/orchestration/workflow-primitives.ts#workflowChildClamps"];
51
+ /**
52
+ * The clamp source a construction site hands the carrier: EVERY seat as a present key. A site whose lane
53
+ * has no value for a seat writes `undefined` explicitly — which is the point, since that is a sentence
54
+ * a reader (and a diff) can see, unlike an absent key.
55
+ */
56
+ export interface ChildClampSource {
57
+ /** The write-hands clamp: only `true` travels. */
58
+ handsReadOnly: boolean | undefined;
59
+ /** The hard-headless clamp: only `false` travels. */
60
+ interactiveTools: boolean | undefined;
61
+ /** The read containment face: only `"roots"` travels. */
62
+ readFace: TaskSpec["readFace"] | undefined;
63
+ /** The write containment face: only `"roots"` travels. */
64
+ writeFace: TaskSpec["writeFace"] | undefined;
65
+ /** The deny-set additions: an add-only axis, so a non-empty list travels as a spawn-time copy. */
66
+ readDenyPatterns: readonly ReadDenyEntry[] | undefined;
67
+ }
68
+ /**
69
+ * Project a parent's clamps onto a child spec — the ONE place that decides, per seat, which value is the
70
+ * tightening one and how it is copied. Spread the result onto the child spec; a seat with nothing to
71
+ * clamp contributes NO KEY, so a constructor may state its own stricter value above the spread and keep
72
+ * it (a `false` from the parent can never lift it).
73
+ *
74
+ * @contract child_spec.clamps_travel_whole — every child spec the ENGINE builds carries the whole of its
75
+ * parent's tighten-only clamp seats (`CHILD_CLAMP_SEATS`) through this one carrier: only the restricting
76
+ * value of each seat travels, arrays are copied at spawn, and a seat the parent did not clamp adds no key
77
+ * — so a clamp can never end one level deep, and a new seat cannot reach some constructors and not others.
78
+ */
79
+ export declare function childClampSeats(source: ChildClampSource): Partial<TaskSpec>;
@@ -0,0 +1,29 @@
1
+ export const CHILD_CLAMP_SEATS = ["handsReadOnly", "interactiveTools", "readFace", "writeFace", "readDenyPatterns"];
2
+ export const CHILD_CLAMP_SEATS_NOT_CARRIED = Object.freeze({
3
+ toolPolicy: "not a clamp a child spec copies: a parent's denies reach its children through the ancestor-constraint chain the child's own prepare folds, not by putting the parent's policy object on the child seat",
4
+ shellGate: "travels on the inherited-gate lane (`InheritedGate.shellGate`, a trusted internals seat), not on the child spec — a spec-level copy would be a second source for the same clamp",
5
+ onAsk: "per-lane by construction: the delegation lane wraps the parent's approver with spawn provenance before it becomes the child's seat, and a lane with no approver must hand down none",
6
+ hooks: "a deployment seat, not an inherited clamp — `tightenTaskSpec` refuses to merge two hook sets rather than composing them, so there is no tightening value to carry",
7
+ excludeTools: "roster control rather than containment: the lanes that copy a parent's roster do so beside these seats, and the workflow lane authors its child roster from the governance baseline instead of the host's",
8
+ deferTools: "roster control, same reading as `excludeTools` above",
9
+ });
10
+ export const ENGINE_CHILD_SPEC_CONSTRUCTORS = [
11
+ "src/agents/subagent.ts#buildChildSpec",
12
+ "src/agents/subagent.ts#observerSpec",
13
+ "src/agents/subagent.ts#resumeSpec",
14
+ "src/agents/teacher.ts#inheritedDeploymentConfig",
15
+ "src/agents/verify.ts#verifierSpec",
16
+ "src/agents/verify.ts#fixLegSpec",
17
+ "src/orchestration/workflow-primitives.ts#workflowChildClamps",
18
+ ];
19
+ export function childClampSeats(source) {
20
+ return {
21
+ ...(source.handsReadOnly === true ? { handsReadOnly: true } : {}),
22
+ ...(source.interactiveTools === false ? { interactiveTools: false } : {}),
23
+ ...(source.readFace === "roots" ? { readFace: "roots" } : {}),
24
+ ...(source.writeFace === "roots" ? { writeFace: "roots" } : {}),
25
+ ...(source.readDenyPatterns !== undefined && source.readDenyPatterns.length > 0
26
+ ? { readDenyPatterns: [...source.readDenyPatterns] }
27
+ : {}),
28
+ };
29
+ }
@@ -10,31 +10,41 @@ export interface EngineNotice {
10
10
  * `autoCompactTokens` ABOVE its physical window; the trigger-side geometry was clamped to the
11
11
  * physical window (an autocompact window only ever lowers the trigger) and the bad value is
12
12
  * announced once per prepared task; `detail: { modelId, declaredAutoCompactTokens,
13
- * physicalWindow, sessionId, runId }` — `runId` (#499) is what makes the "once per prepared
14
- * task" claim checkable: the session id beside it is shared by every run of the session, so two
15
- * runs clamping the same model used to mint byte-identical lines.
13
+ * physicalWindow, sessionId, runId }` — `runId` is what makes the "once per prepared task" claim
14
+ * checkable, the session id beside it being shared by every run of the session.
16
15
  * - `"config.env_timeout_discarded"` — a Bash timeout knob (option or env) held a value that is not
17
16
  * the value in force; `detail: { knob, raw, usedMs }`.
18
17
  * - `"config.materialize_env_discarded"` — `SEMA_TOOL_MATERIALIZE_STRATEGY` held a value outside the
19
18
  * closed set in a seat where it is not in force; `detail: { raw, specStrategy? }`.
19
+ * - `"config.task_root_not_canonical"` (#759) — the task root this run was handed is not its own
20
+ * canonical path (a symlinked directory; `/var` → `/private/var` on macOS). Containment is judged
21
+ * on the CANONICAL spelling, so the deployment and the engine reason about two different
22
+ * directories: a target the host considers inside the root can be judged outside it. The run is
23
+ * unchanged — announce, never narrow, never repair — and the fix is to hand the canonical path in.
24
+ * Audience `operator` (a deployment wiring fact). `detail: { given, canonical, sessionId, runId }`
25
+ * — both spellings, so a consumer can diff them without guessing which one it holds. Once per
26
+ * prepared leg, and never for a root that already is its own canonical form (its absence is the
27
+ * claim that the two agree).
20
28
  * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
21
29
  * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
22
30
  * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
23
31
  * - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
24
32
  * `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
25
33
  * schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
34
+ * - `"agents.listing_truncated"` — the agent-type roster frame did not fit the same listing budget:
35
+ * at least one type is listed name-only (`nameOnly`) or not at all (`dropped`). Audience `operator`.
36
+ * `detail: { budgetBytes, total, kept, nameOnly: [name], dropped: [name], sessionId }`; the same
37
+ * firing rule as its skills sibling below (once per delivered frame, on the carrying message's receipt).
26
38
  * - `"skills.listing_truncated"` — the `<skills>` disclosure block did not fit its byte budget, so
27
39
  * at least one skill is listed without its description (`nameOnly`) or, in the pathological case,
28
40
  * not listed at all (`dropped`); the `Skill` tool still serves every one of them. Audience
29
41
  * `operator` — raising `TaskSpec.skillListingBudgetFraction`, lowering `skillListingMaxDescChars`
30
- * or declaring fewer skills are all deployment acts. `detail: { budgetBytes,
31
- * requestedBudgetBytes, descMaxChars, total, kept, nameOnly: [name], dropped: [name], sessionId }`
32
- * — `requestedBudgetBytes` is what the declared fraction asked for BEFORE the delivery lane's
33
- * structural clamp, and differs from `budgetBytes` only when that clamp bound, so a listing
34
- * shortened by the lane is distinguishable from one shortened by the deployment's own fraction.
35
- * Announced by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the
36
- * turn boundary that a drift (or a resume leg's initial listing) rides — once per delivered frame
37
- * the budget actually degraded, and never for one that fit. Every count is read off that frame, so
42
+ * or declaring fewer skills are all deployment acts. `detail: { budgetBytes, descMaxChars, total,
43
+ * kept, nameOnly: [name], dropped: [name], sessionId }` — `budgetBytes` IS the budget in force;
44
+ * no ceiling stands above the declared fraction, so no second number rides beside it.
45
+ * Announced on the RECEIPT of the message that carried the frame — the leg's first user message, or
46
+ * the boundary steer that a drift (or a resume leg's initial listing) rides — once per delivered
47
+ * frame the budget actually degraded, and never for one that fit. Every count is read off that frame, so
38
48
  * `total` is the whole roster on an initial listing and the newly announced set on a drift frame.
39
49
  * A run whose deployment turned the listing off (`attachments.skillsListing: false`) emits nothing,
40
50
  * since nothing is rendered. There is NO dedup key: each notice is one delivered frame`s cost, and
@@ -43,21 +53,19 @@ export interface EngineNotice {
43
53
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
44
54
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
45
55
  * the notice claims a failed write, not an empty ref;
46
- * `detail: { ref, sessionId, cause, occurrenceId }`.
47
- * Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact — and
48
- * `occurrenceId` (#495, uuidv7 minted at the failure) is what lets a consumer act on that:
49
- * `ref` names the ROW, and the same ref is attempted again on every later re-clear, so two
50
- * genuine losses of one ref were byte-identical notices. Dedup key: (code, occurrenceId).
56
+ * `detail: { ref, sessionId, cause, occurrenceId }`. Per-occurrence, not
57
+ * per-process-deduplicated: each failed write is a distinct fact, and `ref` names the ROW rather
58
+ * than the attempt, so the uuidv7 `occurrenceId` minted at the failure is what tells two genuine
59
+ * losses of one ref apart. Dedup key: (code, occurrenceId).
51
60
  * - `"workflow.governance_key_stripped"` (#235) — the fields of an LLM-authored workflow `agent(spec)`
52
61
  * that did NOT cross the governed default-deny whitelist: an unrecognized/control-plane field, or a
53
62
  * `readFace` value that is not the containment-tightening one. The spawn PROCEEDS on the deployment
54
63
  * baseline (the strip is not a refusal in this window), which is why the drop is announced;
55
64
  * `detail: { total, stripped: [{ key, reason }], omitted?, occurrenceId }`, the rendered key list
56
65
  * bounded in count and length because the names come from the untrusted script. One aggregated
57
- * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact —
58
- * `occurrenceId` (#495, uuidv7 minted at the announcement) carries that claim to the consumer,
59
- * which otherwise saw two builds of the same spec as one repeated notice. Dedup key:
60
- * (code, occurrenceId).
66
+ * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact,
67
+ * and the uuidv7 `occurrenceId` minted at the announcement is what carries that claim to a consumer
68
+ * that would otherwise read two builds of one spec as one repeated notice. Dedup key: (code, occurrenceId).
61
69
  * - `"workflow.agent_option_ignored"` (#505 ①) — the OPTIONS-object twin of the row above: keys of
62
70
  * `agent(spec, opts)`'s SECOND argument that were not applied (unrecognized, or a known option
63
71
  * whose value its type check rejected — `label: 42`). The spawn PROCEEDS without them, which is
@@ -67,6 +75,21 @@ export interface EngineNotice {
67
75
  * key: (code, occurrenceId). NOT the whole family: a SPEC field written on the options object
68
76
  * (`model`, `modelName`, `thinking`, …) is REFUSED at the membrane instead — a silently ignored
69
77
  * `model` runs a different agent than the author asked for.
78
+ * - `"config.forward_sink_failed"` — the deployment's opt-in subagent display sink
79
+ * (`RunInternals.onForwardEvent`) failed and the frame it was handed was dropped: it threw, or —
80
+ * since an `async` function satisfies that `=> void` seat — it returned a promise that rejected.
81
+ * The run is untouched (forwarding is a render channel, and delivery is never awaited, so a sink
82
+ * that never settles is not a failure and announces nothing). `detail: { site, cause }` — `site` is
83
+ * the engine-owned site name the failures are counted under, `cause` the thrown value's message.
84
+ * Audience `operator`: the sink is deployment wiring and nobody else can repair it. Dedup unit:
85
+ * once per RUN (the counters ride `RunInternals.forwardScope`, which every copy of a run's
86
+ * internals carries), later failures of the same run counted only — a sink that fails on every
87
+ * frame therefore costs ONE line, not one per frame.
88
+ * - `"memory.restore_withheld"` — a memory containment DID NOT write a committed baseline back: another
89
+ * writer held the path when the restore reached it, and those newer bytes were left standing.
90
+ * Containment is unaffected and the committed copy is still in the store, so reconciling the two is
91
+ * the host's act (audience `operator`). `detail: { paths: [{ path, entryId?, rev? }], sessionId? }`,
92
+ * `rev` = the declined generation. Dedup unit: once per containment PASS, never per path.
70
93
  * - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
71
94
  * probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
72
95
  * once per materialization (a resume re-materializes and may announce again).
@@ -90,10 +113,9 @@ export interface EngineNotice {
90
113
  * { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
91
114
  * finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
92
115
  * against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
93
- * atomically and mints nothing.
94
- * `occurrenceId` (#495, uuidv7 minted at the announcement): the verb carries no run and no
95
- * session, and the payload is a pair of counts, so two swaps of the same shape were byte-
96
- * identical lines. Dedup key: (code, occurrenceId).
116
+ * atomically and mints nothing. The verb carries no run and no session and the payload is a pair
117
+ * of counts, so the uuidv7 `occurrenceId` minted at the announcement is what tells two swaps of the
118
+ * same shape apart. Dedup key: (code, occurrenceId).
97
119
  * - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
98
120
  * declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
99
121
  * restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
@@ -102,8 +124,7 @@ export interface EngineNotice {
102
124
  * read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
103
125
  * every later prepare resolves against the new declaration. A candidate outside the value gate
104
126
  * (`assertContainmentFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
105
- * reason as the catalog line (no run, no session, a two-word payload). Dedup key:
106
- * (code, occurrenceId).
127
+ * reason as the catalog line (no run, no session, a two-word payload). Dedup key: (code, occurrenceId).
107
128
  * - `"config.write_face_swapped"` — the same line for the WRITE seat (`RunnerDeps.writeFace`),
108
129
  * same payload, same laws. A SEPARATE code rather than a face field on the line above: the audience
109
130
  * table and every consumer table are keyed by code.
@@ -114,10 +135,9 @@ export interface EngineNotice {
114
135
  * `detail: { seat, from, to, cause, fixHint, sessionId?, runId? }` — `cause` is the refusal code
115
136
  * (`route.credential_mismatch` / `route.credential_missing`); `sessionId` (#433, additive)
116
137
  * rides when the adjudicating seat knows its session, and `runId` (#499, additive) names the
117
- * INVOCATION whose seats were being resolved — both correlation keys only, the audience
118
- * stays `"operator"` (see the registry's entitlement-not-routability note). Two runs of one
119
- * session falling back on the same seat used to mint byte-identical lines; `runId` is what
120
- * tells one task falling back twice apart from two tasks falling back once each.
138
+ * INVOCATION whose seats were being resolved — both correlation keys only, the audience stays
139
+ * `"operator"` (see the registry's entitlement-not-routability note), and `runId` is what tells one
140
+ * task falling back twice apart from two tasks falling back once each.
121
141
  * Explicitly-named models never mint this: they refuse at the brain's request gate instead.
122
142
  * - `"route.classifier_unroutable"` (same pre-flight, the other POSTURE) — the derived classifier seat
123
143
  * failed the same pairing pre-flight while the deployment had declared NO fallback
@@ -155,10 +175,9 @@ export interface EngineNotice {
155
175
  * refusal; per-model-hook credentials are invisible to the catalog swap, so the notice is
156
176
  * deliberately conservative (it may fire when a hook-side credential DID change in lockstep).
157
177
  * One aggregated notice per swap; `detail: { entries: [{ modelId, from, to }], total,
158
- * occurrenceId }` (rendered list bounded, total always exact). `occurrenceId` (#495, uuidv7
159
- * minted at the announcement, independent of the `config.models_swapped` line's own): the
160
- * bounded render makes two swaps moving the same first entries byte-identical.
161
- * Dedup key: (code, occurrenceId).
178
+ * occurrenceId }` (rendered list bounded, total always exact); the `occurrenceId` is minted at this
179
+ * announcement, independently of the `config.models_swapped` line's own, because the bounded render
180
+ * makes two swaps moving the same first entries identical. Dedup key: (code, occurrenceId).
162
181
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
163
182
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
164
183
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -194,14 +213,17 @@ export interface EngineNotice {
194
213
  * threw: the `Artifact` tool is not mounted for the task and this is the loud half (an absent seat
195
214
  * is silent; a broken one must not look the same). Once per prepared leg;
196
215
  * `detail: { problem, sessionId, runId }`.
216
+ * - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
217
+ * (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
218
+ * supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
197
219
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
198
220
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
199
221
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
200
222
  * leg's children inherit). A mandate with no park facility cannot be honored: no ask of the leg can suspend, so each one resolves on the LIVE
201
223
  * chain — a live approver / question face answers in-stream, and with none the ask is denied
202
224
  * fail-closed — and NO durable approval record is written. The ask routing itself is not
203
- * changed by the notice (a store-less leg never could park); the notice is the loud half of a
204
- * posture that used to degrade silently, disclosed before the first ask of the leg. Audience
225
+ * changed by the notice (a store-less leg never could park); the notice is its loud half,
226
+ * disclosed before the first ask of the leg. Audience
205
227
  * `"user"` (the person whose asks will not be recorded is entitled to that; the operator hears
206
228
  * it through the sink as always). Once per prepared task leg — a resume or a delegated child
207
229
  * is its own leg and says so again; `detail: { sessionId, runId, principal?, cause, source,
@@ -224,12 +246,10 @@ export interface EngineNotice {
224
246
  * (`sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
225
247
  * per-run identity, the key this account is actually about — see
226
248
  * {@link undrainedUserInputNotices}; all omitted when the caller has none, never fabricated).
227
- * Per-run, at most once per family (the terminal sweep is a single site) — which is exactly why
228
- * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session,
229
- * so two runs each stranding one steer folded into one indistinguishable row.
230
- * **#389 (two corrections).** ① The family now fires on the INTERRUPT path too: `abort()` used
231
- * to empty both queues before agent_end could count them, so the one loss path an operator most
232
- * needs to hear about was the one path that stayed silent. ② On a DURABLE PARK the verdict is
249
+ * Per-run, at most once per family (the terminal sweep is a single site) — which is why
250
+ * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session.
251
+ * ① The family fires on the INTERRUPT path too, `abort()` being barred from emptying either queue
252
+ * before agent_end can count it. ② On a DURABLE PARK the verdict is
233
253
  * settled at the run tail instead of at agent_end, because the tail first carries the still-
234
254
  * undelivered input onto the committed checkpoint's parked-steer queue (the resume delivers it)
235
255
  * — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
@@ -260,10 +280,9 @@ export interface EngineNotice {
260
280
  * the other question (was the halt CONSUMED), see that seat.
261
281
  * In both lanes: one notice per REAL cut (a `now` that found nothing in flight or whose frame
262
282
  * already rode the imminent boundary, and a halt with nothing in flight, announce nothing — no
263
- * false interrupt claims); `runId` (#499) is the INVOCATION that was cut (the other two ids are
264
- * shared by every run of the session); every cut turn counts toward `maxTurns`, so an interrupt
265
- * storm's cost is bounded and each of its cuts is on the record. Audience `"user"`: the person
266
- * who forced the boundary is the one entitled to see that it landed.
283
+ * false interrupt claims); `runId` is the INVOCATION that was cut; every cut turn counts toward
284
+ * `maxTurns`, so an interrupt storm's cost is bounded and each of its cuts is on the record.
285
+ * Audience `"user"`: the person who forced the boundary is the one entitled to see that it landed.
267
286
  * RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` — the ladder is
268
287
  * implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
269
288
  * judge ladder support by VERSION, never by that code's absence.
@@ -350,9 +369,8 @@ export interface EngineNotice {
350
369
  * the inputId, so "accepted at park, screened at redelivery" is auditable end to end. Audience
351
370
  * `"user"` (the person whose instruction was withheld is the one entitled to re-issue it);
352
371
  * `detail: { inputId?, sessionId, runId, taskId? }` — `inputId` is the parked entry's stored
353
- * correlation key (OPTIONAL: a row parked without one exists), and `runId` (#499) names the
354
- * resume leg that withheld it, so a session resumed twice does not report two withholdings as
355
- * one.
372
+ * correlation key (OPTIONAL: a row parked without one exists), and `runId` names the resume leg
373
+ * that withheld it, so a session resumed twice does not report two withholdings as one.
356
374
  *
357
375
  * - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
358
376
  * this session's memory crossed into the one-way externally-exposed state (a tool classified
@@ -20,7 +20,12 @@ export interface FsWriteGatePolicyOptions {
20
20
  acceptDirs?: readonly string[];
21
21
  /** Writes whose canonical target lands inside one of these dirs are ALLOWED and never asked
22
22
  * (auto-approved working areas: the scratchpad dir, plan files). Checked before `acceptDirs`
23
- * (same verdict — the split is intent/observability, exemptions are structural not session-won). */
23
+ * (same verdict — the split is intent/observability, exemptions are structural not session-won).
24
+ * An entry that RESOLVES onto `rootPath` — or above it — grants nothing: a domain that holds the
25
+ * root is not a working area but the whole workspace, and a symlinked scratchpad is all it takes.
26
+ * Same rule, same predicate as the prompt's advertised writable directories; say "every write here
27
+ * is pre-approved" with `defaultWrite: "allow"` instead. `acceptDirs` is NOT judged by it (a person
28
+ * may grant the whole root for a session — see the module header). */
24
29
  exemptDirs?: readonly string[];
25
30
  /** Verdict for a covered write OUTSIDE every domain: `"ask"` (the manual-mode default) routes it
26
31
  * to the ask-resolution chain (live onAsk / durable park / headless deny); `"allow"` is the
@@ -55,17 +60,20 @@ export interface FsWriteGatePolicyOptions {
55
60
  * deployment's gate options, not in core-global state: core stays lane-agnostic (no device concept
56
61
  * here); the deployment that runs guarded-capable envs arms it.
57
62
  *
58
- * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary, codex r1-1): armed guarantees every covered
59
- * write lands through an ATOMIC precondition-verified backend step — the tool's own
60
- * canonicalize→write window is closed. It does NOT yet bind THIS GATE's adjudicated canonical key
61
- * across an approval wait: the tool re-canonicalizes at execute time, so a symlink swapped between
62
- * this gate's verdict and the tool's execution is re-resolved (and then atomically verified against
63
- * the NEW resolution). Closing that remaining window needs the adjudicated key carried through a
64
- * trusted approval receipt into tool execution — a separate seat, deliberately not smuggled into
65
- * this knob.
63
+ * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary): armed guarantees every covered write lands
64
+ * through an ATOMIC precondition-verified backend step, closing the tool's own canonicalize→write
65
+ * window. It does NOT bind THIS GATE's adjudicated canonical key across an approval wait: the tool
66
+ * re-canonicalizes at execute time, so a symlink swapped between this gate's verdict and the tool's
67
+ * execution is re-resolved (then atomically verified against the NEW resolution). Closing that
68
+ * remaining window needs the adjudicated key carried through a trusted approval receipt into tool
69
+ * execution — a separate seat, deliberately not smuggled into this knob.
66
70
  */
67
71
  requireGuardedWrite?: boolean;
68
72
  }
73
+ /** The coded refusal a gate wired with a root-covering exempt domain throws at CONSTRUCTION — the
74
+ * `config.*` prepare-refusal family (one exported spelling per member, thrown so the task never
75
+ * starts), so a deployment can compare against this constant instead of matching message text. */
76
+ export declare const WRITE_EXEMPT_DIR_COVERS_ROOT_CODE = "config.write_exempt_dir_covers_root";
69
77
  /**
70
78
  * Build the file-write approval gate policy. Mount via `combinePolicies` alongside the deployment's
71
79
  * other layers (sensitive-path deny, session rules, caller policy) — see the module header for the