@sema-agent/core 7.8.0 → 7.9.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 +52 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +12 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +34 -28
  6. package/dist/core/gate-lanes.js +49 -23
  7. package/dist/core/gate-outcome.d.ts +8 -4
  8. package/dist/core/gate-outcome.js +2 -1
  9. package/dist/core/hooks.d.ts +56 -31
  10. package/dist/core/permission-rule-consent.d.ts +29 -36
  11. package/dist/core/permission-rule-consent.js +110 -60
  12. package/dist/core/permission-rule-model.d.ts +254 -55
  13. package/dist/core/permission-rule-model.js +323 -43
  14. package/dist/core/permission-rule-org.d.ts +9 -8
  15. package/dist/core/permission-rule-org.js +6 -15
  16. package/dist/core/permission-rule-provider.d.ts +10 -6
  17. package/dist/core/permission-rule-provider.js +12 -8
  18. package/dist/core/permission-rule-session.d.ts +7 -6
  19. package/dist/core/permission-rule-session.js +29 -10
  20. package/dist/core/permission-rule-store.d.ts +40 -18
  21. package/dist/core/permission-rule-store.js +68 -42
  22. package/dist/core/permission-rule-sync.d.ts +9 -3
  23. package/dist/core/permission-rule-sync.js +29 -25
  24. package/dist/core/permission-rule-syntax.d.ts +30 -0
  25. package/dist/core/permission-rule-syntax.js +44 -0
  26. package/dist/core/permission-rules.d.ts +55 -33
  27. package/dist/core/permission-rules.js +65 -55
  28. package/dist/core/persisted-rule-arms.d.ts +56 -0
  29. package/dist/core/persisted-rule-arms.js +48 -0
  30. package/dist/core/runner/contracts.d.ts +21 -2
  31. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  32. package/dist/core/runner/permission-rule-lanes.js +27 -21
  33. package/dist/core/runner/prepare-gate-stations.js +1 -1
  34. package/dist/core/runner/prepare-safety-scan.js +8 -2
  35. package/dist/core/runner/prepare-task.js +1 -1
  36. package/dist/core/runner/resume-admission.d.ts +53 -0
  37. package/dist/core/runner/resume-admission.js +83 -0
  38. package/dist/core/runner/resume-apply.d.ts +50 -0
  39. package/dist/core/runner/resume-apply.js +184 -0
  40. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  41. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  42. package/dist/core/runner/resume-claim.d.ts +32 -0
  43. package/dist/core/runner/resume-claim.js +27 -0
  44. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  45. package/dist/core/runner/resume-internals-and-config.js +50 -0
  46. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  47. package/dist/core/runner/resume-policy-outcome.js +127 -0
  48. package/dist/core/runner/resume-preflight.d.ts +40 -0
  49. package/dist/core/runner/resume-preflight.js +122 -0
  50. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  51. package/dist/core/runner/resume-review-outcome.js +88 -0
  52. package/dist/core/runner/run-harness-handlers.js +1 -1
  53. package/dist/core/runner/runtask.d.ts +20 -0
  54. package/dist/core/runner/runtask.js +85 -734
  55. package/dist/core/runner/tool-end-body.d.ts +12 -5
  56. package/dist/core/runner/tool-end-body.js +5 -5
  57. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  58. package/dist/core/runner/tool-output-projection.js +1 -18
  59. package/dist/core/runner/turn-attachments.d.ts +2 -2
  60. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  61. package/dist/core/tool-roster.d.ts +9 -0
  62. package/dist/core/tool-roster.js +15 -5
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.js +2 -2
  65. package/dist/stores/file/checkpoint-store.js +1 -1
  66. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  67. package/dist/stores/file/permission-rule-store.js +64 -16
  68. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  69. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  70. package/dist/tools/fs/notebook.d.ts +1 -1
  71. package/dist/tools/fs/safety.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/test/export-surface.snapshot.json +43 -11
@@ -7,24 +7,31 @@
7
7
  *
8
8
  * Projections over values the caller already holds, with ONE clock read: `toolResultMsg` stamps the message
9
9
  * it mints with `Date.now()` (as it did inside the driver — the transcript's timestamp is minted where the
10
- * message is). Nothing here reads host state through a callback, awaits, or keeps state between calls.
10
+ * message is). The one callback here is the leg's card projector, handed in by every caller (it keeps the
11
+ * once-per-leg announce state; this module keeps none). Nothing here awaits.
11
12
  * Layer-1 machinery, not a driven lane; the prefix rule it follows is written once, on initial-run-state.ts.
12
13
  */
13
14
  import { type AgentMessage } from "../../internal/harness.js";
14
15
  import type { GateOutcome } from "../gate-outcome.js";
15
16
  import { type McpDelivered } from "../mcp-failure.js";
16
17
  import type { RecoveredOrphan } from "../session-reconcile.js";
18
+ import { type StructuredProjector } from "./tool-output-projection.js";
17
19
  import type { WriteFamily } from "./turn-attachments.js";
18
20
  /** design/134 §3.1b — derive the thin `response` digest from a harness tool result: model-facing text
19
21
  * content only (never `details` — the H4 thin-projection discipline), capped with a truncation note. */
20
22
  export declare function batchResponseDigest(result: unknown): string | undefined;
21
23
  /** The `tool_end` body fields projected from a harness tool result — output/truncated/totalChars via
22
- * {@link toolOutputFrom} and the CC card via {@link structuredFrom}. Single construction point for BOTH
24
+ * {@link toolOutputFrom} and the CC card via the LEG's projector (`Prepared.structuredProjector`: the engine card set
25
+ * ∪ the caller rows' declared `renderHints.resultCards`, an undeclared type dropped and announced once per leg —
26
+ * design/388 L4; handed in because the once-per-leg state lives on the leg). Single construction point for BOTH
23
27
  * the live loop's frames and the resumed batch's frames (`resolvePendingCall` + the deferred-sibling
24
28
  * close): the resumed frames used to carry only `isError`, so a client rendering tool output from frames
25
29
  * showed an empty body for every durable-approved call. Same projection = same source as the transcript.
26
30
  * Also the one place the gate outcome reaches a frame — see the parameter. */
27
31
  export declare function toolEndBodyFrom(result: unknown, isError: boolean,
32
+ /** The leg's card projector — see the doc above. A parameter, never a module default: a body minted with the engine
33
+ * set alone would silently drop every declared caller card and never announce. */
34
+ structured: StructuredProjector,
28
35
  /** The gate's record of the pass that admitted or refused this call, supplied by the CALLER of this
29
36
  * projection — the live loop reads it off the gate's per-call sideband, the resumed leg off the decide's
30
37
  * minted record. Deliberately a parameter and never derived from `result`: a tool's own `details`
@@ -58,10 +65,10 @@ gatedCallIdOfRun?: string): {
58
65
  * disagreed. The projection goes through the same {@link toolEndBodyFrom} every live tool result uses, so
59
66
  * `output` = the persisted model-facing text and `errorCode` = the persisted `details.errorKind`
60
67
  * (`interrupted_never_started` / `interrupted_outcome_unknown`) — a consumer discriminates on the code
61
- * instead of prose-matching. No `structured`: the reconcile mints no CC card (no `details.type`), which
62
- * `structuredFrom`'s allowlist already enforces.
68
+ * instead of prose-matching. No `structured`: the reconcile mints no CC card (no `details.type`), which the
69
+ * leg's projector already enforces.
63
70
  */
64
- export declare function reconciledToolEndBody(orphan: Pick<RecoveredOrphan, "text" | "errorKind">): ReturnType<typeof toolEndBodyFrom>;
71
+ export declare function reconciledToolEndBody(orphan: Pick<RecoveredOrphan, "text" | "errorKind">, structured: StructuredProjector): ReturnType<typeof toolEndBodyFrom>;
65
72
  /** task #51 F2/F3 — the CANONICAL write-tool → reminder-window mapping (CC qFm :480253-480254
66
73
  * `a.name === bD || a.name === hF` = TaskCreate/TaskUpdate; BFm :480217-480219 = TodoWrite). Live
67
74
  * deployment-alias resolution happens at the call sites (run-local `writeFamilyOf` over the mounted
@@ -1,7 +1,7 @@
1
1
  import {} from "../../internal/harness.js";
2
2
  import { MCP_DELIVERY_VERDICTS } from "../mcp-failure.js";
3
3
  import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
4
- import { structuredFrom, toolOutputFrom } from "./tool-output-projection.js";
4
+ import { toolOutputFrom } from "./tool-output-projection.js";
5
5
  import { TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME, TODO_WRITE_TOOL_NAME } from "../tool-catalog-entries.js";
6
6
  const BATCH_RESPONSE_MAX_CHARS = 500;
7
7
  export function batchResponseDigest(result) {
@@ -32,9 +32,9 @@ export function batchResponseDigest(result) {
32
32
  ? `${text.slice(0, BATCH_RESPONSE_MAX_CHARS)}…[+${text.length - BATCH_RESPONSE_MAX_CHARS} chars truncated]`
33
33
  : text;
34
34
  }
35
- export function toolEndBodyFrom(result, isError, gate, gatedCallIdOfRun) {
35
+ export function toolEndBodyFrom(result, isError, structured, gate, gatedCallIdOfRun) {
36
36
  const o = toolOutputFrom(result);
37
- const st = structuredFrom(result);
37
+ const st = structured(result);
38
38
  const det = isError ? result?.details : undefined;
39
39
  const codeRaw = det?.code;
40
40
  const kindRaw = det?.errorKind;
@@ -51,8 +51,8 @@ export function toolEndBodyFrom(result, isError, gate, gatedCallIdOfRun) {
51
51
  ...(gate !== undefined ? { gate } : {}),
52
52
  };
53
53
  }
54
- export function reconciledToolEndBody(orphan) {
55
- return toolEndBodyFrom({ content: orphan.text, details: { code: orphan.errorKind } }, true);
54
+ export function reconciledToolEndBody(orphan, structured) {
55
+ return toolEndBodyFrom({ content: orphan.text, details: { code: orphan.errorKind } }, true, structured);
56
56
  }
57
57
  export function writeFamilyOfCanonical(name) {
58
58
  if (name === TASK_CREATE_TOOL_NAME || name === TASK_UPDATE_TOOL_NAME)
@@ -9,14 +9,17 @@ export declare const toolOutputFrom: (result: unknown) => {
9
9
  truncated: boolean;
10
10
  totalChars?: number;
11
11
  } | undefined;
12
+ /** The `tool_end.structured` projection over a harness tool result: the CC card when `details.type` names a card
13
+ * the leg admits, else undefined. Minted per leg by {@link createStructuredProjector}; every body projection takes it
14
+ * as a parameter — there is no module-level card set to fall back to. */
15
+ export type StructuredProjector = (result: unknown) => unknown;
12
16
  /**
13
17
  * L4's run-time half (design/388 §2.4): a per-leg projector — the engine card set plus the leg's declared
14
- * caller cards — that DROPS an undeclared `details.type` exactly as the module-level gate does and announces
15
- * it ONCE per leg (`config.tool_card_undeclared`, audience operator). The module-level {@link structuredFrom}
16
- * is this projector over the engine set alone, with no announce seat.
18
+ * caller cards — that DROPS an undeclared `details.type` and announces it ONCE per leg
19
+ * (`config.tool_card_undeclared`, audience operator). With no options it is the engine set alone, unannounced —
20
+ * the shape a caller outside a leg (a test, a fixture) uses.
17
21
  */
18
- export declare function createStructuredProjector(opts: {
22
+ export declare function createStructuredProjector(opts?: {
19
23
  declaredCards?: Iterable<string>;
20
24
  onUndeclared?: (type: string) => void;
21
- }): (result: unknown) => unknown;
22
- export declare const structuredFrom: (result: unknown) => unknown;
25
+ }): StructuredProjector;
@@ -58,7 +58,7 @@ export const toolOutputFrom = (result) => {
58
58
  return { output: raw, truncated: true, totalChars };
59
59
  };
60
60
  const CC_DETAIL_TYPES = engineCardTypes();
61
- export function createStructuredProjector(opts) {
61
+ export function createStructuredProjector(opts = {}) {
62
62
  const allowed = new Set([...CC_DETAIL_TYPES, ...(opts.declaredCards ?? [])]);
63
63
  const announced = new Set();
64
64
  return (result) => {
@@ -89,20 +89,3 @@ function boundedCard(details) {
89
89
  }
90
90
  return details;
91
91
  }
92
- export const structuredFrom = (result) => {
93
- const details = result !== null && typeof result === "object" ? result.details : undefined;
94
- if (details === null || typeof details !== "object")
95
- return undefined;
96
- const t = details.type;
97
- if (typeof t !== "string" || !CC_DETAIL_TYPES.has(t))
98
- return undefined;
99
- try {
100
- const serialized = JSON.stringify(details) ?? "";
101
- if (serialized.length > MAX_TOOL_END_OUTPUT_CHARS * 4)
102
- return undefined;
103
- }
104
- catch {
105
- return undefined;
106
- }
107
- return details;
108
- };
@@ -110,7 +110,7 @@ export interface TurnAttachment {
110
110
  source: AttachmentSource;
111
111
  body: string;
112
112
  }
113
- /** H4 — the thin projection of a todo list kept run-local (NEVER the wire-capped structuredFrom card).
113
+ /** H4 — the thin projection of a todo list kept run-local (NEVER the wire-capped `tool_end.structured` card).
114
114
  * Pure CONTENT memory: the write-window anchor lives on {@link AttachmentState.todoLastWriteTurn}
115
115
  * (task #51 F3 — the window resets on tool-call INITIATION, which may precede/outlive any ingest). */
116
116
  export interface ListProjection {
@@ -273,7 +273,7 @@ export declare const INSTRUCTIONS_CHANGE_BYTE_CAP = 512;
273
273
  */
274
274
  export declare function createAttachmentState(): AttachmentState;
275
275
  /**
276
- * H4 — thin-projection reducer over a tool's RAW `result.details` (NOT the wire `structuredFrom`
276
+ * H4 — thin-projection reducer over a tool's RAW `result.details` (NOT the wire card projector
277
277
  * projection and its 4×16K caps: a huge todo list must shrink into a bounded snapshot here, never
278
278
  * disarm the reminder by being dropped wholesale). Only the first-party discriminants `todo` /
279
279
  * `task` / `task-list` are recognized — every other card type (subagent `agent`/`fork`/`task-output`,
@@ -5,14 +5,15 @@ const GLOBAL = { kind: "global" };
5
5
  function add(actor, counter, origin = "user") {
6
6
  return { dot: { actor, counter }, origin, createdAt: "2026-01-01T00:00:00.000Z" };
7
7
  }
8
- function rule(text, adds, scope = GLOBAL) {
8
+ function rule(text, adds, scope = GLOBAL, behavior = "allow") {
9
9
  const m = /^Bash\((.+?)(:\*)?\)$/.exec(text);
10
10
  if (m === null || m[1] === undefined)
11
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 };
12
+ return { behavior, rule: text, tool: "Bash", match: m[2] !== undefined ? "prefix" : "exact", command: m[1], scope, adds };
13
13
  }
14
- function tomb(text, removed, deletedBy, scope = GLOBAL) {
14
+ function tomb(text, removed, deletedBy, scope = GLOBAL, behavior = "allow") {
15
15
  return {
16
+ behavior,
16
17
  rule: text,
17
18
  scope,
18
19
  removedDots: removed.map(([actor, counter]) => ({ actor, counter })),
@@ -25,10 +26,10 @@ function state(rules = [], tombstones = []) {
25
26
  function liveDots(s) {
26
27
  const out = new Set();
27
28
  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
+ const removed = s.tombstones.filter((t) => t.behavior === r.behavior && t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
29
30
  for (const a of r.adds) {
30
31
  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
+ out.add(`${r.behavior === "allow" ? "" : r.behavior + ":"}${r.rule}|${a.dot.actor}#${a.dot.counter}`);
32
33
  }
33
34
  }
34
35
  return out;
@@ -41,7 +42,7 @@ function sameScope(a, b) {
41
42
  return b.kind === "project" && a.root === b.root;
42
43
  }
43
44
  function allDots(s) {
44
- return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
45
+ return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.behavior === "allow" ? "" : r.behavior + ":"}${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
45
46
  }
46
47
  export async function permissionRuleSyncContract(hooks = {}) {
47
48
  const join = hooks.join ?? joinRuleStates;
@@ -141,8 +142,24 @@ export async function permissionRuleSyncContract(hooks = {}) {
141
142
  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");
142
143
  assert.deepStrictEqual(ab, join(b, a));
143
144
  });
145
+ run("behavior is an identity member: the same text as an allow and as a deny are two rows, each with its own adds and its own tombstone", async () => {
146
+ const both = state([rule("Bash(ls)", [add("a", 1)]), rule("Bash(ls)", [add("a", 2)], GLOBAL, "deny")], [tomb("Bash(ls)", [["a", 1]], ["a", 3])]);
147
+ const merged = join(both, state());
148
+ assert.strictEqual(merged.rules.length, 2, "one text, two behaviors — two identity groups, never folded");
149
+ assert.deepStrictEqual(liveDots(merged), new Set(["deny:Bash(ls)|a#2"]), "the allow tombstone must not reach the deny's add");
150
+ assert.deepStrictEqual(join(state(), both), merged, "commutative across the behavior axis");
151
+ const badWord = state([rule("Bash(pwd)", [add("b", 1)])], [Object.assign(tomb("Bash(pwd)", [["b", 1]], ["b", 2]), { behavior: "maybe" })]);
152
+ const screened = join(badWord, state());
153
+ assert.strictEqual(screened.tombstones.length, 0, "an unreadable behavior word on a tombstone must not land");
154
+ assert.ok(liveDots(screened).has("Bash(pwd)|b#1"), "…and must not cover anything");
155
+ });
156
+ run("closed-set identity: a row carrying a behavior word outside the closed set is refused like invalid text", async () => {
157
+ const forgedWord = Object.assign(rule("Bash(ls)", [add("z", 1)]), { behavior: "sometimes" });
158
+ const merged = join(state([forgedWord]), state());
159
+ assert.strictEqual(allDots(merged).size, 0, "a behavior this engine cannot read must not become an allow (or a deny) by falling through");
160
+ });
144
161
  run("metadata re-projection (r2-F2): a record whose stored fields contradict its rule text is refused, both orders", async () => {
145
- const forged = { rule: "Bash(ls)", tool: "Bash", match: "prefix", command: "rm -rf /", scope: GLOBAL, adds: [add("z", 1)] };
162
+ const forged = { behavior: "allow", rule: "Bash(ls)", tool: "Bash", match: "prefix", command: "rm -rf /", scope: GLOBAL, adds: [add("z", 1)] };
146
163
  const clean = state([rule("Bash(git status)", [add("a", 1)])]);
147
164
  const ab = join(state([forged]), clean);
148
165
  const ba = join(clean, state([forged]));
@@ -150,9 +167,11 @@ export async function permissionRuleSyncContract(hooks = {}) {
150
167
  assert.deepStrictEqual(ab, ba, "refusal must preserve commutativity");
151
168
  assert.ok(allDots(ab).has("Bash(git status)|a#1"), "refusing one record must not disturb the rest");
152
169
  });
153
- run("single-validator door: an illegal rule shape (bare-interpreter prefix) cannot ride a join", async () => {
170
+ run("single-validator door: an illegal ALLOW shape (bare-interpreter prefix) cannot ride a join — while the same text as a DENY is a legitimate wide tightening and lands", async () => {
154
171
  const merged = join(state([rule("Bash(node:*)", [add("z", 1)])]), state());
155
172
  assert.strictEqual(allDots(merged).size, 0, "Bash(node:*) must be refused by the shared validator inside the join");
173
+ const denied = join(state([rule("Bash(node:*)", [add("z", 1)], GLOBAL, "deny")]), state());
174
+ assert.deepStrictEqual(allDots(denied), new Set(["deny:Bash(node:*)|z#1"]), "the tightening floor admits the interpreter-headed prefix");
156
175
  });
157
176
  run("durable two-member face (design/382 §4.3): a session-scope row or tombstone never survives a join, and never poisons the round", async () => {
158
177
  const sessionScope = { kind: "session", sessionId: "sess-1" };
@@ -168,8 +187,8 @@ export async function permissionRuleSyncContract(hooks = {}) {
168
187
  const scopeA = { kind: "project", root: "/x" };
169
188
  const scopeB = { kind: "project", root: "/x a" };
170
189
  const s = state([rule("Bash(ls)", [add("v", 1)], scopeA), rule("Bash(ls)", [add("w", 1)], scopeB)], [
171
- { rule: "Bash(ls)", scope: scopeA, removedDots: [{ actor: "v", counter: 1 }], deletedBy: { actor: "a 1", counter: 2 } },
172
- { rule: "Bash(ls)", scope: scopeB, removedDots: [{ actor: "w", counter: 1 }], deletedBy: { actor: "1", counter: 2 } },
190
+ { behavior: "allow", rule: "Bash(ls)", scope: scopeA, removedDots: [{ actor: "v", counter: 1 }], deletedBy: { actor: "a 1", counter: 2 } },
191
+ { behavior: "allow", rule: "Bash(ls)", scope: scopeB, removedDots: [{ actor: "w", counter: 1 }], deletedBy: { actor: "1", counter: 2 } },
173
192
  ]);
174
193
  const merged = join(s, state());
175
194
  assert.strictEqual(merged.tombstones.length, 2, "two distinct delete intents must both survive — a colliding key folds one away");
@@ -396,6 +396,13 @@ export declare class RosterBuilder {
396
396
  /** Which source a `TaskSpec.tools` entry mounts as: a definition carrying a CORE contract is a built-in the host chose
397
397
  * to mount; anything else is the caller's own tool. Identity follows the object, never the name. */
398
398
  export declare function callerMountSource(tool: AgentTool): RosterSource;
399
+ /** The card namespace a roster row owns (design/388 §2.2 ④) — ONE mint, read by the row and by the prepare door that
400
+ * judges a caller's `renderHints.resultCards` against it: `builtin:<name>` for engine-owned definitions,
401
+ * `<mcp|a2a>:<peer>/<tool>` for protocol tools, `<caller|external>:<contractId>` for everything the caller mounts. */
402
+ export declare function rosterCardId(source: RosterSource, toolName: string, peer: string | null, protocolTool: string, contractId: string): string;
403
+ /** The card namespace a `TaskSpec.tools` entry will own once mounted — the same mint the row makes, so the prepare
404
+ * door and the roster row cannot disagree about which namespace a caller card must live under. */
405
+ export declare function callerCardIdOf(tool: Pick<AgentTool, "name" | "parameters">): string;
399
406
  /** One row (design/388 §2.5). Every axis is read off the instance or the leg facts; nothing off a name table. */
400
407
  export declare function mintRosterEntry(tool: AgentTool, meta: RosterMountMeta, facts: RosterSnapshotFacts): ToolRosterEntry;
401
408
  /**
@@ -414,6 +421,8 @@ export declare class ToolRosterDeltaSeat {
414
421
  republish(): ToolRosterDelta | undefined;
415
422
  /** Install the stream sink; anything published before is flushed in order. */
416
423
  subscribe(sink: (delta: ToolRosterDelta) => void): void;
424
+ /** The leg ended: drop the sink so a late republish never reaches a closed stream (it buffers, bounded, unread). */
425
+ unsubscribe(): void;
417
426
  /** Take the buffered deltas (a consumer that polls instead of subscribing). */
418
427
  drain(): ToolRosterDelta[];
419
428
  }
@@ -335,17 +335,24 @@ export class RosterBuilder {
335
335
  export function callerMountSource(tool) {
336
336
  return getToolContract(tool, "caller").origin === "core" ? "builtin" : "caller";
337
337
  }
338
+ export function rosterCardId(source, toolName, peer, protocolTool, contractId) {
339
+ return source === "builtin" || source === "synthetic"
340
+ ? `builtin:${toolName}`
341
+ : source === "mcp" || source === "a2a"
342
+ ? `${source}:${peer}/${protocolTool}`
343
+ : `${source === "external" ? "external" : "caller"}:${contractId}`;
344
+ }
345
+ export function callerCardIdOf(tool) {
346
+ const t = tool;
347
+ return rosterCardId(callerMountSource(t), t.name, null, t.name, getToolContract(t, "caller").contractId);
348
+ }
338
349
  export function mintRosterEntry(tool, meta, facts) {
339
350
  const face = faceOf(tool);
340
351
  const fallbackOrigin = meta.source === "builtin" ? "core" : meta.source === "synthetic" ? "synthetic" : meta.source === "mcp" || meta.source === "a2a" ? meta.source : "caller";
341
352
  const d = getToolContract(tool, fallbackOrigin);
342
353
  const peer = meta.origin?.peer ?? null;
343
354
  const protocolTool = peer !== null ? protocolOf(tool.name)?.parse(tool.name)?.tool ?? tool.name : tool.name;
344
- const cardId = meta.source === "builtin" || meta.source === "synthetic"
345
- ? `builtin:${tool.name}`
346
- : meta.source === "mcp" || meta.source === "a2a"
347
- ? `${meta.source}:${peer}/${protocolTool}`
348
- : `${meta.source === "external" ? "external" : "caller"}:${d.contractId}`;
355
+ const cardId = rosterCardId(meta.source, tool.name, peer, protocolTool, d.contractId);
349
356
  const { keys: inputKeys } = inputKeysOf(tool.parameters);
350
357
  const declaredEffect = face.effect;
351
358
  const foldedEffect = facts.effects.get(tool.name);
@@ -431,6 +438,9 @@ export class ToolRosterDeltaSeat {
431
438
  for (const d of pending)
432
439
  sink(d);
433
440
  }
441
+ unsubscribe() {
442
+ this.#sink = undefined;
443
+ }
434
444
  drain() {
435
445
  const pending = this.#pending;
436
446
  this.#pending = [];
package/dist/index.d.ts CHANGED
@@ -186,8 +186,8 @@ export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRul
186
186
  * and nothing more. Removal is exported without ceremony, because narrowing on a user's behalf is
187
187
  * allowed and widening is not.
188
188
  */
189
- export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, stripFormatCharacters, UNCOVERED_SEGMENT_REASON_BASELINE, RULE_OFFERS_ABSENCE_BASELINE, type UncoveredSegmentDetail, type EditedRuleBreadthWarning, directoryRuleAdmits, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedAllowRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleOffer, type RuleOfferBatchMember, type SegmentRuleSuggestion, type SegmentCoverage, type RuleReject, type RuleRejectCode, type ParsedAllowRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
190
- export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryDurableRulePartition, EMPTY_DURABLE_RULE_PARTITION, type DurableRulePartition, type DurableRulePartitionProvider, type RuleWriteOutcome, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, type PermissionRuleWriter, type WritableDurableRulePartition, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
189
+ export { RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, RULE_BEHAVIORS_BY_PRECEDENCE, isRuleBehavior, readRowBehavior, adjudicatePersistedRules, adjudicatePersistedPathRules, ruleReachesProgramRun, pathRuleReaches, ruleToolGrammarOf, COMMAND_RULE_TOOL, READ_RULE_TOOL, type PathRuleBases, type RuleBehavior, type PersistedRuleVerdict, parseRuleText, formatRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, stripFormatCharacters, UNCOVERED_SEGMENT_REASON_BASELINE, RULE_OFFERS_ABSENCE_BASELINE, type UncoveredSegmentDetail, type EditedRuleBreadthWarning, directoryRuleAdmits, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleOffer, type RuleOfferBatchMember, type SegmentRuleSuggestion, type SegmentCoverage, type RuleReject, type RuleRejectCode, type ParsedRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
190
+ export { removePersistedRule, applyTombstones, sameScope, sameRuleIdentity, isValidConsentScope, isValidDurableScope, InMemoryDurableRulePartition, EMPTY_DURABLE_RULE_PARTITION, type DurableRulePartition, type DurableRulePartitionProvider, type RuleWriteOutcome, type StoredRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, type PermissionRuleWriter, type WritableDurableRulePartition, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
191
191
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, type PermissionRuleSyncTransport, type PermissionRuleSyncResult, type RuleSyncRequestBody, type RuleSyncResponseBody, } from "./core/permission-rule-sync.js";
192
192
  export { createPermissionRuleStoreProvider, effectivePermissionRules, effectiveOrThrow, ruleSourceOf, type PermissionRuleStore, type PermissionRuleStoreProvider, type PermissionRuleStoreConfig, type EffectivePermissionRules, type EffectivePermissionRule, type RemovedPermissionRule, type RuleSource, } from "./core/permission-rule-provider.js";
193
193
  export { InMemorySessionRulePartition, type SessionRulePartition, type SessionRuleAdd, type SessionRuleApplyResult, } from "./core/permission-rule-session.js";
package/dist/index.js CHANGED
@@ -145,8 +145,8 @@ export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/au
145
145
  export { AUTO_MODE_ARMING_RECIPE_VERSION, autoModeArmingRecipeOf, sanitizeAutoModeArmingRecipe, foldAutoModeArming, } from "./core/auto-mode-arming.js";
146
146
  export { rebuildAutoModeDecider, } from "./core/auto-mode-rebuild.js";
147
147
  export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRule, wildcardMatch, isNamespacedCoveringRuleName, namespacedRuleNameCovers, } from "./core/permission-rules.js";
148
- export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, stripFormatCharacters, UNCOVERED_SEGMENT_REASON_BASELINE, RULE_OFFERS_ABSENCE_BASELINE, directoryRuleAdmits, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, } from "./core/permission-rule-model.js";
149
- export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryDurableRulePartition, EMPTY_DURABLE_RULE_PARTITION, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, } from "./core/permission-rule-store.js";
148
+ export { RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, RULE_BEHAVIORS_BY_PRECEDENCE, isRuleBehavior, readRowBehavior, adjudicatePersistedRules, adjudicatePersistedPathRules, ruleReachesProgramRun, pathRuleReaches, ruleToolGrammarOf, COMMAND_RULE_TOOL, READ_RULE_TOOL, parseRuleText, formatRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, stripFormatCharacters, UNCOVERED_SEGMENT_REASON_BASELINE, RULE_OFFERS_ABSENCE_BASELINE, directoryRuleAdmits, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, } from "./core/permission-rule-model.js";
149
+ export { removePersistedRule, applyTombstones, sameScope, sameRuleIdentity, isValidConsentScope, isValidDurableScope, InMemoryDurableRulePartition, EMPTY_DURABLE_RULE_PARTITION, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, } from "./core/permission-rule-store.js";
150
150
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, } from "./core/permission-rule-sync.js";
151
151
  export { createPermissionRuleStoreProvider, effectivePermissionRules, effectiveOrThrow, ruleSourceOf, } from "./core/permission-rule-provider.js";
152
152
  export { InMemorySessionRulePartition, } from "./core/permission-rule-session.js";
@@ -16,7 +16,7 @@ const KNOWN_CHECKPOINT_LEDGER_EVENTS = new Set(Object.keys(CHECKPOINT_LEDGER_EVE
16
16
  function applyCheckpointEvent(cps, ev) {
17
17
  if (!KNOWN_CHECKPOINT_LEDGER_EVENTS.has(ev.t)) {
18
18
  throw new CheckpointError("checkpoint.unsupported_version", `file checkpoint ledger: unrecognised event kind "${String(ev.t)}" — ` +
19
- `it was written by a newer worker; refusing the replay rather than serving an incomplete authority`);
19
+ `it was written by a newer worker; refusing the replay rather than serving an incomplete authority`, { reason: "ledger_event_unknown" });
20
20
  }
21
21
  if (ev.t === "put") {
22
22
  cps.set(ev.token, ev.cp);
@@ -2,17 +2,21 @@
2
2
  * design/179 §5/§8 / design/389 — the file backend of the permission-rule store's DURABLE partition (one
3
3
  * bucket file per owner), plus the file home of the ORG partition's durable state (`org.json`).
4
4
  *
5
- * ## Schema 3 and the one-time migration
5
+ * ## Schema 4 and the one-time migration
6
6
  *
7
7
  * Pre-389 files (schema 1/2) kept the org last-known-good snapshot as an `org` block INSIDE each
8
- * principal's bucket file — deployment-wide policy stored per person. Schema 3 drops that block; the org
9
- * state lives in one `org.json` beside the buckets. `FileDurableRulePartitionProvider` migrates every
10
- * legacy bucket file ONCE at construction (under the writer lock: the migration is a write, and a live
11
- * older writer over the same directory is refused rather than raced), folding each file's `org` block
12
- * into `org.json` by the anti-rollback rule (the highest high-water wins; an equal revision with
13
- * different content keeps what is already installed and discloses). After that the reader accepts
14
- * schema 3 ONLY a 1/2/4 file is refused whole, loudly (zero rules + disclosure). There is no second
15
- * read path for the old shapes: the migrator is the only code that reads them, and it runs once.
8
+ * principal's bucket file — deployment-wide policy stored per person. Schema 3 dropped that block; the org
9
+ * state lives in one `org.json` beside the buckets. Schema 4 adds the `behavior` member to every rule row,
10
+ * tombstone and quarantine row (the rule identity is the (behavior, text, scope) triple): a schema-3 row has
11
+ * none, and every row a schema-3 engine wrote was an allow, so the migration stamps `"allow"` on each — the
12
+ * ONE reading of the pre-field row (`readRowBehavior`), applied once, here, never at a read face.
13
+ * `FileDurableRulePartitionProvider` migrates every legacy bucket file (1, 2 or 3) ONCE at construction
14
+ * (under the writer lock: the migration is a write, and a live older writer over the same directory is
15
+ * refused rather than raced), folding a 1/2 file's `org` block into `org.json` by the anti-rollback rule
16
+ * (the highest high-water wins; an equal revision with different content keeps what is already installed
17
+ * and discloses). After that the reader accepts schema 4 ONLY — a 1/2/3/5 file is refused whole, loudly
18
+ * (zero rules + disclosure). There is no second read path for the old shapes: the migrator is the only
19
+ * code that reads them, and it runs once.
16
20
  *
17
21
  * ## Why this store builds its own symlink refusal
18
22
  *
@@ -55,23 +59,23 @@
55
59
  * and is not: it detects corruption, and it does not detect an editor, because an editor recomputes it.
56
60
  * For this backend the trust is host = user, exactly as for the settings file it mirrors.
57
61
  */
58
- import type { PersistedAllowRule, RuleTombstone } from "../../core/permission-rule-model.js";
59
- import type { DurableRulePartition, DurableRulePartitionProvider, PermissionRuleWriter, QuarantinedRuleAdd, RuleOwner, RuleSyncFrontier, StoredAllowRules, WritableDurableRulePartition } from "../../core/permission-rule-store.js";
62
+ import type { PersistedRule, RuleTombstone } from "../../core/permission-rule-model.js";
63
+ import type { DurableRulePartition, DurableRulePartitionProvider, PermissionRuleWriter, QuarantinedRuleAdd, RuleOwner, RuleSyncFrontier, StoredRules, WritableDurableRulePartition } from "../../core/permission-rule-store.js";
60
64
  import { PERMISSION_RULE_WRITER } from "../../core/permission-rule-store.js";
61
65
  import type { OrgRuleStatePersistence, PersistedOrgRuleState } from "../../core/permission-rule-org.js";
62
66
  import type { StoreDurability, StoreFidelity } from "../../core/checkpoint-store.js";
63
67
  /**
64
68
  * The on-disk shape of one bucket. `schemaVersion` is the only field a future reader may rely on before
65
- * validating. Schema 3 (design/389): the five core fields plus the OPTIONAL `sync` block (observation
66
- * vector + round timestamp) and `quarantined` area (rows moved out of the live view, bytes preserved).
67
- * The org state is NOT here — see {@link OrgStateFile}.
69
+ * validating. Schema 4: the five core fields plus the OPTIONAL `sync` block (observation vector + round
70
+ * timestamp) and `quarantined` area (rows moved out of the live view, bytes preserved); every rule,
71
+ * tombstone and quarantine row carries its `behavior`. The org state is NOT here — see {@link OrgStateFile}.
68
72
  */
69
73
  interface RuleFile {
70
- schemaVersion: 3;
74
+ schemaVersion: 4;
71
75
  actor: string;
72
76
  counter: number;
73
77
  rev: number;
74
- rules: PersistedAllowRule[];
78
+ rules: PersistedRule[];
75
79
  tombstones: RuleTombstone[];
76
80
  sync?: {
77
81
  observedVector?: RuleSyncFrontier;
@@ -88,10 +92,10 @@ interface OrgStateFile {
88
92
  }
89
93
  /** The org partition's durable home in a rule directory. */
90
94
  export declare const ORG_STATE_FILE = "org.json";
91
- /** Written once the directory is known to hold only schema-3 buckets. Its presence ends the legacy
92
- * scan for good: a schema 1/2 bucket that appears AFTERWARDS (an older engine writing after the
95
+ /** Written once the directory is known to hold only schema-4 buckets. Its presence ends the legacy
96
+ * scan for good: a schema 1/2/3 bucket that appears AFTERWARDS (an older engine writing after the
93
97
  * cutover) is refused by the reader on every later construction too, never re-admitted by a restart. */
94
- export declare const SCHEMA3_MARKER_FILE = ".rule-schema-3";
98
+ export declare const SCHEMA_MARKER_FILE = ".rule-schema-4";
95
99
  /** The anti-rollback contract's DESIGNED refusal (a lower high-water mark, or different content under
96
100
  * the same revision) — distinguished by class from every other install failure, because the two are
97
101
  * handled in opposite directions by the migration: a refusal means the block is superseded and may be
@@ -148,7 +152,7 @@ declare class FileDurableRulePartition implements WritableDurableRulePartition {
148
152
  */
149
153
  private screenDurableRows;
150
154
  private write;
151
- list(): Promise<StoredAllowRules>;
155
+ list(): Promise<StoredRules>;
152
156
  /** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. */
153
157
  quarantined(): Promise<QuarantinedRuleAdd[]>;
154
158
  /**
@@ -209,7 +213,8 @@ export declare class FileDurableRulePartitionProvider implements DurableRulePart
209
213
  dataRoot?: string;
210
214
  });
211
215
  /**
212
- * Fold every legacy bucket file in `dir` into schema 3 and its `org` block into `org.json`. A no-op on a
216
+ * Fold every legacy bucket file in `dir` into schema 4 (stamping `behavior: "allow"` on every pre-field
217
+ * row — the one pre-field reading) and a 1/2 file's `org` block into `org.json`. A no-op on a
213
218
  * directory with no legacy file (zero writes, the lock untouched — the second construction over a
214
219
  * migrated directory is byte-silent). A legacy file whose checksum or shape does not hold is NOT
215
220
  * migrated: its bytes stay where they are for a person to look at, and the reader keeps refusing it
@@ -220,10 +225,10 @@ export declare class FileDurableRulePartitionProvider implements DurableRulePart
220
225
  * shape — the old engine's buckets are refused whole until they are migrated.
221
226
  */
222
227
  private migrateLegacyBuckets;
223
- /** Publish the schema-3 completion marker (idempotent, atomic, no lock: its content never changes and
228
+ /** Publish the schema-4 completion marker (idempotent, atomic, no lock: its content never changes and
224
229
  * a concurrent writer of the same bytes is harmless). A failure to write it is disclosed, not thrown —
225
230
  * the next construction simply scans again, which is the safe direction. */
226
- private writeSchema3Marker;
231
+ private writeSchemaMarker;
227
232
  /** The org partition's durable home in this directory (design/182 §7.4 contract; `org.json`). Shares
228
233
  * this provider's writer lock — an install is a write. */
229
234
  orgStatePersistence(): OrgRuleStatePersistence;