@sema-agent/core 7.1.0 → 7.3.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 (64) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/agents/cross-session-envelope.d.ts +145 -0
  3. package/dist/agents/cross-session-envelope.js +195 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/list-agents-tool.d.ts +55 -0
  9. package/dist/agents/list-agents-tool.js +94 -0
  10. package/dist/agents/peer-admission.d.ts +17 -1
  11. package/dist/agents/peer-admission.js +19 -2
  12. package/dist/agents/peer-directory.d.ts +208 -0
  13. package/dist/agents/peer-directory.js +272 -0
  14. package/dist/agents/peer-session-drain.d.ts +159 -0
  15. package/dist/agents/peer-session-drain.js +245 -0
  16. package/dist/agents/send-message-tool.d.ts +44 -0
  17. package/dist/agents/send-message-tool.js +181 -16
  18. package/dist/agents/subagent-steps.d.ts +11 -0
  19. package/dist/agents/subagent-steps.js +27 -4
  20. package/dist/core/auto-mode-arming.d.ts +11 -0
  21. package/dist/core/auto-mode-arming.js +7 -1
  22. package/dist/core/auto-mode-prompt.d.ts +5 -0
  23. package/dist/core/auto-mode-prompt.js +2 -1
  24. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  25. package/dist/core/auto-mode-rebuild.js +2 -0
  26. package/dist/core/checkpoint-store.d.ts +203 -3
  27. package/dist/core/checkpoint-store.js +60 -19
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +6 -0
  30. package/dist/core/hooks.d.ts +15 -8
  31. package/dist/core/hooks.js +6 -3
  32. package/dist/core/mailbox-store.d.ts +89 -2
  33. package/dist/core/mailbox-store.js +77 -2
  34. package/dist/core/permission-rule-consent.d.ts +72 -23
  35. package/dist/core/permission-rule-consent.js +115 -26
  36. package/dist/core/permission-rule-model.d.ts +254 -51
  37. package/dist/core/permission-rule-model.js +316 -55
  38. package/dist/core/permission-rule-org.js +13 -6
  39. package/dist/core/remote-env.d.ts +8 -1
  40. package/dist/core/runner/assemble-result.js +2 -1
  41. package/dist/core/runner/prepare-task.d.ts +59 -1
  42. package/dist/core/runner/prepare-task.js +414 -149
  43. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  44. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  45. package/dist/core/runner/runtask.js +16 -5
  46. package/dist/core/runner/tool-output-projection.js +1 -0
  47. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  48. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  49. package/dist/core/task-notification.d.ts +93 -5
  50. package/dist/core/task-notification.js +31 -4
  51. package/dist/core/tool-policy.d.ts +11 -0
  52. package/dist/core/types.d.ts +155 -21
  53. package/dist/core/untrusted-text.js +17 -1
  54. package/dist/core/wiring-manifest.d.ts +21 -0
  55. package/dist/core/wiring-manifest.js +1 -0
  56. package/dist/index.d.ts +14 -5
  57. package/dist/index.js +13 -4
  58. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  59. package/dist/stores/cc/mailbox-store.js +13 -0
  60. package/dist/stores/file/adoption/marker.d.ts +1 -1
  61. package/dist/stores/file/mailbox-store.d.ts +57 -0
  62. package/dist/stores/file/mailbox-store.js +369 -18
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +233 -1
@@ -1,3 +1,4 @@
1
+ import { redactSecrets } from "../core/untrusted-egress.js";
1
2
  export const STEP_CAP = 10;
2
3
  const FIELD_MAX = 80;
3
4
  const EDITED_FILES_CAP = 32;
@@ -6,9 +7,31 @@ function firstLine(s) {
6
7
  const nl = s.indexOf("\n");
7
8
  return nl === -1 ? s : s.slice(0, nl);
8
9
  }
10
+ const REDACTION_MARKER_RE = /\[redacted(?:-[a-z]+)?\]/g;
11
+ export function cutAt(line, max) {
12
+ if (line.length <= max)
13
+ return line;
14
+ let at = max;
15
+ for (const m of line.matchAll(REDACTION_MARKER_RE)) {
16
+ const start = m.index;
17
+ const end = start + m[0].length;
18
+ if (start < at && at < end) {
19
+ at = start;
20
+ break;
21
+ }
22
+ if (start >= at)
23
+ break;
24
+ }
25
+ const lead = line.charCodeAt(at - 1);
26
+ if (at > 0 && lead >= 0xd800 && lead <= 0xdbff)
27
+ at -= 1;
28
+ return line.slice(0, at);
29
+ }
30
+ export function redactThenCut(s, max) {
31
+ return cutAt(redactSecrets(s), max);
32
+ }
9
33
  function clip(s) {
10
- const line = firstLine(s).trim();
11
- return line.length > FIELD_MAX ? line.slice(0, FIELD_MAX) : line;
34
+ return redactThenCut(firstLine(s).trim(), FIELD_MAX);
12
35
  }
13
36
  export function extractTarget(args) {
14
37
  if (args === null || typeof args !== "object")
@@ -109,7 +132,7 @@ export class SubagentStepRecorder {
109
132
  const tool = start?.tool ?? e.label ?? e.toolName;
110
133
  const target = start?.target ?? "";
111
134
  const body = outputFirstLine(e.output);
112
- const outcome = e.isError ? `error: ${body}`.slice(0, FIELD_MAX) : body;
135
+ const outcome = e.isError ? cutAt(`error: ${body}`, FIELD_MAX) : body;
113
136
  this.steps.push({ tool, target, outcome });
114
137
  if (this.steps.length > STEP_CAP)
115
138
  this.steps.shift();
@@ -156,7 +179,7 @@ export function stepsFromMessages(messages, lastN) {
156
179
  if (b.type !== "toolCall" || typeof b.name !== "string")
157
180
  continue;
158
181
  const res = typeof b.id === "string" ? results.get(b.id) : undefined;
159
- const outcome = res ? (res.isError ? `error: ${res.body}`.slice(0, FIELD_MAX) : res.body) : "";
182
+ const outcome = res ? (res.isError ? cutAt(`error: ${res.body}`, FIELD_MAX) : res.body) : "";
160
183
  steps.push({ tool: b.name, target: extractTarget(b.arguments), outcome });
161
184
  }
162
185
  }
@@ -29,6 +29,14 @@ export interface AutoModeArmingRecipe {
29
29
  timeoutMs?: number;
30
30
  /** Consecutive-failure threshold opening the one-way breaker (floored, as the decider itself floors it). */
31
31
  failureThreshold?: number;
32
+ /**
33
+ * The cross-session lane's classifier rule was spliced into the assembled prompt (the lane was
34
+ * mounted on the arming leg). Part of the PROMPT BODY: the rebuild re-splices the same engine
35
+ * constant when the bit is set, so the recorded `promptDigest` is reproducible, and the fold treats
36
+ * the bit as body (a rebuild under a deployment whose own face does not declare it refuses as
37
+ * `settings_moved` — the two prompts differ by a rule block). Absent = the rule was not spliced.
38
+ */
39
+ crossSessionMessagesRule?: true;
32
40
  /**
33
41
  * A digest of the EXACT classifier system prompt this arming assembled (see
34
42
  * `rebuildAutoModeDecider`). The recipe records the deployment's OVERRIDES; the bulk of the criteria —
@@ -57,6 +65,9 @@ export interface AutoModeArmingFace {
57
65
  timeoutMs?: number;
58
66
  failureThreshold?: number;
59
67
  settingsEpoch?: string;
68
+ /** `true` when the cross-session lane's classifier rule is spliced into this deployment's classifier
69
+ * prompt (the Runner sets it from its own lane mount; a redeeming host declares it from its). */
70
+ crossSessionMessagesRule?: boolean;
60
71
  }
61
72
  /**
62
73
  * Canonicalize + VALIDATE an arming recipe: the plain-data form that persists, or `undefined` when the
@@ -81,9 +81,13 @@ export function sanitizeAutoModeArmingRecipe(value) {
81
81
  const promptDigest = value.promptDigest;
82
82
  if (promptDigest !== undefined && (typeof promptDigest !== "string" || promptDigest === ""))
83
83
  return undefined;
84
+ const crossSessionMessagesRule = value.crossSessionMessagesRule;
85
+ if (crossSessionMessagesRule !== undefined && typeof crossSessionMessagesRule !== "boolean")
86
+ return undefined;
84
87
  return {
85
88
  v: AUTO_MODE_ARMING_RECIPE_VERSION,
86
89
  ...(promptDigest !== undefined ? { promptDigest } : {}),
90
+ ...(crossSessionMessagesRule === true ? { crossSessionMessagesRule: true } : {}),
87
91
  ...(rules !== undefined ? { rules } : {}),
88
92
  ...(settingsDenyRules !== undefined ? { settingsDenyRules } : {}),
89
93
  ...(sessionContext !== undefined ? { sessionContext } : {}),
@@ -104,6 +108,7 @@ export function autoModeArmingRecipeOf(face, bind) {
104
108
  ...(face.timeoutMs !== undefined ? { timeoutMs: face.timeoutMs } : {}),
105
109
  ...(face.failureThreshold !== undefined ? { failureThreshold: face.failureThreshold } : {}),
106
110
  ...(face.settingsEpoch !== undefined ? { settingsEpoch: face.settingsEpoch } : {}),
111
+ ...(face.crossSessionMessagesRule !== undefined ? { crossSessionMessagesRule: face.crossSessionMessagesRule } : {}),
107
112
  });
108
113
  }
109
114
  function sameArmingBody(a, b) {
@@ -116,6 +121,7 @@ function sameArmingBody(a, b) {
116
121
  r.sessionContext ?? null,
117
122
  r.window?.maxEntries ?? null,
118
123
  r.window?.maxCharsPerEntry ?? null,
124
+ r.crossSessionMessagesRule === true,
119
125
  ]);
120
126
  return body(a) === body(b);
121
127
  }
@@ -160,7 +166,7 @@ export function foldAutoModeArming(recorded, current) {
160
166
  ok: false,
161
167
  reason: "settings_moved",
162
168
  message: "the auto-mode settings moved between the recorded arming and this deployment's current ones (rule sections / settings-deny rules / " +
163
- "session context / window bounds differ), and free-text rule sets have no sound stricter-than ordering — refusing to rebuild " +
169
+ "session context / window bounds / the cross-session lane rule differ), and free-text rule sets have no sound stricter-than ordering — refusing to rebuild " +
164
170
  "(the strictest decidable answer: the inherited ask flows the original chain to a human)",
165
171
  };
166
172
  }
@@ -22,6 +22,11 @@ export interface BuildAutoModePromptOptions {
22
22
  /** Extra session-context facts (e.g. the CC user-identity line) — appended as a
23
23
  * `## Session Context` bullet block after the assembled document. */
24
24
  sessionContext?: readonly string[];
25
+ /** design/385 §4.5 — the text spliced into the `<cross_session_messages_rule>` slot. Absent (the
26
+ * default, and every deployment without the cross-session lane) ⇒ the slot is blanked exactly as
27
+ * before; the lane mount passes `CROSS_SESSION_CLASSIFIER_RULE`. Callback form at the splice (the
28
+ * text may carry `$`). */
29
+ crossSessionMessagesRule?: string;
25
30
  }
26
31
  /**
27
32
  * Assemble the classifier SYSTEM prompt (CC `KRg`, content-equivalent single string — CC splits the
@@ -34,7 +34,8 @@ const PAIRED_SECTIONS = [
34
34
  ["environment", /<user_environment_to_replace>([\s\S]*?)<\/user_environment_to_replace>/],
35
35
  ];
36
36
  export function buildAutoModePrompt(options) {
37
- let out = AUTO_MODE_BASE_PROMPT.replace("<permissions_template>", () => AUTO_MODE_PERMISSIONS_EXTERNAL).replace("<cross_session_messages_rule>", () => "");
37
+ const crossSessionRule = options?.crossSessionMessagesRule ?? "";
38
+ let out = AUTO_MODE_BASE_PROMPT.replace("<permissions_template>", () => AUTO_MODE_PERMISSIONS_EXTERNAL).replace("<cross_session_messages_rule>", () => crossSessionRule);
38
39
  for (const [key, re] of PAIRED_SECTIONS) {
39
40
  out = out.replace(re, (_m, inner) => mergeRuleSection(options?.rules?.[key], inner));
40
41
  }
@@ -58,7 +58,8 @@ export type AutoModeRebuildResult = {
58
58
  * Rebuild a decider with a PARKED ancestor's criteria from its recorded recipe plus a fresh model leg.
59
59
  *
60
60
  * What is reproduced: the assembled system prompt (`buildAutoModePrompt` over the recorded rule
61
- * overrides / settings-deny rules / session context), the transcript-window bounds, the round-trip
61
+ * overrides / settings-deny rules / session context, plus the engine's cross-session lane rule when
62
+ * the recipe says the arming leg spliced it), the transcript-window bounds, the round-trip
62
63
  * timeout and the breaker threshold — i.e. every input the ancestor's own `createAutoModeDecider` call
63
64
  * had except the model leg and the alarm closure.
64
65
  *
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { createAutoModeDecider } from "./auto-mode.js";
3
3
  import { buildAutoModePrompt, renderAutoModeAction, renderAutoModeWindow } from "./auto-mode-prompt.js";
4
4
  import { foldAutoModeArming, } from "./auto-mode-arming.js";
5
+ import { CROSS_SESSION_CLASSIFIER_RULE } from "../agents/cross-session-envelope.js";
5
6
  export function rebuildAutoModeDecider(opts) {
6
7
  const folded = foldAutoModeArming(opts.recorded, opts.current);
7
8
  if (!folded.ok)
@@ -11,6 +12,7 @@ export function rebuildAutoModeDecider(opts) {
11
12
  ...(effective.rules !== undefined ? { rules: effective.rules } : {}),
12
13
  ...(effective.settingsDenyRules !== undefined ? { settingsDenyRules: effective.settingsDenyRules } : {}),
13
14
  ...(effective.sessionContext !== undefined ? { sessionContext: effective.sessionContext } : {}),
15
+ ...(effective.crossSessionMessagesRule === true ? { crossSessionMessagesRule: CROSS_SESSION_CLASSIFIER_RULE } : {}),
14
16
  };
15
17
  const systemPrompt = buildAutoModePrompt(promptOptions);
16
18
  const assembledDigest = `apv1:${createHash("sha256").update(systemPrompt).digest("hex")}`;
@@ -765,7 +765,12 @@ export type PendingAction = {
765
765
  * order (redemption TICKETS are keyed on the consent record's flat CANDIDATE list instead —
766
766
  * a chosen offer is redeemed via `redeemRuleBatch`), per-element parse with single-row
767
767
  * degrade on an unknown `kind` and
768
- * original-index preservation. The park→resume redemption chain walks the same consent
768
+ * original-index preservation. design/382 B3: a batch offer's members are the
769
+ * {@link import("./permission-rule-model.js").RuleOfferBatchMember} discriminated union
770
+ * (`command` | `directoryRead`), and the member-level degrade is the union's own normative
771
+ * arm — an unknown MEMBER kind drops the WHOLE batch offer (never one member: a conjunction
772
+ * silently one member short renders "yes to N" as "yes to N−1"), while the sibling single
773
+ * offers stay rendered. The park→resume redemption chain walks the same consent
769
774
  * protocol as the synchronous card — there is no second form. */
770
775
  ruleOffers?: readonly import("./permission-rule-model.js").RuleOffer[];
771
776
  /** #490 修② (additive; no checkpoint-version bump — the `previewWithheld`/`hasBidiControls`
@@ -777,7 +782,11 @@ export type PendingAction = {
777
782
  * the tool's own mandate marks, a hook-raised ask, an ancestor's authority, or — #502 — a
778
783
  * demotion this CALL's reversibility probe declared structural, which for the built-in shell
779
784
  * probe means a read outside the directories the session declared; an inbox must NOT point at
780
- * rule-writing on this arm, nor at a directory grant, which does not exist in this version),
785
+ * rule-writing on this arm "allow rules silence the classifier's questions, never a mandated
786
+ * one" — nor at a directory grant: the subpath-rule family exists, but the out-of-root arm has
787
+ * no clearing configuration in this version, so a grant minted in answer to this line would
788
+ * never take effect where it was minted; confirming the call is the whole of what a person can
789
+ * do about it),
781
790
  * `"shadowed"` (a rule the person already wrote is
782
791
  * speaking and does not clear it), `"lane_cannot_speak"` (this card has no rule to offer — the
783
792
  * grammar has no text for the command, or the card's array-order contract declines to present
@@ -786,6 +795,14 @@ export type PendingAction = {
786
795
  * "there are offers" and the structural doors (no lane wired, another tool, a task that cannot
787
796
  * hold a rule) — so read presence. See the synchronous seat's doc for the full contract. */
788
797
  ruleOffersAbsence?: "mandated" | "lane_cannot_speak" | "shadowed";
798
+ /** design/382 §2.4 (adversarial-review r3; additive — no checkpoint-version bump, the
799
+ * `previewWithheld` precedent: an optional field an older reader ignores and the resume
800
+ * path never reads) — the PARK twin of `AskRequest.execCwd`, minted by the SAME factory as
801
+ * {@link ruleOffers}: the relative-cd resolution base those offers were minted with (the
802
+ * live tracked cwd at park time). An approval inbox reconstructing the authoritative
803
+ * consent record threads it as `prepareCardApproval`'s `execCwd`. Present only beside
804
+ * {@link ruleOffers} when the run had a tracked cwd; echo-only, never a control input. */
805
+ execCwd?: string;
789
806
  /**
790
807
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
791
808
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -1048,6 +1065,20 @@ export interface CheckpointState {
1048
1065
  rules: SessionPermissionRules;
1049
1066
  }>;
1050
1067
  shellGate?: "off" | "always" | "classify";
1068
+ /** The chain's AUTO-MODE INTENT at suspend (data half, same law as `shellGate`): `true` when the
1069
+ * suspended leg was an auto-mode task — its own seat, the bit its live chain carried, or the bit
1070
+ * an earlier suspend of the same chain recorded (carried forward across a re-suspend). A resume
1071
+ * leg reads it as one more INTENT source beside the re-supplied seat and the waking chain, so a
1072
+ * redemption in another process (no seat re-passed, no live chain) still arms exactly as the
1073
+ * suspend leg did. It is a MEMORY of intent, never an authorization: the resuming deployment's
1074
+ * face (`RunnerDeps.autoMode`) and the resuming principal's deny bit (`RuntimeCaps.autoMode`)
1075
+ * are judged afresh on every leg — a bit on the row cannot arm where the redeeming deployment
1076
+ * would not. Follows the classifier's latch: a leg armed here writes it only while its own breaker
1077
+ * is untripped and untouched (a session that fell back to non-auto hands nothing forward); a leg
1078
+ * never armed here carries the memory as is. Absent on older checkpoints and on non-auto tasks
1079
+ * (byte-identical to the pre-bit row); an older worker that ignores it resumes un-armed, the
1080
+ * narrower direction. */
1081
+ autoModeRequested?: true;
1051
1082
  /** Org-memory admission freeze (ruled 2026-08-05): the chain's admitted org-scope set at
1052
1083
  * suspend (data half, plain strings). The resume leg folds it seed ∩ live (tighten-only) and
1053
1084
  * re-runs admission under it — a resume must never widen the delegation freeze. Absent on
@@ -1483,6 +1514,49 @@ export interface ResolveExpectation {
1483
1514
  /** The monotonic {@link Checkpoint.rev} the caller observed at `get()` (absent rev ⇒ legacy `0`). */
1484
1515
  rev: number;
1485
1516
  }
1517
+ /** design/384 slice 2 / S-25-R1 — the terminal intents a claim can carry. Deliberately the
1518
+ * TERMINAL pair only: `resolve` and `expire` are the two verbs that race one pending row to
1519
+ * its end (the design/51 fence law). `reopen` (resolved→pending) is not terminal and `reap`
1520
+ * is a bulk sweep — both stay on their own verbs. */
1521
+ export type TerminalClaimIntent = {
1522
+ kind: "resolve";
1523
+ outcome: ResumeOutcome;
1524
+ expect?: ResolveExpectation;
1525
+ } | {
1526
+ kind: "expire";
1527
+ };
1528
+ /** design/384 slice 2 / S-25-R1 — the single-round-trip answer: EITHER the claim won, OR it lost and
1529
+ * the SAME atomic operation reports what the row is now. `current` reuses the row's own vocabulary
1530
+ * (status / rev / resolvedOutcome) — no new value domain. `status:"pending"` on a loss is the OCC
1531
+ * arm only (`expect.rev` mismatch): the row is still open — re-get + re-validate, zero backoff (a
1532
+ * cleanly KNOWN state is never "unknown"). */
1533
+ export type TerminalClaimOutcome = {
1534
+ claimed: true;
1535
+ } | {
1536
+ claimed: false;
1537
+ current: {
1538
+ status: "pending" | "resolved" | "expired";
1539
+ /** `(cp.rev ?? 0)` — the absent-rev-reads-as-0 rule the OCC key already uses. */
1540
+ rev: number;
1541
+ /** Row-field PASSTHROUGH on every present status: the row PRESERVES it across `reopen`
1542
+ * (resolved→pending rows carry it), `expire` never clears it, and a winnerless resolve
1543
+ * (resource/wake/task_done) does not overwrite an older value — so even
1544
+ * `(status:"resolved", outcome present)` is only "the row is resolved AND this is its
1545
+ * LAST RECORDED decision winner", never a store-level guarantee that THIS resolve minted
1546
+ * it. Interpreting the pair as a current decision is the CALLER's discipline (the
1547
+ * runner's gate-kind matching; a single-decision row schema like a deployment's ask
1548
+ * rows). See {@link CheckpointStore.claimTerminal} law 6. */
1549
+ resolvedOutcome?: ResolvedOutcome;
1550
+ }
1551
+ /** Row missing — OR wrong scope: the CAS predicate is the shared REF-A4 row match
1552
+ * ({@link checkpointRowMatches}), and a wrong-scope claim must lose WITHOUT reporting the
1553
+ * row's truth (multi-tenant isolation: cross-scope truth in a loss answer would be a read
1554
+ * bypass of the scope WHERE). Absent is a KNOWN state (zero backoff): expire intent ⇒ moot;
1555
+ * resolve intent ⇒ the caller's typed not-found path, no retry. */
1556
+ | {
1557
+ status: "absent";
1558
+ };
1559
+ };
1486
1560
  /** A persisted suspension point: enough to resume a task on any replica. `status` drives the 3-state
1487
1561
  * machine (pending → resolved | expired) that makes resume idempotent (§5). */
1488
1562
  export interface Checkpoint {
@@ -2447,6 +2521,78 @@ export interface CheckpointStore {
2447
2521
  * `resolve`/`reap`.
2448
2522
  */
2449
2523
  expire(token: CheckpointToken, scope: string): Promise<boolean>;
2524
+ /**
2525
+ * design/384 slice 2 / S-25-R1 — the ATOMIC terminal claim: "win the terminal race" and "read the
2526
+ * truth on a loss" as ONE store operation. OPTIONAL, probed by presence (the `listByScope`
2527
+ * precedent); the runner's resume chain adopts it when present (the loser triages off `current`
2528
+ * with zero second read) and keeps the two-step resolve+get path otherwise. This JSDoc is the
2529
+ * single normative statement of the claim law — `put`'s create-once/read-back three-state and the
2530
+ * suspend saga's commit discipline are the same single-winner law's PUT variant and cross-reference
2531
+ * here rather than restate.
2532
+ *
2533
+ * The laws:
2534
+ * 1. **Atomicity** — the win/lose decision and the loser's `current` reading MUST come from the
2535
+ * same atomic unit (one `UPDATE … RETURNING` / one transaction / one single-threaded map
2536
+ * operation). A settle is self-sufficient: a caller holding `claimed:false` NEVER needs a
2537
+ * second read to act.
2538
+ * 2. **CAS-predicate identity** — `kind:"resolve"`'s win IS {@link resolve}'s win (same WHERE:
2539
+ * token+scope+pending[+rev]; a win records the winner via {@link winnerFromOutcome} and bumps
2540
+ * `rev` identically); `kind:"expire"` IS {@link expire}. ONE-MACHINE OBLIGATION: a store that
2541
+ * provides `claimTerminal` must route all three verbs through one protected write path
2542
+ * (resolve/expire implemented over claimTerminal, or all three over one shared CAS kernel) —
2543
+ * two predicates would be a drift window, and this law forbids it.
2544
+ * 3. **Timeout posture** — no timeout parameter here: transport deadlines belong to the backend,
2545
+ * wall-clock backstops to the caller. The contract states exactly two halves: a SETTLE carries
2546
+ * the truth; a REJECT proves NOTHING (the row may already be terminal — the caller must not
2547
+ * proclaim a terminal state from its own intent; fail-closed projection of "still pending"
2548
+ * stays legal). A LATE settle is as self-sufficient as a prompt one — an answer arriving after
2549
+ * the caller's own deadline still carries the true decision, so the caller settles on truth
2550
+ * with zero follow-up reads.
2551
+ * 4. **Recovery discipline — truth is comparable, ownership is not.** A bounded retry after a
2552
+ * reject re-sends the SAME claim (the CAS is idempotent). When the retry answers
2553
+ * `claimed:false`, `current` tells the caller what the row IS — never WHOSE claim made it so:
2554
+ * two claimants writing byte-identical outcomes cannot be told apart by content, and
2555
+ * {@link ResolvedOutcome} carries no claimant identity. Two consumption classes follow:
2556
+ * IDEMPOTENT consumption (settle on the recorded truth — who wrote it is irrelevant) may use
2557
+ * `claimed:false` + the recorded decision directly; RESPONSIBILITY-bearing consumption (only
2558
+ * the winner may execute the pending action — the runner's resume) must NEVER self-attribute
2559
+ * after ambiguity: treat it as a loss (the `already_resolved` shape, nothing executed) — the
2560
+ * same law as the existing crash-after-CAS pin ("retry sees resolved and does NOT re-execute"),
2561
+ * which this claim does not weaken. There is NO replay arm: a burned approval takes the
2562
+ * existing reopen/re-ask compensation route, and this contract opens no "assume approved and
2563
+ * replay" door. (A claimant-identity field would be a schema change; not here.) Expire-class
2564
+ * claims are naturally idempotent (expire-by-me ≡ expire-by-reaper; no attribution needed).
2565
+ * 5. **A loss reading is a consistent snapshot at the claim's linearization point, not an eternal
2566
+ * truth** — `resolved` can be reopened back to pending, so "settle is self-sufficient" scopes
2567
+ * to the disposition of THIS loss (settle on truth / re-validate on OCC / close on absent),
2568
+ * never to the row's future; a caller needing current state later still `get`s. `current` is a
2569
+ * projection, not the row (no state / pendingAction payloads).
2570
+ * 6. **`resolvedOutcome` reads as the row field it is, passed through verbatim** — the persisted
2571
+ * winner record: written only by decision-bearing resolves ({@link winnerFromOutcome} answers
2572
+ * `undefined` for resource/wake/task_done), PRESERVED by `reopen`, not cleared by later
2573
+ * winnerless resolves. In a loss answer it means "the row's last recorded decision winner",
2574
+ * NOT "the payload of the claim that beat you"; absence means "no decision winner recorded",
2575
+ * not "the winner had no content". For has-a-human-decided consumption this is exactly the
2576
+ * needed semantics; "who beat me / with what" is claimant-identity territory this contract
2577
+ * does not mint.
2578
+ * 7. **No declaration bit** — presence of the method IS the contract (an implementation
2579
+ * obligation at the same level as `resolve`'s CAS promise); no `redecision`-style declaration,
2580
+ * because that family guards against a stub satisfying a type, and a `claimTerminal` stub has
2581
+ * no such shape — the return form itself carries the obligation.
2582
+ *
2583
+ * Deployment correspondence (an ask-row store implementing the same law maps its own vocabulary):
2584
+ * token+scope ↔ the ask id (+ its run binding); pending/resolved/expired ↔ the ask row's
2585
+ * open/decided/terminal states; intent `resolve(outcome)` ↔ the human-decision verb; intent
2586
+ * `expire` ↔ the expire/cancel verbs; the `claimed:false → (status, resolvedOutcome)` PAIRED read
2587
+ * ↔ the decided row's recorded decision (reading the pair as the CURRENT decision is the CALLER's
2588
+ * discipline here — gate-kind matching — while a single-decision row schema satisfies it
2589
+ * structurally). Two shape notes carried from that correspondence: a store whose rows have NO
2590
+ * OCC/rev axis simply never produces the `status:"pending"` loss arm (an honestly absent arm is
2591
+ * correct — do not fabricate a rev); a store whose domain has NO reopen verb enjoys stronger
2592
+ * monotonicity than law 5 assumes — consumption written against law 5's weaker snapshot reading
2593
+ * stays correct there unchanged.
2594
+ */
2595
+ claimTerminal?(token: CheckpointToken, scope: string, intent: TerminalClaimIntent): Promise<TerminalClaimOutcome>;
2450
2596
  /**
2451
2597
  * CAS-expire `pending` checkpoints in `scope` whose `deadline` has passed (`deadline <= cutoff`):
2452
2598
  * `pending → expired`. Returns the count expired (for metrics). Idempotent across replicas (DB
@@ -2589,7 +2735,14 @@ export type CheckpointFaultMode =
2589
2735
  "resolve-after-commit"
2590
2736
  /** `resolve` throws *before* the CAS — simulates a crash before the commit; the row stays `pending`
2591
2737
  * so a retry can still win it. */
2592
- | "resolve-before-commit";
2738
+ | "resolve-before-commit"
2739
+ /** design/384 slice 2 — `claimTerminal` wins its claim (the commit lands) then throws before the
2740
+ * caller is acked: the claim-law recovery scenario (law 3/4 — a reject proves nothing; the retry's
2741
+ * loss answer carries the truth and a responsibility-bearing consumer must NOT self-attribute). */
2742
+ | "claim-after-commit"
2743
+ /** design/384 slice 2 — `claimTerminal` throws *before* its CAS: the row is untouched (still
2744
+ * pending), so a retry can still win the same claim. */
2745
+ | "claim-before-commit";
2593
2746
  /**
2594
2747
  * Default in-process {@link CheckpointStore}. Single-instance / tests only — it does NOT survive a
2595
2748
  * restart or span replicas, so it cannot deliver the cross-process guarantee a durable backend does.
@@ -2613,7 +2766,54 @@ export declare class InMemoryCheckpointStore implements CheckpointStore {
2613
2766
  private fault;
2614
2767
  put(token: CheckpointToken, cp: Checkpoint): Promise<void>;
2615
2768
  get(token: CheckpointToken): Promise<Checkpoint | null>;
2769
+ /** The loser's single-round-trip reading (claim law 1): what the row IS, in its own vocabulary —
2770
+ * or `absent` for a missing row AND a wrong-scope row alike (the isolation rule: cross-scope truth
2771
+ * in a loss answer would be a read bypass of the scope WHERE). Cloned projection, never a live ref. */
2772
+ protected claimLossFrom(cp: Checkpoint | undefined, scope: string): TerminalClaimOutcome;
2773
+ /**
2774
+ * The ONE protected write core for the terminal-resolve claim — `resolve` and
2775
+ * `claimTerminal({kind:"resolve"})` both run exactly this (claim law 2's one-machine obligation:
2776
+ * one predicate, one commit, no drift window). Single-threaded JS makes the check + flip atomic; a
2777
+ * durable backend folds the same predicate into its CAS WHERE clause.
2778
+ *
2779
+ * ORDER (design/384 slice 2, healing the pre-existing resolve shape): the winner is derived and
2780
+ * CLONED — the point where an uncloneable `updatedInput` says so — BEFORE any field is written, and
2781
+ * the commit is then one uninterrupted write group (status+rev+winner+reopenReason). The old order
2782
+ * cloned AFTER the status/rev flip, so an uncloneable winner threw with the row already resolved
2783
+ * and NO winner recorded; now the same throw leaves the row byte-identical (still pending) — the
2784
+ * claim REJECTS (a reject proves nothing, law 3) instead of half-committing.
2785
+ */
2786
+ protected claimResolveCore(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): TerminalClaimOutcome;
2787
+ /** The one protected write core for the terminal-expire claim — `expire` and
2788
+ * `claimTerminal({kind:"expire"})` both run exactly this (claim law 2, same as the resolve core). */
2789
+ protected claimExpireCore(token: CheckpointToken, scope: string): TerminalClaimOutcome;
2616
2790
  resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise<boolean>;
2791
+ /** design/384 slice 2 — the atomic terminal claim (the interface JSDoc is the law's single
2792
+ * normative statement). Implemented OVER the same protected cores `resolve`/`expire` run, which is
2793
+ * the one-machine obligation discharged rather than restated. ONE fault plane with the one
2794
+ * machine: a fault armed under the `resolve-*` names fires on the claim route of the SAME CAS too
2795
+ * (`kind:"resolve"` only — expire never consumed resolve faults), so a crash simulation pinned
2796
+ * against the two-step verb keeps firing byte-identically when the runner adopts the claim form.
2797
+ *
2798
+ * INHERITANCE OBLIGATION (a subclass provides `claimTerminal` whether it means to or not): a
2799
+ * subclass that overrides `resolve`/`expire` to wrap the CAS (audit hooks, fault injection,
2800
+ * interleave fixtures) must override THIS method consistently. Overriding the `protected` cores
2801
+ * (`claimResolveCore`/`claimExpireCore`) instead covers every COMMIT on every route — the cores
2802
+ * are the one write core all three verbs share — but NOT every ATTEMPT: `resolve`/`expire`
2803
+ * evaluate the shared row predicate themselves and return `false` without entering a core when
2804
+ * the row is not pending (missing, another scope, already terminal), while this claim route
2805
+ * enters the core unconditionally and takes its loss reading from it. A core override therefore
2806
+ * sees every winning claim and every OCC loss, but a row-predicate loss only when it arrived via
2807
+ * the claim route; a hook that must record EVERY attempt, including those losses, on EVERY route
2808
+ * wraps the three public verbs consistently. The same applies to a DELEGATING WRAPPER (Proxy / hand-built object)
2809
+ * that forwards `claimTerminal` while hooking only `resolve`: forwarding IS providing, and the
2810
+ * claim route is then the one that actually runs. A resolve-only wrap goes dark the moment a
2811
+ * consumer prefers the claim route (the runner's resume does), which is a one-machine-law
2812
+ * violation the SUBCLASS created — the base class cannot route through the public verbs
2813
+ * instead, because an `await` between the CAS and the loss reading would break the law-1
2814
+ * atomic unit this method exists to provide. (A wrapper that OMITS the member opts out
2815
+ * cleanly: the consumer's presence probe then keeps the two-step path, hooks intact.) */
2816
+ claimTerminal(token: CheckpointToken, scope: string, intent: TerminalClaimIntent): Promise<TerminalClaimOutcome>;
2617
2817
  reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
2618
2818
  setPendingSteer(token: CheckpointToken, scope: string, steer: PendingSteerInput): Promise<boolean>;
2619
2819
  expire(token: CheckpointToken, scope: string): Promise<boolean>;
@@ -2,7 +2,8 @@ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { uuidv7 } from "../internal/harness.js";
3
3
  import { PROBE_CAUSE_PATH_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
4
4
  import { carriesBidiControls } from "./tool-policy.js";
5
- import { renderUntrustedCommandText } from "./permission-rule-model.js";
5
+ import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
6
+ import { redactSecrets } from "./untrusted-egress.js";
6
7
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
8
  export function mintCheckpointToken() {
8
9
  return randomBytes(16).toString("hex");
@@ -124,7 +125,7 @@ export function buildRiskDescriptor(input) {
124
125
  if (shell || toolName === "Bash") {
125
126
  const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
126
127
  if (typeof cmd === "string" && cmd.length > 0)
127
- summary = renderUntrustedCommandText(cmd, SUMMARY_CMD_MAX);
128
+ summary = renderUntrustedCommandText(redactSecrets(stripFormatCharacters(cmd)), SUMMARY_CMD_MAX);
128
129
  const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
129
130
  if (bg === true)
130
131
  summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
@@ -139,7 +140,7 @@ export function buildRiskDescriptor(input) {
139
140
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
140
141
  const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
141
142
  const k = encDigest(renderUntrustedCommandText(key, 40));
142
- const val = encDigest(renderUntrustedCommandText(String(v), SUMMARY_VALUE_MAX));
143
+ const val = encDigest(renderUntrustedCommandText(redactSecrets(stripFormatCharacters(String(v))), SUMMARY_VALUE_MAX));
143
144
  parts.push(`${k}=${val}`);
144
145
  }
145
146
  }
@@ -527,29 +528,71 @@ export class InMemoryCheckpointStore {
527
528
  const cp = this.cps.get(token);
528
529
  return cp ? structuredClone(cp) : null;
529
530
  }
530
- async resolve(token, scope, outcome, expect) {
531
- if (this.fault === "resolve-before-commit") {
532
- this.fault = null;
533
- throw new Error("injected fault: resolve before commit");
534
- }
531
+ claimLossFrom(cp, scope) {
532
+ if (cp === undefined || cp.scope !== scope)
533
+ return { claimed: false, current: { status: "absent" } };
534
+ return {
535
+ claimed: false,
536
+ current: {
537
+ status: cp.status,
538
+ rev: cp.rev ?? 0,
539
+ ...(cp.resolvedOutcome !== undefined ? { resolvedOutcome: structuredClone(cp.resolvedOutcome) } : {}),
540
+ },
541
+ };
542
+ }
543
+ claimResolveCore(token, scope, outcome, expect) {
535
544
  const cp = this.cps.get(token);
536
545
  if (!checkpointRowMatches(cp, scope, "pending")) {
537
- return false;
546
+ return this.claimLossFrom(cp, scope);
538
547
  }
539
548
  if (!checkpointOccMatches(cp, expect)) {
540
- return false;
549
+ return this.claimLossFrom(cp, scope);
541
550
  }
551
+ const winner = winnerFromOutcome(outcome);
552
+ const winnerClone = winner ? structuredClone(winner) : undefined;
542
553
  cp.status = "resolved";
543
554
  cp.rev = (cp.rev ?? 0) + 1;
544
- const winner = winnerFromOutcome(outcome);
545
- if (winner)
546
- cp.resolvedOutcome = structuredClone(winner);
555
+ if (winnerClone)
556
+ cp.resolvedOutcome = winnerClone;
547
557
  cp.reopenReason = undefined;
548
- if (this.fault === "resolve-after-commit") {
558
+ return { claimed: true };
559
+ }
560
+ claimExpireCore(token, scope) {
561
+ const cp = this.cps.get(token);
562
+ if (!checkpointRowMatches(cp, scope, "pending")) {
563
+ return this.claimLossFrom(cp, scope);
564
+ }
565
+ cp.status = "expired";
566
+ return { claimed: true };
567
+ }
568
+ async resolve(token, scope, outcome, expect) {
569
+ if (this.fault === "resolve-before-commit") {
570
+ this.fault = null;
571
+ throw new Error("injected fault: resolve before commit");
572
+ }
573
+ if (!checkpointRowMatches(this.cps.get(token), scope, "pending")) {
574
+ return false;
575
+ }
576
+ const out = this.claimResolveCore(token, scope, outcome, expect);
577
+ if (out.claimed && this.fault === "resolve-after-commit") {
549
578
  this.fault = null;
550
579
  throw new Error("injected fault: resolve after commit");
551
580
  }
552
- return true;
581
+ return out.claimed;
582
+ }
583
+ async claimTerminal(token, scope, intent) {
584
+ if (this.fault === "claim-before-commit" || (intent.kind === "resolve" && this.fault === "resolve-before-commit")) {
585
+ const name = this.fault === "claim-before-commit" ? "claim" : "resolve";
586
+ this.fault = null;
587
+ throw new Error(`injected fault: ${name} before commit`);
588
+ }
589
+ const out = intent.kind === "resolve" ? this.claimResolveCore(token, scope, intent.outcome, intent.expect) : this.claimExpireCore(token, scope);
590
+ if (out.claimed && (this.fault === "claim-after-commit" || (intent.kind === "resolve" && this.fault === "resolve-after-commit"))) {
591
+ const name = this.fault === "claim-after-commit" ? "claim" : "resolve";
592
+ this.fault = null;
593
+ throw new Error(`injected fault: ${name} after commit`);
594
+ }
595
+ return out;
553
596
  }
554
597
  async reopen(token, scope, reason) {
555
598
  const cp = this.cps.get(token);
@@ -571,12 +614,10 @@ export class InMemoryCheckpointStore {
571
614
  return true;
572
615
  }
573
616
  async expire(token, scope) {
574
- const cp = this.cps.get(token);
575
- if (!checkpointRowMatches(cp, scope, "pending")) {
617
+ if (!checkpointRowMatches(this.cps.get(token), scope, "pending")) {
576
618
  return false;
577
619
  }
578
- cp.status = "expired";
579
- return true;
620
+ return this.claimExpireCore(token, scope).claimed;
580
621
  }
581
622
  async reap(scope, cutoff) {
582
623
  let n = 0;
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_lane_unmounted", "peer.inbound_disposition", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -101,6 +101,9 @@ export const ENGINE_NOTICE_CODES = [
101
101
  "config.tool_model_gate_removed",
102
102
  "config.tool_model_gate_unknown_class",
103
103
  "config.tool_model_gate_env_invalid",
104
+ "config.durable_gate_unavailable",
105
+ "config.peer_lane_unmounted",
106
+ "peer.inbound_disposition",
104
107
  "delegation.transcript_integrity",
105
108
  "mcp.revocation_probe_failed",
106
109
  "workflow.governance_key_stripped",
@@ -143,6 +146,7 @@ const NOTICE_AUDIENCE_TABLE = {
143
146
  "steering.parked_input_blocked": "user",
144
147
  "task.halt_unconsumed": "user",
145
148
  "task.late_approval": "user",
149
+ "config.durable_gate_unavailable": "user",
146
150
  "memory.capture_opted_out": "user",
147
151
  "memory.capture_optout_unpersisted": "user",
148
152
  "memory.consolidation_withheld": "user",
@@ -154,6 +158,8 @@ const NOTICE_AUDIENCE_TABLE = {
154
158
  "config.tool_model_gate_removed": "operator",
155
159
  "config.tool_model_gate_unknown_class": "operator",
156
160
  "config.tool_model_gate_env_invalid": "operator",
161
+ "config.peer_lane_unmounted": "operator",
162
+ "peer.inbound_disposition": "user",
157
163
  "delegation.transcript_integrity": "operator",
158
164
  "mcp.revocation_probe_failed": "operator",
159
165
  "workflow.governance_key_stripped": "operator",