@sema-agent/core 5.21.0 → 5.22.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 (73) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +6 -3
  3. package/dist/agents/subagent.d.ts +6 -0
  4. package/dist/agents/subagent.js +45 -4
  5. package/dist/brain/errors.d.ts +20 -0
  6. package/dist/brain/errors.js +40 -0
  7. package/dist/brain/retry.d.ts +16 -2
  8. package/dist/brain/retry.js +3 -2
  9. package/dist/brain/status-sink.d.ts +9 -2
  10. package/dist/brain/stream-engine.d.ts +22 -0
  11. package/dist/brain/stream-engine.js +41 -10
  12. package/dist/core/ask-class.d.ts +48 -0
  13. package/dist/core/ask-class.js +33 -0
  14. package/dist/core/checkpoint-store.d.ts +103 -10
  15. package/dist/core/checkpoint-store.js +3 -1
  16. package/dist/core/governance-codes.d.ts +38 -0
  17. package/dist/core/governance-codes.js +11 -0
  18. package/dist/core/hooks.d.ts +39 -0
  19. package/dist/core/hooks.js +26 -2
  20. package/dist/core/locked-config.d.ts +7 -1
  21. package/dist/core/locked-config.js +2 -1
  22. package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
  23. package/dist/core/memory-engine/delegation-provenance.js +26 -0
  24. package/dist/core/memory-engine/engine.d.ts +67 -1
  25. package/dist/core/memory-engine/engine.js +270 -12
  26. package/dist/core/memory-engine/header-hints.d.ts +30 -0
  27. package/dist/core/memory-engine/header-hints.js +41 -0
  28. package/dist/core/memory-engine/index.d.ts +3 -2
  29. package/dist/core/memory-engine/index.js +3 -2
  30. package/dist/core/memory-engine/layout.d.ts +166 -0
  31. package/dist/core/memory-engine/layout.js +399 -0
  32. package/dist/core/memory-engine/tools.d.ts +30 -0
  33. package/dist/core/memory-engine/tools.js +108 -17
  34. package/dist/core/permission-rule-consent.d.ts +25 -9
  35. package/dist/core/permission-rule-consent.js +91 -20
  36. package/dist/core/permission-rule-model.d.ts +9 -1
  37. package/dist/core/permission-rule-model.js +2 -2
  38. package/dist/core/permission-rule-org.d.ts +161 -0
  39. package/dist/core/permission-rule-org.js +211 -0
  40. package/dist/core/permission-rule-store.d.ts +249 -6
  41. package/dist/core/permission-rule-store.js +313 -3
  42. package/dist/core/permission-rule-sync.d.ts +131 -0
  43. package/dist/core/permission-rule-sync.js +314 -0
  44. package/dist/core/runner/prepare-memory.js +35 -8
  45. package/dist/core/runner/prepare-task.d.ts +54 -1
  46. package/dist/core/runner/prepare-task.js +246 -27
  47. package/dist/core/runner/runtask.js +147 -6
  48. package/dist/core/shared-memory/contract.js +19 -4
  49. package/dist/core/shared-memory/normalize.d.ts +3 -1
  50. package/dist/core/shared-memory/tools.js +73 -17
  51. package/dist/core/shared-memory/types.d.ts +27 -1
  52. package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
  53. package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
  54. package/dist/core/task-notification.d.ts +5 -2
  55. package/dist/core/task-registry-agent.d.ts +1 -1
  56. package/dist/core/task-registry-agent.js +6 -2
  57. package/dist/core/task-registry-shared.d.ts +9 -2
  58. package/dist/core/task-registry.d.ts +9 -3
  59. package/dist/core/task-registry.js +2 -0
  60. package/dist/core/tool-policy.d.ts +120 -2
  61. package/dist/core/tool-policy.js +116 -6
  62. package/dist/core/trace.d.ts +32 -1
  63. package/dist/core/types.d.ts +56 -3
  64. package/dist/index.d.ts +12 -7
  65. package/dist/index.js +10 -5
  66. package/dist/stores/file/checkpoint-store.d.ts +4 -0
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
  69. package/dist/stores/file/permission-rule-adopt.js +95 -0
  70. package/dist/stores/file/permission-rule-store.d.ts +80 -2
  71. package/dist/stores/file/permission-rule-store.js +189 -46
  72. package/dist/tools/fs/fs-search-tools.js +0 -1
  73. package/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert";
2
+ import { collectBelowFrontier, joinRuleStates, ruleSyncVector } from "../permission-rule-store.js";
3
+ import { beginContract } from "./contract-harness.js";
4
+ const GLOBAL = { kind: "global" };
5
+ function add(actor, counter, origin = "user") {
6
+ return { dot: { actor, counter }, origin, createdAt: "2026-01-01T00:00:00.000Z" };
7
+ }
8
+ function rule(text, adds, scope = GLOBAL) {
9
+ const m = /^Bash\((.+?)(:\*)?\)$/.exec(text);
10
+ if (m === null || m[1] === undefined)
11
+ throw new Error(`contract fixture rule "${text}" is not a Bash(...) form`);
12
+ return { rule: text, tool: "Bash", match: m[2] !== undefined ? "prefix" : "exact", command: m[1], scope, adds };
13
+ }
14
+ function tomb(text, removed, deletedBy, scope = GLOBAL) {
15
+ return {
16
+ rule: text,
17
+ scope,
18
+ removedDots: removed.map(([actor, counter]) => ({ actor, counter })),
19
+ deletedBy: { actor: deletedBy[0], counter: deletedBy[1] },
20
+ };
21
+ }
22
+ function state(rules = [], tombstones = []) {
23
+ return { rules, tombstones };
24
+ }
25
+ function liveDots(s) {
26
+ const out = new Set();
27
+ for (const r of s.rules) {
28
+ const removed = s.tombstones.filter((t) => t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
29
+ for (const a of r.adds) {
30
+ if (!removed.some((d) => d.actor === a.dot.actor && d.counter === a.dot.counter))
31
+ out.add(`${r.rule}|${a.dot.actor}#${a.dot.counter}`);
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ function sameScope(a, b) {
37
+ return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
38
+ }
39
+ function allDots(s) {
40
+ return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
41
+ }
42
+ export async function permissionRuleSyncContract(hooks = {}) {
43
+ const join = hooks.join ?? joinRuleStates;
44
+ const collect = hooks.collect ?? collectBelowFrontier;
45
+ const vector = hooks.vector ?? ruleSyncVector;
46
+ const { run, settle } = beginContract(hooks.runAssertion);
47
+ const samples = () => [
48
+ state(),
49
+ state([rule("Bash(ls)", [add("a", 1)])]),
50
+ state([rule("Bash(ls)", [add("b", 1)]), rule("Bash(git status)", [add("b", 2)])]),
51
+ state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]),
52
+ state([rule("Bash(pwd)", [add("c", 5)], { kind: "project", root: "/repo" })], [tomb("Bash(date)", [["d", 9]], ["c", 6])]),
53
+ ];
54
+ run("join is idempotent: a ⊔ a = a (M11)", async () => {
55
+ for (const s of samples())
56
+ assert.deepStrictEqual(join(s, s), join(s, join(s, s)), "a ⊔ a must equal (a ⊔ a) ⊔ a");
57
+ for (const s of samples())
58
+ assert.deepStrictEqual(liveDots(join(s, s)), liveDots(s), "a ⊔ a must not change the live view");
59
+ });
60
+ run("join is commutative: a ⊔ b = b ⊔ a", async () => {
61
+ const pool = samples();
62
+ for (const a of pool)
63
+ for (const b of pool)
64
+ assert.deepStrictEqual(join(a, b), join(b, a));
65
+ });
66
+ run("join is associative and order/repetition independent (M12)", async () => {
67
+ const [, a, b, c] = samples();
68
+ assert.ok(a && b && c);
69
+ const one = join(join(a, b), c);
70
+ const two = join(a, join(b, c));
71
+ const three = join(join(c, a), join(b, a));
72
+ assert.deepStrictEqual(one, two);
73
+ assert.deepStrictEqual(one, three);
74
+ });
75
+ run("M1: a single-sided add propagates", async () => {
76
+ const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state());
77
+ assert.ok(liveDots(merged).has("Bash(ls)|a#1"));
78
+ });
79
+ run("M2: a multi-dot delete covers every observed dot", async () => {
80
+ const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 2)])], [tomb("Bash(ls)", [["a", 1], ["a", 2]], ["a", 3])]), state());
81
+ assert.strictEqual(liveDots(merged).size, 0, "both observed dots must be dead");
82
+ assert.strictEqual(merged.tombstones.length, 1);
83
+ assert.strictEqual(merged.tombstones[0]?.removedDots.length, 2, "the tombstone must carry BOTH observed dots");
84
+ });
85
+ run("M3: concurrent approvals of the same text coexist under their own dots", async () => {
86
+ const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state([rule("Bash(ls)", [add("b", 1)])]));
87
+ assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|a#1", "Bash(ls)|b#1"]));
88
+ assert.strictEqual(merged.rules.length, 1, "one logical rule, two adds — not two rule rows");
89
+ });
90
+ run("M4: add-wins — an add the deleter never observed survives the tombstone", async () => {
91
+ const merged = join(state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7)])]));
92
+ assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|b#7"]), "dot a#1 dead, unobserved b#7 alive");
93
+ });
94
+ run("M5: deletes on both sides union to cover everything", async () => {
95
+ const merged = join(state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7)])], [tomb("Bash(ls)", [["b", 7]], ["b", 8])]));
96
+ assert.strictEqual(liveDots(merged).size, 0);
97
+ });
98
+ run("M6: a deliberate re-add under a NEW dot survives the old tombstone", async () => {
99
+ const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 3)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state());
100
+ assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|a#3"]));
101
+ });
102
+ run("M7: a double delete is idempotent — distinct deletedBy tombstones both stand, semantics equal", async () => {
103
+ const a = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]);
104
+ const b = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["b", 5])]);
105
+ const merged = join(a, b);
106
+ assert.strictEqual(merged.tombstones.length, 2, "two delete intents, two identities — neither is dropped");
107
+ assert.strictEqual(liveDots(merged).size, 0);
108
+ assert.deepStrictEqual(join(a, b), join(b, a));
109
+ });
110
+ run("M8: partially-observed deletes union to full coverage — each tombstone honest about its own view", async () => {
111
+ const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 2)])], [tomb("Bash(ls)", [["a", 1], ["a", 2]], ["a", 3])]), state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["b", 1])]));
112
+ assert.strictEqual(liveDots(merged).size, 0, "the union of observations must cover both dots");
113
+ });
114
+ run("M9: provenance rides each add and follows the surviving dot", async () => {
115
+ const merged = join(state([rule("Bash(ls)", [add("a", 1, "imported-cc")])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7, "user")])]));
116
+ const survivor = merged.rules.find((r) => r.rule === "Bash(ls)");
117
+ assert.ok(survivor);
118
+ const liveAdds = survivor.adds.filter((x) => !merged.tombstones.some((t) => t.removedDots.some((d) => d.actor === x.dot.actor && d.counter === x.dot.counter)));
119
+ assert.strictEqual(liveAdds.length, 1);
120
+ assert.strictEqual(liveAdds[0]?.origin, "user", "the display origin must be the SURVIVING add's own, not a folded one");
121
+ });
122
+ run("M10: replaying the same dot is deduplicated by identity", async () => {
123
+ const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state([rule("Bash(ls)", [add("a", 1)])]));
124
+ assert.strictEqual(merged.rules[0]?.adds.length, 1);
125
+ });
126
+ run("M13: a tombstone whose (rule, scope) is locally absent is kept — arriving before its add is legal", async () => {
127
+ const merged = join(state(), state([], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]));
128
+ assert.strictEqual(merged.tombstones.length, 1);
129
+ const then = join(merged, state([rule("Bash(ls)", [add("a", 1), add("a", 4)])]));
130
+ assert.deepStrictEqual(liveDots(then), new Set(["Bash(ls)|a#4"]), "the pre-arrived tombstone must cover a#1 when it lands");
131
+ });
132
+ run("tombstone-union (r1-F3): same identity, differing removedDots ⇒ the union, both orders", async () => {
133
+ const a = state([], [tomb("Bash(ls)", [["a", 1]], ["a", 9])]);
134
+ const b = state([], [tomb("Bash(ls)", [["a", 2]], ["a", 9])]);
135
+ const ab = join(a, b);
136
+ assert.strictEqual(ab.tombstones.length, 1, "same (rule, scope, deletedBy) is ONE identity");
137
+ assert.deepStrictEqual(new Set(ab.tombstones[0]?.removedDots.map((d) => `${d.actor}#${d.counter}`)), new Set(["a#1", "a#2"]), "the union — a one-sided pick can drop an observed removal");
138
+ assert.deepStrictEqual(ab, join(b, a));
139
+ });
140
+ run("metadata re-projection (r2-F2): a record whose stored fields contradict its rule text is refused, both orders", async () => {
141
+ const forged = { rule: "Bash(ls)", tool: "Bash", match: "prefix", command: "rm -rf /", scope: GLOBAL, adds: [add("z", 1)] };
142
+ const clean = state([rule("Bash(git status)", [add("a", 1)])]);
143
+ const ab = join(state([forged]), clean);
144
+ const ba = join(clean, state([forged]));
145
+ assert.ok(!allDots(ab).has("Bash(ls)|z#1"), "the forged record must not survive the join");
146
+ assert.deepStrictEqual(ab, ba, "refusal must preserve commutativity");
147
+ assert.ok(allDots(ab).has("Bash(git status)|a#1"), "refusing one record must not disturb the rest");
148
+ });
149
+ run("single-validator door: an illegal rule shape (bare-interpreter prefix) cannot ride a join", async () => {
150
+ const merged = join(state([rule("Bash(node:*)", [add("z", 1)])]), state());
151
+ assert.strictEqual(allDots(merged).size, 0, "Bash(node:*) must be refused by the shared validator inside the join");
152
+ });
153
+ run("identity keys are collision-free: roots and actors containing spaces keep DISTINCT tombstone identities, and the covered add stays dead through an identity join", async () => {
154
+ const scopeA = { kind: "project", root: "/x" };
155
+ const scopeB = { kind: "project", root: "/x a" };
156
+ const s = state([rule("Bash(ls)", [add("v", 1)], scopeA), rule("Bash(ls)", [add("w", 1)], scopeB)], [
157
+ { rule: "Bash(ls)", scope: scopeA, removedDots: [{ actor: "v", counter: 1 }], deletedBy: { actor: "a 1", counter: 2 } },
158
+ { rule: "Bash(ls)", scope: scopeB, removedDots: [{ actor: "w", counter: 1 }], deletedBy: { actor: "1", counter: 2 } },
159
+ ]);
160
+ const merged = join(s, state());
161
+ assert.strictEqual(merged.tombstones.length, 2, "two distinct delete intents must both survive — a colliding key folds one away");
162
+ assert.strictEqual(liveDots(merged).size, 0, "both covered adds must stay dead");
163
+ assert.deepStrictEqual(join(merged, merged), merged, "idempotence must hold with space-carrying roots/actors");
164
+ });
165
+ run("state vector: max over add dots ∪ deletedBy ∪ removedDots (§5.1)", async () => {
166
+ const v = vector(state([rule("Bash(ls)", [add("a", 3)])], [tomb("Bash(git status)", [["a", 7], ["b", 2]], ["c", 4])]));
167
+ assert.deepStrictEqual(v, { a: 7, b: 2, c: 4 });
168
+ });
169
+ run("paired collection: a fully-below tombstone leaves WITH its covered adds; a partial one stays (§5.1)", async () => {
170
+ const s = state([rule("Bash(ls)", [add("a", 1)]), rule("Bash(pwd)", [add("a", 2), add("b", 9)])], [tomb("Bash(ls)", [["a", 1]], ["a", 3]), tomb("Bash(pwd)", [["a", 2], ["b", 9]], ["a", 4])]);
171
+ const { state: out, collectedTombstones } = collect(s, { a: 5 });
172
+ assert.strictEqual(collectedTombstones, 1, "only the fully-below pair is collectable (b#9 is above the frontier)");
173
+ assert.ok(!allDots(out).has("Bash(ls)|a#1"), "the covered add leaves WITH its tombstone — never one without the other");
174
+ assert.ok(allDots(out).has("Bash(pwd)|b#9"), "an above-frontier dot must never be collected");
175
+ assert.strictEqual(out.tombstones.length, 1, "the partially-below tombstone stays");
176
+ assert.deepStrictEqual(liveDots(out), liveDots(s));
177
+ });
178
+ run("collection + rejoin does not resurrect: the recycled pair stays gone through further joins", async () => {
179
+ const s = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]);
180
+ const { state: collected } = collect(s, { a: 2 });
181
+ const rejoined = join(collected, collected);
182
+ assert.strictEqual(liveDots(rejoined).size, 0);
183
+ assert.strictEqual(allDots(rejoined).size, 0, "neither the add nor the tombstone may reappear from a self-join");
184
+ });
185
+ await settle();
186
+ }
@@ -82,8 +82,11 @@ export interface TaskNotificationPayload {
82
82
  recentSteps?: import("../agents/subagent-steps.js").SubagentStep[];
83
83
  /** Residual lane D (core half) — files the child mutated with edit counts. Background-agent lane only. */
84
84
  editedFiles?: import("../agents/subagent-steps.js").SubagentEditedFile[];
85
- /** Residual lane E — `true` when the child can be revived via SendMessage (retained + not user-stopped).
86
- * Lets the parent decide continue-vs-restart without trial-and-error. Background-agent lane only. */
85
+ /** Residual lane E — `true` when the child can be revived via SendMessage: a live retained session that
86
+ * was not killed, OR a NAMED durable row that no USER stop closed (a parent teardown / reap / host
87
+ * death is what the durable revival lane recovers from). Judged by the revival ladder's own
88
+ * preconditions, so the claim and the next SendMessage agree. Lets the parent decide
89
+ * continue-vs-restart without trial-and-error. Background-agent lane only. */
87
90
  resumable?: boolean;
88
91
  /** P1-3(黑板 [1920]/[1921]/[1924]/[1925], cli/server 商定项) — a cross-channel correlation key for
89
92
  * ONE completion event. The same task's completion fans out across several independent read faces
@@ -311,7 +311,7 @@ export declare function reviveBackgroundAgentLane(core: DurableAgentCore, id: st
311
311
  cycle: number;
312
312
  } | {
313
313
  ok: false;
314
- reason: "not_found" | "still_running";
314
+ reason: "not_found" | "still_running" | "recycling";
315
315
  }>;
316
316
  /** S2b RB-27② — settle a REVIVED cycle (cycle-stamped: a stale cycle's late settle is a no-op so
317
317
  * it can never flip a newer revived cycle back to terminal / clear its channel). */
@@ -216,6 +216,7 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
216
216
  continue;
217
217
  }
218
218
  rowsReaped++;
219
+ core.reapedHandles.add(r.handle);
219
220
  if (deps.mailbox !== undefined) {
220
221
  try {
221
222
  await deps.mailbox.drop(scope, r.handle);
@@ -235,6 +236,7 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
235
236
  }
236
237
  finally {
237
238
  core.reapingHandles.delete(r.handle);
239
+ core.reapedHandles.delete(r.handle);
238
240
  }
239
241
  }
240
242
  return { rowsReaped, sessionsReleased, skippedNoSessions };
@@ -919,11 +921,13 @@ async function rollbackRevivalClaim(store, claim) {
919
921
  }
920
922
  }
921
923
  export async function reviveBackgroundAgentLane(core, id, access, abort) {
922
- if (core.reapingHandles.has(id) || core.claimingHandles.has(id))
923
- return { ok: false, reason: "not_found" };
924
924
  const handle = core.handles.get(id);
925
925
  if (!handle || handle.type !== "background_agent" || !canAccess(handle, access))
926
926
  return { ok: false, reason: "not_found" };
927
+ if (core.reapedHandles.has(id))
928
+ return { ok: false, reason: "not_found" };
929
+ if (core.reapingHandles.has(id) || core.claimingHandles.has(id))
930
+ return { ok: false, reason: "recycling" };
927
931
  if (handle.status === "running" || handle.status === "pending")
928
932
  return { ok: false, reason: "still_running" };
929
933
  if (handle.status === "parked")
@@ -773,8 +773,9 @@ export declare function closestName(query: string, candidates: Iterable<string>)
773
773
  * missing axis here is a broken invariant, answered with denial, never a pass. */
774
774
  export declare function canAccess(handle: SemaTaskHandle, access: TaskAccess): boolean;
775
775
  /** design/157 B13 二期(B13 档预调研 §4.2/§4.3 定稿)— the durable-agent lane's widened core seam.
776
- * Extends {@link RegistryCore} with EXACTLY the 5 extra members the AGENT lane's `this.*` census
777
- * reached (73 处/9 成员,扣车道内互调后落在这 5 个上,见 B13 档「二期细察补充」)。
776
+ * Extends {@link RegistryCore} with the extra members the AGENT lane's `this.*` census reached
777
+ * (73 处/9 成员,扣车道内互调后落在 5 个上,见 B13 档「二期细察补充」;`reapedHandles` 是后加的
778
+ * 第 6 个——reap 窗口的后半段,见其自身注释)。
778
779
  * `durableAgentWrite`/`ensureDurableHeartbeat`/`settleBackgroundAgent` are NOT here — they are lane
779
780
  * members themselves (车道内直接函数调用;类上只留转发或不留)。The class satisfies this via the
780
781
  * same cached-view field, upgraded with lazy getter/setter proxies (an IIFE capturing `this`) so
@@ -783,6 +784,12 @@ export declare function canAccess(handle: SemaTaskHandle, access: TaskAccess): b
783
784
  export interface DurableAgentCore extends RegistryCore {
784
785
  readonly writerId: string;
785
786
  readonly reapingHandles: Set<string>;
787
+ /** The SECOND half of the reap window: ids whose durable row the sweep has already DELETED under its
788
+ * rev guard, still inside the lifecycle fence while the sweep drops the mailbox and releases the
789
+ * session. The fence must hold across that cleanup, but the row is gone from the moment the delete
790
+ * wins, and the two halves owe a caller opposite answers — "this clears on its own, send again"
791
+ * versus "it is gone, relaunch". A subset of `reapingHandles`, cleared with it. */
792
+ readonly reapedHandles: Set<string>;
786
793
  readonly claimingHandles: Set<string>;
787
794
  /** get/set proxy — `ensureDurableHeartbeatLane` both reads AND writes the timer slot. */
788
795
  durableHeartbeatTimer: ReturnType<typeof setInterval> | undefined;
@@ -273,7 +273,7 @@ export declare class TaskRegistry {
273
273
  cycle: number;
274
274
  } | {
275
275
  ok: false;
276
- reason: "not_found" | "still_running";
276
+ reason: "not_found" | "still_running" | "recycling";
277
277
  }>;
278
278
  settleRevivedAgent(id: string, cycle: number, outcome: {
279
279
  status: "completed" | "failed" | "killed";
@@ -324,15 +324,21 @@ export declare class TaskRegistry {
324
324
  * the session is released under an ADMITTED resume, and the revive's queued write poisons on
325
325
  * "gone"). Membership is synchronous on both sides, with three readers (the `claimingHandles` twin
326
326
  * below carries the same fence set): reap marks BEFORE its terminal check, a revival lane refuses
327
- * while marked (the row is being recycled — not_found is the honest answer), and the terminal-handle
327
+ * while marked (the row is being recycled), and the terminal-handle
328
328
  * GC skips a marked id instead of evicting it. */
329
329
  private reapingHandles;
330
+ /** The post-delete half of the window above (see {@link DurableAgentCore.reapedHandles}): the sweep's
331
+ * guarded delete has WON, and the fence is still held while the mailbox is dropped and the session
332
+ * released. The fence's readers are unchanged — this only splits what a refused revival is TOLD,
333
+ * since a caller advised to retry a row that no longer exists would retry forever. */
334
+ private reapedHandles;
330
335
  /** design/151 §7.3 (F-8) — handles a revival lane is CURRENTLY claiming (between the claim-CAS
331
336
  * decision and the revive registration/rollback). BOTH lanes are writers: the tier-3 SendMessage
332
337
  * claim, and (since the retained revive started claiming its own row) the in-process retained-revive
333
338
  * path, which holds the mark across its store round trip. Synchronous membership on both sides, the
334
339
  * `reapingHandles` twin, with three readers: the reap sweep skips a mid-claim row (its rev is about
335
- * to move), the sibling revival lane refuses one (`reviveBackgroundAgent` not_found), and the
340
+ * to move), the sibling revival lane refuses one (`reviveBackgroundAgent` recycling a transient
341
+ * hold, told apart from a row that is actually gone), and the
336
342
  * terminal-handle GC skips it — so the revival lanes, the reaper and the GC can never adjudicate the
337
343
  * same handle concurrently in-process. */
338
344
  private claimingHandles;
@@ -116,6 +116,7 @@ export class TaskRegistry {
116
116
  pokeBgQuiescence: (owner) => self.pokeBgQuiescence(owner),
117
117
  get writerId() { return self.writerId; },
118
118
  get reapingHandles() { return self.reapingHandles; },
119
+ get reapedHandles() { return self.reapedHandles; },
119
120
  get claimingHandles() { return self.claimingHandles; },
120
121
  get durableHeartbeatTimer() { return self.durableHeartbeatTimer; },
121
122
  set durableHeartbeatTimer(v) { self.durableHeartbeatTimer = v; },
@@ -233,6 +234,7 @@ export class TaskRegistry {
233
234
  }
234
235
  writerId = randomBytes(8).toString("hex");
235
236
  reapingHandles = new Set();
237
+ reapedHandles = new Set();
236
238
  claimingHandles = new Set();
237
239
  durableHeartbeatTimer;
238
240
  mintTaskId(type) {
@@ -44,6 +44,12 @@ export interface ToolCallRequest {
44
44
  * when a policy is invoked directly (outside a Runner). A NORMAL-ask escalation policy reads it; a SAFETY
45
45
  * ask (egress / irreversible) ignores it and ALWAYS asks (invariant #2 stays structural — core mints
46
46
  * `irreversible_ask` for safety tools regardless of any budget).
47
+ *
48
+ * ⚖️ SCOPE (A-005.13, ruled 2026-08-09): this snapshot is PER-RUN — it is the ledger of the run whose
49
+ * gate is asking, NEVER the ledger of the run that authored the policy. A policy inherited down the
50
+ * delegation chain (`InheritedGate.parentConstraints`) therefore self-limits against each CHILD's own
51
+ * fresh ledger; the ancestor's counts do not travel (declared design — see the chain field's A-005.13
52
+ * note for the reasoning and the closure-state alternative).
47
53
  */
48
54
  budget?: {
49
55
  /**
@@ -60,8 +66,11 @@ export interface ToolCallRequest {
60
66
  suspendCount: number;
61
67
  };
62
68
  }
63
- /** Where a decision came from, for audit (design/37). */
64
- export type DecisionReason = "rule" | "mode" | "hook" | "safety" | "classifier" | "persisted_rule";
69
+ /** Where a decision came from, for audit (design/37). `"sandbox"` (F-012 L2) marks an allow the
70
+ * sandbox-admission leg resolved: the deployment declared an isolated execution env, every surviving
71
+ * ask on the call was engine-classified sandbox-local, and the call crosses no declared boundary —
72
+ * disclosed durably as `permission.sandbox_admitted`. */
73
+ export type DecisionReason = "rule" | "mode" | "hook" | "safety" | "classifier" | "persisted_rule" | "sandbox";
65
74
  /**
66
75
  * WHO (or what) ENDED an approval — the machine-readable twin of a settlement's human-readable text,
67
76
  * so a consumer tells "a person decided this" from "nobody answered" without prose-matching a sentence.
@@ -164,7 +173,116 @@ export declare function decisionText(d: PermissionResult): string | undefined;
164
173
  */
165
174
  export interface ToolPolicy {
166
175
  check(req: ToolCallRequest, signal?: AbortSignal): PermissionResult | Promise<PermissionResult>;
176
+ /**
177
+ * The policy's CONSTRAINT PROJECTION — its deterministic DENY-verdict half expressed as pure data
178
+ * (F-012 L1), so a delegation chain / durable checkpoint can freeze what this policy refuses without
179
+ * carrying the live closure. Optional: a policy without one is OPAQUE to the projection plane (its
180
+ * live closure keeps the resume re-supply contract it has today). Built-in factories in this module
181
+ * attach one where the deny half IS data (name allow/deny lists, shell command-name lists);
182
+ * a custom closure policy simply opts out.
183
+ *
184
+ * The projection is a SUBSET contract, never a replacement: executing it yields every deny the
185
+ * data half implies, and says nothing about the policy's live remainder (asks, approvals,
186
+ * rewrites). `requiresLiveRemainder` states explicitly whether such a remainder exists — "has a
187
+ * projection" ≠ "fully projectable", and consumers deciding whether a frozen projection COMPLETELY
188
+ * describes the policy must read that bit, not the field's presence.
189
+ */
190
+ readonly projection?: ToolPolicyProjection;
167
191
  }
192
+ /**
193
+ * One deterministic DENY component of a {@link ToolPolicyProjection} (F-012 L1). All fields are plain
194
+ * serializable data — a component round-trips JSON/`structuredClone` unchanged, which is what lets a
195
+ * chain of them persist on a checkpoint and be re-executed verbatim on resume.
196
+ *
197
+ * - `tool_deny`: deny any call whose toolName is in `names`.
198
+ * - `tool_allowlist`: deny any call whose toolName is NOT in `names`.
199
+ * - `shell_command`: for calls to the named shell `tools`, parse the leading command name and deny
200
+ * per `deny`/`allow` lists; `unmatchedAction`/`unparseableAction` say whether the projection may
201
+ * speak for those arms (`"deny"`) or must stay silent (`"none"` — the live policy's ask arm owns
202
+ * them). Non-shell tools are out of scope (no opinion).
203
+ */
204
+ export type ToolPolicyProjectionComponent = {
205
+ kind: "tool_deny";
206
+ names: readonly string[];
207
+ } | {
208
+ kind: "tool_allowlist";
209
+ names: readonly string[];
210
+ } | {
211
+ kind: "shell_command";
212
+ tools: readonly string[];
213
+ deny: readonly string[];
214
+ allow?: readonly string[];
215
+ /** Verdict for a parsed command name outside `allow` (only meaningful when `allow` is present). */
216
+ unmatchedAction: "deny" | "none";
217
+ /** Verdict for a command string this projection cannot parse (compound/missing command). */
218
+ unparseableAction: "deny" | "none";
219
+ };
220
+ /**
221
+ * The deterministic data half of a {@link ToolPolicy} (F-012 L1): ordered deny components plus the
222
+ * explicit live-remainder bit. See {@link ToolPolicy.projection} for the contract.
223
+ */
224
+ export interface ToolPolicyProjection {
225
+ readonly components: readonly ToolPolicyProjectionComponent[];
226
+ /**
227
+ * `true` when the policy has behavior BEYOND these components (an ask arm, an approval callback,
228
+ * a rewrite) — the projection then freezes only the deny half and the live policy must still ride
229
+ * the resume re-supply channel for the rest. `false` = the components plus "allow otherwise" ARE
230
+ * the whole policy (fully projectable — e.g. a plain name allow/deny policy).
231
+ */
232
+ readonly requiresLiveRemainder: boolean;
233
+ }
234
+ /**
235
+ * Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
236
+ * component's deny, or `undefined` when the projection has no opinion (it is deny-only by
237
+ * construction — never an allow, never an ask). This is what a durable RESUME runs instead of
238
+ * trusting a re-supplied policy's execution half for the frozen deny data: the projection persisted
239
+ * at suspend is the authority for what the chain refused, whatever a re-supplied closure claims.
240
+ */
241
+ export declare function checkToolPolicyProjection(projection: Pick<ToolPolicyProjection, "components">, req: {
242
+ toolName: string;
243
+ args: unknown;
244
+ }): Extract<PermissionResult, {
245
+ action: "deny";
246
+ }> | undefined;
247
+ /**
248
+ * One entry of the persisted constraint-chain topology (F-012 L1): either a layer's projection
249
+ * content, or an explicit OPAQUE placeholder for a layer that exports none. The placeholder is
250
+ * load-bearing — the chain digest binds the ORDERED topology (layer boundaries included), so an
251
+ * opaque layer's position is part of what a resume re-supply must reproduce.
252
+ *
253
+ * F-012 (codex round 12): each entry ALSO carries the serializable DECISION-CHAIN METADATA posture —
254
+ * whether the layer was classifier-armed (`autoModeArmed`) and its durable-mandate / content-mandate
255
+ * flags. The digest binds these, so a cross-process resume re-supplying a same-shape chain whose
256
+ * projections match but whose classifier was DROPPED (or whose mandate posture changed) — a
257
+ * version-skew / reconfiguration widening — is rejected pre-CAS. The metadata is NOT read by
258
+ * {@link checkToolPolicyProjection} (execution reads only `components`); it exists purely to be
259
+ * digested. The frozen closures themselves (approver, decider) are non-serializable and stay outside
260
+ * the digest — the re-supply trust contract governs their identity — but their PRESENCE/posture is
261
+ * bound so a re-supply cannot silently shed a decision-chain layer.
262
+ */
263
+ export type ConstraintChainEntryMeta = {
264
+ /** True iff the layer carried a frozen auto-mode classifier (`autoMode`). */
265
+ autoModeArmed?: true;
266
+ durableMandate?: true;
267
+ contentMandate?: true;
268
+ };
269
+ export type ConstraintChainEntry = ConstraintChainEntryMeta & ({
270
+ opaque: true;
271
+ } | {
272
+ components: readonly ToolPolicyProjectionComponent[];
273
+ requiresLiveRemainder: boolean;
274
+ });
275
+ /** Project one policy into its chain entry (opaque placeholder when it exports no projection),
276
+ * carrying the layer's decision-chain metadata posture for the digest (round 12). */
277
+ export declare function constraintChainEntryOf(policy: ToolPolicy, meta?: ConstraintChainEntryMeta): ConstraintChainEntry;
278
+ /**
279
+ * Content digest over an ordered constraint chain (F-012 L1) — the durable half's anti-tamper anchor.
280
+ * Binds the chain TOPOLOGY (order, layer boundaries, opaque placeholders) and each projection's full
281
+ * content; version-prefixed (`cpv1:`) so a digest computed under different projection semantics can
282
+ * never false-match. Replaces the count-only re-supply contract: two chains of equal length with
283
+ * different frozen deny data now digest differently.
284
+ */
285
+ export declare function constraintChainDigest(chain: readonly ConstraintChainEntry[]): string;
168
286
  /**
169
287
  * Refuse a decision that speaks the retired `reason` dialect (RB-479-B①, tripwire ruled 2026-08-03).
170
288
  *