@sema-agent/core 7.53.0 → 7.54.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 (244) hide show
  1. package/CHANGELOG.md +277 -1
  2. package/LICENSE +3 -1
  3. package/dist/agents/peer-session-drain.d.ts +0 -1
  4. package/dist/agents/peer-session-drain.js +0 -2
  5. package/dist/agents/send-message-tool.js +3 -4
  6. package/dist/agents/subagent.d.ts +3 -5
  7. package/dist/agents/subagent.js +18 -11
  8. package/dist/agents/tool-filter.d.ts +7 -4
  9. package/dist/agents/tool-filter.js +7 -3
  10. package/dist/core/a2a.js +4 -2
  11. package/dist/core/allow-entry.d.ts +1 -0
  12. package/dist/core/allow-entry.js +1 -0
  13. package/dist/core/approval-object.d.ts +87 -0
  14. package/dist/core/approval-object.js +85 -0
  15. package/dist/core/arg-summary.d.ts +3 -2
  16. package/dist/core/arg-summary.js +17 -11
  17. package/dist/core/ask-origin.d.ts +43 -27
  18. package/dist/core/ask-origin.js +20 -11
  19. package/dist/core/ask-question.d.ts +0 -1
  20. package/dist/core/ask-question.js +4 -67
  21. package/dist/core/ask-unresolvable-notice.d.ts +3 -2
  22. package/dist/core/auto-compaction.d.ts +49 -50
  23. package/dist/core/auto-compaction.js +41 -51
  24. package/dist/core/auto-mode-prompt.d.ts +4 -2
  25. package/dist/core/auto-mode-prompt.js +4 -3
  26. package/dist/core/canonical-json.d.ts +69 -0
  27. package/dist/core/canonical-json.js +130 -8
  28. package/dist/core/checkpoint-requirements.d.ts +13 -7
  29. package/dist/core/checkpoint-requirements.js +4 -1
  30. package/dist/core/checkpoint-store.d.ts +33 -18
  31. package/dist/core/checkpoint-store.js +5 -4
  32. package/dist/core/child-clamp-seats.d.ts +45 -6
  33. package/dist/core/child-clamp-seats.js +56 -4
  34. package/dist/core/consolidate-scope.d.ts +1 -1
  35. package/dist/core/cross-boundary-taint.d.ts +213 -0
  36. package/dist/core/cross-boundary-taint.js +196 -0
  37. package/dist/core/decision-receipt.d.ts +44 -1
  38. package/dist/core/decision-receipt.js +16 -0
  39. package/dist/core/effective-path-target.d.ts +3 -0
  40. package/dist/core/effective-path-target.js +4 -0
  41. package/dist/core/engine-notice.d.ts +34 -2
  42. package/dist/core/engine-notice.js +17 -1
  43. package/dist/core/engine-storage-backend.d.ts +73 -0
  44. package/dist/core/engine-storage-backend.js +1 -0
  45. package/dist/core/file-history-store.d.ts +6 -2
  46. package/dist/core/file-history-store.js +1 -0
  47. package/dist/core/gate-fold.js +10 -2
  48. package/dist/core/gate-lanes.js +30 -21
  49. package/dist/core/gate-outcome.d.ts +7 -3
  50. package/dist/core/governance-codes.d.ts +7 -2
  51. package/dist/core/governance-codes.js +6 -0
  52. package/dist/core/hooks.d.ts +14 -14
  53. package/dist/core/hooks.js +1 -2
  54. package/dist/core/locked-config.d.ts +28 -1
  55. package/dist/core/locked-config.js +30 -3
  56. package/dist/core/lsp.d.ts +1 -1
  57. package/dist/core/mcp-server-spec.d.ts +66 -9
  58. package/dist/core/mcp-server-spec.js +52 -19
  59. package/dist/core/mcp.js +5 -4
  60. package/dist/core/memory-engine/engine.js +2 -1
  61. package/dist/core/memory-engine/export-bundle.d.ts +1 -1
  62. package/dist/core/memory-engine/export-bundle.js +1 -1
  63. package/dist/core/memory-engine/file-backend.d.ts +2 -6
  64. package/dist/core/memory-engine/file-backend.js +1 -12
  65. package/dist/core/memory.d.ts +3 -3
  66. package/dist/core/mode-policy.d.ts +13 -8
  67. package/dist/core/mode-policy.js +3 -2
  68. package/dist/core/mods/kernel/receive.d.ts +2 -1
  69. package/dist/core/mods/kernel/receive.js +7 -2
  70. package/dist/core/parent-spec-seat.d.ts +10 -13
  71. package/dist/core/parent-spec-seat.js +14 -6
  72. package/dist/core/pause-registry.d.ts +35 -1
  73. package/dist/core/pause-registry.js +10 -0
  74. package/dist/core/permission-rule-consent.d.ts +18 -5
  75. package/dist/core/permission-rule-consent.js +13 -8
  76. package/dist/core/permission-rule-model.d.ts +29 -6
  77. package/dist/core/permission-rule-model.js +6 -1
  78. package/dist/core/permission-rule-org.d.ts +37 -0
  79. package/dist/core/permission-rule-org.js +34 -42
  80. package/dist/core/permission-rule-session.js +2 -2
  81. package/dist/core/permission-rule-store.d.ts +8 -0
  82. package/dist/core/permission-rule-store.js +1 -0
  83. package/dist/core/permission-rule-sync.js +3 -2
  84. package/dist/core/read-only-shell.d.ts +1 -1
  85. package/dist/core/read-only-shell.js +30 -9
  86. package/dist/core/remote-env.d.ts +41 -1
  87. package/dist/core/remote-env.js +18 -0
  88. package/dist/core/resume-startup-cause.d.ts +18 -0
  89. package/dist/core/resume-startup-cause.js +12 -0
  90. package/dist/core/runner/approval-feedback.d.ts +46 -0
  91. package/dist/core/runner/approval-feedback.js +41 -0
  92. package/dist/core/runner/compaction-call-options.d.ts +3 -3
  93. package/dist/core/runner/compaction-call-options.js +2 -2
  94. package/dist/core/runner/contracts.d.ts +33 -41
  95. package/dist/core/runner/cross-boundary-lane.d.ts +87 -0
  96. package/dist/core/runner/cross-boundary-lane.js +190 -0
  97. package/dist/core/runner/decide-continuation.d.ts +4 -4
  98. package/dist/core/runner/decide-continuation.js +3 -1
  99. package/dist/core/runner/gate-exit.d.ts +32 -12
  100. package/dist/core/runner/gate-exit.js +75 -33
  101. package/dist/core/runner/inherited-ask-grants.js +2 -2
  102. package/dist/core/runner/person-path-rows.d.ts +2 -2
  103. package/dist/core/runner/prepare-acquire-reconcile.d.ts +3 -4
  104. package/dist/core/runner/prepare-acquire-reconcile.js +3 -3
  105. package/dist/core/runner/prepare-ask-lane.js +3 -3
  106. package/dist/core/runner/prepare-caps-and-workflow.d.ts +6 -5
  107. package/dist/core/runner/prepare-caps-and-workflow.js +3 -3
  108. package/dist/core/runner/prepare-config-doors.d.ts +12 -15
  109. package/dist/core/runner/prepare-config-doors.js +8 -3
  110. package/dist/core/runner/prepare-context-lane.js +6 -3
  111. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -2
  112. package/dist/core/runner/prepare-delegation-surface.js +2 -3
  113. package/dist/core/runner/prepare-execution-env.d.ts +1 -2
  114. package/dist/core/runner/prepare-execution-env.js +2 -1
  115. package/dist/core/runner/prepare-gate-stations.d.ts +2 -0
  116. package/dist/core/runner/prepare-gate-stations.js +4 -3
  117. package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
  118. package/dist/core/runner/prepare-hands-readface.js +1 -1
  119. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -0
  120. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  121. package/dist/core/runner/prepare-park-ask.d.ts +1 -1
  122. package/dist/core/runner/prepare-park-ask.js +9 -3
  123. package/dist/core/runner/prepare-policy-chain.js +2 -7
  124. package/dist/core/runner/prepare-safety-scan.d.ts +7 -2
  125. package/dist/core/runner/prepare-safety-scan.js +34 -27
  126. package/dist/core/runner/prepare-suspend-saga.d.ts +2 -2
  127. package/dist/core/runner/prepare-suspend-saga.js +6 -2
  128. package/dist/core/runner/prepare-task.js +13 -10
  129. package/dist/core/runner/prepare-turn-wiring.d.ts +10 -5
  130. package/dist/core/runner/prepare-turn-wiring.js +14 -49
  131. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  132. package/dist/core/runner/prepare-wiring-manifest.js +2 -2
  133. package/dist/core/runner/prepare-workspace-restore.js +2 -2
  134. package/dist/core/runner/resume-admission.js +9 -9
  135. package/dist/core/runner/resume-apply.js +4 -4
  136. package/dist/core/runner/resume-policy-outcome.d.ts +2 -2
  137. package/dist/core/runner/resume-policy-outcome.js +18 -15
  138. package/dist/core/runner/resume-preflight.js +2 -2
  139. package/dist/core/runner/resume-review-outcome.js +3 -3
  140. package/dist/core/runner/run-leg.js +57 -33
  141. package/dist/core/runner/run-settle-and-teardown.js +2 -0
  142. package/dist/core/runner/run-terminal-adoption.js +15 -4
  143. package/dist/core/runner/session-file-state-replay.d.ts +5 -2
  144. package/dist/core/runner/session-file-state-replay.js +3 -2
  145. package/dist/core/runner/stream-steer-verb.js +2 -2
  146. package/dist/core/runner/tool-face-overlay.d.ts +24 -6
  147. package/dist/core/runner/tool-face-overlay.js +24 -15
  148. package/dist/core/runner-deps.d.ts +9 -9
  149. package/dist/core/session-store.d.ts +35 -16
  150. package/dist/core/session-store.js +20 -12
  151. package/dist/core/{shell-lexer.d.ts → shell-run-candidates.d.ts} +36 -12
  152. package/dist/core/{shell-lexer.js → shell-run-candidates.js} +145 -225
  153. package/dist/core/shell-wrapper-table.d.ts +5 -3
  154. package/dist/core/shell-wrapper-table.js +1 -1
  155. package/dist/core/store-contracts/checkpoint-store-contract.js +2 -2
  156. package/dist/core/store-contracts/session-store-contract.d.ts +49 -0
  157. package/dist/core/store-contracts/session-store-contract.js +639 -0
  158. package/dist/core/store-contracts/session-store-incarnation-contract.d.ts +12 -0
  159. package/dist/core/store-contracts/session-store-incarnation-contract.js +5 -2
  160. package/dist/core/task-event.d.ts +6 -4
  161. package/dist/core/task-registry-agent.js +1 -0
  162. package/dist/core/task-registry-shared.d.ts +5 -0
  163. package/dist/core/task-registry.d.ts +7 -0
  164. package/dist/core/task-registry.js +17 -0
  165. package/dist/core/task-result.d.ts +1 -1
  166. package/dist/core/task-spec.d.ts +6 -6
  167. package/dist/core/tighten-task-spec.d.ts +6 -4
  168. package/dist/core/tighten-task-spec.js +2 -2
  169. package/dist/core/tool-catalog-entries.d.ts +1 -1
  170. package/dist/core/tool-catalog-entries.js +52 -52
  171. package/dist/core/tool-face-reader.d.ts +49 -0
  172. package/dist/core/tool-face-reader.js +94 -0
  173. package/dist/core/tool-face.d.ts +17 -8
  174. package/dist/core/tool-face.js +1 -0
  175. package/dist/core/tool-policy.d.ts +64 -23
  176. package/dist/core/tool-policy.js +62 -56
  177. package/dist/core/tool-roster.d.ts +46 -28
  178. package/dist/core/tool-roster.js +91 -14
  179. package/dist/core/tool-spec-members.js +2 -2
  180. package/dist/core/tool-spec.d.ts +20 -4
  181. package/dist/core/tools.js +2 -0
  182. package/dist/core/trace.d.ts +24 -2
  183. package/dist/core/types.d.ts +2 -2
  184. package/dist/core/types.js +1 -1
  185. package/dist/core/untrusted-text.d.ts +7 -0
  186. package/dist/core/untrusted-text.js +3 -8
  187. package/dist/core/user-attachment.d.ts +43 -0
  188. package/dist/core/user-attachment.js +92 -15
  189. package/dist/engine/compaction/compaction.d.ts +2 -2
  190. package/dist/engine/compaction/utils.d.ts +2 -2
  191. package/dist/engine/harness/types.d.ts +7 -4
  192. package/dist/engine/llm/types.d.ts +7 -2
  193. package/dist/engine/llm/types.js +2 -1
  194. package/dist/engine/session/repo-utils.d.ts +5 -0
  195. package/dist/engine/session/repo-utils.js +9 -0
  196. package/dist/engine/session/storage-base.js +2 -2
  197. package/dist/index.d.ts +23 -9
  198. package/dist/index.js +19 -8
  199. package/dist/internal/llm.d.ts +1 -1
  200. package/dist/internal/llm.js +1 -1
  201. package/dist/orchestration/run-workflow-tool.d.ts +3 -0
  202. package/dist/orchestration/run-workflow-tool.js +1 -0
  203. package/dist/orchestration/workflow-governance.d.ts +1 -1
  204. package/dist/orchestration/workflow.d.ts +3 -0
  205. package/dist/orchestration/workflow.js +2 -1
  206. package/dist/prompt-assembly/catalog/schema.d.ts +3 -0
  207. package/dist/prompt-assembly/catalog/schema.js +3 -0
  208. package/dist/prompt-assembly/overlay.js +2 -2
  209. package/dist/prompt-assembly/select.js +2 -2
  210. package/dist/prompts/default.d.ts +1 -1
  211. package/dist/shell/lex.d.ts +87 -8
  212. package/dist/shell/lex.js +283 -49
  213. package/dist/stores/file/index.d.ts +8 -6
  214. package/dist/tools/artifact/artifact-tool.js +10 -0
  215. package/dist/tools/fs/bash-program-position.d.ts +3 -15
  216. package/dist/tools/fs/bash-program-position.js +3 -10
  217. package/dist/tools/fs/bash-readonly-classifier.js +2 -0
  218. package/dist/tools/fs/fs-bash.d.ts +3 -3
  219. package/dist/tools/fs/fs-bash.js +72 -101
  220. package/dist/tools/fs/fs-read.d.ts +12 -0
  221. package/dist/tools/fs/fs-read.js +10 -9
  222. package/dist/tools/fs/fs-shared.d.ts +27 -31
  223. package/dist/tools/fs/fs-shared.js +12 -15
  224. package/dist/tools/fs/fs-write.js +1 -1
  225. package/dist/tools/fs/index.js +2 -2
  226. package/dist/tools/fs/read-deny-home.d.ts +1 -1
  227. package/dist/tools/fs/read-deny-home.js +5 -4
  228. package/dist/tools/fs/read-deny.d.ts +16 -3
  229. package/dist/tools/fs/read-deny.js +12 -9
  230. package/dist/tools/fs/safety.d.ts +7 -6
  231. package/dist/tools/fs/shell-read-bindings.d.ts +5 -3
  232. package/dist/tools/fs/shell-read-bindings.js +15 -16
  233. package/dist/tools/fs/shell-read-device.js +1 -1
  234. package/dist/tools/fs/shell-read-plan.js +36 -47
  235. package/dist/tools/fs/shell-read-word.d.ts +1 -6
  236. package/dist/tools/fs/shell-read-word.js +2 -20
  237. package/dist/tools/fs/shell-reentry-text.js +1 -1
  238. package/dist/tools/web.d.ts +2 -2
  239. package/package.json +4 -1
  240. package/test/export-surface.snapshot.json +5654 -5520
  241. package/wire-changes/core@7.53.0.json +30 -8
  242. package/wire-changes/core@7.54.0.json +2870 -0
  243. package/dist/core/shell-scan.d.ts +0 -60
  244. package/dist/core/shell-scan.js +0 -183
@@ -113,8 +113,8 @@ export interface AskOriginFacts {
113
113
  /** Which of the gate's OWN post-fold stations minted the standing ask, if one did — again the gate's
114
114
  * record of its own layer (a station replaces an `allow` wholesale, so at most one fires per pass),
115
115
  * never a read-back of `decisionReason: "safety"` off a decision a policy could have composed. `unstated` is
116
- * the last station: the mode's answer for a call to a tool that states no permission of its own when no layer
117
- * stated the allow — its origin is `policy` (no tighten word), its class `unstated`. */
116
+ * the last station: the mode's answer for a call to a tool that states no permission of its own when no answer
117
+ * covers the fold's allow — its origin is `policy` (no tighten word), its class `unstated`. */
118
118
  readonly tightened: "shell_gate" | "safety" | "unstated" | undefined;
119
119
  /** The MANDATE mark inputs of this call — the tool SEAT's marks plus the gate's own per-call records
120
120
  * ({@link MandateMarks}), read from the same two places the lane's own derivation reads them. The
@@ -191,19 +191,15 @@ export type RuleOffersAbsence = (typeof RULE_OFFERS_ABSENCES)[number];
191
191
  * the reason is that a RULE is already speaking, not that the question is beyond rules, and a surface
192
192
  * that auto-answers by posture is not crossing a mandate when it answers one.
193
193
  *
194
- * FOUR named readings of the one pass, because four callers need four different things and taking
195
- * the wrong one is how this boundary drifts:
194
+ * THREE named readings of the one pass, because three callers need three different things and taking
195
+ * the wrong one is how this boundary drifts. None is the ENFORCEMENT reading: whether an allow rule clears the ask is the
196
+ * class row's `ruleMayClear` ({@link classifyAsk} — every door below is a class there), read once by the gate's allow layer.
196
197
  * · `word` — the DISCLOSED reason ({@link RuleOffersAbsence}), first door wins, in the display order the card has always
197
198
  * used (governance, the outranked allow, the hook, the ask rule, the two ancestor doors, then the marks). Display
198
199
  * only. A structural door the offer factory owns (no lane wired, a tool it does not speak for, a
199
200
  * task that cannot hold a rule) is decided BEFORE this and is deliberately silent;
200
201
  * · `mandated` — the FACT the ask carries as its own member. Read off the whole mandate set, so which
201
202
  * door is DISPLAYED cannot change it: an ask that is both outranked and mandated is mandated;
202
- * · `ruleMayClear` — the ENFORCEMENT reading the gate's clearing arms take. It is `mandated` plus the
203
- * person's explicit `ask` rule, and deliberately NOT `word !== undefined`: `allowRuleShadowed`
204
- * is an ACCOUNT of a refusal that already happened, display-only by contract, and reading it as a
205
- * blocker would let a policy forwarding that metadata refuse a call nothing is blocking. A
206
- * display word is never an authority;
207
203
  * · `mandate` — WHICH table word the bit stands on, only when the MARKS family is its whole reason (no
208
204
  * governance, hook or ancestor door, and no bit stamped without a word); absent, the bit's reason is
209
205
  * not one word.
@@ -211,7 +207,6 @@ export type RuleOffersAbsence = (typeof RULE_OFFERS_ABSENCES)[number];
211
207
  export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, marks: Omit<MandateMarks, "probeMandated">): {
212
208
  word?: Exclude<RuleOffersAbsence, "lane_cannot_speak">;
213
209
  mandated: boolean;
214
- ruleMayClear: boolean;
215
210
  mandate?: PersistedRuleMandate;
216
211
  };
217
212
  /** The facts one ask carries about whether a rule could clear it — the members {@link ruleClearanceOf}
@@ -235,6 +230,8 @@ export interface RuleClearanceFacts {
235
230
  /** The engine-stamped probe mandate (the reversibility probe declared this call's demotion
236
231
  * structural — for the built-in shell probe, a read outside the session's allowed directories). */
237
232
  probeMandated?: boolean;
233
+ /** The ask stands on the call's cross-boundary obligation (see {@link import("./tool-policy.js").PermissionResult}). */
234
+ crossBoundaryMandated?: boolean;
238
235
  /** An ancestor's approver answered "unavailable" for this call, or a durable mandate was floated here. */
239
236
  inheritedUnresolved?: boolean;
240
237
  /** The ask is being resolved at an ANCESTOR's frozen approver (the three inherited-lane mint sites
@@ -309,8 +306,11 @@ export type RealApprovalTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<As
309
306
  * re-authorizes the agent, so the question is a property of the TARGET, not of anyone's hesitation.
310
307
  * - `target_unresolved` — the reading of a written target could not say where it leads on disk:
311
308
  * `probe_unanswered`'s doctrine one layer over.
309
+ * - `cross_boundary_taint` — the call's cross-boundary obligation is a person's confirmation (an org-side write from a tree
310
+ * that executes on a device of a multi-tenant deployment, or a device-bound call after an off-target read): a fact of
311
+ * the tree, not of the tool; read off the ask's own `crossBoundaryMandated` ({@link ruleClearanceOf}).
312
312
  */
313
- export declare const PERSISTED_RULE_MANDATES: readonly ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "target_unresolved"];
313
+ export declare const PERSISTED_RULE_MANDATES: readonly ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "target_unresolved", "cross_boundary_taint"];
314
314
  export type PersistedRuleMandate = (typeof PERSISTED_RULE_MANDATES)[number];
315
315
  /** Membership test for {@link PersistedRuleMandate} — the screen a row reader applies to a stored word. */
316
316
  export declare function isPersistedRuleMandate(v: unknown): v is PersistedRuleMandate;
@@ -323,6 +323,7 @@ declare const MANDATE_DISCLOSURE: {
323
323
  readonly tool_marks: "this tool carries egress/irreversibility/interaction marks (a mandated confirmation a rule cannot clear)";
324
324
  readonly write_protection: "this write's target is on the deployment's write-protection table (the agent's own configuration is guarded one look per write) — it is cleared by confirming this call, never by a standing allow rule";
325
325
  readonly target_unresolved: "where this write leads on disk could not be determined (a link on the way could not be examined, or the environment could not resolve the target), so whether it lands on a guarded path is unknown — it is cleared by confirming this call: no standing rule and no automatic decider answers for it, though a wired approver seat can";
326
+ readonly cross_boundary_taint: "this run executes on a device outside the deployment's trust boundary and the deployment is multi-tenant, so an org-side write — or, after an off-target read, a call bound for the device — is confirmed by the deployment's approver for this call: no standing allow rule and no automatic lane answers for it, and an allow the configuration gives (a blanket seat or a policy's answer) is refused";
326
327
  };
327
328
  /** The fence over the disclosure table: `never` while every word has a row. */
328
329
  export type DisclosureTableCoversEveryMandate = AssertAllKeysHandled<Exclude<PersistedRuleMandate, keyof typeof MANDATE_DISCLOSURE>>;
@@ -331,10 +332,9 @@ export type DisclosureTableCoversEveryMandate = AssertAllKeysHandled<Exclude<Per
331
332
  export declare function mandateDisclosureOf(mandate: PersistedRuleMandate | undefined): string;
332
333
  /**
333
334
  * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
334
- * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
335
- * classifier's questions, never a mandated one, and this predicate is the mandated-family half of
336
- * that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
337
- * bit, which the org layer stamps):
335
+ * the WORD a mandated ask discloses for its marks (the class derivation reads the same marks as classes,
336
+ * and the allow layer reads the classes). Allow rules silence the classifier's questions, never a
337
+ * mandated one; this is the mark family's word for that boundary:
338
338
  * · `probeMandated` — this CALL's own reversibility probe declared its demotion STRUCTURAL
339
339
  * (`"probe_mandate"`), judged FIRST because it is the only per-call member here: the three below
340
340
  * are properties of the TOOL and are true of every call on the seat, so a mandate that is true of
@@ -355,10 +355,11 @@ export declare function mandateDisclosureOf(mandate: PersistedRuleMandate | unde
355
355
  * in {@link irreversibilityMandateOf} which {@link safetyAxisOf} shares: such a write is not an irreversible action, and
356
356
  * its park stays budgetable.
357
357
  *
358
- * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as outranked
359
- * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
360
- * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
361
- * two would let a card offer a rule the lane then refuses to honor. The per-call member reaches both
358
+ * Two consumers, one derivation: the ask's stamped `mandated` / `mandate` members and the runner's suggestion factory (a
359
+ * mandated ask offers no "stop asking me this" option — a rule minted from it would never clear it). Every word here is also a
360
+ * class the allow layer reads, so an ask this derivation mandates is never cleared by a rule; the converse does not hold — a
361
+ * class with no word here (a caller policy's own guard on an ask without the real-approval bit) leaves the ask unmandated, and its
362
+ * card can offer a rule the allow layer then discloses as outranked. The per-call member reaches both
362
363
  * the same way every other per-call fact does: the gate stamps it on the surviving ask, the
363
364
  * synchronous sites read it off the decision they spread, and the park leg threads its own parameter.
364
365
  */
@@ -465,8 +466,10 @@ export type ToolMarkSource = "deployment" | "mcp_annotation" | "a2a_default";
465
466
  * `requires_user_interaction` is the SERVER's declaration on the called tool (`_meta["anthropic/requiresUserInteraction"] === true`): a
466
467
  * permission question, unlike `content_question` (the reserved question tool's content-routing signal) — it carries that class's arbitration
467
468
  * row cell by cell, because the reference implementation judges the declaration ahead of its allow-rule lookup and its bypass release.
469
+ * `cross_boundary` is an ask carrying `crossBoundaryMandated` (the gate's cross-boundary station mints it with the real-approval bit; the
470
+ * decision receipt adds the bit to a policy's or hook's own): no rule, classifier, sandbox or mode answers it.
468
471
  * `unstated` is the mode's answer for a call nobody stated: a tool whose permission seat is `passthrough` (it states no
469
- * permission of its own — a remote peer's tool) reached the end of the fold with an allow no layer stated.
472
+ * permission of its own — a remote peer's tool) reached the end of the fold with an allow no answer covers.
470
473
  * `classifier_no_ruling` is a classifier station's question to a person because the auto-mode classification did not rule
471
474
  * (`classifierNoRuling` — it could not run, or replied with no verdict): the denial-limit fallback's row (only a person
472
475
  * answers it; the classifier is never asked again).
@@ -480,7 +483,7 @@ export type ToolMarkSource = "deployment" | "mcp_annotation" | "a2a_default";
480
483
  * `rule_unreadable` is a rule that may govern the call and cannot be read for it: the organization's snapshot, a partition of
481
484
  * the person's store, or this call against a deny / ask row (the lexer's word, a content row, the read station's rows).
482
485
  */
483
- export declare const ASK_CLASSES: readonly ["content_question", "hook", "ancestor", "dangerous_delete", "unverifiable_delete", "unverifiable_shell_text", "transcript_write", "org_rule", "rule_unreadable", "ask_rule", "denial_limit_fallback", "classifier_no_ruling", "requires_user_interaction", "operator_forced", "shell_floor", "target_unresolved", "probe", "write_protection", "tool_marks_mcp", "tool_marks_a2a", "policy_rra", "artifact_shared_read", "mode", "unstated", "ordinary"];
486
+ export declare const ASK_CLASSES: readonly ["content_question", "hook", "ancestor", "dangerous_delete", "unverifiable_delete", "unverifiable_shell_text", "transcript_write", "org_rule", "rule_unreadable", "ask_rule", "denial_limit_fallback", "classifier_no_ruling", "requires_user_interaction", "cross_boundary", "operator_forced", "shell_floor", "target_unresolved", "probe", "write_protection", "tool_marks_mcp", "tool_marks_a2a", "policy_rra", "artifact_shared_read", "mode", "unstated", "ordinary"];
484
487
  export type AskClass = (typeof ASK_CLASSES)[number];
485
488
  /** Membership test for {@link AskClass} — the word crosses the wire on `tool_end.gate.disposition`. */
486
489
  export declare function isAskClass(v: unknown): v is AskClass;
@@ -504,7 +507,8 @@ export interface ModeArbitrationRow {
504
507
  * The mode × class table. `bypassImmune` is the one new column (nothing said it before); the classifier and
505
508
  * sandbox columns are the fold of the two former per-word lane tables (only `probe` moved: the classifier may
506
509
  * now answer it — the read boundary's unestablishable spelling is fed to it as a deny-table row instead), and
507
- * `ruleMayClear` restates {@link ruleClearanceOf}'s conclusion per class (a real-approval bit is never rule-clearable).
510
+ * `ruleMayClear` is the allow layer's ONE reading: an allow member clears a standing ask exactly when the folded row says so
511
+ * (every class a real-approval bit, a governance verdict, a hook, a mark or an ask rule brings says no).
508
512
  * `probe`'s `classifierEligible` is the OPEN read face's reading: a narrowed (`roots`) face withholds the classifier from the class,
509
513
  * folded where the ask's row is ({@link foldArbitrationRow}).
510
514
  * `mode` is the permission mode's OWN question (`plan`'s two sentences, `decisionReason: "mode"`): the reference
@@ -610,6 +614,13 @@ export declare const MODE_ARBITRATION: {
610
614
  readonly ruleMayClear: false;
611
615
  readonly hookAskFloor: true;
612
616
  };
617
+ readonly cross_boundary: {
618
+ readonly bypassImmune: true;
619
+ readonly classifierEligible: false;
620
+ readonly sandboxAdmission: false;
621
+ readonly ruleMayClear: false;
622
+ readonly hookAskFloor: false;
623
+ };
613
624
  readonly operator_forced: {
614
625
  readonly bypassImmune: true;
615
626
  readonly classifierEligible: true;
@@ -751,6 +762,9 @@ export interface AskClassification {
751
762
  readonly classes: ReadonlySet<AskClass>;
752
763
  readonly askClass: AskClass;
753
764
  readonly row: ModeArbitrationRow;
765
+ /** The tool face the classification was derived on ({@link AskClassFacts.askFace}); absent ⇒ the face-less reading, the `other`
766
+ * face's. The mode point and the headless remedy read the face here and nowhere else ({@link bypassImmuneOf}). */
767
+ readonly askFace?: AskFace;
754
768
  }
755
769
  /** The two members an ask's classification puts on the approval request and the park row: the disclosed word and
756
770
  * the whole set, the set as an array in {@link ASK_CLASSES} order (so `classes[0] === askClass`). */
@@ -859,14 +873,16 @@ export type Arbitration = {
859
873
  * The ONE mode read point. `dontAsk` refuses every ask — one value for every class — but a hook's that no member of the
860
874
  * set floors (`hookAskFloor`: a hook's ask beside a rule's or a safety check's ask is refused, as the reference's full
861
875
  * pipeline refuses it; a hook's ask alone is the reference's forced decision, which never reaches that conversion);
862
- * `bypassPermissions` refuses a write whose target's reading could not say where it leads where that class stays immune
863
- * (every face but the shell's: a person cannot be asked — the folded row says it, {@link foldArbitrationRow}), keeps every
864
- * immune class and releases the rest: the shell face releases that class ({@link RELEASED_ON_SHELL_FACE}), the reference
865
- * implementation's answer for a command whose written target could not be resolved;
876
+ * `bypassPermissions` refuses a write whose target's reading could not say where it leads on a face where THAT class stays
877
+ * immune (every face but the shell's: a person cannot be asked — the class's own reading on the face the classification records,
878
+ * {@link bypassImmuneOn}; another immune class beside it does not make it immune), keeps every immune class and releases the
879
+ * rest: the shell face releases that class ({@link RELEASED_ON_SHELL_FACE}), the reference implementation's answer for a
880
+ * command whose written target could not be resolved — so on that face an immune class beside it keeps the question;
866
881
  * `acceptEdits` answers an ORDINARY ask itself on a call the caller vouches is one of the mode's file commands
867
882
  * (`call.fileCommand`, `accepted`); `plan`, `default` and `auto` hand every ask on (plan's own questions were raised by
868
883
  * its write gate; the reference implementation refuses nothing at this point under plan — auto's classifier reads the
869
- * row at its own station). Reads the class SET and the folded row — never the disclosed word.
884
+ * row at its own station). Reads the class SET on the face the classification records ({@link bypassImmuneOf}) and the folded
885
+ * row's face-free `hookAskFloor` — never the row's `bypassImmune`, never the disclosed word.
870
886
  */
871
887
  export declare function arbitrateByMode(mode: PermissionMode, c: AskClassification, disclosure?: {
872
888
  origin?: AskOrigin;
@@ -25,7 +25,7 @@ export function stampAskOrigin(decision, facts, classification) {
25
25
  }
26
26
  export const RULE_OFFERS_ABSENCES = Object.freeze(["mandated", "allow_outranked", "ask_rule_matched", "lane_cannot_speak"]);
27
27
  export function ruleClearanceOf(ask, marks) {
28
- const marksWord = persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
28
+ const marksWord = ask?.crossBoundaryMandated === true ? "cross_boundary_taint" : persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
29
29
  const foldedDoor = (ask?.foldedAskClasses ?? []).some((c) => !MODE_ARBITRATION[c].ruleMayClear);
30
30
  const doorOutsideTable = ask?.requiresRealApproval === true || ask?.decisionReason === "hook" || ask?.decisionReason === "mode" || foldedDoor || ask?.authoredBy === "deployment" || ask?.inheritedUnresolved === true || ask?.ancestorResolved === true;
31
31
  const mandated = ask?.mandated === true || doorOutsideTable || marksWord !== undefined;
@@ -50,7 +50,7 @@ export function ruleClearanceOf(ask, marks) {
50
50
  return "mandated";
51
51
  return mandated ? "mandated" : undefined;
52
52
  })();
53
- return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined, ...(mandate !== undefined ? { mandate } : {}) };
53
+ return { ...(word !== undefined ? { word } : {}), mandated, ...(mandate !== undefined ? { mandate } : {}) };
54
54
  }
55
55
  export function askMandateMember(ask) {
56
56
  const { mandated, mandate } = ruleClearanceOf(ask, {});
@@ -58,8 +58,9 @@ export function askMandateMember(ask) {
58
58
  return mandated ? { mandated: true, ...(mandate !== undefined ? { mandate } : {}), ...authored } : authored;
59
59
  }
60
60
  export function ownedClearanceFacts(d) {
61
- const { mandated, requiresRealApproval, decisionReason, matchedAskRule, probeMandated, authoredBy, foldedAskClasses } = d;
61
+ const { mandated, requiresRealApproval, decisionReason, matchedAskRule, probeMandated, crossBoundaryMandated, authoredBy, foldedAskClasses } = d;
62
62
  return {
63
+ ...(crossBoundaryMandated === true ? { crossBoundaryMandated } : {}),
63
64
  ...(mandated === true ? { mandated } : {}),
64
65
  ...(Array.isArray(foldedAskClasses) && foldedAskClasses.length > 0 ? { foldedAskClasses: [...foldedAskClasses] } : {}),
65
66
  ...(authoredBy === "deployment" ? { authoredBy } : {}),
@@ -106,7 +107,7 @@ export const ORIGIN_IMPLIES_REAL_APPROVAL = {
106
107
  safety_tighten: false,
107
108
  policy: false,
108
109
  };
109
- export const PERSISTED_RULE_MANDATES = Object.freeze(["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "target_unresolved"]);
110
+ export const PERSISTED_RULE_MANDATES = Object.freeze(["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "target_unresolved", "cross_boundary_taint"]);
110
111
  const PERSISTED_RULE_MANDATE_SET = new Set(PERSISTED_RULE_MANDATES);
111
112
  export function isPersistedRuleMandate(v) {
112
113
  return PERSISTED_RULE_MANDATE_SET.has(v);
@@ -118,6 +119,7 @@ const MANDATE_DISCLOSURE = {
118
119
  tool_marks: "this tool carries egress/irreversibility/interaction marks (a mandated confirmation a rule cannot clear)",
119
120
  write_protection: "this write's target is on the deployment's write-protection table (the agent's own configuration is guarded one look per write) — it is cleared by confirming this call, never by a standing allow rule",
120
121
  target_unresolved: "where this write leads on disk could not be determined (a link on the way could not be examined, or the environment could not resolve the target), so whether it lands on a guarded path is unknown — it is cleared by confirming this call: no standing rule and no automatic decider answers for it, though a wired approver seat can",
122
+ cross_boundary_taint: "this run executes on a device outside the deployment's trust boundary and the deployment is multi-tenant, so an org-side write — or, after an off-target read, a call bound for the device — is confirmed by the deployment's approver for this call: no standing allow rule and no automatic lane answers for it, and an allow the configuration gives (a blanket seat or a policy's answer) is refused",
121
123
  };
122
124
  export function mandateDisclosureOf(mandate) {
123
125
  return mandate === undefined ? "an explicit ask rule matched this call (a person's or the deployment's ask-me-each-time outranks a standing allow rule)" : MANDATE_DISCLOSURE[mandate];
@@ -183,6 +185,7 @@ export const ASK_CLASSES = Object.freeze([
183
185
  "denial_limit_fallback",
184
186
  "classifier_no_ruling",
185
187
  "requires_user_interaction",
188
+ "cross_boundary",
186
189
  "operator_forced",
187
190
  "shell_floor",
188
191
  "target_unresolved",
@@ -215,6 +218,7 @@ export const MODE_ARBITRATION = {
215
218
  denial_limit_fallback: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: false },
216
219
  classifier_no_ruling: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: false },
217
220
  requires_user_interaction: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: true },
221
+ cross_boundary: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: false },
218
222
  operator_forced: { bypassImmune: true, classifierEligible: true, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: false },
219
223
  shell_floor: { bypassImmune: true, classifierEligible: true, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: false },
220
224
  target_unresolved: { bypassImmune: true, classifierEligible: false, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: true },
@@ -236,8 +240,11 @@ const RELEASED_ON_SHELL_FACE = new Set(["target_unresolved"]);
236
240
  function bypassImmuneOn(c, face) {
237
241
  return MODE_ARBITRATION[c].bypassImmune && !(face === "shell" && RELEASED_ON_SHELL_FACE.has(c));
238
242
  }
243
+ function bypassImmuneOf(c) {
244
+ return [...c.classes].some((k) => bypassImmuneOn(k, c.askFace));
245
+ }
239
246
  const CLASSIFIER_WITHHELD_ON_ROOTS = new Set(["probe"]);
240
- export const RRA_EXPLAINERS = new Set(["dangerous_delete", "unverifiable_delete", "unverifiable_shell_text", "transcript_write", "org_rule", "rule_unreadable", "denial_limit_fallback", "classifier_no_ruling", "ancestor", "artifact_shared_read", "requires_user_interaction"]);
247
+ export const RRA_EXPLAINERS = new Set(["dangerous_delete", "unverifiable_delete", "unverifiable_shell_text", "transcript_write", "org_rule", "rule_unreadable", "denial_limit_fallback", "classifier_no_ruling", "ancestor", "artifact_shared_read", "requires_user_interaction", "cross_boundary"]);
241
248
  const RELEASABLE_BY_FACE = Object.freeze({
242
249
  shell: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "shell"))),
243
250
  other: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "other"))),
@@ -280,6 +287,8 @@ export function decisionAskClassesOf(d) {
280
287
  out.add("operator_forced");
281
288
  if (d.probeMandated === true)
282
289
  out.add("probe");
290
+ if (d.crossBoundaryMandated === true)
291
+ out.add("cross_boundary");
283
292
  if (d.decisionReason === "hook")
284
293
  out.add("hook");
285
294
  if (d.decisionReason === "mode")
@@ -323,7 +332,7 @@ export function classifyAsk(d, facts) {
323
332
  classes.add("requires_user_interaction");
324
333
  if (marks.shellGated === true && marks.irreversibility === "always")
325
334
  classes.add("shell_floor");
326
- const toolMarked = marks.shellGated !== true && (marks.egress === true || marks.irreversibility === "always" || marks.irreversibility === "maybe");
335
+ const toolMarked = marks.egress === true || (marks.shellGated !== true && (marks.irreversibility === "always" || marks.irreversibility === "maybe"));
327
336
  if (toolMarked) {
328
337
  const sources = facts.toolMarkSources;
329
338
  if (sources === undefined || sources.size === 0 || sources.has("deployment"))
@@ -352,7 +361,7 @@ export function classifyAsk(d, facts) {
352
361
  if (classes.size === 0)
353
362
  classes.add("ordinary");
354
363
  const askClass = ASK_CLASSES.find((c) => classes.has(c));
355
- return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace, facts.askFace) };
364
+ return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace, facts.askFace), ...(facts.askFace !== undefined ? { askFace: facts.askFace } : {}) };
356
365
  }
357
366
  export function headlessDenialMessage(toolName, askMessage) {
358
367
  return `Permission for this tool use was denied: it requires interactive approval, and permission prompts are not available in this session (nobody can be asked to approve "${toolName}" in this run — headless auto-deny). The action was NOT performed. Do not claim it succeeded, and do not retry it in this session — report the limitation to the user, or suggest an alternative. What was requested: ${askMessage}`;
@@ -366,12 +375,12 @@ export function headlessApprovalRemedy(input) {
366
375
  const c = input.classification;
367
376
  if (input.mode === "acceptEdits" && input.unplacedOptions !== undefined && (c === undefined || (c.classes.size === 1 && c.classes.has("ordinary"))))
368
377
  return `${fileCommandOptionsSentence(input.unplacedOptions.program)} Spell the ${input.unplacedOptions.program} without option words for the mode to answer it, or ${approver}`;
369
- if (c?.row.bypassImmune === true)
378
+ if (c !== undefined && bypassImmuneOf(c))
370
379
  return `no permission mode releases this ask (${c.askClass}): ${approver}, or change the call`;
371
380
  if (input.mode === "plan" && c?.classes.has("mode") === true)
372
381
  return `the plan permission mode asks before every write and every call to a tool the engine does not define that is not read-only — leave plan mode (\`setPermissionMode\` to another mode, or approve the plan) to run it, or ${approver}`;
373
382
  if (c !== undefined && c.classes.size === 1 && c.classes.has("unstated"))
374
- return `no rule, policy or hook granted this tool, and it states no permission of its own — grant it with an allow rule (a permission rule \`allow\` for its name, or an allow-list entry), run with \`permissionMode: "bypassPermissions"\`, or ${approver}`;
383
+ return `no allow entry, stored allow rule or hook granted this tool, and it states no permission of its own — grant it with an allow entry for its name (\`RunnerDeps.allowedTools\` / \`TaskSpec.allowedTools\`), run with \`permissionMode: "bypassPermissions"\`, or ${approver}`;
375
384
  const onlyOrdinary = c === undefined || (c.classes.size === 1 && c.classes.has("ordinary"));
376
385
  if (input.coveredWrite && onlyOrdinary) {
377
386
  if (input.mode === "acceptEdits")
@@ -391,9 +400,9 @@ export function arbitrateByMode(mode, c, disclosure, call) {
391
400
  case "dontAsk":
392
401
  return has("hook") && !c.row.hookAskFloor ? { kind: "ask" } : { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
393
402
  case "bypassPermissions":
394
- if (has("target_unresolved") && c.row.bypassImmune)
403
+ if (has("target_unresolved") && bypassImmuneOn("target_unresolved", c.askFace))
395
404
  return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
396
- if (c.row.bypassImmune)
405
+ if (bypassImmuneOf(c))
397
406
  return { kind: "ask" };
398
407
  const reason = [...MODE_RELEASE_REASON_SET].find((r) => has(r));
399
408
  return {
@@ -154,7 +154,6 @@ export type QuestionOutcomeReading =
154
154
  * meant to withhold. Neither guess is safe, so neither is made.
155
155
  */
156
156
  export declare function classifyQuestionOutcome(outcome: OnQuestionOutcome): QuestionOutcomeReading;
157
- export declare function canonicalizeCapturedPlainData(node: unknown, seen?: WeakSet<object>): boolean;
158
157
  /**
159
158
  * Narrow an {@link OnQuestionOutcome} to its no-interaction arm — the ONLY value that licenses
160
159
  * re-routing a question to a later decider, so it is read strictly (the discriminant must be present
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { uuidv7 } from "../engine/session/uuid.js";
3
- import { boundInputHashOf } from "./canonical-json.js";
3
+ import { boundInputHashOf, capturedJsonOf } from "./canonical-json.js";
4
4
  import { defineTool, errorResult } from "./tools.js";
5
5
  import { delimitUntrusted, inlineUntrusted } from "./untrusted-text.js";
6
6
  import { toolFace } from "./tool-catalog-entries.js";
@@ -25,73 +25,10 @@ export function classifyQuestionOutcome(outcome) {
25
25
  return { shape: "unavailable" };
26
26
  if (!carriesAnswers)
27
27
  return { shape: "contradictory" };
28
- let captured;
29
- try {
30
- captured = structuredClone(answers);
31
- }
32
- catch {
28
+ const captured = capturedJsonOf(answers, "list");
29
+ if (!captured.ok)
33
30
  return { shape: "contradictory" };
34
- }
35
- if (!isCanonicalAnswerTree(captured))
36
- return { shape: "contradictory" };
37
- return { shape: "answered", answer: { answers: captured } };
38
- }
39
- function isCanonicalAnswerTree(answers) {
40
- return canonicalizeCapturedPlainData(answers);
41
- }
42
- function isUnknownArray(v) {
43
- return Array.isArray(v);
44
- }
45
- export function canonicalizeCapturedPlainData(node, seen = new WeakSet()) {
46
- if (node === null)
47
- return true;
48
- switch (typeof node) {
49
- case "string":
50
- case "boolean":
51
- return true;
52
- case "number":
53
- return Number.isFinite(node);
54
- case "object":
55
- break;
56
- default:
57
- return false;
58
- }
59
- const obj = node;
60
- if (seen.has(obj))
61
- return false;
62
- seen.add(obj);
63
- if (isUnknownArray(obj)) {
64
- const arr = obj;
65
- for (let i = 0; i < arr.length; i++) {
66
- if (!Object.prototype.hasOwnProperty.call(arr, i))
67
- return false;
68
- if (arr[i] === undefined)
69
- return false;
70
- if (Object.is(arr[i], -0))
71
- arr[i] = 0;
72
- if (!canonicalizeCapturedPlainData(arr[i], seen))
73
- return false;
74
- }
75
- if (Object.keys(arr).length !== arr.length)
76
- return false;
77
- }
78
- else {
79
- const proto = Object.getPrototypeOf(obj);
80
- if (proto !== Object.prototype && proto !== null)
81
- return false;
82
- const rec = obj;
83
- for (const key of Object.keys(rec)) {
84
- if (rec[key] === undefined) {
85
- delete rec[key];
86
- continue;
87
- }
88
- if (Object.is(rec[key], -0))
89
- rec[key] = 0;
90
- if (!canonicalizeCapturedPlainData(rec[key], seen))
91
- return false;
92
- }
93
- }
94
- return true;
31
+ return { shape: "answered", answer: { answers: captured.value } };
95
32
  }
96
33
  export function isQuestionUnavailable(outcome) {
97
34
  return classifyQuestionOutcome(outcome).shape === "unavailable";
@@ -48,8 +48,9 @@ export interface AskUnresolvableFacts {
48
48
  * did not take the call — it declined or failed; `false`: no park lane at all, the usual shape). */
49
49
  readonly parkLaneExisted: boolean;
50
50
  /** Present for the causes that say nobody could be asked ({@link askUnresolvableRemedySeat}): the caller-side change that
51
- * would have let the call run, as the gate minted it for the refusal's tool text (`headlessApprovalRemedy`, ask-origin.ts)
52
- * — carried verbatim, never re-composed here. */
51
+ * would have let the call run, as the gate minted it for the refusal's tool text (`headlessApprovalRemedy`, ask-origin.ts;
52
+ * for arguments no approval can be bound to, the binding's own sentence, which is that text — the remedy's approver half would
53
+ * not let them run) — carried verbatim, never re-composed here. */
53
54
  readonly remedy?: string;
54
55
  }
55
56
  /**
@@ -1,11 +1,12 @@
1
1
  import { type AgentMessage, type CompactionSettings, type Session, type ThinkingLevel } from "../internal/harness.js";
2
- import type { Message, Model, Tool } from "../internal/llm.js";
2
+ import { type Message, type Model, type Tool } from "../internal/llm.js";
3
3
  import type { ContextWindowOverlay } from "./context-settings.js";
4
4
  import type { PostCompactContext, PreCompactContext, PreCompactResult } from "./hooks.js";
5
5
  import type { TraceEvent } from "./trace.js";
6
6
  import type { Brain } from "./types.js";
7
7
  import type { TaskEvent, TaskEventIdentity } from "./task-event.js";
8
8
  import type { CompactionEffect } from "./frame-settlement.js";
9
+ import type { MintedAttachmentFrame } from "./user-attachment.js";
9
10
  /**
10
11
  * Default summarization focus, applied when a task does not provide `compaction.instructions`.
11
12
  * Addresses the common fidelity failure where summaries drop specific facts.
@@ -113,6 +114,20 @@ export interface CompactionPassStart {
113
114
  /** Engine clock, epoch ms, at the commit. */
114
115
  readonly startedAt: number;
115
116
  }
117
+ /** What a landed compaction asks its restore for ({@link MaybeCompactOptions.workingFileAttachments}). */
118
+ export interface CompactionRestoreAsk {
119
+ /** The files to re-read, most recent first — already without the ones the context the summary leaves still shows. */
120
+ readonly candidates: readonly string[];
121
+ readonly maxFiles: number;
122
+ /** What one file may deliver, in its own text (before the Read's line numbers — the unit `maxCharsPerFile` counts),
123
+ * and all of them together, in the file contents the frames send (line numbers included — the unit of the
124
+ * window's share); an image costs the engine estimate's fixed weight × `charsPerToken` in both. */
125
+ readonly perFileChars: number;
126
+ readonly totalChars: number;
127
+ readonly charsPerToken: number;
128
+ /** The reads the context the summary leaves still shows (canonical keys) — the restore dedups against them. */
129
+ readonly preserveKeys: readonly string[];
130
+ }
116
131
  export interface MaybeCompactOptions {
117
132
  /** Disclosure channel for CONTAINED host-callback failures in this scope (the RB-463
118
133
  * isolation primitive's bounded first-failure per site). Absent ⇒ failures are counted only. */
@@ -279,31 +294,26 @@ export interface MaybeCompactOptions {
279
294
  */
280
295
  forceUnderThreshold?: boolean;
281
296
  /**
282
- * Working-file attachments (LONGRUN-2, CC-compact parity review 2026-06-12): after a successful
283
- * summary, re-read up to `maxFiles` of the files the summarized region MODIFIED (from
284
- * CompactionDetails.modifiedFiles) and append their current on-disk contents to the summary text.
285
- * Why: LONGRUN-2 measured the model spending ≈2.3 extra read calls per compaction re-fetching
286
- * files it had just lost — each one a full request round-trip at post-compaction context size.
287
- * CC ships the same idea ("attachments", ≤5 files × ~5k tokens). Bounded: per-file char cap plus
288
- * a total cap that self-scales to ~15% of the model window, so small windows never drown in
289
- * attachments; the §25.2 freedTokens measurement naturally accounts for the added mass (the
290
- * anti-thrash floor stays truthful). Reader failures skip the file (best-effort, never fails the
291
- * compaction). Absent (default) = current behavior, paths-only.
292
- * Pick order (design/71 P2-① resolved the LONGRUN-2b limitation): `maxFiles` picks the most
293
- * RECENTLY touched files first (`CompactionDetails.modifiedFilesByRecency`, recency threaded
294
- * through the engine fileOps accumulator); alphabetical `modifiedFiles` remains the fallback for
295
- * details persisted before the field existed.
297
+ * The post-compaction restore: after a summary lands, re-read up to `maxFiles`
298
+ * of the files the conversation had read (or, without read tracking, modified) that the context the summary leaves
299
+ * no longer shows, and append each as an attachment frame — its own engine-note message after the compaction entry
300
+ * (summary, kept tail, restored files: the reference's post-compact order, cli296.js:249517 `hookResults`). Why: without it the model spends ≈2.3 extra read round-trips per
301
+ * compaction re-fetching what it just lost (LONGRUN-2). Bounded: a per-file cap in the file's own characters and a
302
+ * total, in the file contents the frames send (line numbers included), that self-scales to ~15% of the window; a
303
+ * file over either is a frame that names it and says it is too large, never part of its contents. The post measurement counts the frames, so the anti-thrash floor stays truthful. Absent ⇒ no restore.
304
+ * Pick order: most recently READ first (`recentlyReadFiles`), else most recently modified
305
+ * (`CompactionDetails.modifiedFilesByRecency`), else the alphabetical `modifiedFiles` of older details.
296
306
  */
297
307
  workingFileAttachments?: {
298
- /** Read a task workspace file; null/throw = skip it. Wired by the Runner from the task's ExecutionEnv. */
299
- readFile: (path: string) => Promise<string | null | {
300
- withheld: {
301
- pattern: string;
302
- };
303
- }>;
304
- /** Max modified files to attach. Default 3. */
308
+ /** Re-read the candidates and mint their frames — the engine's one read of a file for the model, deduplicated against
309
+ * the context the summary leaves (`preserveKeys` = the reads that context still shows). Side-effect free: each frame
310
+ * carries the commit its delivery owes, run here once the frame is in the transcript. Wired by the Runner. */
311
+ restore: (ask: CompactionRestoreAsk) => Promise<ReadonlyArray<MintedAttachmentFrame>>;
312
+ /** A minted restore frame the transcript would not take (not delivered, nothing committed) — the operator's line. */
313
+ undelivered?: (label: string, reason: string) => void;
314
+ /** Max files to restore. Default 3. */
305
315
  maxFiles?: number;
306
- /** Per-file char cap (≈4 chars/token). Default 16_000 (~4k tokens). */
316
+ /** Per-file cap in the file's own characters (≈4 chars/token). Default 16_000 (~4k tokens). */
307
317
  maxCharsPerFile?: number;
308
318
  /**
309
319
  * CC-parity selection set: the files the task has READ, most recent
@@ -325,9 +335,9 @@ export interface MaybeCompactOptions {
325
335
  instructionSourcePaths?: ReadonlyArray<string>;
326
336
  /**
327
337
  * `recentlyReadFiles` yields CANONICAL keys (resolveKey
328
- * output — the same coordinate `ReadFileState` is keyed in), but `instructionSourcePaths` and the
329
- * kept-tail scan inside `maybeCompact` (which reads raw tool-call arguments via
330
- * `extractFileOpsFromMessage`) do NOT share that coordinate — a model-typed relative path and its
338
+ * output — the same coordinate `ReadFileState` is keyed in, and the one an attachment frame's recorded
339
+ * read carries), but `instructionSourcePaths` and the kept tail's Read tool calls (raw model-typed
340
+ * arguments) do NOT share that coordinate — a model-typed relative path and its
331
341
  * canonical form are different strings, so a plain `Set.has()` between them silently never matches
332
342
  * even for the identical file. Optional callback (present exactly when there's a containment root
333
343
  * to resolve against — i.e. hands enabled) that canonicalizes a raw path the SAME way the hands
@@ -352,29 +362,18 @@ export interface MaybeCompactOptions {
352
362
  isDedupStubResult?: (resultText: string) => boolean;
353
363
  };
354
364
  /**
355
- * Fires ONCE per LANDED compaction (after `appendCompaction` committed the new baseline), with the
356
- * files that were attached WHOLE (untruncated) into the summary. The Runner wires this to the read-state
357
- * invalidation (`applyContextShrinkToReadFileState`): the summary just replaced the history the read-dedup
358
- * stubs point at, so stale entries must drop and the re-attached content re-registers. Kept OUTSIDE
359
- * `workingFileAttachments` deliberately — the invalidation is owed even when attachments are disabled
360
- * (an empty array then). Best-effort: a throwing callback must not fail the landed compaction.
365
+ * Fires ONCE per LANDED compaction (after `appendCompaction` committed the new baseline and the restore frames were
366
+ * appended), immediately followed by the delivered restore frames' commits — the read state's clear and the restore's
367
+ * entries land together, after the baseline; a compaction that does not land touches neither. The Runner wires this to
368
+ * the read-state invalidation (`applyContextShrinkToReadFileState`): the summary just replaced the history the
369
+ * read-dedup stubs point at. Kept OUTSIDE `workingFileAttachments` deliberately — the invalidation is owed even when
370
+ * the restore is off. Best-effort: a throwing callback must not fail the landed compaction.
361
371
  *
362
- * `preserveReadState`:
363
- * canonical-coordinate paths (same shape as `normalizePath`'s output) whose read-state entry must
364
- * survive the compaction's clear even though they are NOT in `attachedComplete` — every file
365
- * `maybeCompact` deliberately skipped re-attaching, for EITHER exclusion reason (kept-tail-visible,
366
- * or matches a declared instruction source). Without this the read-state clear (see
367
- * `applyContextShrinkToReadFileState`) would wipe them anyway, defeating both the read-before-edit gate
368
- * and the next read's dedup for exactly the files this optimization was supposed to leave untouched.
369
- * (The instruction-source half of this was missing in an earlier revision: `instructionSources` and
370
- * `seededFiles` are independent `ProjectMemoryLoad` fields — a path can be declared in the former
371
- * without ever being seeded via the latter, so an instruction-excluded file the model explicitly Read
372
- * has a live, non-seeded entry that needs the SAME preservation as a kept-tail one.)
372
+ * `preserveReadState`: canonical-coordinate paths whose entry survives the clear — every file the context the summary
373
+ * leaves still shows (a kept-tail read or attachment frame) or that a declared instruction source re-seeds; the restore
374
+ * deduplicates against the same set.
373
375
  */
374
- onApplied?: (attachedComplete: ReadonlyArray<{
375
- path: string;
376
- content: string;
377
- }>, preserveReadState?: ReadonlyArray<string>) => void;
376
+ onApplied?: (preserveReadState: ReadonlyArray<string>) => void;
378
377
  /**
379
378
  * #1036 — THE COMMIT-BEAT EFFECT SINK: what this compaction did to the transcript, reported
380
379
  * once, right after the `appendCompaction` that landed it. `summarizedEntryIds` = every entry id
@@ -444,7 +443,7 @@ export interface MaybeCompactOptions {
444
443
  * Inputs:
445
444
  * - `messagesToSummarize`: the message range that would be summarized this boundary.
446
445
  * - `previousSummary`: the prior compaction's summary (present on the UPDATE/iterative-merge path).
447
- * - `modifiedFiles`: files the summarized range modified, newest-first (same source the working-file
446
+ * - `modifiedFiles`: files the summarized range modified, newest-first (same source the restore's
448
447
  * attachments use), so a provider can decide whether the reused summary is still representative.
449
448
  */
450
449
  summaryProvider?: (input: {
@@ -621,7 +620,7 @@ export declare function maybeCompact(opts: MaybeCompactOptions): Promise<{
621
620
  * consult → clamp dry run + 门A/门B gating (everything before the summary call). `summaryMs` = the
622
621
  * summary LLM leg (`compact()`: serialization → clamp → completion, incl. any PTL retries) — ABSENT
623
622
  * on a provider-reuse pass (no LLM call happened; its provider consult time sits in `prepareMs`).
624
- * `persistMs` = working-file attachments → session rewrite (`appendCompaction`) → post measurement →
623
+ * `persistMs` = restore reads → session rewrite (`appendCompaction`) → restore frames → post measurement →
625
624
  * observers. The phases tile the pass exactly: prepareMs + (summaryMs ?? 0) + persistMs ===
626
625
  * durationMs. `ptlRetries` = reactive prompt-too-long retries burned inside this pass's summary
627
626
  * call(s) (both split-turn legs accumulate into the one counter; 0 on provider reuse). Set only when
@@ -794,7 +793,7 @@ export declare function countSummarizedCrossSessionMessages(branch: ReadonlyArra
794
793
  * 5. (COMPACTION-LIVE-AUDIT D-1, evaluated BEFORE rule 3) a TRIM-FORCED landing with
795
794
  * `freedTokens ≤ 0` → negative-yield: BACKOFF ON. The trim layer deceives the usage-anchored
796
795
  * estimate down into the sawtooth trough (live: est 18807 on a ~55k session), the forced pass
797
- * finds little compressible history, and summary + working-file attachments make the transcript
796
+ * finds little compressible history, and summary + restore frames make the transcript
798
797
  * LARGER (live: 18807→44609, 12679→35214). `freedTokens` is the structural pre/post delta
799
798
  * (clamped ≥0, so ≤0 means "freed nothing") — orthogonal to rule 2/3's threshold test, which
800
799
  * misses this shape because the bloated post (44609) can still sit UNDER the threshold (44800),