@eir-labs/coltrane 0.24.30 → 0.24.31

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 (86) hide show
  1. package/agents/change-verifier.json +4 -2
  2. package/agents/deploy-scout.json +2 -2
  3. package/agents/red-law-reviewer.json +54 -0
  4. package/agents/red-spec-attester.json +53 -0
  5. package/agents/red-spec-builder.json +56 -0
  6. package/agents/red-spec-drafter.json +1 -1
  7. package/agents/spec-reviewer.json +1 -1
  8. package/dist/src/chart.js +4 -0
  9. package/dist/src/chart.js.map +1 -1
  10. package/dist/src/chat_completions_port.d.ts +41 -0
  11. package/dist/src/chat_completions_port.js +221 -0
  12. package/dist/src/chat_completions_port.js.map +1 -0
  13. package/dist/src/claude_invoker.d.ts +18 -0
  14. package/dist/src/claude_invoker.js +771 -22
  15. package/dist/src/claude_invoker.js.map +1 -1
  16. package/dist/src/cli.d.ts +1 -1
  17. package/dist/src/cli.js +49 -8
  18. package/dist/src/cli.js.map +1 -1
  19. package/dist/src/completions_invoker.d.ts +79 -0
  20. package/dist/src/completions_invoker.js +220 -0
  21. package/dist/src/completions_invoker.js.map +1 -0
  22. package/dist/src/composition.d.ts +13 -0
  23. package/dist/src/composition.js +16 -0
  24. package/dist/src/composition.js.map +1 -1
  25. package/dist/src/genome_schema.d.ts +200 -0
  26. package/dist/src/genome_schema.js +37 -0
  27. package/dist/src/genome_schema.js.map +1 -1
  28. package/dist/src/genome_store.d.ts +30 -2
  29. package/dist/src/genome_store.js +51 -8
  30. package/dist/src/genome_store.js.map +1 -1
  31. package/dist/src/index.d.ts +5 -0
  32. package/dist/src/index.js +9 -0
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/invoker_selection.d.ts +58 -0
  35. package/dist/src/invoker_selection.js +110 -0
  36. package/dist/src/invoker_selection.js.map +1 -0
  37. package/dist/src/ledger.d.ts +45 -2
  38. package/dist/src/ledger.js +23 -3
  39. package/dist/src/ledger.js.map +1 -1
  40. package/dist/src/mcp.js +2 -2
  41. package/dist/src/mcp.js.map +1 -1
  42. package/dist/src/outputs.d.ts +24 -0
  43. package/dist/src/outputs.js +1 -0
  44. package/dist/src/outputs.js.map +1 -1
  45. package/dist/src/registry.js +16 -0
  46. package/dist/src/registry.js.map +1 -1
  47. package/dist/src/repo_index.d.ts +13 -0
  48. package/dist/src/repo_index.js +90 -10
  49. package/dist/src/repo_index.js.map +1 -1
  50. package/dist/src/reside_backing.d.ts +3 -2
  51. package/dist/src/reside_backing.js +8 -2
  52. package/dist/src/reside_backing.js.map +1 -1
  53. package/dist/src/reuse.d.ts +8 -0
  54. package/dist/src/reuse.js.map +1 -1
  55. package/dist/src/run_deps.d.ts +10 -10
  56. package/dist/src/run_deps.js +25 -15
  57. package/dist/src/run_deps.js.map +1 -1
  58. package/dist/src/runtime.d.ts +205 -109
  59. package/dist/src/runtime.js +904 -145
  60. package/dist/src/runtime.js.map +1 -1
  61. package/dist/src/server.js +148 -45
  62. package/dist/src/server.js.map +1 -1
  63. package/dist/src/server_relay.js +28 -0
  64. package/dist/src/server_relay.js.map +1 -1
  65. package/dist/src/skill_subprocess.d.ts +1 -0
  66. package/dist/src/skill_subprocess.js +5 -0
  67. package/dist/src/skill_subprocess.js.map +1 -1
  68. package/dist/src/transcript_store.d.ts +14 -0
  69. package/dist/src/transcript_store.js +39 -0
  70. package/dist/src/transcript_store.js.map +1 -0
  71. package/dist/src/turn_loop.d.ts +173 -0
  72. package/dist/src/turn_loop.js +283 -0
  73. package/dist/src/turn_loop.js.map +1 -0
  74. package/dist/src/version.d.ts +1 -1
  75. package/dist/src/version.js +1 -1
  76. package/dist/src/worker.js +6 -0
  77. package/dist/src/worker.js.map +1 -1
  78. package/dist/src/worker_env.d.ts +4 -2
  79. package/dist/src/worker_env.js +42 -0
  80. package/dist/src/worker_env.js.map +1 -1
  81. package/domain_types/change-set.json +33 -22
  82. package/domain_types/red-spec.json +32 -23
  83. package/domain_types/seat-primer.json +65 -0
  84. package/package.json +1 -1
  85. package/standards/build-from-red-spec-v0.json +93 -0
  86. package/standards/draft-red-laws-v0.json +68 -0
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { writeFileSync, unlinkSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
- import { randomUUID } from "node:crypto";
9
+ import { randomUUID, createHash } from "node:crypto";
10
10
  import { abortReasonText } from "./runtime.js";
11
11
  import { resolveAgentGrants, hostBuiltinDenials, toolBaseName, ENGINE_MCP_SERVER } from "./tool_providers.js";
12
12
  import { venueEffectiveTools } from "./chart.js";
@@ -110,12 +110,45 @@ export function buildPrompt(ctx, outputSchema,
110
110
  // single object — the runtime then seals one record per key.
111
111
  outputSchemas, seal) {
112
112
  const a = ctx.agent;
113
+ // contract-amend-resume-prompt-v1 (O1/O2/I1) — a RESUMED amend carries ONLY what is new. The
114
+ // resumed conversation already holds # Disposition / # Identity / # Method / # Context and the gig
115
+ // input from round one, so re-sending them re-pays the whole cold read the resume exists to avoid
116
+ // (measured: the amend prompt was byte-for-byte the full round-one prompt). Emit a trimmed prompt:
117
+ // a short statement that this is an amend round of the same chair, plus the failing verdict's
118
+ // content (its pass:false and failing checks) — the one thing round one did not yet have. The cold
119
+ // fallback for a lost session re-invokes buildPrompt with resume OFF, so the FULL prompt is still
120
+ // reachable when the resume's conversation is gone (I2).
121
+ // contract-resumed-gig-session-v1 (O1) — a re-VERIFY resume keeps the FULL prompt: it re-invokes
122
+ // with `resume` (so buildInvokerArgs emits --resume) but `resume_keep_prompt` set, meaning it re-reads
123
+ // the amended tree under its own identity rather than being handed a trimmed "here is the one new
124
+ // input" continuation. The trimmed branch below is for a MAKER amend, whose one new thing IS the
125
+ // failing verdict; a stateless door (chat-completions) that holds no conversation must still carry the
126
+ // seat's identity, which the trim would strip — so a keep-prompt resume falls through to the full stack.
127
+ if (ctx.resume === true && ctx.resume_keep_prompt !== true) {
128
+ const failing = ctx.inputs.find((o) => o.data?.pass === false);
129
+ const verdictBlock = failing
130
+ ? JSON.stringify(failing.data)
131
+ : "(the failing verdict was not carried into this amend round)";
132
+ return [
133
+ `# Amend round`,
134
+ `This is an amend round of the same "${ctx.role ?? a.slug}" chair, resuming the conversation ` +
135
+ `that already holds your disposition, identity, method, tools and the gig input. Fix ONLY ` +
136
+ `what the verify below caught, then re-seal your output exactly as you did before.`,
137
+ `# Failing verdict\nThe verify FAILED (pass: false). Its failing checks are what to fix:\n${verdictBlock}`,
138
+ ].join("\n\n");
139
+ }
113
140
  const layers = [];
114
141
  // 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
115
142
  const dispo = a.behavioral_primitives.map((r) => `- **${r}**: ${BELBIN_DESCRIPTIONS[r] ?? r}`).join("\n");
116
143
  layers.push(`# Disposition\nYou hold these cognitive modes in equal tension:\n${dispo}\nHold every mode active throughout your work; none dominates.`);
117
- // 2. Identity — who you are: the slug line plus the agent's own prose.
118
- layers.push(`# Identity\nYou are the agent "${a.slug}"${a.domain ? ` in the "${a.domain}" domain` : ""}.\n\n${a.identity}`);
144
+ // 2. Identity — who you are: the slug line plus the agent's own prose. When the context carries
145
+ // a chair role, name the seat this invocation holds and ONLY this seat. Two chairs seating the
146
+ // same agent in one phase share every other layer, so the seat line is what splits their prompts;
147
+ // without it the division of labour a standard declares between them exists only in the role
148
+ // names and each chair does the same work. A ctx without a role (hand-built literals, the
149
+ // text-seal path) renders no seat line, so those prompts stay valid and byte-identical.
150
+ const seatLine = ctx.role ? `\nYou are seated as the "${ctx.role}" chair in this phase.` : "";
151
+ layers.push(`# Identity\nYou are the agent "${a.slug}"${a.domain ? ` in the "${a.domain}" domain` : ""}.${seatLine}\n\n${a.identity}`);
119
152
  // 3. Method — how THIS agent does its job, the step-by-step.
120
153
  layers.push(`# Method\n${a.method}`);
121
154
  // 4. Skills — content the agent's bound skills contribute to the prompt. Each
@@ -229,8 +262,57 @@ outputSchemas, seal) {
229
262
  layers.push(`# Task\nProduce exactly one "${outType}".${schemaHint}\n` +
230
263
  `Respond with ONLY a single JSON object (the output's data) — no prose, no code fence.`);
231
264
  }
265
+ // contract-seat-primer-v1 (O4) — a FORK warm-starts from a primer that already read this area; the
266
+ // files whose working-tree blob CHANGED since priming are named here so the seat re-reads exactly
267
+ // those, not the whole area cold. An empty stale set adds nothing (the primed reading still holds).
268
+ if (ctx.fork?.stale_paths && ctx.fork.stale_paths.length > 0) {
269
+ layers.push(`# Changed since priming\nYou forked a primer that had already read this area. These files have ` +
270
+ `CHANGED since it was primed — RE-READ them before relying on them, the primer's copy is stale:\n` +
271
+ ctx.fork.stale_paths.map((p) => `- ${p}`).join("\n"));
272
+ }
232
273
  return layers.join("\n\n");
233
274
  }
275
+ /**
276
+ * contract-reverify-resume-prompt-v1 (O1) — the SHORT prompt a RESUMED Claude re-verify spawn carries.
277
+ * A re-verify RESUMES the verifier's own round-one conversation (`ctx.resume` + `ctx.resume_keep_prompt`),
278
+ * which already holds its disposition, identity, method, tools and the gig input, so this re-sends NONE
279
+ * of them — no buildPrompt layer, no gig input. It states only what is new: the makers AMENDED their
280
+ * work, so the verdict must be re-derived from the CURRENT working tree; plus the chair's output
281
+ * contract (its output type, and the in-band `output_write` seal directive when this door seals that way).
282
+ *
283
+ * The trim lives HERE, on the resuming Claude side, NOT in the shared buildPrompt: buildPrompt keeps the
284
+ * full prompt for a keep-prompt resume (its trim branch keys on `resume_keep_prompt !== true`), so the
285
+ * stateless chat-completions door — which builds via buildPrompt and never reaches this function — keeps
286
+ * the full prompt it needs to place the seat (O2), and the cold fallback for a lost resume re-sends the
287
+ * full prompt built with resume OFF (F1). By construction this is a small fraction of the round-one
288
+ * prompt (I1). buildPrompt's Task/seal directive is mirrored here rather than shared because the two
289
+ * shapes diverge in what they re-send: the full stack vs. only-what-is-new.
290
+ */
291
+ function buildReverifyResumePrompt(sealTypes, outputSchema, outputSchemas, seal) {
292
+ const types = sealTypes.length ? sealTypes : ["output"];
293
+ const contract = seal
294
+ ? `Re-seal each of your output types by calling the \`output_write\` tool — one call per type, ` +
295
+ `exactly as you did in round one:\n` +
296
+ types
297
+ .map((t) => {
298
+ const s = outputSchemas?.[t] ?? (types.length === 1 ? outputSchema : undefined);
299
+ const core = seal.core_by_type[t] ?? "";
300
+ return (`- output_write({ "core_type": "${core}", "domain_type": "${t}", ` +
301
+ `"gig_id": "${seal.gig_id}", "phase": "${seal.phase}", "agent_slug": "${seal.agent_slug}", ` +
302
+ `"data": <object${s ? ` matching ${JSON.stringify(s)}` : ""}> })`);
303
+ })
304
+ .join("\n")
305
+ : `Re-seal your output — ${types.map((t) => `"${t}"`).join(", ")} — exactly as you did in round one: ` +
306
+ `respond with ONLY the single JSON object (the output's data), no prose, no code fence.`;
307
+ return [
308
+ `# Re-verify (amend round)`,
309
+ `The makers AMENDED their work in response to your failing verdict. You are RESUMING the conversation ` +
310
+ `that already holds your disposition, identity, method, tools and the gig input, so this prompt ` +
311
+ `carries only what is new: re-derive your verdict from the CURRENT working tree — read the amended ` +
312
+ `artifact as it now stands, do not rely on what you saw in round one — and rule again.`,
313
+ `# Output contract\n${contract}`,
314
+ ].join("\n\n");
315
+ }
234
316
  // ───────────────────────── JSON extraction (#221, #226) ─────────────────────────
235
317
  //
236
318
  // The old implementation took "the first balanced brace run" — string-blind, anchored on
@@ -742,6 +824,181 @@ export function captureOutputWrites(stdout, sealTypes) {
742
824
  }
743
825
  return blob;
744
826
  }
827
+ /**
828
+ * contract-seat-primer-v1 (O1/I2) — the file paths a PRIME seat Read, parsed from the run's stdout the
829
+ * SAME way `captureOutputWrites` parses its `output_write` calls: a `Read` tool_use's `file_path`. The
830
+ * primer's `files` list is the SET of files the seat read, so paths are de-duped preserving first
831
+ * appearance. Read through the RETURNED stdout (not the streaming `onEvent` path) because an injected
832
+ * `run` seam bypasses streaming — the sealer must see what the run actually returned.
833
+ */
834
+ function captureReadPaths(stdout) {
835
+ const seen = new Set();
836
+ const out = [];
837
+ for (const raw of stdout.split("\n")) {
838
+ const line = raw.trim();
839
+ if (!line)
840
+ continue;
841
+ let e;
842
+ try {
843
+ e = JSON.parse(line);
844
+ }
845
+ catch {
846
+ continue;
847
+ }
848
+ const type = typeof e["type"] === "string" ? e["type"] : "";
849
+ const msg = e["message"];
850
+ if ((type !== "assistant" && type !== "user") || !msg || typeof msg !== "object")
851
+ continue;
852
+ const content = msg.content ?? [];
853
+ for (const b of content) {
854
+ if (String(b["type"] ?? "") !== "tool_use" || String(b["name"] ?? "") !== "Read")
855
+ continue;
856
+ const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
857
+ const path = input["file_path"];
858
+ if (typeof path === "string" && path.length > 0 && !seen.has(path)) {
859
+ seen.add(path);
860
+ out.push(path);
861
+ }
862
+ }
863
+ }
864
+ return out;
865
+ }
866
+ /**
867
+ * contract-rolling-seat-primer-v1 (O3) — the context size (input + cache_read + cache_creation) of the
868
+ * LAST assistant usage in the run's stdout, parsed the SAME way `captureReadPaths` parses its Read
869
+ * events. Every seat-primer records this as `context_tokens`, so the next build's `max_context_tokens`
870
+ * ceiling can weigh the primer. Read through the RETURNED stdout (not the streaming `onEvent` path)
871
+ * because an injected `run` seam bypasses streaming — the sealer must see what the run actually
872
+ * returned. `undefined` when the run reported no usage (the runtime then falls back to its own
873
+ * streamed `lastAssistantContext`, or 0).
874
+ */
875
+ function captureLastContext(stdout) {
876
+ let last;
877
+ for (const raw of stdout.split("\n")) {
878
+ const line = raw.trim();
879
+ if (!line)
880
+ continue;
881
+ let e;
882
+ try {
883
+ e = JSON.parse(line);
884
+ }
885
+ catch {
886
+ continue;
887
+ }
888
+ if (String(e["type"] ?? "") !== "assistant")
889
+ continue;
890
+ const msg = e["message"];
891
+ if (!msg || typeof msg !== "object")
892
+ continue;
893
+ const usage = msg.usage;
894
+ if (usage && typeof usage === "object") {
895
+ last = (usage["input_tokens"] ?? 0) + (usage["cache_read_input_tokens"] ?? 0) + (usage["cache_creation_input_tokens"] ?? 0);
896
+ }
897
+ }
898
+ return last;
899
+ }
900
+ /** The write tools whose first call ends a seat's READING (contract-primer-reading-frontier-v1 O1). */
901
+ const FRONTIER_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
902
+ /**
903
+ * The input+cache_read+cache_creation of an assistant line's usage, or undefined when it carries none.
904
+ * contract-fork-continuation-is-exact-v1 (O2) — a usage reporting NONE of the three context fields
905
+ * (e.g. a write line carrying only `output_tokens`) contributes NO reading: it returns `undefined`, not
906
+ * `0`, so `usage ?? lastUsageBefore` falls through to the last REPORTED reading rather than discarding
907
+ * it. A usage reporting ANY of the three sums the ones present (a missing field as `0`), as today.
908
+ */
909
+ function assistantContextOf(msg) {
910
+ const u = msg?.usage;
911
+ if (!u || typeof u !== "object")
912
+ return undefined;
913
+ if (u["input_tokens"] === undefined && u["cache_read_input_tokens"] === undefined && u["cache_creation_input_tokens"] === undefined) {
914
+ return undefined;
915
+ }
916
+ return (u["input_tokens"] ?? 0) + (u["cache_read_input_tokens"] ?? 0) + (u["cache_creation_input_tokens"] ?? 0);
917
+ }
918
+ /**
919
+ * contract-primer-reading-frontier-v1 (O1/O3/O4/I2/I3/F1) — a seat-primer is forked at its READING
920
+ * FRONTIER: the point where the seat stopped reading and started writing. Parsed over the concatenated
921
+ * seal stdout (every spawn, in order — so a first write in a reserve continuation is found, I2):
922
+ * · `frontier` — the uuid of the last `type:"user"` line before the FIRST assistant line whose content
923
+ * calls a write tool (Write/Edit/MultiEdit/NotebookEdit). No write, or no user line before it (F1),
924
+ * yields no frontier.
925
+ * · `context_tokens` — WITH a frontier, the context of that first-write assistant line (its own usage,
926
+ * or the last usage before it); WITHOUT one, the seat's LAST usage exactly as today (I3/F1).
927
+ * · `reads` — WITH a frontier, the Read paths on assistant lines BEFORE the FRONTIER line — the reads
928
+ * the cut conversation holds. contract-fork-continuation-is-exact-v1 (O3): the frontier is the last
929
+ * `user` line before the first write, so a Read in the write's OWN turn (after that user line, with
930
+ * no user line between it and the write) is AFTER the frontier and is NOT recorded. Reads since the
931
+ * last user line are PENDING; a user line commits them, and the first write discards the pending set.
932
+ * WITHOUT a frontier, every Read as today.
933
+ * Derived by the engine from the stream, never typed by the model.
934
+ */
935
+ function captureReadingFrontier(stdout) {
936
+ let lastUserUuid;
937
+ let lastUsageBefore;
938
+ const readsBefore = []; // reads BEFORE the frontier line (committed at each user line)
939
+ let pendingReads = []; // reads SINCE the last user line — not yet before a frontier
940
+ const seen = new Set();
941
+ let frontier;
942
+ let firstWriteContext;
943
+ let foundWrite = false;
944
+ const commitPending = () => {
945
+ for (const p of pendingReads)
946
+ if (!seen.has(p)) {
947
+ seen.add(p);
948
+ readsBefore.push(p);
949
+ }
950
+ pendingReads = [];
951
+ };
952
+ for (const raw of stdout.split("\n")) {
953
+ const line = raw.trim();
954
+ if (!line)
955
+ continue;
956
+ let e;
957
+ try {
958
+ e = JSON.parse(line);
959
+ }
960
+ catch {
961
+ continue;
962
+ }
963
+ const type = typeof e["type"] === "string" ? e["type"] : "";
964
+ if (type === "user") {
965
+ if (typeof e["uuid"] === "string")
966
+ lastUserUuid = e["uuid"];
967
+ // reads seen since the last user line are now BEFORE a user line ⇒ before any later frontier (O3)
968
+ commitPending();
969
+ continue;
970
+ }
971
+ const msg = e["message"];
972
+ if (type !== "assistant" || !msg || typeof msg !== "object")
973
+ continue;
974
+ const content = msg.content ?? [];
975
+ const usage = assistantContextOf(msg);
976
+ if (content.some((b) => String(b["type"] ?? "") === "tool_use" && FRONTIER_WRITE_TOOLS.has(String(b["name"] ?? "")))) {
977
+ foundWrite = true;
978
+ frontier = lastUserUuid; // undefined ⇒ no user line before the write ⇒ no frontier (F1)
979
+ firstWriteContext = usage ?? lastUsageBefore;
980
+ // pendingReads (this write's OWN turn, after the frontier) are DISCARDED — the cut ends before them
981
+ break;
982
+ }
983
+ for (const b of content) {
984
+ if (String(b["type"] ?? "") !== "tool_use" || String(b["name"] ?? "") !== "Read")
985
+ continue;
986
+ const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
987
+ const path = input["file_path"];
988
+ if (typeof path === "string" && path.length > 0 && !seen.has(path) && !pendingReads.includes(path)) {
989
+ pendingReads.push(path);
990
+ }
991
+ }
992
+ if (usage !== undefined)
993
+ lastUsageBefore = usage;
994
+ }
995
+ // A frontier requires BOTH a write AND a user line before it. Otherwise the primer is as today:
996
+ // the last usage over the whole run, and every Read.
997
+ if (foundWrite && frontier !== undefined) {
998
+ return { frontier, context_tokens: firstWriteContext, reads: readsBefore };
999
+ }
1000
+ return { context_tokens: captureLastContext(stdout), reads: captureReadPaths(stdout) };
1001
+ }
745
1002
  // The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
746
1003
  // terminus (it can search/loop), and the gig runs the spawn synchronously — so without
747
1004
  // this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
@@ -849,6 +1106,145 @@ export function promptViaStdin(prompt) {
849
1106
  return true;
850
1107
  return prompt.length > promptArgLimit();
851
1108
  }
1109
+ /**
1110
+ * The `claude` session id a chair's conversation is NAMED by, derived deterministically from
1111
+ * `(gig_id, role)`. The same seat used twice in one gig derives the SAME uuid, so its second reach
1112
+ * can `--resume` the conversation the first opened instead of re-reading cold; two roles in one gig,
1113
+ * or one role across two gigs, never collide (a v5-shaped uuid over a `gig_id\x1frole` string).
1114
+ *
1115
+ * A gig-less invocation (no `gig_id`) returns undefined: with nothing to key the session on there is
1116
+ * nothing to name and nothing to resume, so the spawn carries neither `--session-id` nor `--resume`.
1117
+ * The uuid is a valid RFC 4122 string (version nibble 5, variant 8–b) so the CLI accepts it as a
1118
+ * session id and the spec's UUID shape holds.
1119
+ */
1120
+ export function sessionUuidFor(gig_id, role) {
1121
+ if (!gig_id)
1122
+ return undefined;
1123
+ const h = createHash("sha1").update(`coltrane-chair-session${gig_id}${role ?? ""}`).digest();
1124
+ h[6] = (h[6] & 0x0f) | 0x50; // version 5
1125
+ h[8] = (h[8] & 0x3f) | 0x80; // RFC 4122 variant
1126
+ const hex = h.subarray(0, 16).toString("hex");
1127
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
1128
+ }
1129
+ /**
1130
+ * Rewrite a built arg list to RESUME a session rather than open one: it must carry EXACTLY ONE
1131
+ * `--resume <uuid>` naming the session to CONTINUE, and none of the flags that open or fork a fresh
1132
+ * conversation. A resume must not ALSO name a fresh session (`--session-id`, which the CLI reads as
1133
+ * opening, not continuing), nor re-run a warm start: for a FORK chair `baseArgs` is the first-spawn
1134
+ * warm start `--resume <primer> --resume-session-at <frontier> --fork-session --session-id <own>`
1135
+ * (contract-fork-continuation-is-exact-v1 O1), and a continuation of that chair's OWN session carries
1136
+ * none of it — the warm start belongs to the FIRST spawn only. So every `--session-id`,
1137
+ * `--resume-session-at` and inherited `--resume` PAIR is dropped (flag and value), `--fork-session`
1138
+ * (valueless) is dropped, and a single `--resume <sessionId>` is appended.
1139
+ */
1140
+ function withResume(args, sessionId) {
1141
+ const out = [];
1142
+ for (let i = 0; i < args.length; i++) {
1143
+ // drop each opening/warm-start flag AND its value — a continuation resumes ONE session and no more
1144
+ if (args[i] === "--session-id" || args[i] === "--resume" || args[i] === "--resume-session-at") {
1145
+ i++;
1146
+ continue;
1147
+ }
1148
+ if (args[i] === "--fork-session")
1149
+ continue; // valueless flag: the warm start belongs to the first spawn
1150
+ out.push(args[i]);
1151
+ }
1152
+ out.push("--resume", sessionId);
1153
+ return out;
1154
+ }
1155
+ /**
1156
+ * Did a `--resume` run fail because its session could not be found? The CLI reports a missing resume
1157
+ * target as an error result whose text names it ("No conversation found with session ID …"). Read
1158
+ * from the child's stream so it is caught whether the run resolved with the error result or threw a
1159
+ * non-zero exit carrying the same stdout. Deliberately narrow — a result event's text, not any line —
1160
+ * so an ordinary failure is never mistaken for a lost session (F1).
1161
+ */
1162
+ function resumeSessionLost(stdout) {
1163
+ for (const raw of stdout.split("\n")) {
1164
+ const line = raw.trim();
1165
+ if (!line)
1166
+ continue;
1167
+ let e;
1168
+ try {
1169
+ e = JSON.parse(line);
1170
+ }
1171
+ catch {
1172
+ continue;
1173
+ }
1174
+ if (e["type"] !== "result")
1175
+ continue;
1176
+ const txt = typeof e["result"] === "string" ? e["result"] : "";
1177
+ if (/no conversation found|no such session|session .*not found/i.test(txt))
1178
+ return true;
1179
+ }
1180
+ return false;
1181
+ }
1182
+ /**
1183
+ * contract-primer-reading-frontier-v1 (F2) — did a fork fail because its `--resume-session-at` named a
1184
+ * message uuid the primer's session does not hold? claude 2.1.274 (probed 2026-09-18) exits 1 with the
1185
+ * notice `No message found with message.uuid of: <uuid>` on stderr (so in the ChildExitError message),
1186
+ * and a stdout result event that carries it ONLY in `errors: [...]` — there is no `result` text, so
1187
+ * `resumeSessionLost` (which reads `result` alone) cannot see it. Detect it in EITHER place, next to
1188
+ * `resumeSessionLost`, so the unresolvable-frontier fork takes the cold fallback rather than failing.
1189
+ */
1190
+ function frontierNotFound(stdout, message) {
1191
+ const re = /no message found with message\.uuid of/i;
1192
+ if (re.test(message))
1193
+ return true;
1194
+ for (const raw of stdout.split("\n")) {
1195
+ const line = raw.trim();
1196
+ if (!line)
1197
+ continue;
1198
+ let e;
1199
+ try {
1200
+ e = JSON.parse(line);
1201
+ }
1202
+ catch {
1203
+ continue;
1204
+ }
1205
+ if (e["type"] !== "result")
1206
+ continue;
1207
+ const errs = e["errors"];
1208
+ if (Array.isArray(errs) && errs.some((x) => typeof x === "string" && re.test(x)))
1209
+ return true;
1210
+ const txt = typeof e["result"] === "string" ? e["result"] : "";
1211
+ if (re.test(txt))
1212
+ return true;
1213
+ }
1214
+ return false;
1215
+ }
1216
+ /**
1217
+ * Did a spawn fail because the `--session-id` it opened with is ALREADY IN USE? A chair's session id
1218
+ * is deterministic in `(gig_id, role)`, so a KILLED attempt that already opened it leaves the id live;
1219
+ * a resumed gig's first spawn re-opens it and the CLI refuses it (`Error: Session ID <uuid> is already
1220
+ * in use.`). Reported both as an error result in the stream AND in the non-zero exit message, so read
1221
+ * both — the exit `message` and the result stream (like `resumeSessionLost`) — and catch it whichever
1222
+ * carries it. This is the COLLISION case the LOST case (`resumeSessionLost`) does NOT cover; the two
1223
+ * are disjoint (one names "already in use", the other "no conversation found").
1224
+ */
1225
+ function sessionIdInUse(stdout, message) {
1226
+ const inUse = /session id .*is already in use/i;
1227
+ if (inUse.test(message))
1228
+ return true;
1229
+ for (const raw of stdout.split("\n")) {
1230
+ const line = raw.trim();
1231
+ if (!line)
1232
+ continue;
1233
+ let e;
1234
+ try {
1235
+ e = JSON.parse(line);
1236
+ }
1237
+ catch {
1238
+ continue;
1239
+ }
1240
+ if (e["type"] !== "result")
1241
+ continue;
1242
+ const txt = typeof e["result"] === "string" ? e["result"] : "";
1243
+ if (inUse.test(txt))
1244
+ return true;
1245
+ }
1246
+ return false;
1247
+ }
852
1248
  export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
853
1249
  // `-p` is a BOOLEAN flag and the prompt is a POSITIONAL argument, which is what makes the
854
1250
  // large-prompt path clean: keep the flag, drop the positional, write it to stdin. The
@@ -857,9 +1253,38 @@ export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
857
1253
  const args = promptViaStdin(prompt) ? ["-p"] : ["-p", prompt];
858
1254
  if (opts.model)
859
1255
  args.push("--model", opts.model);
1256
+ // contract-chair-session-continuity-v1 — NAME the chair's conversation so a second reach can
1257
+ // resume it. Every spawn with a session id opens one with `--session-id`; a re-invocation that
1258
+ // is resuming (the amend round) carries `--resume` instead — never both, or the CLI opens a
1259
+ // fresh session rather than continuing. A gig-less spawn has no session id and carries neither.
1260
+ // contract-seat-primer-v1 (O2) — a FORK chair's FIRST spawn WARM-STARTS: it `--resume`s the primer
1261
+ // seat's session, `--fork-session`s that conversation into a NEW branch, and names THAT branch with
1262
+ // its own (gig, role) `--session-id`. Distinct from an amend `--resume` (which continues the SAME
1263
+ // session under the same id): a fork opens its own session forked FROM another's.
1264
+ if (opts.fork_from_session && opts.session_id) {
1265
+ args.push("--resume", opts.fork_from_session);
1266
+ // contract-primer-reading-frontier-v1 (O2) — a fork of a primer that recorded a reading FRONTIER
1267
+ // CUTS the resumed conversation there: `--resume-session-at <frontier>` starts the branch with the
1268
+ // conversation as it stood after that message, so the fork loads what the primer READ, never what
1269
+ // it then DID. A frontier-less primer carries none, so the whole session resumes exactly as today.
1270
+ if (opts.resume_session_at)
1271
+ args.push("--resume-session-at", opts.resume_session_at);
1272
+ args.push("--fork-session", "--session-id", opts.session_id);
1273
+ }
1274
+ else if (opts.session_id) {
1275
+ if (opts.resume)
1276
+ args.push("--resume", opts.session_id);
1277
+ else
1278
+ args.push("--session-id", opts.session_id);
1279
+ }
860
1280
  // per-agent blast-radius cap: a runaway agent can't burn past its own turn budget.
861
1281
  if (opts.max_tool_calls !== undefined)
862
1282
  args.push("--max-turns", String(opts.max_tool_calls));
1283
+ // #seat-effort (O3/I2) — the spawn ALWAYS carries exactly one --effort, whatever depth or turn
1284
+ // budget applies. Floored to `medium` here so a hand-built ctx that resolved to no effort still
1285
+ // gets an explicit level rather than inheriting the operator's ~/.claude/settings.json effortLevel
1286
+ // — the measured defect. The invoke door passes the resolved `ctx.effort ?? "medium"`.
1287
+ args.push("--effort", opts.effort ?? "medium");
863
1288
  // the cage floor: no ambient MCP servers leak into the spawn, ever.
864
1289
  args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
865
1290
  // The OTHER half of that floor, and it was missing. A seat's cwd is a freshly cloned repository
@@ -878,6 +1303,15 @@ export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
878
1303
  //
879
1304
  // This is what makes one gig's write to a repo stop being every later gig's execution.
880
1305
  args.push("--setting-sources", "user");
1306
+ // contract-seat-memory-v1 (O1/I1) — a seat's writes are its grants; the operator's auto-memory is
1307
+ // NOT a seat's to write. Measured: a `claude -p` seat spawned as chairs are (`--setting-sources
1308
+ // user`) wrote ~/.claude/projects/<repo>/memory/, loaded by every later session in the repo.
1309
+ // `--setting-sources user` bounds which settings FILES load; it does not turn the auto-memory tool
1310
+ // off. Pass exactly ONE `--settings` whose JSON disables it, here at the single point every spawn
1311
+ // kind is built from (first run, resumed amend, reserve continuation, cold fallback), so the pair
1312
+ // rides through every arg-list transform. Disjoint from --setting-sources / --effort / the session
1313
+ // flags, so the effort and session-continuity contracts are untouched.
1314
+ args.push("--settings", JSON.stringify({ autoMemoryEnabled: false }));
881
1315
  if (opts.allowed_tools && opts.allowed_tools.length > 0)
882
1316
  args.push("--allowedTools", opts.allowed_tools.join(","));
883
1317
  if (opts.disallowed_tools && opts.disallowed_tools.length > 0)
@@ -1090,7 +1524,25 @@ export function makeClaudeInvoker(opts = {}) {
1090
1524
  core_by_type: Object.fromEntries(sealTypes.map((t) => [t, coreTypeOf(t)])),
1091
1525
  }
1092
1526
  : undefined;
1093
- const prompt = buildPrompt(ctx, schema, outputSchemas, seal);
1527
+ // contract-amend-resume-prompt-v1 on an amend RESUME (ctx.resume, and a session to resume) the
1528
+ // spawn carries a TRIMMED prompt (buildPrompt keys on ctx.resume); the FULL prompt is built with
1529
+ // resume OFF so the cold fallback for a lost resume session can re-send it (I2). On every
1530
+ // non-resume spawn the two are identical, so nothing else changes shape.
1531
+ //
1532
+ // contract-reverify-resume-prompt-v1 (O1/I1/F1) — a re-VERIFY resume (ctx.resume_keep_prompt) is a
1533
+ // DIFFERENT trim: buildPrompt keeps the FULL prompt for a keep-prompt resume (so the stateless
1534
+ // completions door keeps everything it needs — O2), so the trim to only-what-is-new has to be
1535
+ // applied HERE, on the resuming Claude side. A MAKER amend resume (no keep_prompt) still takes
1536
+ // buildPrompt's own trim. fullPrompt stays the full round-one prompt in every case, so the cold
1537
+ // fallback for a lost resume re-sends the verifier's whole context (F1).
1538
+ const resumingWithSession = ctx.resume === true && sessionUuidFor(ctx.gig_id, ctx.role) !== undefined;
1539
+ const reverifyResume = resumingWithSession && ctx.resume_keep_prompt === true;
1540
+ const fullPrompt = buildPrompt(resumingWithSession ? { ...ctx, resume: false } : ctx, schema, outputSchemas, seal);
1541
+ const prompt = reverifyResume
1542
+ ? buildReverifyResumePrompt(sealTypes, schema, outputSchemas, seal)
1543
+ : resumingWithSession
1544
+ ? buildPrompt(ctx, schema, outputSchemas, seal)
1545
+ : fullPrompt;
1094
1546
  // #221 — the key signal for candidate selection, derived from what we just resolved.
1095
1547
  // Threaded into BOTH extract calls below; threading only the injected-run one would
1096
1548
  // leave every real chair unscored.
@@ -1175,18 +1627,63 @@ export function makeClaudeInvoker(opts = {}) {
1175
1627
  ];
1176
1628
  // NO OVER-DENIAL (LAW 5, and LAW 2's structural half): nothing the seat legitimately holds may be
1177
1629
  // denied — most sharply OUTPUT_WRITE_TOOL, which effectiveAllowed now carries on the seal path.
1178
- // Subtract the effective allow set by exact name AND base name, so a scoped grant like
1179
- // `Bash(npx …)` still protects its `Bash` then dedupe. code_tool_access-kept tools are NOT
1180
- // subtracted here: a venue that excludes a code tool must still deny it even under access "full".
1630
+ // Subtract the effective allow set, then dedupe. The base-name half is right ONLY for a BARE deny
1631
+ // (`Write`): emitting it would kill a scoped `Write(src/**)` grant of the same tool too, so a
1632
+ // grant of that base scoped or not protects it. Applied to a SCOPED deny it is wrong:
1633
+ // `Write(tests/**)` and `Write(src/**)` share the base `Write`, yet name different surfaces, so a
1634
+ // scoped deny is removed ONLY by an EXACT-string grant. Without that split a seat could never be
1635
+ // granted `src/**` while denied `tests/**` — the one shape a builder that must not weaken its own
1636
+ // laws needs. code_tool_access-kept tools are NOT subtracted here: a venue that excludes a code
1637
+ // tool must still deny it even under access "full".
1181
1638
  const allowExact = new Set(effectiveAllowed ?? []);
1182
1639
  const allowBase = new Set((effectiveAllowed ?? []).map(toolBaseName));
1183
- const disallowedTools = [...new Set(denyUnion)].filter((t) => !allowExact.has(t) && !allowBase.has(toolBaseName(t)));
1184
- const baseArgs = buildInvokerArgs(prompt, cfgPath, {
1640
+ const disallowedTools = [...new Set(denyUnion)].filter((t) => {
1641
+ if (allowExact.has(t))
1642
+ return false; // an exact grant of the very string always protects it
1643
+ const isBare = t === toolBaseName(t); // no scope parens → a bare tool name
1644
+ return !(isBare && allowBase.has(toolBaseName(t)));
1645
+ });
1646
+ // contract-chair-session-continuity-v1 — the chair's own session, deterministic in (gig_id,
1647
+ // role). A first invocation OPENS it (--session-id); an amend re-invocation (ctx.resume, set
1648
+ // by the runtime) RESUMES it (--resume). Absent gig_id ⇒ no session ⇒ neither flag.
1649
+ const sessionId = sessionUuidFor(ctx.gig_id, ctx.role);
1650
+ const resumeRound = ctx.resume === true && sessionId !== undefined;
1651
+ // contract-seat-primer-v1 (O2/I1) — a FORK chair (ctx.fork threaded, and this seat has a session)
1652
+ // WARM-STARTS its first spawn from the primer's session. A plain chair carries no ctx.fork, so
1653
+ // isFork is false and the spawn is byte-identical to today (no --fork-session — the I1 control).
1654
+ const forkFromSession = ctx.fork?.primer_session_id;
1655
+ const isFork = forkFromSession !== undefined && sessionId !== undefined;
1656
+ // contract-primer-reading-frontier-v1 (O2) — the primer's reading frontier, if it recorded one:
1657
+ // the fork's first spawn CUTS the resumed conversation there. Absent ⇒ the whole session resumes.
1658
+ const forkFrontier = ctx.fork?.frontier;
1659
+ // #seat-effort (O3) — the runtime already resolved precedence onto ctx.effort; floor to medium
1660
+ // so an undeclared, untiered seat (or any hand-built ctx) still spawns with an explicit
1661
+ // --effort rather than the operator's settings-file effort. Hoisted so baseArgs and the cold
1662
+ // arg list below share ONE opts object rather than two parallel derivations.
1663
+ const invokerOpts = {
1185
1664
  model: resolveModel(a.model_tier, opts.model),
1186
1665
  allowed_tools: effectiveAllowed,
1187
1666
  disallowed_tools: disallowedTools,
1188
1667
  max_tool_calls: maxToolCalls,
1668
+ effort: ctx.effort ?? "medium",
1669
+ };
1670
+ const baseArgs = buildInvokerArgs(prompt, cfgPath, {
1671
+ ...invokerOpts,
1672
+ ...(sessionId !== undefined ? { session_id: sessionId, resume: resumeRound } : {}),
1673
+ // contract-seat-primer-v1 (O2) — the fork warm-start rides on the FIRST spawn (baseArgs); the
1674
+ // cold arg list below carries no fork_from_session, so an unresumable primer (F2) falls back to
1675
+ // a plain fresh-session spawn with no --fork-session.
1676
+ ...(isFork ? { fork_from_session: forkFromSession } : {}),
1677
+ // contract-primer-reading-frontier-v1 (O2) — cut the fork at the primer's frontier when it has one.
1678
+ ...(isFork && forkFrontier !== undefined ? { resume_session_at: forkFrontier } : {}),
1189
1679
  });
1680
+ // contract-amend-resume-prompt-v1 (I2) — the cold arg list for a resume whose session is gone:
1681
+ // a FRESH --session-id spawn (resume:false) carrying the FULL prompt, because nothing else
1682
+ // carries the chair's context once the resume fell through. Identical to baseArgs on every
1683
+ // non-resume spawn (same session flag, same full prompt), so it changes shape only on an amend.
1684
+ const coldArgs = sessionId !== undefined
1685
+ ? buildInvokerArgs(fullPrompt, cfgPath, { ...invokerOpts, session_id: sessionId, resume: false })
1686
+ : baseArgs;
1190
1687
  // SEAT IN THE ROOM. When the substrate stood up a SEAT-BEARING room, ctx.seatExec names its
1191
1688
  // container and per-realization workspace, and the chair runs INSIDE it:
1192
1689
  // `docker exec -i -w <workspace> <container> claude …` — so the seat's cwd is the room's own
@@ -1236,20 +1733,156 @@ export function makeClaudeInvoker(opts = {}) {
1236
1733
  const recoverable = seal !== undefined &&
1237
1734
  e instanceof ChildExitError &&
1238
1735
  finalText(e.stdout).errorSubtype === BUDGET_STOP_SUBTYPE;
1239
- if (!recoverable)
1240
- throw e;
1241
- return { stdout: e.stdout, budgetStopped: true };
1736
+ if (recoverable)
1737
+ return { stdout: e.stdout, budgetStopped: true };
1738
+ // A non-recoverable non-zero exit still carries the child's stream, and on the provider-
1739
+ // usage-limit path that stream — NOT stderr — holds the only account of why the run stopped:
1740
+ // the CLI emits the notice as a synthetic assistant message and/or an is_error result and
1741
+ // writes nothing to stderr. The default runner builds its failure from stderr alone, so the
1742
+ // operator was handed `claude exited 1:` with a blank reason for a failure that had a precise
1743
+ // one. Fold the stream's notice into the failure so the reason — and when the limit resets —
1744
+ // survives; keep it a ChildExitError so the stdout it carries is preserved. When the stream
1745
+ // says nothing, the original error (which carries stderr) is rethrown unchanged, so an
1746
+ // ordinary non-zero exit still reports stderr (the control).
1747
+ if (e instanceof ChildExitError) {
1748
+ const notice = providerNoticeFrom(e.stdout);
1749
+ if (notice) {
1750
+ const sep = e.message === "" || e.message.endsWith(" ") ? "" : " ";
1751
+ throw new ChildExitError(`${e.message}${sep}${notice}`, e.stdout);
1752
+ }
1753
+ }
1754
+ throw e;
1242
1755
  }
1243
1756
  };
1244
- let { stdout, budgetStopped } = await runTolerantOfBudgetStop(baseArgs, prompt);
1757
+ // F1 the cold fallback for a resume whose session is gone: a FRESH spawn with --session-id
1758
+ // and the FULL prompt (coldArgs/fullPrompt), recorded loudly so the fallback is never silent.
1759
+ // Shared by the main amend resume below AND the reserve continuation so the fallback is one
1760
+ // shape; on the reserve path coldArgs === baseArgs and fullPrompt === prompt, so it is unchanged
1761
+ // there. Defined before the first run so the main amend path can reach it.
1762
+ const resumeColdFallback = async () => {
1763
+ ctx.onEvent?.({
1764
+ type: "resume_fallback",
1765
+ raw: {
1766
+ agent: a.slug,
1767
+ session_id: sessionId,
1768
+ resumed: false,
1769
+ reason: "the session named for --resume could not be found; re-running cold with " +
1770
+ "--session-id and the full prompt",
1771
+ },
1772
+ });
1773
+ return runTolerantOfBudgetStop(coldArgs, fullPrompt);
1774
+ };
1775
+ // contract-seat-primer-v1 (F2) — a FORK whose primer session cannot be resumed (the run seam
1776
+ // reports "no conversation" for its id) falls back COLD: a FRESH --session-id spawn with the full
1777
+ // prompt (coldArgs carries no fork_from_session, so no --fork-session), NEVER failing the chair.
1778
+ // The `fork_fallback` event names the unresumable primer session so chair_complete records the
1779
+ // reason — a resume that did not happen, not a fork the record falsely claims.
1780
+ // contract-primer-reading-frontier-v1 (F2) — an `reason` overrides the default when the fallback is
1781
+ // due to an unresolvable reading FRONTIER (not an unresumable session), so chair_complete names the
1782
+ // frontier the fork could not cut at rather than a session that never failed.
1783
+ const forkColdFallback = async (reason) => {
1784
+ ctx.onEvent?.({
1785
+ type: "fork_fallback",
1786
+ raw: {
1787
+ agent: a.slug,
1788
+ primer_session_id: forkFromSession,
1789
+ forked: false,
1790
+ reason: reason ??
1791
+ `the primer session ${forkFromSession} could not be resumed; re-running cold with ` +
1792
+ `--session-id and the full prompt`,
1793
+ },
1794
+ });
1795
+ return runTolerantOfBudgetStop(coldArgs, fullPrompt);
1796
+ };
1797
+ // contract-primer-reading-frontier-v1 (F2) — the reason for an unresolvable-frontier cold fallback,
1798
+ // naming the frontier so chair_complete.fork_fallback records exactly what could not be resolved.
1799
+ const frontierFallbackReason = () => `the primer's reading frontier ${forkFrontier} could not be resolved in its session; re-running ` +
1800
+ `cold with --session-id and the full prompt`;
1801
+ // contract-resumed-gig-session-v1 (O2/O3/F1) — a FIRST `--session-id` open can COLLIDE: the
1802
+ // chair's session id is deterministic in (gig_id, role), so a KILLED attempt that already opened
1803
+ // it leaves the id live, and a resumed gig's first spawn re-opens it — the CLI refuses it
1804
+ // ("already in use"). That is a RESUME, never a failure: re-spawn ONCE with `--resume` and a
1805
+ // SHORT prompt (the previous attempt was interrupted, the current tree is authoritative), and
1806
+ // emit a resume-on-collision event so chair_complete records the chair CONTINUED its session.
1807
+ // If the resume then finds no conversation, the same cold fallback (F1) runs. Only a non-resume
1808
+ // spawn with a session id can collide (a resume carries `--resume`, never `--session-id`).
1809
+ const collisionResume = async () => {
1810
+ ctx.onEvent?.({
1811
+ type: "resume_on_collision",
1812
+ raw: {
1813
+ agent: a.slug,
1814
+ session_id: sessionId,
1815
+ resumed: true,
1816
+ reason: "the session named for --session-id was already in use — a killed prior attempt " +
1817
+ "created it; re-spawning with --resume to continue that conversation",
1818
+ },
1819
+ });
1820
+ const retryPrompt = `This chair's previous attempt was interrupted. The conversation you are resuming already ` +
1821
+ `holds your disposition, identity, method, tools and the gig input, so this prompt carries ` +
1822
+ `only what is new: the current working tree is authoritative — re-derive your output from ` +
1823
+ `it and re-seal exactly as before.`;
1824
+ const retryArgs = buildInvokerArgs(retryPrompt, cfgPath, { ...invokerOpts, session_id: sessionId, resume: true });
1825
+ try {
1826
+ const retry = await runTolerantOfBudgetStop(retryArgs, retryPrompt);
1827
+ return resumeSessionLost(retry.stdout) ? await resumeColdFallback() : retry;
1828
+ }
1829
+ catch (e) {
1830
+ if (e instanceof ChildExitError && resumeSessionLost(e.stdout))
1831
+ return await resumeColdFallback();
1832
+ throw e;
1833
+ }
1834
+ };
1835
+ // contract-amend-resume-prompt-v1 (I2/F1) — run the chair once. On an amend RESUME whose session
1836
+ // the seam reports gone (whether the run resolved with the error result or threw a non-zero exit
1837
+ // carrying it), fall back COLD rather than failing the chair. On a FIRST open whose --session-id
1838
+ // COLLIDES (contract-resumed-gig-session-v1 O2), resume it instead of failing. Every other spawn
1839
+ // kind is untouched.
1840
+ const collided = (s, m) => !resumeRound && sessionId !== undefined && sessionIdInUse(s, m);
1841
+ let stdout;
1842
+ let budgetStopped;
1843
+ try {
1844
+ const first = await runTolerantOfBudgetStop(baseArgs, prompt);
1845
+ if (resumeRound && resumeSessionLost(first.stdout)) {
1846
+ ({ stdout, budgetStopped } = await resumeColdFallback());
1847
+ }
1848
+ else if (isFork && forkFrontier !== undefined && frontierNotFound(first.stdout, "")) {
1849
+ // contract-primer-reading-frontier-v1 (F2) — the fork's --resume-session-at named a uuid the
1850
+ // primer session does not hold; re-run cold, naming the frontier.
1851
+ ({ stdout, budgetStopped } = await forkColdFallback(frontierFallbackReason()));
1852
+ }
1853
+ else if (isFork && resumeSessionLost(first.stdout)) {
1854
+ // contract-seat-primer-v1 (F2) — the fork's --resume of the primer found no conversation.
1855
+ ({ stdout, budgetStopped } = await forkColdFallback());
1856
+ }
1857
+ else if (collided(first.stdout, "")) {
1858
+ ({ stdout, budgetStopped } = await collisionResume());
1859
+ }
1860
+ else {
1861
+ ({ stdout, budgetStopped } = first);
1862
+ }
1863
+ }
1864
+ catch (e) {
1865
+ if (resumeRound && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
1866
+ ({ stdout, budgetStopped } = await resumeColdFallback());
1867
+ }
1868
+ else if (isFork && forkFrontier !== undefined && e instanceof ChildExitError && frontierNotFound(e.stdout, e.message)) {
1869
+ // contract-primer-reading-frontier-v1 (F2) — the fork exited 1 because --resume-session-at named
1870
+ // a uuid the primer session does not hold (notice on stderr and in the result event's `errors`).
1871
+ ({ stdout, budgetStopped } = await forkColdFallback(frontierFallbackReason()));
1872
+ }
1873
+ else if (isFork && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
1874
+ ({ stdout, budgetStopped } = await forkColdFallback());
1875
+ }
1876
+ else if (e instanceof ChildExitError && collided(e.stdout, e.message)) {
1877
+ ({ stdout, budgetStopped } = await collisionResume());
1878
+ }
1879
+ else {
1880
+ throw e;
1881
+ }
1882
+ }
1245
1883
  // Every stream whose writes count toward the seal. Diverges from `stdout` only when a reserve
1246
1884
  // was granted, which is the one case where a chair's output spans more than one invocation.
1247
1885
  let sealStdout = stdout;
1248
- // THE RESERVE GRANT. The chair spent its declared budget; rather than losing whatever it was
1249
- // mid-way through, it is told where it stands and given a bounded extension to close out.
1250
- // Once. The continuation names what already sealed so the chair does not redo it, and says
1251
- // plainly that nothing follows — a chair that believes another extension is coming will spend
1252
- // this one reaching rather than landing.
1253
1886
  if (budgetStopped && reserveTurns > 0 && seal !== undefined) {
1254
1887
  const sealedSoFar = captureOutputWrites(stdout, sealTypes);
1255
1888
  const already = Object.keys(sealedSoFar);
@@ -1257,6 +1890,12 @@ export function makeClaudeInvoker(opts = {}) {
1257
1890
  type: "budget_reserve_granted",
1258
1891
  raw: { agent: a.slug, reserve_turns: reserveTurns, sealed_before_grant: already },
1259
1892
  });
1893
+ // contract-chair-session-continuity-v1 (O2) — the continuation RESUMES the chair's own
1894
+ // session and carries ONLY the reserve text. The original prompt is NOT re-sent: the resumed
1895
+ // conversation already holds it, so re-sending it would pay the whole cold read the resume
1896
+ // exists to avoid. When there is a session to resume (there always is on the seal path, which
1897
+ // requires a gig_id), swap --session-id for --resume; otherwise the old fresh-spawn shape
1898
+ // stands and the prompt is re-sent as before.
1260
1899
  const continuation = `You reached your turn budget and were stopped mid-run. You are now in RESERVE: ` +
1261
1900
  `${reserveTurns} turns remain and this is the LAST extension — it will not be extended ` +
1262
1901
  `again, so land the work rather than reaching for more.\n\n` +
@@ -1264,9 +1903,30 @@ export function makeClaudeInvoker(opts = {}) {
1264
1903
  ? `Already sealed through the write boundary, do NOT redo: [${already.join(", ")}].\n\n`
1265
1904
  : `Nothing sealed yet.\n\n`) +
1266
1905
  `Close out now: seal what you already have, and state plainly what you did NOT reach so ` +
1267
- `the record shows the boundary instead of implying coverage.\n\n${prompt}`;
1268
- const reserveArgs = withPrompt(withMaxTurns(baseArgs, reserveTurns), continuation);
1269
- const second = await runTolerantOfBudgetStop(reserveArgs, continuation);
1906
+ `the record shows the boundary instead of implying coverage.` +
1907
+ (sessionId !== undefined ? `` : `\n\n${prompt}`);
1908
+ const reserveArgs = sessionId !== undefined
1909
+ ? withPrompt(withMaxTurns(withResume(baseArgs, sessionId), reserveTurns), continuation)
1910
+ : withPrompt(withMaxTurns(baseArgs, reserveTurns), continuation);
1911
+ // F1 — a resume whose session cannot be found must fall back COLD (a fresh spawn with
1912
+ // --session-id and the FULL original prompt) and never fail the chair. Detect the lost
1913
+ // session from the reserve run's stream, whether it resolved with the error result or threw
1914
+ // a non-zero exit carrying it, and re-run cold; loudly, so the fallback is recorded.
1915
+ let second;
1916
+ try {
1917
+ second = await runTolerantOfBudgetStop(reserveArgs, continuation);
1918
+ if (sessionId !== undefined && resumeSessionLost(second.stdout)) {
1919
+ second = await resumeColdFallback();
1920
+ }
1921
+ }
1922
+ catch (e) {
1923
+ if (sessionId !== undefined && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
1924
+ second = await resumeColdFallback();
1925
+ }
1926
+ else {
1927
+ throw e;
1928
+ }
1929
+ }
1270
1930
  // Two streams, two different questions, and conflating them is a bug: the OUTCOME (did the
1271
1931
  // run complete?) is the last pass's to answer, while the WRITES are cumulative — the first
1272
1932
  // pass's payloads passed the boundary too, and a continuation that sealed nothing must not
@@ -1301,6 +1961,32 @@ export function makeClaudeInvoker(opts = {}) {
1301
1961
  throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
1302
1962
  `answer: ${outcome.apiErrorText.slice(0, 300)}`);
1303
1963
  }
1964
+ // contract-seat-primer-v1 (O1/I2) — a PRIME seat's Read events, parsed from the run's stdout and
1965
+ // emitted so the runtime seals the seat-primer from EXACTLY the files this seat read. Emitted on
1966
+ // both seal and text paths (an injected `run` bypasses streaming, so the returned stdout is the
1967
+ // one place the reads are), and before the seal branches so the reads reach the runtime whatever
1968
+ // the seat produced.
1969
+ if (ctx.prime) {
1970
+ // contract-primer-reading-frontier-v1 (O1/O3/O4/I2/I3/F1) — parse the reading frontier over the
1971
+ // concatenated seal stdout: the reads are cut to those before the first write, the context is the
1972
+ // first-write context (or the last usage when there is no frontier), and the frontier uuid is
1973
+ // forwarded on the seat_reads event so the runtime seals it onto the primer. A no-write/no-user
1974
+ // seat yields no frontier and the reads/context are exactly as the rolling-primer laws expect.
1975
+ const rf = captureReadingFrontier(sealStdout);
1976
+ ctx.onEvent?.({
1977
+ type: "seat_reads",
1978
+ raw: {
1979
+ agent: a.slug, area: ctx.prime.area, reads: rf.reads,
1980
+ ...(rf.frontier !== undefined ? { frontier: rf.frontier } : {}),
1981
+ },
1982
+ });
1983
+ // contract-rolling-seat-primer-v1 (O3) — forward the seat's context size at seal the SAME way, so
1984
+ // the runtime records it on the seat-primer even though the injected `run` seam bypassed the
1985
+ // streamed usages. Emitted only when the run reported a usage; otherwise the runtime falls back.
1986
+ if (rf.context_tokens !== undefined) {
1987
+ ctx.onEvent?.({ type: "seat_context", raw: { context_tokens: rf.context_tokens } });
1988
+ }
1989
+ }
1304
1990
  if (seal) {
1305
1991
  // THE IN-BAND WRITE BOUNDARY. The chair sealed each output by calling output_write, whose
1306
1992
  // payload the engine adjudicated against the FULL seal predicate (checkWritable, run in
@@ -1355,7 +2041,13 @@ export function makeClaudeInvoker(opts = {}) {
1355
2041
  `The work you already did still counts. Do NOT redo it. Seal it now, by calling ` +
1356
2042
  `output_write — the only channel that seals:\n${calls}\n\n` +
1357
2043
  `This is the LAST attempt; it will not be offered again.\n\n${prompt}`;
1358
- const repairArgs = withPrompt(withMaxTurns(baseArgs, SEAL_REPAIR_TURNS), correction);
2044
+ // contract-fork-continuation-is-exact-v1 (O1) — the repair CONTINUES the chair's own
2045
+ // session, exactly as the reserve continuation does: resume `<own>`, and carry none of the
2046
+ // warm start (`--session-id`, `--fork-session`, `--resume-session-at`, the primer `--resume`)
2047
+ // that `baseArgs` holds for a fork chair. Only a gig-less spawn (no session) reuses baseArgs.
2048
+ const repairArgs = sessionId !== undefined
2049
+ ? withPrompt(withMaxTurns(withResume(baseArgs, sessionId), SEAL_REPAIR_TURNS), correction)
2050
+ : withPrompt(withMaxTurns(baseArgs, SEAL_REPAIR_TURNS), correction);
1359
2051
  const repaired = await runTolerantOfBudgetStop(repairArgs, correction);
1360
2052
  // Cumulative, exactly as the reserve path is: a write that passed in either pass counts.
1361
2053
  sealStdout = `${sealStdout}\n${repaired.stdout}`;
@@ -1660,4 +2352,61 @@ function finalText(stdout) {
1660
2352
  return { text: result };
1661
2353
  return { text: answerBlock(assistant) };
1662
2354
  }
2355
+ /**
2356
+ * The provider's own account of why a run stopped, read from the child's STREAM rather than stderr.
2357
+ *
2358
+ * When the Claude CLI hits the account's usage limit it writes NOTHING to stderr — it emits the
2359
+ * notice as a synthetic assistant message (model `<synthetic>`) and/or an is_error result whose
2360
+ * `result` field is the notice, then exits non-zero (measured on build gig 13ea0d99: its verify seat
2361
+ * failed with `claude exited 1: `, and the session transcript ended on "You've hit your session
2362
+ * limit · resets …"). Both carriers are read because the exact fields of the CLI's final event on
2363
+ * this path were not captured, so a fix that read only one could miss whichever shape the day's CLI
2364
+ * happens to take. Returns "" when the stream carries no such notice, so an ordinary non-zero exit's
2365
+ * failure keeps reporting stderr unchanged.
2366
+ *
2367
+ * Deliberately narrow — a `<synthetic>` assistant, not any assistant text, and an is_error result,
2368
+ * not any result — so a budget-stopped TEXT run's partial reasoning is never mistaken for a reason.
2369
+ */
2370
+ function providerNoticeFrom(stdout) {
2371
+ const texts = [];
2372
+ const push = (v) => {
2373
+ if (typeof v === "string" && v.trim() !== "")
2374
+ texts.push(v.trim());
2375
+ };
2376
+ for (const raw of stdout.split("\n")) {
2377
+ const line = raw.trim();
2378
+ if (!line)
2379
+ continue;
2380
+ let e;
2381
+ try {
2382
+ e = JSON.parse(line);
2383
+ }
2384
+ catch {
2385
+ continue; /* non-json */
2386
+ }
2387
+ const type = typeof e["type"] === "string" ? e["type"] : "";
2388
+ if (type === "result" && e["is_error"] === true) {
2389
+ push(e["result"]);
2390
+ }
2391
+ else if (type === "assistant" && e["message"] && typeof e["message"] === "object") {
2392
+ const msg = e["message"];
2393
+ if (msg.model === "<synthetic>") {
2394
+ for (const b of msg.content ?? [])
2395
+ if (b["type"] === "text")
2396
+ push(b["text"]);
2397
+ }
2398
+ }
2399
+ }
2400
+ // Distinct, first-seen order — the limit notice usually rides BOTH carriers, and repeating it
2401
+ // would only pad the failure line.
2402
+ const seen = new Set();
2403
+ const distinct = [];
2404
+ for (const t of texts) {
2405
+ if (seen.has(t))
2406
+ continue;
2407
+ seen.add(t);
2408
+ distinct.push(t);
2409
+ }
2410
+ return distinct.join(" — ");
2411
+ }
1663
2412
  //# sourceMappingURL=claude_invoker.js.map