@sema-agent/core 5.32.0 → 5.33.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 (41) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/agents/cascade.d.ts +49 -1
  3. package/dist/agents/cascade.js +2 -2
  4. package/dist/agents/verify.d.ts +70 -4
  5. package/dist/agents/verify.js +62 -16
  6. package/dist/core/checkpoint-store.d.ts +95 -0
  7. package/dist/core/checkpoint-store.js +40 -0
  8. package/dist/core/hooks.d.ts +14 -6
  9. package/dist/core/hooks.js +14 -3
  10. package/dist/core/memory-engine/file-backend.d.ts +172 -22
  11. package/dist/core/memory-engine/file-backend.js +877 -79
  12. package/dist/core/memory-engine/memory-backend-contract.js +33 -0
  13. package/dist/core/runner/assemble-result.d.ts +7 -0
  14. package/dist/core/runner/assemble-result.js +1 -1
  15. package/dist/core/runner/prepare-acquire-reconcile.d.ts +72 -0
  16. package/dist/core/runner/prepare-acquire-reconcile.js +126 -0
  17. package/dist/core/runner/prepare-config-doors.d.ts +140 -0
  18. package/dist/core/runner/prepare-config-doors.js +250 -0
  19. package/dist/core/runner/prepare-safety-scan.d.ts +53 -0
  20. package/dist/core/runner/prepare-safety-scan.js +80 -0
  21. package/dist/core/runner/prepare-task.d.ts +27 -81
  22. package/dist/core/runner/prepare-task.js +83 -586
  23. package/dist/core/runner/prepare-workspace-restore.d.ts +102 -0
  24. package/dist/core/runner/prepare-workspace-restore.js +144 -0
  25. package/dist/core/runner/runtask.js +8 -2
  26. package/dist/core/tool-policy.d.ts +25 -0
  27. package/dist/core/types.d.ts +140 -13
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +1 -1
  30. package/dist/orchestration/workflow-governance.d.ts +6 -4
  31. package/dist/tools/fs/bash-readonly-classifier.d.ts +9 -3
  32. package/dist/tools/fs/bash-readonly-classifier.js +4 -1
  33. package/dist/tools/fs/fs-bash.d.ts +19 -3
  34. package/dist/tools/fs/fs-bash.js +26 -1
  35. package/dist/tools/fs/index.d.ts +20 -4
  36. package/dist/tools/fs/index.js +4 -1
  37. package/dist/tools/fs/read-deny.d.ts +66 -8
  38. package/dist/tools/fs/read-deny.js +75 -39
  39. package/dist/tools/fs/read-face.d.ts +3 -2
  40. package/dist/tools/fs/search.js +2 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.33.0 — 2026-08-14
4
+
5
+ ### BREAKING (operational)
6
+
7
+ - memory-engine file backend: ledger schema v2 (design/186). Slug/scope rebinds and cross-scope
8
+ transfers are journaled transactions (`transfers.jsonl` + journaled rebind with a tri-state
9
+ projection probe); a v2 journal is REFUSED LOUDLY by pre-5.33 readers. Mixed-version deployments:
10
+ drain writers BEFORE downgrading (ship post carries the three-step rollback and the drain
11
+ precondition). Downgrade is fail-closed by design, not an oversight.
12
+
13
+ ### Behavior changes (named)
14
+
15
+ - read-deny built-in table is deployment-configurable (#245, ruled): four tiers
16
+ (credentials 18 / shell-history 2 / browser 9 / wallet 6); the two shell-history rows
17
+ (`.bash_history` / `.zsh_history`) are OFF by default (ruled widening). Knobs:
18
+ `readDenyBuiltinTiers` (whole tiers) + `readDenyBuiltinExclude` (canonical pattern names),
19
+ deployment seats only (TaskSpec carries neither), every bad value refuses loudly. Persisted
20
+ checkpoint face rows freeze EXPLICIT additions only — built-in liveness stays a deployment
21
+ authority across suspend/resume (deploy-time exclude applies to resumed legs; explicit denies
22
+ survive verbatim).
23
+ - verify/fix hands-less legs run the FULL read-face resolution (#242): one unified else-branch
24
+ (resolver + one deny compile over deps∪spec∪seed + carrier). Five narrowings enumerated in the
25
+ ship post; consumers keying on seat ABSENCE must re-key (absence now = "prepare never completed").
26
+ - built-in deny config snapshot (#245 interference fix): prepare reads
27
+ `readDenyBuiltinTiers` / `readDenyBuiltinExclude` ONCE and every station eats the snapshot —
28
+ previously the toolkit mount re-read the caller-retained arrays past an await, so a hot-reload
29
+ mutation mid-prepare could give one task two different built-in deny sets.
30
+ - internals activity sink containment (#248): a throwing `onActivity` no longer fails the leg it
31
+ fires on (previously: leg terminated `failed` misclassified as `provider.error`; inside a cascade
32
+ the false FAIL escalated and re-ran non-idempotent writes — measured). Contained via the RB-463
33
+ safe-notifier; first failure per site disclosed via `console.warn`, later ones counted.
34
+
35
+ ### Added
36
+
37
+ - Structured reversibility-probe cause (#239): `ToolSpec.reversibilityProbe` may return a
38
+ `ReversibilityVerdict` (`{reversible, reason?, cause?}`); `AskRequest.probeCause` /
39
+ `RiskDescriptor.probeCause` carry `{code, roots: {shown, total}, further?}` with per-item caps,
40
+ percent-encoded separators and undercount coercion. The cause NEVER enters the ask `message`
41
+ (that text feeds the auto-mode classifier). #95 closed by the same structure.
42
+ - TaskResult observation seats `effectiveReadFace` / `effectiveReadDenyPatterns` (#240, widened by
43
+ #242): present on every leg that completes prepare — including every hands-less leg — and ride
44
+ every terminal, not just `completed`. verifyCompleted folds them stricter-wins into the returned
45
+ result (`carryFrozenPosture`) so a chained gate cannot widen from a fix leg's own seats.
46
+ - verify/cascade run-internals seat (#246): `runWithVerification` / `resumeWithVerification` /
47
+ `runCascade` / `verifyCompleted` / `runDeveloperTask` take a tail-optional `internals` —
48
+ forwarded WHOLE by identity to every leg; `isolation` is refused loudly
49
+ (`config.verify_internals_invalid`, distinct door / before-leg phases, re-checked before every
50
+ dispatch; the before-leg refusal is ordered AFTER the durable-pause hand-back). `RunInternals` +
51
+ `ResolvedWorkspace` are now package-root type exports.
52
+ - prepare-task phase extraction (design/238 batches 1+2): prepare-config-doors / safety-scan /
53
+ acquire-reconcile / workspace-restore phase modules, verbatim slices behind Input/Result seams;
54
+ phase-API rules piece ratified (D-9). Internal restructuring — zero wire change; body span
55
+ ratchet banked 10138 → 9420. Both new modules joined the fail-open scan surface.
56
+ - Retry-progress disclosure documented as built (#223): the per-attempt `status` TaskEvent family
57
+ (phase/attempt/maxRetries/retryInSec/errClass, 30s slice replay, exactly-one terminal frame) has
58
+ shipped since v5.22.0; the EngineNotice family table now records the negative space (no notice
59
+ family duplicates it).
60
+
61
+ ### Notes
62
+
63
+ - #244 (scan-vs-mount TOCTOU): ruled — roster snapshot form, lands in a normal window (not this one).
64
+ - Governed workflow key candidates (excludeTools/handsReadOnly/shellGate): ruled OBSERVED-NOT-ADDED.
65
+
3
66
  ## 5.32.0 — 2026-08-14
4
67
 
5
68
  No BREAKING changes. One additive disclosure surface; zero resolution-result changes (the read-face
@@ -1,4 +1,5 @@
1
1
  import type { Runner } from "../core/runner/runtask.js";
2
+ import type { RunInternals } from "../core/runner/prepare-task.js";
2
3
  import type { ModelRef, TaskResult, TaskSpec } from "../core/types.js";
3
4
  /**
4
5
  * Quality-gate cascade (design/27). Run the SAME task across a ladder of models (cheapest → strongest);
@@ -153,4 +154,51 @@ export interface CascadeRunResult extends TaskResult {
153
154
  * (`effect: "write"`), those execute once PER rung — ensure idempotency, or gate before the side effect
154
155
  * (e.g. a structured-output gate). `TaskSpec` never guaranteed idempotency; cascade makes it explicit.
155
156
  */
156
- export declare function runCascade(runner: Runner, spec: TaskSpec, config: CascadeConfig): Promise<CascadeRunResult>;
157
+ export declare function runCascade(runner: Runner, spec: TaskSpec, config: CascadeConfig,
158
+ /**
159
+ * The TRUSTED, run-scoped internals channel — the same object `Runner.runTask` takes as its second
160
+ * argument — forwarded UNCHANGED to EVERY rung.
161
+ *
162
+ * WHY THE SEAT EXISTS: `runCascade` returns a `TaskResult` and DRAINS each rung's event stream
163
+ * internally, so the host never holds a `TaskStream` for any rung. Without this parameter a
164
+ * deployment can observe nothing a rung did — tool beats, forwarded child progress, the resolved
165
+ * workspace — only the per-rung {@link CascadeAttempt} audit rows and the final result. Wiring an
166
+ * observation sink here (`onActivity`, `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
167
+ *
168
+ * ATTRIBUTION: rungs run SEQUENTIALLY and each is dispatched with the SAME object, so one sink
169
+ * receives the rungs' beats CONCATENATED in ladder order. `ToolActivity` carries no rung identity of
170
+ * its own — the frames of two rungs running the same tool are indistinguishable in content, so the
171
+ * boundary a host keys on is {@link CascadeConfig.onRung}, which fires only after its own rung's
172
+ * beats have been delivered (the sole rung that ends without one is a durable pause, which returns
173
+ * immediately). Two honesty notes: SUBAGENT events forwarded through `onForwardEvent` do carry their
174
+ * own `sourceTaskId`, and a BACKGROUND child outlives the rung that spawned it — its frames can
175
+ * arrive after that rung's `onRung`, i.e. inside the next rung's segment.
176
+ *
177
+ * FAILURE-ISOLATED since #248, by the same primitive as {@link CascadeConfig.onRung}: engine-invoked
178
+ * through a run-scoped safe notifier — a throwing `onActivity` is contained (first failure per site
179
+ * disclosed via `console.warn`, later ones counted) and no longer faults the rung. Before #248 the
180
+ * fault AMPLIFIED: the faulted rung was rejected by the gate, the cascade escalated, and a
181
+ * non-idempotent tool the rung had ALREADY executed ran AGAIN on the next rung — the MEASURED
182
+ * HAZARD pin that recorded that now pins containment.
183
+ *
184
+ * FORWARDED WHOLE, NOT FILTERED: every field rides every rung, including the ref-valued ones
185
+ * (`peerSelfRef`, `ownOrgAdmissionRef`) whose own contract is one ref per RUN — wiring those here
186
+ * makes the ladder's rungs share one carrier. The seat is meant for observation sinks; anything else
187
+ * is a choice the caller is making explicitly, not a default this layer applies.
188
+ *
189
+ * `isolation: "worktree"` in particular rides through here, where the verification gate REFUSES it —
190
+ * an asymmetry with a reason. That field mints a NEW workspace per RUN. The gate's runs are one
191
+ * artifact judged and repaired by several legs, so separate workspaces would have the verifier
192
+ * grading files the implementation never wrote to; a ladder's rungs are INDEPENDENT COLD RE-RUNS of
193
+ * the same task (this function's own contract), so one workspace per rung is the coherent reading —
194
+ * each attempt gets its own tree. What a host must then know, stated without promises this function
195
+ * cannot keep: the returned result is ONE rung's, its work lives in THAT rung's workspace, and no
196
+ * field of the result names it — `onWorkspaceResolved` is the only place the path appears, once per
197
+ * rung in ladder order. Whether the path is still readable afterwards is the FACTORY's contract, not
198
+ * this function's: each rung's factory-minted env is torn down at the end of that rung's run, and
199
+ * what teardown does to the workspace is the factory's business.
200
+ *
201
+ * Absent ⇒ every rung is dispatched exactly as before (the call site passes `undefined`, which is
202
+ * what an omitted optional argument already was).
203
+ */
204
+ internals?: RunInternals): Promise<CascadeRunResult>;
@@ -14,7 +14,7 @@ function createDefaultGate(spec) {
14
14
  const requiresStructured = spec.outputSchema != null;
15
15
  return (result) => result.status === "completed" && (!requiresStructured || result.structuredOutput !== undefined);
16
16
  }
17
- export async function runCascade(runner, spec, config) {
17
+ export async function runCascade(runner, spec, config, internals) {
18
18
  const ladder = config.ladder;
19
19
  if (!ladder || ladder.length === 0) {
20
20
  throw new Error("runCascade: config.ladder must have at least one rung");
@@ -82,7 +82,7 @@ export async function runCascade(runner, spec, config) {
82
82
  : rungAbort.signal;
83
83
  let result;
84
84
  try {
85
- result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) });
85
+ result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) }, internals);
86
86
  }
87
87
  finally {
88
88
  if (deadlineTimer !== undefined)
@@ -1,5 +1,6 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import type { Runner, ResumeTaskConfig } from "../core/runner/runtask.js";
3
+ import type { RunInternals } from "../core/runner/prepare-task.js";
3
4
  import type { CheckpointGate, CheckpointToken, ResumeOutcome } from "../core/checkpoint-store.js";
4
5
  import type { ModelRef, TaskResult, TaskSpec, ToolSpec } from "../core/types.js";
5
6
  /**
@@ -217,7 +218,54 @@ export interface VerificationResult extends TaskResult {
217
218
  * override is given, the verifier uses {@link STATIC_VERIFICATION_PROMPT} (read the diff/results; don't try
218
219
  * to execute — that was L2's job), avoiding the "PARTIAL on everything" failure in a read-only sandbox.
219
220
  */
220
- export declare function verifyCompleted(runner: Runner, result: TaskResult, specBase: ResumeTaskConfig, objective: string, config: VerifyConfig): Promise<VerificationResult>;
221
+ export declare function verifyCompleted(runner: Runner, result: TaskResult, specBase: ResumeTaskConfig, objective: string, config: VerifyConfig,
222
+ /**
223
+ * The TRUSTED, run-scoped internals channel — the same object `Runner.runTask` takes as its second
224
+ * argument — forwarded UNCHANGED to EVERY leg this gate drives: the verifier run of each round and
225
+ * each fix continuation (and, from {@link runWithVerification} / {@link resumeWithVerification} /
226
+ * {@link runDeveloperTask}, the implementation leg as well). This doc is the contract for the whole
227
+ * family; the sibling entries carry the same parameter and point here.
228
+ *
229
+ * WHY THE SEAT EXISTS: these entries return a `TaskResult` and DRAIN each leg's event stream
230
+ * internally, so the host never holds a `TaskStream` for any of them. Without this parameter a
231
+ * deployment can observe nothing a leg did — tool beats, forwarded child progress, the resolved
232
+ * workspace — only the final result. Wiring an observation sink here (`onActivity`,
233
+ * `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
234
+ *
235
+ * ATTRIBUTION — WEAK, and deliberately stated as such: the legs run SEQUENTIALLY (impl → verifier →
236
+ * fix → verifier → …) and each is dispatched with the SAME object, so a leg's own tool beats are all
237
+ * delivered before the next leg is dispatched. That ORDERING is the whole of it. `ToolActivity`
238
+ * carries no leg identity, so generic activity CANNOT be attributed to a leg from the frame alone:
239
+ * two adjacent legs calling the same tool are indistinguishable, and the structured-output tool is
240
+ * no verifier marker either (an impl spec carrying its own `outputSchema` mounts it too).
241
+ * {@link VerifyConfig.onRound} bounds a ROUND, not a leg — the segment before it holds the impl (or
242
+ * fix) leg's beats AND that round's verifier beats together. A host that needs true per-leg
243
+ * attribution must drive the legs itself (one `runner.runTask` per leg) rather than read it out of
244
+ * this stream. Two further honesty notes: SUBAGENT events forwarded through `onForwardEvent` do
245
+ * carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that spawned it — its
246
+ * frames can arrive after that leg returned, i.e. interleaved into a later leg's segment.
247
+ *
248
+ * FAILURE-ISOLATED since #248, by the same primitive as {@link VerifyConfig.onRound} /
249
+ * {@link VerifyConfig.onVerifierPause}: the fields on this object are invoked by the ENGINE on the
250
+ * run's own path, through a run-scoped safe notifier — a throwing `onActivity` is contained (first
251
+ * failure per site disclosed via `console.warn`, later ones counted) and never faults the leg it
252
+ * fires on. Before #248 a throw here failed the leg (misclassified `provider.error`) and an impl
253
+ * leg was reported incomplete — the MEASURED HAZARD pins that recorded that now pin containment.
254
+ *
255
+ * FORWARDED WHOLE, NOT FILTERED, with ONE refusal: every field rides every leg, including the
256
+ * ref-valued ones (`peerSelfRef`, `ownOrgAdmissionRef`) whose own contract is one ref per RUN —
257
+ * wiring those here makes the gate's legs share one carrier. The seat is meant for observation
258
+ * sinks; anything else is a choice the caller is making explicitly, not a default this layer
259
+ * applies. The single exception is `isolation`, which is REFUSED — at the entry door and again
260
+ * before each leg is dispatched, typed `config.verify_internals_invalid` — because it would mint a
261
+ * separate workspace per leg and break the one invariant the gate is for. The refusal's own doc
262
+ * (`refuseUnhonorableInternals`) carries the reasoning, the re-check rule, and what a mid-gate
263
+ * refusal does and does not undo.
264
+ *
265
+ * Absent ⇒ every leg is dispatched exactly as before (each call site passes `undefined`, which is
266
+ * what an omitted optional argument already was).
267
+ */
268
+ internals?: RunInternals): Promise<VerificationResult>;
221
269
  /**
222
270
  * Run an implementation task, then gate it behind an independent falsification-style verifier, looping
223
271
  * fix→re-verify until PASS (or a round cap). Returns the implementation result plus the
@@ -226,7 +274,12 @@ export declare function verifyCompleted(runner: Runner, result: TaskResult, spec
226
274
  * failed-with-token (verdict `unverified`); the caller approves and calls {@link resumeWithVerification}
227
275
  * to resume AND verify (design/51 P1-b: the durable + HITL + verify integration).
228
276
  */
229
- export declare function runWithVerification(runner: Runner, implSpec: TaskSpec, config?: VerifyConfig): Promise<VerificationResult>;
277
+ export declare function runWithVerification(runner: Runner, implSpec: TaskSpec, config?: VerifyConfig,
278
+ /** The trusted run-scoped internals channel, forwarded UNCHANGED to every leg this entry drives —
279
+ * the implementation run, then (on completion) the verifier + fix legs of the gate. Full contract,
280
+ * including the attribution and forwarded-whole rules, on {@link verifyCompleted}. Absent ⇒ every
281
+ * leg is dispatched exactly as before. */
282
+ internals?: RunInternals): Promise<VerificationResult>;
230
283
  /**
231
284
  * Resume a durable-suspended implementation task (design/45 F4) **and** verify it on completion — the
232
285
  * durable + HITL + verify integration (design/51 P1-b). The mirror of {@link runWithVerification} for the
@@ -242,7 +295,14 @@ export declare function runWithVerification(runner: Runner, implSpec: TaskSpec,
242
295
  * after approval, so a pre-suspend diff would grade stale code. The verifier already verifies the current working
243
296
  * tree (verifierObjective), so dropping the stale diff closes the timing window.
244
297
  */
245
- export declare function resumeWithVerification(runner: Runner, token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, objective: string, config?: VerifyConfig): Promise<VerificationResult>;
298
+ export declare function resumeWithVerification(runner: Runner, token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, objective: string, config?: VerifyConfig,
299
+ /** The trusted run-scoped internals channel, forwarded UNCHANGED to every leg this entry drives —
300
+ * `Runner.resume`'s own internals argument for the RESUMED implementation leg, then (on completion)
301
+ * the verifier + fix legs of the gate. Note that this seat carries the resume-path duty of the same
302
+ * channel as well: a checkpoint suspended under OPAQUE inherited parent constraints re-supplies them
303
+ * through `internals.inheritedGate.parentConstraints`, which had no way in here before. Full
304
+ * contract on {@link verifyCompleted}. Absent ⇒ every leg is dispatched exactly as before. */
305
+ internals?: RunInternals): Promise<VerificationResult>;
246
306
  export interface DeveloperTaskConfig extends VerifyConfig {
247
307
  /** Run the verification gate. Default `true` (that's the point of developer mode). Set `false` for prompt-only. */
248
308
  verify?: boolean;
@@ -255,4 +315,10 @@ export interface DeveloperTaskConfig extends VerifyConfig {
255
315
  * Pair with a role map that gives a strong implementation model and cheaper helper/verifier models for
256
316
  * "auto model selection" (design/28 §3.2): `roles: { default: strong, subagent: cheap, verifier: strong }`.
257
317
  */
258
- export declare function runDeveloperTask(runner: Runner, spec: TaskSpec, config?: DeveloperTaskConfig): Promise<VerificationResult>;
318
+ export declare function runDeveloperTask(runner: Runner, spec: TaskSpec, config?: DeveloperTaskConfig,
319
+ /** The trusted run-scoped internals channel, forwarded UNCHANGED to every leg this entry drives — in
320
+ * BOTH arms: the lone implementation run when `verify: false`, and the whole gate otherwise. The two
321
+ * arms differ in ONE respect: the gate's door refusal of `isolation` (see {@link verifyCompleted})
322
+ * applies only to the gated arm, since `verify: false` is a single run that can honor it. Full
323
+ * contract on {@link verifyCompleted}. Absent ⇒ every leg is dispatched exactly as before. */
324
+ internals?: RunInternals): Promise<VerificationResult>;
@@ -61,6 +61,24 @@ export const VerdictSchema = Type.Object({
61
61
  });
62
62
  const VERIFY_ON_ROUND_SITE = "verify.onRound";
63
63
  const VERIFY_ON_VERIFIER_PAUSE_SITE = "verify.onVerifierPause";
64
+ const VERIFY_INTERNALS_ERROR_CODE = "config.verify_internals_invalid";
65
+ function verifyInternalsError(message) {
66
+ const e = new Error(message);
67
+ e.code = VERIFY_INTERNALS_ERROR_CODE;
68
+ return e;
69
+ }
70
+ function refuseUnhonorableInternals(internals, phase) {
71
+ if (internals?.isolation !== undefined) {
72
+ throw verifyInternalsError(`the verification gate cannot honor internals.isolation ("${internals.isolation}"): it mints a new workspace per RUN, and this gate runs several of them ` +
73
+ `(implementation, verifier, and one per fix round), so the verifier would judge a checkout the implementation never wrote to. Isolate at the ` +
74
+ `execution-env factory instead, so every leg of the gate resolves the same root. ` +
75
+ (phase === "door"
76
+ ? `This call dispatched no leg before refusing.`
77
+ : `The object gained the field AFTER this call's entry check (it is forwarded by identity, so anything running between legs can set it): ` +
78
+ `no FURTHER leg was dispatched. What already ran is not undone and is not returned — this throws instead of returning a result, so an ` +
79
+ `implementation leg keeps whatever it did (a consumed checkpoint stays consumed, its session stays the caller's to reclaim).`));
80
+ }
81
+ }
64
82
  function fixObjective(findings) {
65
83
  const list = findings.length
66
84
  ? findings.map((f) => `- ${sanitizeUntrustedText(f)}`).join("\n")
@@ -81,16 +99,37 @@ function verifierObjective(originalObjective, impl, evidence) {
81
99
  }
82
100
  return parts.join("\n\n");
83
101
  }
84
- export async function verifyCompleted(runner, result, specBase, objective, config) {
102
+ export async function verifyCompleted(runner, result, specBase, objective, config, internals) {
103
+ refuseUnhonorableInternals(internals, "door");
85
104
  if (result.status !== "completed") {
86
105
  return { ...result, verification: { verdict: "unverified", unverifiedReason: "impl_incomplete", rounds: 0, findings: [] } };
87
106
  }
88
107
  const maxRounds = Number.isFinite(config.maxRounds) ? Math.max(1, Math.floor(config.maxRounds)) : 2;
89
108
  const notifier = createSafeNotifier(config.onNotifyError !== undefined ? { onError: config.onNotifyError } : undefined);
90
109
  const verifierTools = config.verifierTools ?? (specBase.tools ?? []).filter((t) => t.effect === "read");
110
+ const foldedRootsFace = specBase.readFace === "roots" || result.effectiveReadFace === "roots";
111
+ const foldedReadDeny = [...(specBase.readDenyPatterns ?? []), ...(result.effectiveReadDenyPatterns ?? [])];
112
+ const carryFrozenPosture = (r) => {
113
+ const face = result.effectiveReadFace === "roots" ? "roots" : r.effectiveReadFace;
114
+ const keyOf = (e) => `${e.caseSensitive ? "s" : "i"} ${e.pattern}`;
115
+ const seen = new Set((r.effectiveReadDenyPatterns ?? []).map(keyOf));
116
+ const merged = [...(r.effectiveReadDenyPatterns ?? [])];
117
+ for (const e of result.effectiveReadDenyPatterns ?? []) {
118
+ if (!seen.has(keyOf(e))) {
119
+ seen.add(keyOf(e));
120
+ merged.push({ ...e });
121
+ }
122
+ }
123
+ return {
124
+ ...r,
125
+ ...(face !== undefined ? { effectiveReadFace: face } : {}),
126
+ ...(merged.length > 0 ? { effectiveReadDenyPatterns: merged } : {}),
127
+ };
128
+ };
91
129
  const evidenceMode = config.evidence != null && config.evidence.trim() !== "";
92
130
  const verifierPrompt = config.verifierPrompt ?? (evidenceMode ? STATIC_VERIFICATION_PROMPT : VERIFICATION_PROMPT);
93
131
  const runVerifier = async (impl, round) => {
132
+ refuseUnhonorableInternals(internals, "before_leg");
94
133
  const roundEvidence = round === 1 ? config.evidence : undefined;
95
134
  const v = await runner.runTask({
96
135
  objective: verifierObjective(objective, impl, roundEvidence),
@@ -99,8 +138,8 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
99
138
  roles: specBase.roles,
100
139
  tools: verifierTools,
101
140
  handsReadOnly: config.verifierHandsReadOnly ?? true,
102
- ...(specBase.readFace === "roots" ? { readFace: "roots" } : {}),
103
- ...(specBase.readDenyPatterns !== undefined ? { readDenyPatterns: [...specBase.readDenyPatterns] } : {}),
141
+ ...(foldedRootsFace ? { readFace: "roots" } : {}),
142
+ ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
104
143
  interactiveTools: false,
105
144
  outputSchema: VerdictSchema,
106
145
  enableBlockedReport: false,
@@ -113,7 +152,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
113
152
  ...(specBase.clientContext !== undefined ? { clientContext: { ...specBase.clientContext } } : {}),
114
153
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
115
154
  signal: specBase.signal,
116
- });
155
+ }, internals);
117
156
  try {
118
157
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
119
158
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.status) ? { paused: v } : {}) };
@@ -183,39 +222,46 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
183
222
  break;
184
223
  if (config.costCeilingMicroUsd != null && spend >= config.costCeilingMicroUsd)
185
224
  break;
225
+ refuseUnhonorableInternals(internals, "before_leg");
186
226
  current = await runner.runTask({
187
227
  ...specBase,
188
228
  sessionId: current.sessionId,
189
229
  objective: fixObjective(outcome.findings),
190
- });
230
+ ...(foldedRootsFace ? { readFace: "roots" } : {}),
231
+ ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
232
+ }, internals);
191
233
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
192
234
  if (isDurablePause(current.status)) {
193
- return { ...mapNestedSuspend(current), verification: outcome };
235
+ return { ...mapNestedSuspend(carryFrozenPosture(current)), verification: outcome };
194
236
  }
195
237
  if (current.status !== "completed")
196
238
  break;
197
239
  }
198
- return { ...current, verification: outcome };
240
+ return { ...carryFrozenPosture(current), verification: outcome };
199
241
  }
200
- export async function runWithVerification(runner, implSpec, config = {}) {
201
- const result = await runner.runTask(implSpec);
242
+ export async function runWithVerification(runner, implSpec, config = {}, internals) {
243
+ refuseUnhonorableInternals(internals, "door");
244
+ const result = await runner.runTask(implSpec, internals);
202
245
  if (isDurablePause(result.status)) {
203
246
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: result.status === "needs_review" ? "needs_review" : "suspended", rounds: 0, findings: [] } };
204
247
  }
205
- return verifyCompleted(runner, result, implSpec, implSpec.objective, config);
248
+ refuseUnhonorableInternals(internals, "before_leg");
249
+ return verifyCompleted(runner, result, implSpec, implSpec.objective, config, internals);
206
250
  }
207
- export async function resumeWithVerification(runner, token, outcome, taskConfig, objective, config = {}) {
208
- const result = await runner.resume(token, outcome, taskConfig);
251
+ export async function resumeWithVerification(runner, token, outcome, taskConfig, objective, config = {}, internals) {
252
+ refuseUnhonorableInternals(internals, "door");
253
+ const result = await runner.resume(token, outcome, taskConfig, internals);
209
254
  if (isDurablePause(result.status)) {
210
255
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: result.status === "needs_review" ? "needs_review" : "suspended", rounds: 0, findings: [] } };
211
256
  }
212
- return verifyCompleted(runner, result, taskConfig, objective, config);
257
+ refuseUnhonorableInternals(internals, "before_leg");
258
+ return verifyCompleted(runner, result, taskConfig, objective, config, internals);
213
259
  }
214
- export async function runDeveloperTask(runner, spec, config = {}) {
260
+ export async function runDeveloperTask(runner, spec, config = {}, internals) {
215
261
  const implSpec = { systemPrompt: CODE_AGENT_PROMPT, ...spec };
216
262
  if (config.verify === false) {
217
- const result = await runner.runTask(implSpec);
263
+ const result = await runner.runTask(implSpec, internals);
218
264
  return { ...result, verification: { verdict: "unverified", unverifiedReason: "opted_out", rounds: 0, findings: [] } };
219
265
  }
220
- return runWithVerification(runner, implSpec, config);
266
+ return runWithVerification(runner, implSpec, config, internals);
221
267
  }
@@ -140,7 +140,94 @@ export interface RiskDescriptor {
140
140
  * carries the same disclosure the synchronous ask does. The inbox renders "their rule is alive,
141
141
  * just outranked" instead of the person concluding their rule silently broke. */
142
142
  shadowedRule?: string;
143
+ /** backlog #239 (additive): WHY the reversibility probe did not clear this call, in the probe's own
144
+ * words — the cause the tighten acted on, which until now existed only inside the probe and left the
145
+ * card showing WHAT is gated with no account of WHY. Present only for a `"maybe"`-tier tighten whose
146
+ * probe supplied a cause (an `"always"`-tier tighten runs no probe; a timeout/throw yields no verdict).
147
+ * Neutralized + capped at {@link PROBE_REASON_MAX} — it is deployment-authored text on a human trust
148
+ * boundary, so it is DISPLAY/TRIAGE metadata only, never adjudication input.
149
+ *
150
+ * The UNSTRUCTURED face: a deployment's own probe writes prose here and this engine cannot say what it
151
+ * means. An engine-minted cause uses {@link probeCause} instead — see that field for why. */
152
+ probeReason?: string;
153
+ /** backlog #239 (additive): the STRUCTURED cause, for a probe whose verdict this engine understands —
154
+ * the built-in shell probe is the first. Carries a machine-readable {@link ProbeCause.code} and the
155
+ * operand families as ARRAYS with honest totals, so a consumer renders its own sentence in its own
156
+ * language instead of receiving one this engine had to write in advance.
157
+ *
158
+ * The prose sibling above is what this replaces for engine-minted causes, and the reason is not
159
+ * aesthetic: a sentence asserting what a command DOES is false for the demotion's documented
160
+ * over-fire, a sentence asserting what its OPERANDS are is false when the check synthesizes one, and
161
+ * serializing a variable-length multi-part disclosure into one length-capped string silently deletes
162
+ * whichever part sorts last. A code cannot be false and an array cannot be truncated into a lie. */
163
+ probeCause?: ProbeCause;
164
+ }
165
+ /** backlog #239: one operand family inside a {@link ProbeCause} — the entries a card may NAME, plus how
166
+ * many the family really has. `shown.length` ≤ {@link total}, and a consumer that renders only `shown`
167
+ * is still obliged to tell the person when `total` exceeds it: the count is the part that cannot be
168
+ * cut, and "there is a second unreviewed operand" is exactly what a reader cannot recover from the
169
+ * command text. */
170
+ export interface ProbeCauseOperands {
171
+ /** The named entries. Each `inlineUntrusted`-neutralized and capped at {@link PROBE_CAUSE_PATH_MAX}
172
+ * INDIVIDUALLY, so one pathological entry costs only itself. Bounded in count by the minting probe. */
173
+ readonly shown: readonly string[];
174
+ /** How many entries the family has in total — `shown.length` when nothing was elided. A non-negative
175
+ * integer; never a lie by omission. */
176
+ readonly total: number;
177
+ }
178
+ /**
179
+ * backlog #239: an ENGINE-MINTED, structured account of why a reversibility probe did not clear a call.
180
+ *
181
+ * DISPLAY/TRIAGE metadata, exactly like the rest of {@link RiskDescriptor} — nothing reads it to gate,
182
+ * budget or suppress anything, and it deliberately never reaches the ask's `message` (that string is
183
+ * handed to the auto-mode classifier, which may answer an ask with `allow`; a probe must not own an
184
+ * input to the decider that clears it).
185
+ *
186
+ * STRUCTURE IS THE CONTRACT. The entries are model-influenced text, so a consumer must render them as
187
+ * DATA — never re-derive structure by splitting or joining them. Structural characters are
188
+ * percent-encoded at the mint precisely so that a path cannot spell whatever grammar it is rendered
189
+ * into, but the array is the boundary that makes that guarantee cheap to keep.
190
+ */
191
+ export interface ProbeCause {
192
+ /** Machine-readable cause id, `<domain>.<snake_case>` like this codebase's other notice codes. The
193
+ * consumer maps it to its own prose. A code is the one part of a cause that cannot become false as
194
+ * the check's edge cases are discovered. */
195
+ readonly code: string;
196
+ /** The paths the check TOOK as traversal roots — deduplicated and resolved, which is why they are
197
+ * described by what the check did with them rather than by what the caller wrote: a lexical operand
198
+ * walk has no option-value table (`du -d 2 sub` takes `2`), and it synthesizes the working directory
199
+ * when no operand is written at all (`du --help`). */
200
+ readonly roots: ProbeCauseOperands;
201
+ /** Operands the SAME verdict left unreviewed for a different reason (an unexpanded glob). Present only
202
+ * when non-empty. Named separately so a card can say what else this one approval clears — a partial
203
+ * account of an approval's reach is worse than none. */
204
+ readonly further?: ProbeCauseOperands;
143
205
  }
206
+ /** backlog #239: the bloat ceiling on {@link RiskDescriptor.probeReason} — one sentence of cause, sized
207
+ * like its {@link RiskDescriptor.shadowedRule} sibling. The neutralizer is the injection ceiling; this
208
+ * is only the length one. Exported (module-level, not part of the package's public surface) so the gate
209
+ * that sanitizes at the entry boundary and the descriptor builder that re-sanitizes at the persist
210
+ * boundary cannot cap the same value at two different lengths. */
211
+ export declare const PROBE_REASON_MAX = 200;
212
+ /** backlog #239: the per-ENTRY ceiling inside a {@link ProbeCause} operand family, sized like the
213
+ * descriptor's own {@link RiskDescriptor.touchedPaths} entries (the same thing on the same card). Per
214
+ * entry, not per cause: that is the whole point of the structured shape — one pathological path costs
215
+ * only itself, where a single joined string let it consume every other entry's room. */
216
+ export declare const PROBE_CAUSE_PATH_MAX = 200;
217
+ /**
218
+ * backlog #239: the ONE validator for a {@link ProbeCause} arriving from a probe.
219
+ *
220
+ * The value crosses a trust boundary twice — once at the gate (where a deployment-supplied probe hands
221
+ * it over) and once at the persist boundary (where {@link buildRiskDescriptor} is an exported function a
222
+ * caller may reach directly) — so both call it rather than each writing its own idea of "valid". A
223
+ * malformed cause yields `undefined`: the ASK still stands (the verdict was never this member's to
224
+ * decide), the card simply carries no account. Fail-closed on the disclosure, never on the gate.
225
+ *
226
+ * Every field is re-derived rather than trusted: strings are neutralized and capped individually, the
227
+ * shown list is clamped, and `total` is coerced to a non-negative integer that cannot understate what is
228
+ * shown (a `total` below `shown.length` would turn an honest count into a false reassurance).
229
+ */
230
+ export declare function normalizeProbeCause(value: unknown): ProbeCause | undefined;
144
231
  /**
145
232
  * design/80 §D-E: the DETERMINISTIC severity tier (1..5) for an escalation checkpoint, a PURE function of the
146
233
  * tripped {@link RiskDescriptor.axes} (NO LLM, NO clock/random) so the inbox's sort order is stable and
@@ -273,6 +360,14 @@ export declare function buildRiskDescriptor(input: {
273
360
  shellGateDoctrine?: "classify" | "always";
274
361
  /** #144: the matched-but-outranked persisted rule to persist (see {@link RiskDescriptor.shadowedRule}). */
275
362
  shadowedRule?: string;
363
+ /** backlog #239: the reversibility probe's cause for not clearing this call (see
364
+ * {@link RiskDescriptor.probeReason}). Re-neutralized here like its shadowed-rule sibling: this
365
+ * function is exported, so a caller reaching it directly must get the same redaction the gate applies. */
366
+ probeReason?: string;
367
+ /** backlog #239: the STRUCTURED cause (see {@link RiskDescriptor.probeCause}). Re-validated here for
368
+ * the same reason the prose sibling is re-neutralized — this function is exported, so a caller
369
+ * reaching it directly must not be able to persist a shape the gate would have rejected. */
370
+ probeCause?: unknown;
276
371
  }): RiskDescriptor;
277
372
  /**
278
373
  * #130/#131/#120 (2026-08-10) — the durable record of an ask's `requiresRealApproval` bit, which used
@@ -5,6 +5,39 @@ import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
5
5
  export function mintCheckpointToken() {
6
6
  return randomBytes(16).toString("hex");
7
7
  }
8
+ export const PROBE_REASON_MAX = 200;
9
+ export const PROBE_CAUSE_PATH_MAX = 200;
10
+ const PROBE_CAUSE_MAX_SHOWN = 8;
11
+ export function normalizeProbeCause(value) {
12
+ if (!isPlainRecord(value))
13
+ return undefined;
14
+ const code = safeDataValue(value, "code");
15
+ if (typeof code !== "string" || code.trim().length === 0)
16
+ return undefined;
17
+ const family = (raw) => {
18
+ if (!isPlainRecord(raw))
19
+ return undefined;
20
+ const shownRaw = safeDataValue(raw, "shown");
21
+ if (!Array.isArray(shownRaw))
22
+ return undefined;
23
+ const shown = shownRaw
24
+ .filter((p) => typeof p === "string")
25
+ .slice(0, PROBE_CAUSE_MAX_SHOWN)
26
+ .map((p) => inlineUntrusted(p, PROBE_CAUSE_PATH_MAX));
27
+ const totalRaw = safeDataValue(raw, "total");
28
+ const total = typeof totalRaw === "number" && Number.isFinite(totalRaw) ? Math.max(0, Math.floor(totalRaw)) : shown.length;
29
+ return { shown, total: Math.max(total, shown.length) };
30
+ };
31
+ const roots = family(safeDataValue(value, "roots"));
32
+ if (roots === undefined)
33
+ return undefined;
34
+ const further = family(safeDataValue(value, "further"));
35
+ return {
36
+ code: inlineUntrusted(code, PROBE_REASON_MAX),
37
+ roots,
38
+ ...(further !== undefined && further.total > 0 ? { further } : {}),
39
+ };
40
+ }
8
41
  export function riskSeverity(axes) {
9
42
  const egress = axes.egress === true;
10
43
  const irreversible = axes.irreversible === true;
@@ -121,6 +154,13 @@ export function buildRiskDescriptor(input) {
121
154
  toolName,
122
155
  ...(input.shellGated && input.shellGateDoctrine !== undefined ? { shellGateDoctrine: input.shellGateDoctrine } : {}),
123
156
  ...(input.shadowedRule !== undefined ? { shadowedRule: inlineUntrusted(input.shadowedRule, 200) } : {}),
157
+ ...(input.probeReason !== undefined ? { probeReason: inlineUntrusted(input.probeReason, PROBE_REASON_MAX) } : {}),
158
+ ...(() => {
159
+ if (input.probeCause === undefined)
160
+ return {};
161
+ const cause = normalizeProbeCause(input.probeCause);
162
+ return cause !== undefined ? { probeCause: cause } : {};
163
+ })(),
124
164
  ...(summary !== undefined ? { summary } : {}),
125
165
  ...(touchedPaths !== undefined ? { touchedPaths } : {}),
126
166
  };
@@ -645,7 +645,14 @@ export interface ToolGateInput {
645
645
  * read the ask's origin on the durable route exactly as the synchronous mints read it off the
646
646
  * decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
647
647
  * offer to mint one). */
648
- askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
648
+ askDecisionReason?: DecisionReason,
649
+ /** backlog #239: the reversibility probe's cause for the tighten (the surviving ask's `probeReason`)
650
+ * — threaded so the park mint's risk descriptor carries on the durable route the same account of
651
+ * WHY the call was gated that the synchronous card gets. Already neutralized + capped at the gate. */
652
+ probeReason?: string,
653
+ /** backlog #239: the STRUCTURED cause (the surviving ask's `probeCause`) — same durable-route errand
654
+ * as `probeReason`, already validated at the gate. */
655
+ probeCause?: import("./checkpoint-store.js").ProbeCause) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
649
656
  /**
650
657
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
651
658
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -680,12 +687,13 @@ export interface ToolGateInput {
680
687
  * tool argument). Called ONLY when `irreversibility === "maybe"` && the surviving decision is `allow`,
681
688
  * time-bounded by {@link approvalTimeoutMs}. Fail-closed: anything other than `{ reversible: true }`
682
689
  * (including a timeout or a throw) tightens to `ask`. A probe is never trusted to AUTO-ALLOW past the gate.
690
+ *
691
+ * An optional `reason` on a TIGHTENING verdict names the cause. The gate neutralizes + caps it and
692
+ * carries it onto both approval routes (`AskRequest.probeReason`, `RiskDescriptor.probeReason`) — see
693
+ * `ToolSpec.reversibilityProbe`. Display/triage only: `reversible` alone decides the verdict, so a
694
+ * probe cannot widen anything through this member.
683
695
  */
684
- reversibilityProbe?: (args: unknown) => {
685
- reversible: boolean;
686
- } | Promise<{
687
- reversible: boolean;
688
- }>;
696
+ reversibilityProbe?: (args: unknown) => import("./types.js").ReversibilityVerdict | Promise<import("./types.js").ReversibilityVerdict>;
689
697
  /** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`. */
690
698
  approvalTimeoutMs?: number;
691
699
  /** design/77 §4: the task abort signal — bounds {@link reversibilityProbe} by the task's real deadline