@sema-agent/core 5.65.0 → 7.0.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 (165) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/subagent.d.ts +2 -2
  3. package/dist/agents/subagent.js +11 -0
  4. package/dist/agents/verify.d.ts +1 -1
  5. package/dist/brain/anthropic.js +1 -1
  6. package/dist/brain/errors.d.ts +29 -0
  7. package/dist/brain/errors.js +20 -0
  8. package/dist/brain/open-responses.js +2 -2
  9. package/dist/brain/route-adjudicator.d.ts +8 -1
  10. package/dist/brain/route-adjudicator.js +1 -0
  11. package/dist/brain/status-sink.js +12 -1
  12. package/dist/brain/stream-engine.js +17 -6
  13. package/dist/core/auto-compaction.d.ts +26 -0
  14. package/dist/core/auto-compaction.js +7 -2
  15. package/dist/core/auto-mode-arming.d.ts +138 -0
  16. package/dist/core/auto-mode-arming.js +181 -0
  17. package/dist/core/auto-mode-defaults.d.ts +13 -0
  18. package/dist/core/auto-mode-defaults.js +5 -0
  19. package/dist/core/auto-mode-prompt.d.ts +14 -3
  20. package/dist/core/auto-mode-prompt.js +10 -7
  21. package/dist/core/auto-mode-rebuild.d.ts +75 -0
  22. package/dist/core/auto-mode-rebuild.js +41 -0
  23. package/dist/core/auto-mode.d.ts +15 -0
  24. package/dist/core/auto-mode.js +4 -2
  25. package/dist/core/checkpoint-store.d.ts +18 -0
  26. package/dist/core/context-edit.d.ts +47 -5
  27. package/dist/core/context-guard.d.ts +1 -1
  28. package/dist/core/file-history-retention.d.ts +106 -0
  29. package/dist/core/file-history-retention.js +36 -0
  30. package/dist/core/file-history-store.d.ts +768 -0
  31. package/dist/core/file-history-store.js +880 -0
  32. package/dist/core/governance-codes.d.ts +13 -3
  33. package/dist/core/governance-codes.js +15 -0
  34. package/dist/core/hooks.d.ts +39 -7
  35. package/dist/core/hooks.js +38 -21
  36. package/dist/core/lsp.d.ts +2 -2
  37. package/dist/core/mcp.d.ts +29 -7
  38. package/dist/core/memory-engine/consolidation-driver.d.ts +11 -0
  39. package/dist/core/memory-engine/consolidation-driver.js +71 -4
  40. package/dist/core/memory-engine/consolidation.d.ts +25 -2
  41. package/dist/core/memory-engine/consolidation.js +4 -1
  42. package/dist/core/memory-engine/distiller.d.ts +84 -1
  43. package/dist/core/memory-engine/distiller.js +68 -0
  44. package/dist/core/memory-engine/dual-root.js +3 -0
  45. package/dist/core/memory-engine/engine.d.ts +328 -15
  46. package/dist/core/memory-engine/engine.js +355 -29
  47. package/dist/core/memory-engine/file-backend.d.ts +30 -0
  48. package/dist/core/memory-engine/file-backend.js +14 -13
  49. package/dist/core/memory-engine/frontmatter.d.ts +22 -1
  50. package/dist/core/memory-engine/frontmatter.js +3 -0
  51. package/dist/core/memory-engine/header-hints.d.ts +5 -0
  52. package/dist/core/memory-engine/index.d.ts +5 -4
  53. package/dist/core/memory-engine/index.js +5 -4
  54. package/dist/core/memory-engine/layout.d.ts +88 -2
  55. package/dist/core/memory-engine/layout.js +112 -3
  56. package/dist/core/memory-engine/provenance-wording.d.ts +7 -0
  57. package/dist/core/memory-engine/provenance-wording.js +3 -0
  58. package/dist/core/memory-engine/tools.d.ts +89 -8
  59. package/dist/core/memory-engine/tools.js +263 -22
  60. package/dist/core/memory-engine/types.d.ts +64 -1
  61. package/dist/core/memory-recall.d.ts +6 -0
  62. package/dist/core/memory.d.ts +27 -1
  63. package/dist/core/memory.js +16 -2
  64. package/dist/core/permission-rule-consent.d.ts +62 -2
  65. package/dist/core/permission-rule-consent.js +105 -14
  66. package/dist/core/permission-rule-model.d.ts +118 -16
  67. package/dist/core/permission-rule-model.js +57 -9
  68. package/dist/core/permission-rule-session.d.ts +124 -0
  69. package/dist/core/permission-rule-session.js +121 -0
  70. package/dist/core/permission-rule-store.d.ts +65 -2
  71. package/dist/core/permission-rule-store.js +75 -16
  72. package/dist/core/permission-rule-sync.d.ts +9 -0
  73. package/dist/core/permission-rule-sync.js +52 -19
  74. package/dist/core/retention-policy.d.ts +9 -0
  75. package/dist/core/retention-policy.js +5 -2
  76. package/dist/core/retention.d.ts +13 -2
  77. package/dist/core/runner/assemble-result.d.ts +19 -1
  78. package/dist/core/runner/assemble-result.js +17 -2
  79. package/dist/core/runner/compaction-call-options.d.ts +93 -0
  80. package/dist/core/runner/compaction-call-options.js +3 -0
  81. package/dist/core/runner/memory-capture-optout.d.ts +80 -0
  82. package/dist/core/runner/memory-capture-optout.js +53 -0
  83. package/dist/core/runner/prepare-config-doors.d.ts +5 -0
  84. package/dist/core/runner/prepare-config-doors.js +16 -0
  85. package/dist/core/runner/prepare-hands-readface.d.ts +110 -5
  86. package/dist/core/runner/prepare-hands-readface.js +99 -7
  87. package/dist/core/runner/prepare-memory.d.ts +88 -0
  88. package/dist/core/runner/prepare-memory.js +305 -24
  89. package/dist/core/runner/prepare-task.d.ts +141 -1
  90. package/dist/core/runner/prepare-task.js +478 -81
  91. package/dist/core/runner/runtask.d.ts +9 -20
  92. package/dist/core/runner/runtask.js +133 -96
  93. package/dist/core/runner/session-file-state-replay.d.ts +18 -10
  94. package/dist/core/runner/session-file-state-replay.js +52 -1
  95. package/dist/core/runner/tool-disclosure.js +2 -1
  96. package/dist/core/runner/turn-attachments.d.ts +22 -12
  97. package/dist/core/session-store.d.ts +1 -1
  98. package/dist/core/session-store.js +6 -1
  99. package/dist/core/session.d.ts +34 -1
  100. package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
  101. package/dist/core/store-contracts/file-history-store-contract.js +720 -0
  102. package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
  103. package/dist/core/task-registry-shared.js +11 -1
  104. package/dist/core/tool-errors.js +1 -0
  105. package/dist/core/tool-policy.d.ts +172 -1
  106. package/dist/core/tool-policy.js +32 -1
  107. package/dist/core/tool-result-store.js +2 -1
  108. package/dist/core/trace.d.ts +31 -2
  109. package/dist/core/types.d.ts +795 -89
  110. package/dist/core/types.js +4 -3
  111. package/dist/core/untrusted-text.d.ts +1 -1
  112. package/dist/core/untrusted-text.js +8 -0
  113. package/dist/core/workflow-run-store-contract.js +6 -2
  114. package/dist/core/workflow-run-store.d.ts +4 -1
  115. package/dist/engine/compaction/compaction.d.ts +88 -10
  116. package/dist/engine/compaction/compaction.js +109 -30
  117. package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
  118. package/dist/engine/execution-env/node-execution-env.js +28 -0
  119. package/dist/engine/harness/agent-harness.d.ts +52 -1
  120. package/dist/engine/harness/agent-harness.js +36 -1
  121. package/dist/engine/harness/types.d.ts +26 -1
  122. package/dist/engine/llm/types.d.ts +50 -4
  123. package/dist/engine/loop/agent-loop.d.ts +5 -1
  124. package/dist/engine/loop/agent-loop.js +25 -0
  125. package/dist/engine/loop/types.d.ts +19 -0
  126. package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
  127. package/dist/engine/session/session.js +1 -1
  128. package/dist/index.d.ts +20 -9
  129. package/dist/index.js +16 -7
  130. package/dist/orchestration/run-workflow-tool.d.ts +20 -2
  131. package/dist/orchestration/run-workflow-tool.js +22 -3
  132. package/dist/orchestration/workflow-governance.d.ts +59 -1
  133. package/dist/orchestration/workflow-governance.js +61 -8
  134. package/dist/orchestration/workflow-meta.d.ts +4 -2
  135. package/dist/orchestration/workflow-primitives.js +56 -13
  136. package/dist/orchestration/workflow-types.d.ts +78 -2
  137. package/dist/orchestration/workflow.d.ts +20 -0
  138. package/dist/orchestration/workflow.js +163 -14
  139. package/dist/prompt-assembly/event-registry.js +1 -1
  140. package/dist/prompts/default.d.ts +7 -7
  141. package/dist/stores/file/file-history-store.d.ts +368 -0
  142. package/dist/stores/file/file-history-store.js +1248 -0
  143. package/dist/stores/file/index.d.ts +22 -13
  144. package/dist/stores/file/index.js +4 -4
  145. package/dist/stores/file/permission-rule-store.d.ts +11 -0
  146. package/dist/stores/file/permission-rule-store.js +23 -9
  147. package/dist/stores/file/strategy-store.d.ts +3 -3
  148. package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
  149. package/dist/tools/fs/bash-readonly-classifier.js +106 -4
  150. package/dist/tools/fs/fs-bash.js +9 -5
  151. package/dist/tools/fs/fs-shared.d.ts +52 -1
  152. package/dist/tools/fs/fs-shared.js +14 -0
  153. package/dist/tools/fs/fs-write.d.ts +5 -5
  154. package/dist/tools/fs/fs-write.js +71 -14
  155. package/dist/tools/fs/index.d.ts +6 -1
  156. package/dist/tools/fs/index.js +1 -1
  157. package/dist/tools/web.js +2 -1
  158. package/package.json +5 -1
  159. package/test/export-surface.snapshot.json +169 -23
  160. package/dist/core/file-snapshot-store.d.ts +0 -165
  161. package/dist/core/file-snapshot-store.js +0 -259
  162. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
  163. package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
  164. package/dist/stores/file/file-snapshot-store.d.ts +0 -58
  165. package/dist/stores/file/file-snapshot-store.js +0 -353
@@ -11,14 +11,18 @@ function refuseLocalOwnerSync() {
11
11
  const SYNC_MAX_ATTEMPTS = 8;
12
12
  const DISCLOSED_REFUSED_TOMBSTONES = 5;
13
13
  export async function syncPermissionRules(opts) {
14
- if (opts.owner !== undefined) {
15
- if (opts.owner.kind === "local-owner")
14
+ const principal = opts.principal;
15
+ const owner = opts.owner;
16
+ const provider = opts.provider;
17
+ const transport = opts.transport;
18
+ if (owner !== undefined) {
19
+ if (owner.kind === "local-owner")
16
20
  refuseLocalOwnerSync();
17
- if (opts.owner.principal !== opts.principal) {
18
- throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
21
+ if (owner.principal !== principal) {
22
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(principal)}" and owner principal "${escapeForDisclosure(owner.principal)}" disagree`);
19
23
  }
20
24
  }
21
- if (typeof opts.principal !== "string" || opts.principal === "") {
25
+ if (typeof principal !== "string" || principal === "") {
22
26
  throw new Error("syncPermissionRules requires a verified principal — an unauthenticated deployment has no cloud bucket to sync");
23
27
  }
24
28
  const traceClock = opts.now ?? Date.now;
@@ -30,7 +34,7 @@ export async function syncPermissionRules(opts) {
30
34
  emitTrace(tracer, () => ({
31
35
  kind: "permission.rule_sync_resurrected",
32
36
  version: 1,
33
- principal: opts.principal,
37
+ principal,
34
38
  rule: r.rule,
35
39
  scopeKind: r.scope.kind,
36
40
  ts: traceClock(),
@@ -40,7 +44,7 @@ export async function syncPermissionRules(opts) {
40
44
  emitTrace(tracer, () => ({
41
45
  kind: "permission.rule_sync_dropped",
42
46
  version: 1,
43
- principal: opts.principal,
47
+ principal,
44
48
  rule: d.rule,
45
49
  scopeKind: d.scope.kind,
46
50
  reason: d.reason,
@@ -49,20 +53,20 @@ export async function syncPermissionRules(opts) {
49
53
  }
50
54
  return result;
51
55
  };
52
- const store = opts.provider.forPrincipal(opts.principal);
56
+ const store = provider.forPrincipal(principal);
53
57
  const writer = writerOf(store);
54
58
  if (writer === undefined) {
55
59
  throw new Error("the resolved permission-rule store has no write face — a sync round cannot land through it");
56
60
  }
57
61
  const raw = await writer.readRaw();
58
62
  const request = {
59
- principal: opts.principal,
63
+ principal,
60
64
  replica: raw.actor,
61
65
  state: { rules: raw.rules, tombstones: raw.tombstones },
62
66
  ...(raw.observedVector !== undefined ? { observedVector: raw.observedVector } : {}),
63
67
  };
64
- const rawResponse = await opts.transport(PERMISSION_RULE_SYNC_PATH, request);
65
- const response = parseRuleSyncResponse(rawResponse, opts.principal);
68
+ const rawResponse = await transport(PERMISSION_RULE_SYNC_PATH, request);
69
+ const response = parseRuleSyncResponse(rawResponse, principal);
66
70
  const warnings = [...response.warnings];
67
71
  const dropped = [];
68
72
  const inboundTombstones = [];
@@ -87,6 +91,9 @@ export async function syncPermissionRules(opts) {
87
91
  for (const t of response.textRefusedTombstones) {
88
92
  dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
89
93
  }
94
+ for (const t of response.sessionScopeTombstones) {
95
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
96
+ }
90
97
  if (response.textRefusedTombstones.length > 0) {
91
98
  const named = response.textRefusedTombstones
92
99
  .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
@@ -127,6 +134,10 @@ export async function syncPermissionRules(opts) {
127
134
  }
128
135
  const localDots = new Set(raw.rules.flatMap((r) => r.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
129
136
  for (const d of response.dropped) {
137
+ if (d.scope.kind === "session") {
138
+ warnings.push(`the server's dropped list names a session-scope row (${escapeForDisclosure(d.rule)}) — the durable store never holds one, so there is no local row it could mean; ignored`);
139
+ continue;
140
+ }
130
141
  if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
131
142
  const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
132
143
  quarantine.push({ rule: d.rule, scope: d.scope, dots: [d.dot], reason });
@@ -186,7 +197,7 @@ export async function syncPermissionRules(opts) {
186
197
  const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
187
198
  const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
188
199
  const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
189
- const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind === "global" ? null : t.scope.root, dotKeyOf(t.deletedBy)]);
200
+ const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
190
201
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
191
202
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
192
203
  const landedReport = res.sync;
@@ -224,13 +235,23 @@ function pickDot(v) {
224
235
  const counter = d?.counter;
225
236
  return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
226
237
  }
227
- function pickScope(v) {
238
+ function pickScopeCaptured(v) {
228
239
  const s = v;
229
- if (s?.kind === "global")
230
- return { kind: "global" };
231
- if (s?.kind === "project" && typeof s.root === "string" && s.root !== "")
232
- return { kind: "project", root: s.root };
233
- return undefined;
240
+ const kind = s?.kind;
241
+ if (kind === "global")
242
+ return { kind, scope: { kind: "global" } };
243
+ if (kind === "project") {
244
+ const root = s?.root;
245
+ return { kind, scope: typeof root === "string" && root !== "" ? { kind: "project", root } : undefined };
246
+ }
247
+ if (kind === "session") {
248
+ const sessionId = s?.sessionId;
249
+ return { kind, scope: typeof sessionId === "string" && sessionId !== "" ? { kind: "session", sessionId } : undefined };
250
+ }
251
+ return { kind, scope: undefined };
252
+ }
253
+ function pickScope(v) {
254
+ return pickScopeCaptured(v).scope;
234
255
  }
235
256
  function pickAdd(v) {
236
257
  const a = v;
@@ -321,9 +342,11 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
321
342
  }
322
343
  const tombstones = [];
323
344
  const textRefusedTombstones = [];
345
+ const sessionScopeTombstones = [];
324
346
  for (const entry of mergedTombstones) {
325
347
  const t = entry;
326
- const scope = pickScope(t?.scope);
348
+ const pickedScope = pickScopeCaptured(t?.scope);
349
+ const scope = pickedScope.scope;
327
350
  const ruleText = t?.rule;
328
351
  const deletedBy = pickDot(t?.deletedBy);
329
352
  const removedRaw = t?.removedDots;
@@ -338,6 +361,15 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
338
361
  removedDots.push(dot);
339
362
  }
340
363
  }
364
+ if (pickedScope.kind === "session") {
365
+ if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined) {
366
+ sessionScopeTombstones.push({ rule: ruleText, scope, dot: deletedBy });
367
+ }
368
+ else {
369
+ warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
370
+ }
371
+ continue;
372
+ }
341
373
  if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
342
374
  tombstones.push(undefined);
343
375
  continue;
@@ -380,6 +412,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
380
412
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
381
413
  dropped,
382
414
  textRefusedTombstones,
415
+ sessionScopeTombstones,
383
416
  warnings,
384
417
  };
385
418
  }
@@ -31,6 +31,15 @@ export interface RetentionPolicyKnobs {
31
31
  /** Row-count bound, whole rows (roster's write-time cap). */
32
32
  maxEntries?: number;
33
33
  }
34
+ /**
35
+ * The family's typed refusal, exported so a seam whose bound has its own ADMISSIBLE RANGE refuses in
36
+ * the same grammar under the same code instead of minting a second one. The one in-tree case:
37
+ * file-history's boundary cap requires `>= 1` where the row stores accept `0` ("keep no rows" is a
38
+ * coherent policy for a ledger; for a history whose every commit mints a boundary it is not — the
39
+ * cap would reap the boundary its own commit just published), so that door cannot reuse
40
+ * {@link assertRetentionPolicy}'s "0 or more" wording without telling an operator something false.
41
+ */
42
+ export declare function refuseRetentionPolicy(label: string, knob: string, value: unknown, requirement: string): never;
34
43
  /**
35
44
  * Refuse a malformed retention policy at the door. `undefined` on any field keeps its documented
36
45
  * meaning — that arm is simply not applied — so a deployment that sets nothing is untouched.
@@ -1,8 +1,11 @@
1
- function invalidPolicy(label, knob, value, requirement) {
2
- const e = new Error(`${label}: ${knob} must be ${requirement} (got ${String(value)}) — a retention bound that cannot be evaluated silently decides what to delete instead of bounding it`);
1
+ export function refuseRetentionPolicy(label, knob, value, requirement) {
2
+ const e = new Error(`${label}: ${knob} must be ${requirement} (got ${typeof value === "string" ? JSON.stringify(value) : String(value)}) — a retention bound that cannot be evaluated silently decides what to delete instead of bounding it`);
3
3
  e.code = "config.retention_policy_invalid";
4
4
  throw e;
5
5
  }
6
+ function invalidPolicy(label, knob, value, requirement) {
7
+ refuseRetentionPolicy(label, knob, value, requirement);
8
+ }
6
9
  export function assertRetentionPolicy(label, opts) {
7
10
  if (opts === undefined)
8
11
  return;
@@ -27,7 +27,12 @@ export interface RetentionPolicy {
27
27
  * - **idempotent**: a retried invocation after a crash re-converges (already-deleted rows are
28
28
  * counted, not errors);
29
29
  * - **tombstoned**: deletions leave tombstones so replicas/backups converge instead of resurrecting;
30
- * - **audited**: every destructive call returns a receipt the scheduler persists (append-only).
30
+ * - **audited**: every destructive call is recorded append-only. The IDENTITY of that record —
31
+ * which occurrence, which of the three operations, at what wall clock — belongs to the side that
32
+ * WRITES the row, not to the receipt: an implementation records it in the same transaction as the
33
+ * deletion (recording after the delete would leave an unwitnessed delete on a crash), and it is
34
+ * the only side that knows its own op, sweep round, and policy generation. The receipt below is
35
+ * therefore the CALL's three counts, deliberately not an audit row.
31
36
  */
32
37
  export interface ManagedRetentionCapability {
33
38
  /** Enumerate the retention domains (tenant/scope keys) this store holds data for. */
@@ -54,7 +59,13 @@ export interface ManagedRetentionCapability {
54
59
  cutoffMs: number;
55
60
  }): Promise<RetentionReceipt>;
56
61
  }
57
- /** Audit receipt of one destructive retention call (append-only material for the scheduler). */
62
+ /**
63
+ * The three counts one destructive retention call produced. NOT an audit row and deliberately
64
+ * identity-free (see the capability's `audited` clause): a scheduler that needs to tell two
65
+ * same-shaped calls apart reads its store's audit records, which the implementation writes with its
66
+ * own occurrence id, operation, and timestamp inside the deleting transaction. Adding those here
67
+ * would ask a return value to know its own call context.
68
+ */
58
69
  export interface RetentionReceipt {
59
70
  domain: string;
60
71
  /** Rows destroyed by THIS call (idempotent retries count already-gone rows as 0, not errors). */
@@ -100,6 +100,11 @@ export type BudgetAxis = "tokens" | "cost";
100
100
  */
101
101
  export interface ResultFlags {
102
102
  threw: unknown;
103
+ /** #499 — the run's ENGINE-minted identity (`Prepared.runId`), echoed verbatim on
104
+ * `TaskResult.runId`. Pure pass-through on every terminal: assembly neither mints nor derives it
105
+ * (a caller reaching assembly at all has completed prepare, so the value exists), and a
106
+ * hand-constructed flags bag without one simply produces a result without one. */
107
+ runId?: string;
103
108
  /** design/99 MF-25: the EFFECTIVE (resolved) model id that served the task — echoed on `TaskResult.model`. */
104
109
  model?: string;
105
110
  /** RB-368: some spend was accounted while the serving model had NO configured price table
@@ -118,6 +123,15 @@ export interface ResultFlags {
118
123
  * path the terminal is `completed`, and this is what tells that completion apart from a natural
119
124
  * one — the model did not finish; the person stopped it and the run awaits their direction). */
120
125
  haltedOnUserRejection?: boolean;
126
+ /** design/373 (#504) — a `TaskStream.halt` (the bare user interrupt, CC Esc form) was accepted
127
+ * while the run was live; echoed on `TaskResult.haltedByUser`. Pure pass-through on every
128
+ * terminal (the sibling `haltedOnUserRejection` law: the fact is about the leg that ran) PLUS
129
+ * one status decision it owns (slot 9.7 below): a run whose ONLY stop was this halt reads
130
+ * `"completed"`, never the `!final` failure and never the aborted terminal — whether the halt
131
+ * landed before the model said anything (empty result) or cut a turn whose partial text the
132
+ * brain kept (that text IS the result). The person stopped the run, which is the verb working,
133
+ * not a defect. A REAL abort/limit that also fired still owns the terminal above it. */
134
+ userHalted?: boolean;
121
135
  /** design/174 final-round: call ids of answered-but-never-collected questions, echoed on
122
136
  * `TaskResult.strandedHumanAnswers`. Pure pass-through; empty/absent ⇒ the field is omitted. The
123
137
  * optional `onError` alert is NOT the disclosure — this mandatory result face is. */
@@ -240,7 +254,11 @@ export declare function errorCodeOf(err: unknown): string | undefined;
240
254
  * discriminant guarantees only one is ever set)
241
255
  * 9. `abortedLive` | stopReason aborted → `"limits.max_walltime_exceeded"` | `"limits.max_turns_exceeded"` | undefined
242
256
  * (audit A-1: hoisted ABOVE no-final — the loop exits cleanly on abort, so `final` may be a
243
- * normal message or absent; a plain user interrupt carries NO errorCode — switch on `status`)
257
+ * normal message or absent; a plain user interrupt carries NO errorCode — switch on `status`),
258
+ * EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`)
259
+ * 9.7 `userHalted` + (no `final` | the halt's own aborted `final`) → status `"completed"` (#504: a
260
+ * bare user halt — the verb working, not a defect; below the aborts on purpose, a real
261
+ * abort/limit that also fired owns the terminal and the halt rides as the pass-through seat)
244
262
  * 10. no `final` → status `"failed"`
245
263
  * 11. stopReason error → brain `[code]` prefix (or `"conflict"` from the storage-layer flag)
246
264
  * 12. else → `"completed"`
@@ -57,12 +57,18 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
57
57
  const result = text;
58
58
  let errorMessage;
59
59
  let errorCode;
60
+ let apiFailure;
60
61
  let blockedReason;
61
62
  let salvagedOutput;
62
63
  let checkpointToken;
63
64
  let checkpointId;
64
65
  let checkpointGate;
65
66
  let workspaceRestoreMode;
67
+ const haltOwnsAbortedFinal = flags.userHalted === true &&
68
+ final?.stopReason === "aborted" &&
69
+ !flags.abortedLive &&
70
+ flags.abortedForTimeout !== true &&
71
+ flags.abortedForTurns !== true;
66
72
  const isDegenerate = final?.stopReason === "error" && isDegenerateCutMessage(final);
67
73
  if (flags.outputInvalid) {
68
74
  status = "failed";
@@ -127,11 +133,14 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
127
133
  checkpointGate = flags.reviewRef.gate;
128
134
  workspaceRestoreMode = flags.reviewRef.restoreMode;
129
135
  }
130
- else if (flags.abortedLive || final?.stopReason === "aborted") {
136
+ else if ((flags.abortedLive || final?.stopReason === "aborted") && !haltOwnsAbortedFinal) {
131
137
  status = "failed";
132
138
  errorCode = flags.abortedForTimeout ? "limits.max_walltime_exceeded" : flags.abortedForTurns ? "limits.max_turns_exceeded" : undefined;
133
139
  errorMessage = final?.errorMessage ?? (flags.abortedForTurns ? "max turns exceeded" : "run aborted");
134
140
  }
141
+ else if (flags.userHalted === true && (!final || haltOwnsAbortedFinal)) {
142
+ status = "completed";
143
+ }
135
144
  else if (!final) {
136
145
  status = "failed";
137
146
  errorMessage = "no assistant message produced";
@@ -139,6 +148,12 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
139
148
  else if (final.stopReason === "error") {
140
149
  status = "failed";
141
150
  errorMessage = final.errorMessage ?? "model error";
151
+ if (final.isApiErrorMessage === true) {
152
+ apiFailure = {
153
+ ...(final.apiErrorStatus !== undefined ? { status: final.apiErrorStatus } : {}),
154
+ ...(final.requestId !== undefined ? { requestId: final.requestId } : {}),
155
+ };
156
+ }
142
157
  const lifted = extractErrorCode(errorMessage);
143
158
  if (lifted) {
144
159
  errorCode = lifted;
@@ -165,5 +180,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
165
180
  void _internalCompaction;
166
181
  if (flags.unpricedSpend)
167
182
  delete publicStats.costMicroUsd;
168
- return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
183
+ return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(flags.userHalted === true ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
169
184
  }
@@ -29,6 +29,99 @@ import type { Prepared } from "./prepare-task.js";
29
29
  * task has no hands (no `readTaskFile` ⇒ nothing can be re-read).
30
30
  */
31
31
  export declare function buildWorkingFileAttachments(spec: TaskSpec, prepared: Prepared): MaybeCompactOptions["workingFileAttachments"];
32
+ /**
33
+ * #500 — `MaybeCompactOptions.contextInstructionFiles`: the seat that delivers the deployment's own
34
+ * instruction-file content (the CLAUDE.md family) to the SUMMARIZER, assembled once here and spread
35
+ * on all three lanes. Without it the compaction boundary is the one place a long task loses the
36
+ * standing instructions every other turn has been reading — and it loses them exactly where the
37
+ * summary REPLACES the transcript that carried them, so the loss is durable.
38
+ *
39
+ * WHICH BYTES, and why the neighbours are not candidates. `Prepared.projectInstructionContent` =
40
+ * the RAW loader text (`ProjectMemoryLoad.content`, exactly the string prepare-task feeds
41
+ * `composeMemoryBlock(projectMem, "project")`), captured beside the existing `instructionSources`
42
+ * capture under the SAME non-blank predicate that composes the project layer:
43
+ * · the assembled `memoryBlock` is NOT a candidate. It is the memory ENGINE's composed layers —
44
+ * bytes the model itself authored in-band this session through the memory tools — with the
45
+ * project layer appended at the tail. The seat's contract is host/systemPrompt tier and says in
46
+ * terms: never from tool output, model text, or any other in-band channel. Handing
47
+ * model-authored bytes an envelope whose preamble tells the summarizer they may instruct it is
48
+ * the laundering shape, not a purity nit. (Mechanically moot too — `memoryBlock` is a
49
+ * prepare-task local and never reaches `Prepared`.)
50
+ * · the COMPOSED project block is not it either, and this one is checkable rather than a
51
+ * judgement: `contextInstructionFilesSection` neutralizes the whole authority family
52
+ * (ENGINE_AUTHORITY_ENVELOPE_TAGS) over its payload, and `user_memory`/`scope` ARE in that
53
+ * family — so feeding a composed block would have the engine rewrite the tags of its own
54
+ * composition. Raw text in, one fence at the mint.
55
+ * · `prepared.instructionSources` carries path + contentHash only, by design (core never learns a
56
+ * file-name policy), so it cannot serve this seat at all.
57
+ *
58
+ * PROVENANCE, stated plainly because "the host supplies it" is the easy half of the truth. On this
59
+ * engine the value is REPOSITORY-CONTROLLED text: the loading block one file over calls a CLAUDE.md
60
+ * of unknown authorship untrusted in so many words, the MAIN lane's own framing tells the model it is
61
+ * "repository-controlled DATA, not instructions to obey", and this seat hands the same bytes to the
62
+ * summarizer with instruction standing for the summarization task. Delivery THROUGH a host callback
63
+ * is not host AUTHORSHIP: a prior agent, or anyone who can land a commit, can write a
64
+ * "## Compact Instructions" section, and the mint's neutralization stops tag breakout — not a
65
+ * sentence asking for a fact to be left out of the summary that replaces the transcript.
66
+ * Why it is nevertheless this value: the seat's own contract names this path ("hosts pass the same
67
+ * instruction-file content their prompt assembly mounts... on the runner path"), the parity form this
68
+ * channel exists to reach reads the user's and the project's instruction files at every boundary with
69
+ * exactly this standing, and the two alternatives are worse for reasons above rather than better.
70
+ * The standing is also narrower than the framing gap suggests — the section's preamble scopes it to
71
+ * guidance about HOW to summarize and classifies the rest as background — and a deployment that does
72
+ * not want it has two exits that need no code: do not wire the loader, or return content it vouches
73
+ * for. Recorded as an open design candidate rather than built here, because it is a contract change
74
+ * and not a wiring one: a SEPARATE deployment-vouched summarization-instructions seat, leaving
75
+ * repository-derived context at data tier.
76
+ *
77
+ * RULING ① — BOTH summary forms, no per-form split. Fork eligibility is decided per pass INSIDE
78
+ * maybeCompact (main-model-only, recorded-request-present, not lossy), so a call site cannot say
79
+ * "independent form only"; the closest approximation — pass the seat only when `prepared.compModel`
80
+ * is set, since an independent compaction model has no main prefix to fork — leaves the hole the
81
+ * wrong way round: the configuration it does NOT cover (no compaction model) is precisely the one
82
+ * where a fork degrades mid-pass to the independent form, which would then run WITHOUT the files.
83
+ * Feeding both forms closes that case by construction, and the library face already handles the fork
84
+ * half (the section rides the APPENDED instruction message, never the cached prefix, so the fork's
85
+ * whole economic point is untouched).
86
+ *
87
+ * RULING ② — no new size knob. The bound is the library's own `fitContextInstructionFilesSection`
88
+ * (independent form: cap against the compaction model's window, disclosed elision, and a complete
89
+ * yield when not even the framing fits). Its three KNOWN LIMITS are accepted here KNOWINGLY, as the
90
+ * pre-condition of this wiring rather than something it discovered: a token-dense payload (CJK,
91
+ * emoji) can pass a chars-domain fit and still overflow the gateway's tokenizer; an unknown
92
+ * `contextWindow` leaves the section unbounded (the conversation clamp declines to guess there too);
93
+ * and the FORK form cannot fit at all — its bound is a prompt-too-long that degrades ONCE to the
94
+ * independent form, which the engine's own note records as real but NOT total (an oversize rejection
95
+ * the classifier cannot read as prompt-too-long returns terminally instead).
96
+ *
97
+ * HOW BIG THAT ACCEPTANCE ACTUALLY IS — measured, because the sentence above is easy to read more
98
+ * comfortably than it deserves. The fitted form is NOT the default path: when a fork is eligible and
99
+ * the model answers in its envelope, the fork IS the pass, and its section is delivered WHOLE at any
100
+ * size. The independent form — the only one that fits — runs when there is no fork to take or the
101
+ * fork's answer did not conform. Both facts are pinned in the REF-A2 suite (a 55 KB payload: whole on
102
+ * the fork, elided on the independent fallback). And when a limit does bite, the failure is not a
103
+ * quiet degradation: the section is a FIXED part of the request, the reactive retry loop may drop
104
+ * only CONVERSATION groups, so a request the SECTION overflowed cannot converge and the boundary
105
+ * fails — the task loses that compaction (or that prompt-too-long recovery, or its end-of-task
106
+ * summary) rather than sending a shorter one. The exposure is what the wiring widened: this was
107
+ * previously reachable only by hosts calling the library directly, and is now reachable by every
108
+ * deployment that wires `loadProjectMemory` with a large instruction file and an unknown or small
109
+ * window. Making the section participate in the retry loop is the structural answer; it is a change
110
+ * to that loop's contract, tracked at the library face, and a runner-side knob would only move the
111
+ * decision to a place with less information.
112
+ *
113
+ * WHAT IT COSTS, measured rather than rounded: every deployment that wires `loadProjectMemory` now
114
+ * sends its instruction-file content plus EXACTLY 716 chars of framing (the preamble and the fence)
115
+ * on EVERY summarization request, both forms, once per pass — where a real project instruction file
116
+ * in this family runs to tens of KB, so the payload, not the framing, is the bill. The number is
117
+ * pinned in the REF-A2 suite against the engine's own renderer, so a preamble edit shows up as a
118
+ * changed assertion rather than as prose drifting away from the code it describes.
119
+ * Deployments that never wired the loader pay nothing: the key is absent, not
120
+ * `undefined`, so their requests are byte-identical to the pre-wiring shape on the independent and
121
+ * UPDATE passes (the FORK form appends its ~860-char authority clause with or without the seat, and
122
+ * always did).
123
+ */
124
+ export declare function contextInstructionFilesOption(prepared: Prepared): Partial<Pick<MaybeCompactOptions, "contextInstructionFiles">>;
32
125
  /**
33
126
  * design/169-A — the CC-form fork seam, assembled identically on all three lanes (REF-A2): the
34
127
  * summary request forks the main lane's latest real request (recorded by prepare-task's provider
@@ -14,6 +14,9 @@ export function buildWorkingFileAttachments(spec, prepared) {
14
14
  ...(typeof spec.compaction?.attachWorkingFiles === "object" ? spec.compaction.attachWorkingFiles : undefined),
15
15
  };
16
16
  }
17
+ export function contextInstructionFilesOption(prepared) {
18
+ return prepared.projectInstructionContent !== undefined ? { contextInstructionFiles: prepared.projectInstructionContent } : {};
19
+ }
17
20
  export function forkContextOption(prepared, disable) {
18
21
  return disable ? {} : { forkContext: prepared.lastBrainContext };
19
22
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * design/383 §3 — the session capture opt-out's ENTITLEMENT ADJUDICATOR (slice 3's core): one pure
3
+ * function every ingress shares — the prepare-time declaration, the resume-time standing-record
4
+ * re-check (§3.3), and the Runner flip verb's fresh-resolve closure — so the posture table cannot
5
+ * fork across call sites.
6
+ *
7
+ * The separation law (§3.3) is the module's spine: THIS adjudicator answers "may the declaration /
8
+ * the continued run proceed"; the one-way control-plane RECORD answers "how does this session run"
9
+ * (record wins for capture — nothing here ever re-enables capture for a recorded session; the
10
+ * governed/capture-required continuation arm refuses to RUN instead, keeping both promises: zero
11
+ * capture and zero out-of-policy running, availability paid).
12
+ *
13
+ * The two fault polarities, and why both are deliberate (the code-side home of the §3.1 cost
14
+ * table):
15
+ * - `"open"` (and absent): a resolver fault ALLOWS the opt-out (+ named disclosure). Wrongly
16
+ * allowing costs one uncaptured session — recoverable. Wrongly denying captures a session whose
17
+ * user explicitly said no — irreversible (read, foldable into products, syncable). The
18
+ * fail-safe faces the irreversible side's OPPOSITE, which is why this member is the documented
19
+ * exception to the caps family's deny-on-outage degrade.
20
+ * - `"governed"`: the deployment has EXPLICITLY declared the compliance side's irreversibility
21
+ * (a retention-required principal acquiring an opt-out through an outage window) the heavier
22
+ * one, so a fault/absent verdict refuses the run — fail-closed, the declared direction.
23
+ * A mutation flipping the governed fault arm to the open polarity must turn the G-姿态 escape pin
24
+ * red (m12).
25
+ */
26
+ /** The screened posture (prepare-config-doors folds absent → `"open"`). */
27
+ export type MemoryCapturePosture = "open" | "governed" | "capture-required";
28
+ /** The per-principal entitlement input as RESOLVED for this adjudication:
29
+ * - `value` — `RuntimeCaps.allowMemoryOptOut` when the resolver answered a boolean; `undefined`
30
+ * when absent OR when the resolver faulted (the F2 fault-transit contract: a fault must never
31
+ * be encoded as an explicit value);
32
+ * - `faulted` — the resolve THREW / timed out / answered a non-boolean (the non-boolean case is
33
+ * a fault by the member's own contract, never truthiness-read). Distinguished from a clean
34
+ * `undefined` only for DISCLOSURE under `"open"` (both allow there) — under `"governed"` both
35
+ * are the same refusal (verdict absent = face fault). */
36
+ export interface CaptureEntitlementInput {
37
+ value: boolean | undefined;
38
+ faulted: boolean;
39
+ }
40
+ export type CaptureOptOutVerdict = {
41
+ allowed: true;
42
+ /** Present ⇔ the open-posture fault arm fired: the opt-out takes effect AND the caller owes
43
+ * the named `onError({phase:"config"})` disclosure line (this string). */
44
+ faultDisclosure?: string;
45
+ } | {
46
+ allowed: false;
47
+ /** Always `memory.capture_optout_denied` (terminal). The caller throws it; a DECLARATION
48
+ * denial must leave zero record residue (§2.1 order invariant — adjudicate BEFORE marking). */
49
+ code: "memory.capture_optout_denied";
50
+ detail: string;
51
+ };
52
+ /**
53
+ * Adjudicate one capture opt-out ingress. `subject` names what is being adjudicated, for the
54
+ * refusal detail only: `"declaration"` (a `capture:"off"` spelling / the flip verb) or
55
+ * `"standing-record"` (a resumed session whose one-way record stands — §3.3: under
56
+ * governed/capture-required the question is whether the run may CONTINUE; the record's zero-capture
57
+ * promise is not on the table).
58
+ */
59
+ export declare function adjudicateCaptureOptOut(input: {
60
+ posture: MemoryCapturePosture;
61
+ entitlement: CaptureEntitlementInput;
62
+ subject: "declaration" | "standing-record";
63
+ }): CaptureOptOutVerdict;
64
+ /** The typed terminal refusal builder (one spelling for every ingress). */
65
+ export declare function captureOptOutDeniedError(detail: string): Error & {
66
+ code: string;
67
+ };
68
+ /** The typed could-not-record refusal (§2.8 — never a success notice's sibling; the caller mints
69
+ * {@link import("../memory-engine/engine.js").memoryCaptureOptOutUnpersistedNotice} beside it).
70
+ *
71
+ * `ingress` is REQUIRED (internal builder, both call sites in prepare-memory state it): the two
72
+ * arms' throws settle different things and the message must say which. The declaration arm's throw
73
+ * is whitelisted out of prepare-memory's fail-open catch and refuses the RUN; the host flip verb's
74
+ * throw rejects only the verb promise — `captureOptedOut` is assigned below it — so the run
75
+ * continues with capture still ON. One sentence for both said "refusing to run", which was false
76
+ * in the direction that matters: it told the caller the session had stopped capturing when it had
77
+ * not. */
78
+ export declare function captureOptOutUnpersistedError(sessionId: string, ingress: "declaration" | "flip-verb"): Error & {
79
+ code: string;
80
+ };
@@ -0,0 +1,53 @@
1
+ export function adjudicateCaptureOptOut(input) {
2
+ const { posture, entitlement, subject } = input;
3
+ const what = subject === "standing-record" ? "continuing a session whose one-way capture opt-out record stands" : "the session memory-capture opt-out declaration";
4
+ if (posture === "capture-required") {
5
+ return {
6
+ allowed: false,
7
+ code: "memory.capture_optout_denied",
8
+ detail: `${what} is refused: this deployment declares memoryCapturePolicy "capture-required" (forced retention, resolver-independent). ` +
9
+ (subject === "standing-record"
10
+ ? `The standing record keeps its zero-capture promise — the session is refused rather than run out of policy; start a new session without the opt-out.`
11
+ : `Remove the memory.capture declaration and resubmit.`),
12
+ };
13
+ }
14
+ if (posture === "governed") {
15
+ if (entitlement.value === true)
16
+ return { allowed: true };
17
+ return {
18
+ allowed: false,
19
+ code: "memory.capture_optout_denied",
20
+ detail: entitlement.value === false
21
+ ? `${what} is refused: the entitlement resolver answered allowMemoryOptOut=false for this principal (governed posture — the deployment requires capture for it).`
22
+ : `${what} is refused: memoryCapturePolicy "governed" requires an explicit per-principal allowMemoryOptOut verdict and none was resolvable (${entitlement.faulted ? "resolver fault" : "verdict absent"}) — fail-closed by the deployment's own declaration (a retention-required principal must not acquire an opt-out through an outage window).`,
23
+ };
24
+ }
25
+ if (entitlement.value === false) {
26
+ return {
27
+ allowed: false,
28
+ code: "memory.capture_optout_denied",
29
+ detail: `${what} is refused: the entitlement resolver answered allowMemoryOptOut=false for this principal.`,
30
+ };
31
+ }
32
+ if (entitlement.faulted) {
33
+ return {
34
+ allowed: true,
35
+ faultDisclosure: `the runtime-caps resolve faulted while adjudicating a memory-capture opt-out; under the "open" posture the opt-out takes effect anyway ` +
36
+ `(privacy-side irreversibility wins the fault arm — an explicit allowMemoryOptOut=false is the only denial). Repair the resolver if denial was intended.`,
37
+ };
38
+ }
39
+ return { allowed: true };
40
+ }
41
+ export function captureOptOutDeniedError(detail) {
42
+ const e = new Error(`memory capture opt-out denied: ${detail}`);
43
+ e.code = "memory.capture_optout_denied";
44
+ return e;
45
+ }
46
+ export function captureOptOutUnpersistedError(sessionId, ingress) {
47
+ const e = new Error(`memory capture opt-out could not be durably recorded for session ${sessionId} (control-plane write failed) — ` +
48
+ (ingress === "declaration"
49
+ ? `refusing to run under an opt-out that would silently stop holding at the next resume. Nothing ran and nothing was captured.`
50
+ : `the opt-out request is REJECTED and not applied: the run continues with memory capture still ON. Nothing was recorded, so an identical retry fails the same way.`));
51
+ e.code = "memory.capture_optout_unpersisted";
52
+ return e;
53
+ }
@@ -196,6 +196,11 @@ export interface PrepareConfigDoorsResult {
196
196
  * the synchronous pre-await stretch, screened (#123), absent folded to the `"carry"` default.
197
197
  * The memory phase constructs every engine with THIS value — never a live deps re-read. */
198
198
  memoryProvenance: "off" | "carry";
199
+ /** owned — the NORMALIZED memory-capture posture (design/383 §3.1): the deps seat read ONCE,
200
+ * screened (#123 — a bad value refuses, never folds), absent folded to `"open"`. The capture
201
+ * opt-out adjudicator consumes THIS value on every leg (declaration, resume record re-check,
202
+ * and the flip verb's frozen closure) — never a live deps re-read. */
203
+ memoryCapturePolicy: "open" | "governed" | "capture-required";
199
204
  /** owned — validated deployment governance windows (undefined = ungoverned). */
200
205
  usageWindows: readonly UsageWindow[] | undefined;
201
206
  /** owned, out-param cell — created EMPTY here; the brain-call wiring later installs into
@@ -225,6 +225,21 @@ export function prepareConfigDoors(input) {
225
225
  throw e;
226
226
  }
227
227
  const memoryProvenance = memoryProvenanceRaw === "off" ? "off" : "carry";
228
+ const memoryCapturePolicyRaw = deps.memoryCapturePolicy;
229
+ if (memoryCapturePolicyRaw !== undefined && memoryCapturePolicyRaw !== "open" && memoryCapturePolicyRaw !== "governed" && memoryCapturePolicyRaw !== "capture-required") {
230
+ const got = typeof memoryCapturePolicyRaw === "string"
231
+ ? JSON.stringify(memoryCapturePolicyRaw.length > 64 ? `${memoryCapturePolicyRaw.slice(0, 64)}…` : memoryCapturePolicyRaw)
232
+ : memoryCapturePolicyRaw === null
233
+ ? "null"
234
+ : Array.isArray(memoryCapturePolicyRaw)
235
+ ? "an array"
236
+ : typeof memoryCapturePolicyRaw;
237
+ const e = new Error(`RunnerDeps.memoryCapturePolicy must be "open", "governed" or "capture-required" when present (got ${got}) — ` +
238
+ `an unevaluable capture posture is refused loudly, never folded to any posture (the fault arms differ per posture, so a guess would pick a polarity nobody configured).`);
239
+ e.code = "config.memory_capture_policy";
240
+ throw e;
241
+ }
242
+ const memoryCapturePolicy = memoryCapturePolicyRaw === "governed" ? "governed" : memoryCapturePolicyRaw === "capture-required" ? "capture-required" : "open";
228
243
  if (spec.resumeAtMode !== undefined) {
229
244
  if (spec.resumeAt === undefined) {
230
245
  const e = new Error(`resumeAtMode "${spec.resumeAtMode}" requires resumeAt (there is no branch target to position against)`);
@@ -420,6 +435,7 @@ export function prepareConfigDoors(input) {
420
435
  modelGate,
421
436
  memoryDelegationEvidence,
422
437
  memoryProvenance,
438
+ memoryCapturePolicy,
423
439
  usageWindows,
424
440
  brainCallGuardrailRef,
425
441
  brainCallGuardrailMs,