@eir-labs/coltrane 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/agents/change-verifier.json +40 -0
  2. package/agents/code-implementer.json +42 -0
  3. package/agents/contract-definer.json +31 -0
  4. package/agents/defect-investigator.json +60 -0
  5. package/agents/grounder.json +38 -0
  6. package/agents/pr-publisher.json +48 -0
  7. package/agents/red-spec-drafter.json +46 -0
  8. package/agents/spec-publisher.json +48 -0
  9. package/agents/spec-reviewer.json +32 -0
  10. package/dist/src/bifrost_invoker.js +53 -52
  11. package/dist/src/bifrost_invoker.js.map +1 -1
  12. package/dist/src/change_set_branch.d.ts +131 -0
  13. package/dist/src/change_set_branch.js +179 -0
  14. package/dist/src/change_set_branch.js.map +1 -0
  15. package/dist/src/claude_invoker.d.ts +79 -2
  16. package/dist/src/claude_invoker.js +377 -36
  17. package/dist/src/claude_invoker.js.map +1 -1
  18. package/dist/src/cli.d.ts +1 -1
  19. package/dist/src/cli.js +26 -3
  20. package/dist/src/cli.js.map +1 -1
  21. package/dist/src/committed_work.d.ts +206 -0
  22. package/dist/src/committed_work.js +0 -0
  23. package/dist/src/committed_work.js.map +1 -0
  24. package/dist/src/composition.d.ts +11 -0
  25. package/dist/src/composition.js +3 -1
  26. package/dist/src/composition.js.map +1 -1
  27. package/dist/src/genome_schema.d.ts +803 -10
  28. package/dist/src/genome_schema.js +413 -7
  29. package/dist/src/genome_schema.js.map +1 -1
  30. package/dist/src/genome_store.d.ts +16 -1
  31. package/dist/src/genome_store.js +68 -0
  32. package/dist/src/genome_store.js.map +1 -1
  33. package/dist/src/gig_tracker.js +4 -0
  34. package/dist/src/gig_tracker.js.map +1 -1
  35. package/dist/src/hosted_tools.js +19 -0
  36. package/dist/src/hosted_tools.js.map +1 -1
  37. package/dist/src/institution_enforcement.d.ts +77 -0
  38. package/dist/src/institution_enforcement.js +442 -0
  39. package/dist/src/institution_enforcement.js.map +1 -0
  40. package/dist/src/institution_loader.d.ts +63 -0
  41. package/dist/src/institution_loader.js +239 -0
  42. package/dist/src/institution_loader.js.map +1 -0
  43. package/dist/src/loader.d.ts +4 -1
  44. package/dist/src/loader.js +19 -1
  45. package/dist/src/loader.js.map +1 -1
  46. package/dist/src/mcp.js +8 -1
  47. package/dist/src/mcp.js.map +1 -1
  48. package/dist/src/runtime.d.ts +66 -0
  49. package/dist/src/runtime.js +202 -2
  50. package/dist/src/runtime.js.map +1 -1
  51. package/dist/src/server.d.ts +7 -0
  52. package/dist/src/server.js +90 -1
  53. package/dist/src/server.js.map +1 -1
  54. package/dist/src/venue_realize.d.ts +90 -0
  55. package/dist/src/venue_realize.js +129 -0
  56. package/dist/src/venue_realize.js.map +1 -0
  57. package/dist/src/version.d.ts +1 -1
  58. package/dist/src/version.js +1 -1
  59. package/dist/src/worker.d.ts +45 -3
  60. package/dist/src/worker.js +67 -2
  61. package/dist/src/worker.js.map +1 -1
  62. package/domain_types/change-request.json +2 -1
  63. package/domain_types/class-sweep.json +34 -0
  64. package/domain_types/defect-class.json +25 -0
  65. package/domain_types/defect-location.json +28 -0
  66. package/domain_types/defect-report.json +28 -0
  67. package/domain_types/fix-spec.json +28 -0
  68. package/domain_types/fix-verification.json +28 -0
  69. package/domain_types/grounding-dossier.json +95 -0
  70. package/domain_types/pull-request.json +43 -0
  71. package/domain_types/red-spec.json +76 -0
  72. package/domain_types/reproduction.json +28 -0
  73. package/domain_types/root-cause.json +28 -0
  74. package/domain_types/subsystem-contract.json +106 -0
  75. package/institutions/coltrane.json +166 -0
  76. package/institutions/quartet.json +143 -26
  77. package/package.json +2 -1
  78. package/standards/defect-investigation-v1.json +156 -0
  79. package/standards/software-change-pr-v1.json +151 -0
  80. package/standards/spec-drafting-v1.json +125 -0
@@ -8,8 +8,12 @@ import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { abortReasonText } from "./runtime.js";
11
- import { resolveAgentGrants } from "./tool_providers.js";
11
+ import { resolveAgentGrants, ENGINE_MCP_SERVER } from "./tool_providers.js";
12
+ import { venueEffectiveTools } from "./chart.js";
13
+ import { CORE_TYPES } from "./core_types.js";
12
14
  const EMPTY_TOOL_REGISTRY = new Map();
15
+ /** The tool name a chair's in-band `output_write` seal is advertised under in the spawn. */
16
+ const OUTPUT_WRITE_TOOL = `mcp__${ENGINE_MCP_SERVER}__output_write`;
13
17
  // Per-tier model resolution (the old MODEL_TIER_MAP: economy/standard/premium →
14
18
  // haiku/sonnet/opus). An agent's model_tier picks the concrete spawn model; falls back to
15
19
  // the invoker's static default only when the agent declares no tier.
@@ -88,7 +92,7 @@ export function buildPrompt(ctx, outputSchema,
88
92
  // Per-type schemas for a MULTI-output agent (slug → schema). When the agent declares
89
93
  // more than one output type, the Task layer asks for a blob keyed by type rather than a
90
94
  // single object — the runtime then seals one record per key.
91
- outputSchemas) {
95
+ outputSchemas, seal) {
92
96
  const a = ctx.agent;
93
97
  const layers = [];
94
98
  // 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
@@ -149,7 +153,26 @@ outputSchemas) {
149
153
  // chair is asked for only its promised subset, not its whole catalogue. Legacy ctx without it
150
154
  // falls back to the agent's full output_types.
151
155
  const sealTypes = ctx.output_types?.length ? ctx.output_types : a.output_types;
152
- if (sealTypes.length > 1) {
156
+ if (seal) {
157
+ // IN-BAND WRITE-BOUNDARY SEAL. Each output is sealed by an `output_write` call whose payload
158
+ // the engine adjudicates against the FULL contract; a rejection returns in-band and the agent
159
+ // fixes `data` and calls again — its own single run self-corrects, no invoker re-prompt.
160
+ const perType = sealTypes
161
+ .map((t) => {
162
+ const s = outputSchemas?.[t] ?? (sealTypes.length === 1 ? outputSchema : undefined);
163
+ const core = seal.core_by_type[t] ?? "";
164
+ return (`- output_write({ "core_type": "${core}", "domain_type": "${t}", ` +
165
+ `"gig_id": "${seal.gig_id}", "phase": "${seal.phase}", "agent_slug": "${seal.agent_slug}", ` +
166
+ `"data": <object${s ? ` matching ${JSON.stringify(s)}` : ""}> })`);
167
+ })
168
+ .join("\n");
169
+ layers.push(`# Task\nSeal each of your output types by calling the \`output_write\` tool — one call per type:\n${perType}\n\n` +
170
+ `The tool validates your \`data\` against the complete output contract and returns ` +
171
+ `\`{ ok: false, error }\` if it does not pass. When that happens, read the error, correct the ` +
172
+ `\`data\`, and call \`output_write\` again — repeat until it returns \`ok: true\`. The successful ` +
173
+ `call IS the seal; do NOT print the output as text, and do not stop until every type is sealed.`);
174
+ }
175
+ else if (sealTypes.length > 1) {
153
176
  // multi-output: one JSON object keyed by each output-type slug; each value is that
154
177
  // type's data. The runtime seals one record per key (a SENSE+JUDGE agent yields its
155
178
  // Signal and its Judgment in one pass).
@@ -202,6 +225,28 @@ export class ModelOutputParseError extends Error {
202
225
  raw.length > EXCERPT_MAX_CHARS ? `${raw.slice(0, EXCERPT_MAX_CHARS)}…` : raw;
203
226
  }
204
227
  }
228
+ /**
229
+ * A chair completed its run but sealed NONE of its promised outputs through `output_write`.
230
+ * Distinct from ModelOutputParseError (a text-seal chair that emitted no parseable answer): this
231
+ * is the output_write-seal path, where the boundary that adjudicates a payload against its full
232
+ * output contract is the chair's own in-band `output_write` call (validated by the engine's
233
+ * checkWritable, corrected in-band by the agent), NOT a re-prompt from this invoker. A chair that
234
+ * never gets a single write past that boundary produced nothing this invoker can hand back.
235
+ *
236
+ * The runtime's own floor check (executeChair, `missingRequired`) also catches a shortfall and is
237
+ * the authority on which promised types were merely optional; this error is the earlier, chair-
238
+ * local signal that the write boundary sealed nothing at all.
239
+ */
240
+ export class ModelOutputContractError extends Error {
241
+ slug;
242
+ reason;
243
+ constructor(slug, reason) {
244
+ super(`chair "${slug}" sealed no output through its write boundary: ${reason}`);
245
+ this.name = "ModelOutputContractError";
246
+ this.slug = slug;
247
+ this.reason = reason;
248
+ }
249
+ }
205
250
  /**
206
251
  * Walk forward from `start` (which must be a `{`) honouring JSON string literals and
207
252
  * backslash escapes, so only STRUCTURAL braces move the depth counter. Returns the index
@@ -387,6 +432,120 @@ export function extractOptionsForChair(sealTypes, schema) {
387
432
  const props = schemaPropertyNames(schema);
388
433
  return props.length > 0 ? { expectKeys: props } : { requireUnambiguous: true };
389
434
  }
435
+ // ───────────────────── output_write capture (the write-boundary seal path) ─────────────────────
436
+ //
437
+ // A model chair on the output_write-seal path does NOT print a final-text answer — it SEALS each
438
+ // output in-band by calling `output_write`, whose payload the engine adjudicates against the full
439
+ // contract (validate-mode, returning the verdict in-band so the agent self-corrects). This reads
440
+ // those calls back out of the child's stream-json stdout: the payload of each SUCCESSFUL
441
+ // output_write call (a tool_use whose tool_result was not an error) is what the chair sealed, and
442
+ // the runtime's own boundary seals it exactly once. Because the agent corrects a rejected write by
443
+ // calling again, the LAST non-errored call per type is the one that passed.
444
+ /** The name a chair's `output_write` grant is advertised under in the spawn (mcp__<server>__<tool>),
445
+ * plus the bare slug for a legacy pass-through invoker. Matched by suffix so either resolves. */
446
+ function isOutputWriteToolName(name) {
447
+ return name === "output_write" || name.endsWith("__output_write");
448
+ }
449
+ /**
450
+ * Extract the chair's sealed payloads from a stream-json stdout, keyed by the chair's seal types.
451
+ * Returns the blob shape the runtime already consumes (a key per domain_type, or the bare data for
452
+ * a lone single-output write), so executeChair seals it through its one boundary unchanged.
453
+ */
454
+ /** A child that exited non-zero, carrying the stdout it produced before dying.
455
+ *
456
+ * The stream is not incidental to the error — for a chair stopped by its tool budget it holds
457
+ * validated payloads, and discarding it destroys work the engine had already adjudicated as good.
458
+ * Exported because the injected-run seam (`opts.run`) is how tests reproduce a non-zero exit. */
459
+ export class ChildExitError extends Error {
460
+ stdout;
461
+ constructor(message, stdout) {
462
+ super(message);
463
+ this.stdout = stdout;
464
+ this.name = "ChildExitError";
465
+ }
466
+ }
467
+ /** The result subtype the CLI reports when `--max-turns` cut the run off.
468
+ *
469
+ * This is the ONE non-success subtype whose stream is still worth reading, and the distinction is
470
+ * the whole of the policy: a budget stop says "the agent was interrupted", while every other error
471
+ * subtype says "what the agent produced is unreliable". Writes survive the first and must not
472
+ * survive the second. */
473
+ const BUDGET_STOP_SUBTYPE = "error_max_turns";
474
+ /** Rewrite `--max-turns` to the reserve. The continuation gets the EXTENSION as its whole budget,
475
+ * not a second full allowance — otherwise the grant silently doubles the chair's cost ceiling. */
476
+ function withMaxTurns(args, turns) {
477
+ const out = [...args];
478
+ const i = out.indexOf("--max-turns");
479
+ if (i >= 0 && i + 1 < out.length)
480
+ out[i + 1] = String(turns);
481
+ else
482
+ out.push("--max-turns", String(turns));
483
+ return out;
484
+ }
485
+ /** Swap the prompt a built arg list carries.
486
+ *
487
+ * `-p` is a boolean flag with the prompt as a POSITIONAL that follows it — except on the
488
+ * large-prompt path, where the positional is dropped and the text goes to stdin instead. A
489
+ * continuation has to replace whichever form is in play, and the two are not interchangeable:
490
+ * editing only the stdin text would re-send the ORIGINAL prompt to a small-prompt chair, which
491
+ * reads as the engine ignoring its own grant. */
492
+ function withPrompt(args, prompt) {
493
+ const out = [...args];
494
+ const i = out.indexOf("-p");
495
+ if (i < 0)
496
+ return out;
497
+ const positionalFollows = i + 1 < out.length && !out[i + 1].startsWith("-");
498
+ if (positionalFollows)
499
+ out[i + 1] = prompt;
500
+ return out;
501
+ }
502
+ export function captureOutputWrites(stdout, sealTypes) {
503
+ const writes = [];
504
+ const errored = new Set();
505
+ for (const raw of stdout.split("\n")) {
506
+ const line = raw.trim();
507
+ if (!line)
508
+ continue;
509
+ let e;
510
+ try {
511
+ e = JSON.parse(line);
512
+ }
513
+ catch {
514
+ continue;
515
+ }
516
+ const type = typeof e["type"] === "string" ? e["type"] : "";
517
+ const msg = e["message"];
518
+ if ((type === "assistant" || type === "user") && msg && typeof msg === "object") {
519
+ const content = msg.content ?? [];
520
+ for (const b of content) {
521
+ const bt = String(b["type"] ?? "");
522
+ if (bt === "tool_use" && isOutputWriteToolName(String(b["name"] ?? ""))) {
523
+ const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
524
+ writes.push({ id: String(b["id"] ?? ""), domain_type: String(input["domain_type"] ?? ""), data: input["data"] });
525
+ }
526
+ else if (bt === "tool_result" && b["is_error"] === true) {
527
+ errored.add(String(b["tool_use_id"] ?? ""));
528
+ }
529
+ }
530
+ }
531
+ }
532
+ const passed = writes.filter((w) => !w.id || !errored.has(w.id));
533
+ const byType = new Map();
534
+ for (const w of passed)
535
+ byType.set(w.domain_type, w.data); // last non-errored write per type wins
536
+ const blob = {};
537
+ for (const t of sealTypes) {
538
+ if (byType.has(t))
539
+ blob[t] = byType.get(t);
540
+ }
541
+ // Single-output chairs may seal with an empty/other domain_type (buildPrompt names it, but a
542
+ // model can still omit it). If nothing matched by name and exactly one output was sealed, that
543
+ // lone payload IS the single output — key it under the promised type.
544
+ if (sealTypes.length === 1 && blob[sealTypes[0]] === undefined && passed.length > 0) {
545
+ blob[sealTypes[0]] = passed[passed.length - 1].data;
546
+ }
547
+ return blob;
548
+ }
390
549
  // The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
391
550
  // terminus (it can search/loop), and the gig runs the spawn synchronously — so without
392
551
  // this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
@@ -515,6 +674,24 @@ export function makeClaudeInvoker(opts = {}) {
515
674
  // running engine always resolves + fails closed.
516
675
  const resolutionEnabled = opts.toolProviders !== undefined || opts.mcpServerConfigs !== undefined;
517
676
  const abortGraceMs = opts.abort_grace_ms ?? DEFAULT_ABORT_GRACE_MS;
677
+ const sealViaOutputWrite = opts.sealVia === "output_write";
678
+ // A reserve is a grant, so an absent or nonsensical one grants nothing rather than defaulting to
679
+ // some house number — an extension the author did not ask for is spend they did not authorise.
680
+ // #turn-budget — resolved PER INVOCATION so the chair-scoped reserve (ctx.turn_reserve, the pool-
681
+ // capped offer the runtime threads) wins over the invoker-level default (opts.turn_reserve). Both
682
+ // pass through the identical > 0 floor. ctx.turn_reserve === 0 (a declared chair whose pool was
683
+ // dry) is a hard zero, NOT a fall-through to opts — 0 is not nullish, so `??` stops there.
684
+ const resolveReserveTurns = (ctxReserve) => {
685
+ const src = ctxReserve ?? opts.turn_reserve;
686
+ return Number.isFinite(src) && (src ?? 0) > 0 ? Math.floor(src) : 0;
687
+ };
688
+ // The core type each domain type extends — the `core_type` the agent must pass to output_write.
689
+ const coreTypeOf = (slug) => {
690
+ if (CORE_TYPES.includes(slug))
691
+ return slug;
692
+ const dt = opts.registry?.listTypes().find((t) => t.slug === slug);
693
+ return dt ? dt.extends : "";
694
+ };
518
695
  return async (ctx) => {
519
696
  // #250 — a chair whose gig is already cancelled spends nothing: no prompt, no mcp-config,
520
697
  // no spawn. This is the cheapest point on the whole cancellation chain.
@@ -546,6 +723,34 @@ export function makeClaudeInvoker(opts = {}) {
546
723
  resolvedMcpServers = resolved.mcpServers;
547
724
  effectiveAllowed = resolved.effectiveAllowed;
548
725
  }
726
+ // VENUE CONFINEMENT BY CONSTRUCTION. When the dispatch path resolved a room for this chair
727
+ // (ctx.realization + ctx.venue both threaded by runGig), the spawn reflects the realization:
728
+ // - `--allowedTools` carries EXACTLY venueEffectiveTools(agent, venue) — the SAME shared oracle
729
+ // the compose-time R10 check refuses against (src/chart.ts), never a re-inlined intersection
730
+ // and never the un-intersected grant, so runtime enforcement and compose-time refusal cannot
731
+ // drift.
732
+ // - the child env is the realization's deny-by-default allowlist (SeatRealization.env, `{}` when
733
+ // the surface admits nothing), so an undeclared ambient credential never reaches the child.
734
+ // Narrowed BEFORE the in-band-seal block below, so the engine's own output_write grant — engine
735
+ // mechanism, not an optional capability — is re-added on top of the room's ceiling. Absent on
736
+ // either field → the un-narrowed path above stands and no child env is constructed (INV10).
737
+ let childEnv;
738
+ if (ctx.realization && ctx.venue) {
739
+ effectiveAllowed = venueEffectiveTools(ctx.agent, ctx.venue);
740
+ const seat = ctx.realization.seats.find((s) => s.agent_slug === ctx.agent.slug);
741
+ childEnv = seat?.env ?? {};
742
+ }
743
+ // WIRE THE IN-BAND SEAL. A model chair on the output_write-seal path must be able to CALL
744
+ // output_write regardless of what it declared in allowed_tools — the seal is engine mechanism,
745
+ // not an optional capability. Bridge the engine's own MCP server into the spawn and add the
746
+ // output_write grant so the child can reach it. Gated on the engine server config being wired
747
+ // (bootstrapServerDeps always supplies it); a bare/test invoker without it captures from the
748
+ // injected stream instead of a real spawn, so it needs no grant.
749
+ const engineServerCfg = (opts.mcpServerConfigs ?? {})[ENGINE_MCP_SERVER];
750
+ if (sealViaOutputWrite && engineServerCfg !== undefined) {
751
+ resolvedMcpServers = { ...resolvedMcpServers, [ENGINE_MCP_SERVER]: engineServerCfg };
752
+ effectiveAllowed = [...new Set([...(effectiveAllowed ?? []), OUTPUT_WRITE_TOOL])];
753
+ }
549
754
  const schemaOf = (slug) => promptSchemaFor(opts.registry, slug);
550
755
  // #174 — schemas follow the chair's promised subset (ctx.output_types), not the agent's
551
756
  // whole catalogue; legacy ctx without it falls back to the agent's full output_types.
@@ -557,7 +762,18 @@ export function makeClaudeInvoker(opts = {}) {
557
762
  const outputSchemas = sealTypes.length > 1
558
763
  ? Object.fromEntries(sealTypes.map((t) => [t, schemaOf(t)]))
559
764
  : undefined;
560
- const prompt = buildPrompt(ctx, schema, outputSchemas);
765
+ // The in-band seal directive: present only when this invoker seals via output_write AND the
766
+ // runtime threaded a gig_id (the write needs it). Absent → the legacy text-seal Task layer.
767
+ const seal = sealViaOutputWrite && ctx.gig_id
768
+ ? {
769
+ via: "output_write",
770
+ gig_id: ctx.gig_id,
771
+ agent_slug: ctx.agent.slug,
772
+ phase: ctx.phase,
773
+ core_by_type: Object.fromEntries(sealTypes.map((t) => [t, coreTypeOf(t)])),
774
+ }
775
+ : undefined;
776
+ const prompt = buildPrompt(ctx, schema, outputSchemas, seal);
561
777
  // #221 — the key signal for candidate selection, derived from what we just resolved.
562
778
  // Threaded into BOTH extract calls below; threading only the injected-run one would
563
779
  // leave every real chair unscored.
@@ -567,45 +783,131 @@ export function makeClaudeInvoker(opts = {}) {
567
783
  // the base map (opts.mcpServers) + the per-agent servers its grants resolved to (#185).
568
784
  const servers = { ...(opts.mcpServers ?? {}), ...resolvedMcpServers };
569
785
  const parent = opts.parent_session_id;
570
- // Inject parent_session_id env into every named server so children seal lineage.
571
- const enriched = parent
572
- ? Object.fromEntries(Object.entries(servers).map(([name, def]) => {
573
- const d = (def && typeof def === "object" ? def : {});
574
- const env = (d["env"] && typeof d["env"] === "object" ? d["env"] : {});
575
- return [name, { ...d, env: { ...env, COLTRANE_PARENT_SESSION_ID: parent } }];
576
- }))
577
- : servers;
786
+ // Per-server env additions: parent_session_id into every server (so children seal lineage), and
787
+ // COLTRANE_OUTPUT_WRITE_MODE=validate into the ENGINE server on the output_write-seal path — so
788
+ // the child's coltrane server ADJUDICATES the chair's in-band output_write calls against the
789
+ // full seal predicate and returns the verdict, without persisting (the runtime is the one
790
+ // sealer, so this is what keeps the output sealed exactly once).
791
+ const envFor = (name) => ({
792
+ ...(parent ? { COLTRANE_PARENT_SESSION_ID: parent } : {}),
793
+ ...(sealViaOutputWrite && name === ENGINE_MCP_SERVER ? { COLTRANE_OUTPUT_WRITE_MODE: "validate" } : {}),
794
+ });
795
+ const enriched = Object.fromEntries(Object.entries(servers).map(([name, def]) => {
796
+ const additions = envFor(name);
797
+ if (Object.keys(additions).length === 0)
798
+ return [name, def];
799
+ const d = (def && typeof def === "object" ? def : {});
800
+ const env = (d["env"] && typeof d["env"] === "object" ? d["env"] : {});
801
+ return [name, { ...d, env: { ...env, ...additions } }];
802
+ }));
578
803
  writeFileSync(cfgPath, JSON.stringify({ mcpServers: enriched }));
579
804
  try {
580
805
  const a = ctx.agent;
806
+ // #turn-budget — the reserve the invoker will grant on a budget stop, chair-scoped first.
807
+ const reserveTurns = resolveReserveTurns(ctx.turn_reserve);
581
808
  // #237 — a shallow run depth tightens the turn cap; it never widens the agent's own.
582
809
  const depthCap = ctx.depth ? DEPTH_MAX_TOOL_CALLS[ctx.depth] : undefined;
810
+ // #turn-budget — resolve chair > agent > engine default, THEN let a shallow depth cap tighten
811
+ // (never widen). `ctx.turn_budget === 0` is a deliberate hard floor and does NOT fall through
812
+ // (0 is not nullish); absent falls to the agent's own cap, then the CLI default (undefined →
813
+ // no --max-turns emitted at all).
814
+ const resolvedBudget = ctx.turn_budget ?? a.max_tool_calls;
583
815
  const maxToolCalls = depthCap === undefined
584
- ? a.max_tool_calls
585
- : Math.min(depthCap, a.max_tool_calls ?? depthCap);
816
+ ? resolvedBudget
817
+ : Math.min(depthCap, resolvedBudget ?? depthCap);
818
+ // Run ONE invocation and return the child's raw stdout. Custom run (tests): the returned
819
+ // string IS the transcript (a bare JSON blob on the text path, a stream-json transcript on
820
+ // the output_write path). Default: stream-json so the child's tool calls / reasoning are
821
+ // observable LIVE, teed to ctx.onEvent.
586
822
  const baseArgs = buildInvokerArgs(prompt, cfgPath, {
587
823
  model: resolveModel(a.model_tier, opts.model),
588
824
  allowed_tools: effectiveAllowed,
589
825
  disallowed_tools: [...(a.disallowed_tools ?? []), ...codeToolDenials(a.code_tool_access)],
590
826
  max_tool_calls: maxToolCalls,
591
827
  });
592
- // Custom run (tests): plain mode, the returned string IS the JSON blob no streaming.
593
- if (customRun)
594
- return extractJson(await customRun(bin, baseArgs, spawnBounds), extractOpts);
595
- // Default: stream-json so the child's tool calls / reasoning are observable LIVE. Each
596
- // event is forwarded to ctx.onEvent (the runtime tees it to the gig's per-chair log);
597
- // the final result text is extracted from the stream and parsed into the typed output.
598
- const args = [...baseArgs, "--output-format", "stream-json", "--verbose"];
599
- // #250 level 3 the cancellation signal and grace window reach the spawn, so an
600
- // aborted gig actually kills its chair child (and never spawns one if already aborted).
601
- const stdout = await spawnStreaming(bin, args, spawnBounds, ctx.onEvent, ctx.signal, abortGraceMs, promptViaStdin(prompt) ? prompt : undefined);
828
+ // ONE invocation only on the output_write path the agent self-corrects a rejected write
829
+ // WITHIN this single run (each output_write rejection returns in-band and it calls again),
830
+ // and on the text path there is a single answer. Either way, the invoker never re-prompts.
831
+ // A chair stopped by its TOOL BUDGET is not a chair that failed. `--max-turns` cuts the run
832
+ // off mid-flight, and everything it had already written through the in-band boundary was
833
+ // adjudicated against the full seal predicate at the moment of writing those are validated
834
+ // payloads, not the partial reasoning the error subtypes exist to catch. Discarding them
835
+ // destroyed real work and billed for it: one observed sweep landed nine sealed lineage-hits,
836
+ // satisfied its output_contract, and was reported as a failure with nothing kept.
837
+ //
838
+ // So the budget stop is caught HERE, and only here: the stream is recovered, and the decision
839
+ // about whether anything survives is left to the seal path below, which is the only place that
840
+ // knows what passed. Every other non-zero exit still propagates untouched.
841
+ const runOnce = async (args, text) => customRun
842
+ ? await customRun(bin, [...args], spawnBounds, childEnv)
843
+ : await spawnStreaming(bin, [...args, "--output-format", "stream-json", "--verbose"], spawnBounds, ctx.onEvent, ctx.signal, abortGraceMs, promptViaStdin(text) ? text : undefined, childEnv);
844
+ /** Run, and hand back the stream even when the child died on its turn cap. */
845
+ const runTolerantOfBudgetStop = async (args, text) => {
846
+ try {
847
+ return { stdout: await runOnce(args, text), budgetStopped: false };
848
+ }
849
+ catch (e) {
850
+ const recoverable = seal !== undefined &&
851
+ e instanceof ChildExitError &&
852
+ finalText(e.stdout).errorSubtype === BUDGET_STOP_SUBTYPE;
853
+ if (!recoverable)
854
+ throw e;
855
+ return { stdout: e.stdout, budgetStopped: true };
856
+ }
857
+ };
858
+ let { stdout, budgetStopped } = await runTolerantOfBudgetStop(baseArgs, prompt);
859
+ // Every stream whose writes count toward the seal. Diverges from `stdout` only when a reserve
860
+ // was granted, which is the one case where a chair's output spans more than one invocation.
861
+ let sealStdout = stdout;
862
+ // THE RESERVE GRANT. The chair spent its declared budget; rather than losing whatever it was
863
+ // mid-way through, it is told where it stands and given a bounded extension to close out.
864
+ // Once. The continuation names what already sealed so the chair does not redo it, and says
865
+ // plainly that nothing follows — a chair that believes another extension is coming will spend
866
+ // this one reaching rather than landing.
867
+ if (budgetStopped && reserveTurns > 0 && seal !== undefined) {
868
+ const sealedSoFar = captureOutputWrites(stdout, sealTypes);
869
+ const already = Object.keys(sealedSoFar);
870
+ ctx.onEvent?.({
871
+ type: "budget_reserve_granted",
872
+ raw: { agent: a.slug, reserve_turns: reserveTurns, sealed_before_grant: already },
873
+ });
874
+ const continuation = `You reached your turn budget and were stopped mid-run. You are now in RESERVE: ` +
875
+ `${reserveTurns} turns remain and this is the LAST extension — it will not be extended ` +
876
+ `again, so land the work rather than reaching for more.\n\n` +
877
+ (already.length > 0
878
+ ? `Already sealed through the write boundary, do NOT redo: [${already.join(", ")}].\n\n`
879
+ : `Nothing sealed yet.\n\n`) +
880
+ `Close out now: seal what you already have, and state plainly what you did NOT reach so ` +
881
+ `the record shows the boundary instead of implying coverage.\n\n${prompt}`;
882
+ const reserveArgs = withPrompt(withMaxTurns(baseArgs, reserveTurns), continuation);
883
+ const second = await runTolerantOfBudgetStop(reserveArgs, continuation);
884
+ // Two streams, two different questions, and conflating them is a bug: the OUTCOME (did the
885
+ // run complete?) is the last pass's to answer, while the WRITES are cumulative — the first
886
+ // pass's payloads passed the boundary too, and a continuation that sealed nothing must not
887
+ // erase them. Concatenating for both would let the first pass's error_max_turns result event
888
+ // outrank the second's success and fail a run that finished.
889
+ sealStdout = `${stdout}\n${second.stdout}`;
890
+ stdout = second.stdout;
891
+ budgetStopped = second.budgetStopped;
892
+ }
893
+ else if (budgetStopped && reserveTurns === 0 && seal !== undefined) {
894
+ // #turn-budget — the chair hit its budget and the pool had nothing left to extend it (an
895
+ // empty reserve). Keep-sealed-writes below is unchanged, but the starvation is now VISIBLE:
896
+ // emit a denial so a parent watching the stream — and the runtime's draw ledger — can
897
+ // attribute it, rather than a silent no-op that reads like a chair that simply finished.
898
+ ctx.onEvent?.({
899
+ type: "budget_reserve_denied",
900
+ raw: { agent: a.slug, requested: ctx.turn_reserve ?? opts.turn_reserve ?? 0, pool_remaining: 0 },
901
+ });
902
+ }
602
903
  const outcome = finalText(stdout);
603
- // #223 — the child reported an error result. Both discriminators are required, and
604
- // both are verified against the CLI (see the note on StreamOutcome): `subtype` for a
605
- // run that did not complete, `is_error` for an API-error payload riding subtype
606
- // "success". Neither is a chair answer, and the CLI exits 0 for the subtype cases
607
- // so without this the partial reasoning seals as if it had succeeded.
608
- if (outcome.errorSubtype !== undefined) {
904
+ // #223 — the child reported an error result. `subtype` catches a run that did not complete;
905
+ // `is_error` catches an API-error payload riding subtype "success". The CLI exits 0 for both,
906
+ // so without this the partial reasoning seals as if it had succeeded. Applies to both paths.
907
+ // A recovered budget stop reaches here with its subtype still set; that is expected and is
908
+ // not an error for the seal path. The text path gets no such reprieve — its payload IS the
909
+ // final answer text, and a truncated run's text is exactly the partial reasoning this guards.
910
+ if (outcome.errorSubtype !== undefined && !budgetStopped) {
609
911
  throw new Error(`claude ended with result subtype "${outcome.errorSubtype}" — the run did not ` +
610
912
  `complete, so any text it emitted is partial reasoning, not an answer`);
611
913
  }
@@ -613,9 +915,41 @@ export function makeClaudeInvoker(opts = {}) {
613
915
  throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
614
916
  `answer: ${outcome.apiErrorText.slice(0, 300)}`);
615
917
  }
616
- // #222 — the stream parsed but carried no answer at all (e.g. only a system/init
617
- // event). Report THAT, with the raw stdout as evidence, instead of blaming the model
618
- // for emitting no JSON.
918
+ if (seal) {
919
+ // THE IN-BAND WRITE BOUNDARY. The chair sealed each output by calling output_write, whose
920
+ // payload the engine adjudicated against the FULL seal predicate (checkWritable, run in
921
+ // validate-mode) and whose rejection it corrected in-band. Capture the payloads that
922
+ // PASSED; the runtime (executeChair) then seals them through its own boundary exactly once.
923
+ // A chair that got nothing past the boundary produced nothing — fail here, legibly.
924
+ const blob = captureOutputWrites(sealStdout, sealTypes);
925
+ if (Object.keys(blob).length === 0) {
926
+ throw new ModelOutputContractError(a.slug, budgetStopped
927
+ ? `ran out of tool budget (max_tool_calls) before any output_write passed the write ` +
928
+ `boundary for [${sealTypes.join(", ")}] — nothing was salvageable`
929
+ : `no output_write call passed the write boundary for [${sealTypes.join(", ")}]`);
930
+ }
931
+ // The stop is REPORTED, never swallowed. What survived is real and sealed; what the agent
932
+ // would have gone on to find is unknown, and a caller reading this chair's output as a
933
+ // complete sweep would be reading a truncation as a finding.
934
+ if (budgetStopped) {
935
+ ctx.onEvent?.({
936
+ type: "budget_stop",
937
+ raw: {
938
+ agent: a.slug,
939
+ max_tool_calls: a.max_tool_calls,
940
+ sealed_types: Object.keys(blob),
941
+ note: "the chair exhausted its tool budget; the outputs it had already passed through " +
942
+ "the write boundary were kept, and the sweep is TRUNCATED, not complete",
943
+ },
944
+ });
945
+ }
946
+ return blob;
947
+ }
948
+ // TEXT-SEAL PATH (default; every injected-run test). No in-band tool surface, so the payload
949
+ // is the child's final answer text; the runtime's own seal is this path's write boundary and
950
+ // its full checkWritable adjudicates the extracted payload.
951
+ // #222 — the stream parsed but carried no answer at all. Report THAT, with the raw stdout as
952
+ // evidence, rather than blaming the model for emitting no JSON.
619
953
  if (outcome.text.trim() === "") {
620
954
  throw new ModelOutputParseError("the model produced no answer — the stream carried no result text and no assistant text", 0, stdout);
621
955
  }
@@ -639,7 +973,10 @@ export function makeClaudeInvoker(opts = {}) {
639
973
  // nothing holds is a process nothing can stop.
640
974
  function spawnStreaming(bin, args, bounds, onEvent, signal, abortGraceMs = DEFAULT_ABORT_GRACE_MS,
641
975
  /** The prompt, when it is too large for the command line. Written to the child's stdin. */
642
- stdinPayload) {
976
+ stdinPayload,
977
+ /** The venue → dispatch wire's deny-by-default child env. When present it REPLACES the inherited
978
+ * process.env (no ambient credential leaks into a confined child); absent = inherit, unchanged. */
979
+ childEnv) {
643
980
  return new Promise((resolve, reject) => {
644
981
  if (signal?.aborted) {
645
982
  reject(new Error(`chair child not spawned — gig aborted (${abortReasonText(signal)})`));
@@ -648,7 +985,7 @@ stdinPayload) {
648
985
  // stdin is a pipe ONLY when a payload is going down it. Leaving it open otherwise would
649
986
  // change the child's TTY detection for every existing caller.
650
987
  const stdio = [stdinPayload === undefined ? "ignore" : "pipe", "pipe", "pipe"];
651
- const child = spawn(bin, [...args], { stdio });
988
+ const child = spawn(bin, [...args], { stdio, ...(childEnv ? { env: childEnv } : {}) });
652
989
  // Slots 1 and 2 are literally "pipe" above, so both streams exist. Only slot 0 varies,
653
990
  // and widening it costs the compiler the overload that proved this.
654
991
  const childOut = child.stdout;
@@ -723,8 +1060,12 @@ stdinPayload) {
723
1060
  const tail = buf.trim();
724
1061
  buf = "";
725
1062
  forwardLine(tail);
1063
+ // The stdout travels WITH the failure. A non-zero exit used to discard it, which threw away
1064
+ // the one thing a budget-stopped chair leaves behind: output_write calls that already passed
1065
+ // the engine's write boundary. Whether those are recoverable is a decision for the caller,
1066
+ // which knows the seal mode and the result subtype; it cannot make it without the stream.
726
1067
  if (code !== 0)
727
- reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
1068
+ reject(new ChildExitError(`claude exited ${code}: ${stderr.slice(0, 500)}`, stdout));
728
1069
  else
729
1070
  resolve(stdout);
730
1071
  });