@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
@@ -5,10 +5,14 @@
5
5
  // that carries model_version + (empty, v0) eval_scores — honestly un-tempered.
6
6
  import { lineageAdoption } from "./lineage_adoption.js";
7
7
  import { randomUUID } from "node:crypto";
8
+ import { performance } from "node:perf_hooks";
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { join as joinPath, relative as relPath, isAbsolute as isAbsPath, sep as pathSep } from "node:path";
8
12
  import { PRIMITIVE_OUTPUT_TYPE, CORE_TYPES } from "./core_types.js";
9
13
  import { executeSkillAsync } from "./skill_subprocess.js";
10
14
  import { loadSkillPackage } from "./skills.js";
11
- import { resolveModel } from "./claude_invoker.js";
15
+ import { resolveModel, sessionUuidFor } from "./claude_invoker.js";
12
16
  import { resolveAgentGrants } from "./tool_providers.js";
13
17
  import { resolveAndRealize } from "./venue_realize.js";
14
18
  // core type → the process primitive that produces it (reverse of PRIMITIVE_OUTPUT_TYPE).
@@ -27,6 +31,34 @@ import { drainGigHeader } from "./output_mirror.js";
27
31
  import { LEDGER_SCHEMA_VERSION } from "./ledger.js";
28
32
  import { PlacementRefused } from "./placement.js";
29
33
  import { COLTRANE_VERSION } from "./version.js";
34
+ // #seat-effort — the ONE place effort precedence resolves, mirroring how `depth` threads. The
35
+ // RESOLVED value is set on AgentInvocationContext.effort and reaches both invokers. Order: the
36
+ // dispatch effort wins, else the agent's declared effort, else the agent's tier default
37
+ // (economy→low, standard→medium, premium→high), else `medium` for an untiered agent — never the
38
+ // operator's ~/.claude/settings.json. Total by construction (always returns a level), which is what
39
+ // makes an undeclared, untiered seat still carry an explicit effort to the spawn (O3).
40
+ function resolveEffort(dispatchEffort, agent) {
41
+ if (dispatchEffort)
42
+ return dispatchEffort;
43
+ if (agent.effort)
44
+ return agent.effort;
45
+ switch (agent.model_tier) {
46
+ case "economy": return "low";
47
+ case "standard": return "medium";
48
+ case "premium": return "high";
49
+ default: return "medium";
50
+ }
51
+ }
52
+ // contract-seat-context-ceiling-v1 (O2) — the ONE place the context ceiling resolves, a sibling of
53
+ // resolveEffort but with NO floor: dispatch ▷ agent ▷ none. Absent everywhere ⇒ `undefined`, which the
54
+ // completions invoker reads as "no ceiling" and runs to done past any context (I1) — never a guessed
55
+ // default. Set on AgentInvocationContext.max_context_tokens at the invoke ctx site, the same seam
56
+ // `effort` threads through.
57
+ function resolveMaxContextTokens(dispatchCap, agent) {
58
+ if (dispatchCap !== undefined)
59
+ return dispatchCap;
60
+ return agent.max_context_tokens;
61
+ }
30
62
  /**
31
63
  * #236 — settled spend used to be discarded on every failed gig: `usage` was written only on
32
64
  * the success path, and the async dispatcher's `.catch` set status/error and nothing else. A
@@ -131,51 +163,139 @@ export function abortReasonText(signal) {
131
163
  return "cancelled";
132
164
  }
133
165
  /**
134
- * Raised when a gig's budget cannot cover the next agent's cost-of-append.
135
- * Carries the agent_slug, the available balance, and the required cost so
136
- * the caller can render the exact reason. The in-memory BudgetState is
137
- * also attached for downstream telemetry.
166
+ * Raised when a gig's SETTLED dollar spend reaches its `max_usd` ceiling and the next batch may
167
+ * not start (O2/O5). Denominated in dollars: `spent_usd`, `max_usd`, `unit: "usd"`, and a message
168
+ * that names the amounts with `$` and `usd` — never append-units. The in-memory BudgetState is
169
+ * attached so a caller can render the full snapshot.
138
170
  */
139
171
  export class BudgetExhausted extends Error {
140
172
  agent_slug;
141
- balance;
142
- cost;
173
+ spent_usd;
174
+ max_usd;
175
+ unit = "usd";
143
176
  state;
144
- constructor(agent_slug, balance, cost, state) {
145
- super(`BudgetExhausted: agent "${agent_slug}" needs cost=${cost} but balance=${balance} (opening=${state.opening}, spent=${state.spent}, credit=${state.credit})`);
177
+ constructor(agent_slug, spent_usd, max_usd, state) {
178
+ super(`BudgetExhausted: gig stopped before agent "${agent_slug}" settled spend $${spent_usd} reached the $${max_usd} usd ceiling`);
146
179
  this.name = "BudgetExhausted";
147
180
  this.agent_slug = agent_slug;
148
- this.balance = balance;
149
- this.cost = cost;
181
+ this.spent_usd = spent_usd;
182
+ this.max_usd = max_usd;
150
183
  this.state = state;
151
184
  }
152
185
  }
153
186
  /**
154
- * Cost-of-append for an agent invocation, in synthetic append units (see BudgetState.unit).
155
- * Deterministic function of the input context size same input same cost. Keeps cost
156
- * calculation inside the runtime (not the invoker) so budget enforcement cannot be spoofed by
157
- * a misbehaving invoker.
158
- *
159
- * #233 this used to serialize `input_ids`: the UUIDs of the upstream outputs a chair
160
- * consumes, not their data. An upstream output contributed exactly 36 bytes whether it was a
161
- * one-line signal or a 40-page draft, so the proxy was not even monotonic in the thing that
162
- * drives real cost. It now measures the CONTENT the invoker actually receives.
163
- *
164
- * Still excluded, honestly: resolved skills, the agent charter, type schemas, model tier,
165
- * max_tool_calls, and the entire output side. This is a rate limiter on consumed context, not
166
- * a price. Money is `BudgetState.settled_usd`.
187
+ * Raised when a gig under a dollar ceiling cannot VERIFY its spend (F3): a settled invocation
188
+ * reported usage but no `total_cost_usd`, so the runtime cannot know whether the next batch is
189
+ * affordable. Fail-closed no further batch starts. `reason` is the typed `budget_unverifiable`,
190
+ * and the message names the chairs whose dollar spend is unknown.
191
+ */
192
+ export class BudgetUnverifiable extends Error {
193
+ reason = "budget_unverifiable";
194
+ chairs;
195
+ state;
196
+ constructor(chairs, state) {
197
+ super(`budget_unverifiable: cannot enforce the usd ceiling chair(s) settled without reporting usd: ${chairs.join(", ")}`);
198
+ this.name = "BudgetUnverifiable";
199
+ this.chairs = chairs;
200
+ this.state = state;
201
+ }
202
+ }
203
+ /**
204
+ * The model that DID a chair's work: the model that WROTE the most output tokens across the chair's
205
+ * `modelUsage` breakdown — NOT whichever key the CLI listed first. Claude Code spends a small
206
+ * background call on a fast model before the real work, and that model is listed FIRST while writing
207
+ * almost nothing, so a first-key stamp names a model that did none of the chair's work. Output
208
+ * tokens are the honest signal of which model produced the answer. Ties break on the model id
209
+ * (lexicographic ascending), so the stamp is deterministic regardless of the breakdown's key order.
210
+ * An empty map (a transport that reported no per-model breakdown) yields undefined — nothing to
211
+ * stamp, exactly as before.
167
212
  */
168
- export function computeAppendCost(ctx, base_cost, k) {
169
- // Canonical serialization of what the invoker actually sees. JSON.stringify
170
- // is sufficient for v0 — deterministic order isn't required since size is
171
- // the only thing we extract, and Record key order in V8 is insertion-stable.
172
- const size_bytes = JSON.stringify({
173
- agent_slug: ctx.agent.slug,
174
- phase: ctx.phase,
175
- inputs: ctx.inputs.map((i) => i.data),
176
- gig_input: ctx.gig_input,
177
- }).length;
178
- return base_cost + k * size_bytes;
213
+ export function workingModel(outputByModel) {
214
+ let best;
215
+ let bestTokens = -1;
216
+ for (const [model, tokens] of outputByModel) {
217
+ if (tokens > bestTokens || (tokens === bestTokens && best !== undefined && model < best)) {
218
+ best = model;
219
+ bestTokens = tokens;
220
+ }
221
+ }
222
+ return best;
223
+ }
224
+ function gitInTree(tree_root, args) {
225
+ return execFileSync("git", ["-C", tree_root, ...args]).toString();
226
+ }
227
+ // The it/test titles a law blob declares, in file order — the `tests` the reviewer is handed instead
228
+ // of a sentence about the law. Matches `it(...)`/`test(...)` (with any `.only`/`.skip`/… chain) taking
229
+ // a string literal as its first argument; the leading \b keeps `it`/`test` from matching inside a
230
+ // longer identifier (submit, audit, …). Escaped quotes/backslashes in the title are unescaped.
231
+ function testTitlesIn(body) {
232
+ const re = /\b(?:it|test)(?:\.\w+)*\s*\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
233
+ const titles = [];
234
+ for (let m = re.exec(body); m !== null; m = re.exec(body)) {
235
+ titles.push(m[2].replace(/\\(["'`\\])/g, "$1"));
236
+ }
237
+ return titles;
238
+ }
239
+ /**
240
+ * Stamp each law's `blob_sha` (`git rev-parse <commit>:<path>`) and `tests` (the it/test titles in
241
+ * that blob, in file order) from a supplied `{path, commit}`, reading git in `tree_root`.
242
+ * REFUSALS: no `tree_root` → `tree_root_unknown` (never a `process.cwd()` fallback); an address that
243
+ * does not resolve (unknown commit, or a path absent at that commit) → `law_record_unresolvable`
244
+ * naming `path@commit`; a seat-supplied `blob_sha`/`tests` that disagrees with git → `law_bytes_mismatch`
245
+ * naming the path and field.
246
+ */
247
+ export function stampLawAddresses(laws, tree_root) {
248
+ if (tree_root === undefined) {
249
+ throw new RuntimeError("tree_root_unknown: a red-spec carrying `laws` cannot be stamped without a RunDeps.tree_root — the seal reads git objects from a named tree and never falls back to process.cwd().");
250
+ }
251
+ return laws.map((law) => {
252
+ let blob_sha;
253
+ try {
254
+ blob_sha = gitInTree(tree_root, ["rev-parse", `${law.commit}:${law.path}`]).trim();
255
+ }
256
+ catch {
257
+ throw new RuntimeError(`law_record_unresolvable: the law ${law.path}@${law.commit} does not resolve in tree_root — git holds no object for that <commit>:<path>. Nothing is sealed.`);
258
+ }
259
+ const body = gitInTree(tree_root, ["show", `${law.commit}:${law.path}`]);
260
+ const tests = testTitlesIn(body);
261
+ if (law.blob_sha !== undefined && law.blob_sha !== blob_sha) {
262
+ throw new RuntimeError(`law_bytes_mismatch: the seat-supplied blob_sha for law ${law.path} (${law.blob_sha}) disagrees with the engine (${blob_sha}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
263
+ }
264
+ if (law.tests !== undefined && (law.tests.length !== tests.length || law.tests.some((t, i) => t !== tests[i]))) {
265
+ throw new RuntimeError(`law_bytes_mismatch: the seat-supplied tests for law ${law.path} disagree with the titles git holds in that blob. A seat's claim is refused, never overwritten. Nothing is sealed.`);
266
+ }
267
+ return { path: law.path, commit: law.commit, blob_sha, tests };
268
+ });
269
+ }
270
+ /**
271
+ * Stamp each change's `blob_sha` (`git hash-object` of the file in `tree_root`, or the literal
272
+ * `"deleted"` when the file is absent from the tree), `patch_sha256` (sha256 of `git diff <base> --
273
+ * <path>` in `tree_root`) and `bytes` (that diff's length) from a supplied `{path, base}`.
274
+ * REFUSALS: no `tree_root` → `tree_root_unknown`; a seat-supplied `blob_sha`/`patch_sha256`/`bytes`
275
+ * that disagrees with git → `law_bytes_mismatch` naming the path and field.
276
+ */
277
+ export function stampChangeAddresses(changes, tree_root) {
278
+ if (tree_root === undefined) {
279
+ throw new RuntimeError("tree_root_unknown: a change-set carrying `changes` cannot be stamped without a RunDeps.tree_root — the seal reads git objects from a named tree and never falls back to process.cwd().");
280
+ }
281
+ return changes.map((change) => {
282
+ const blob_sha = existsSync(joinPath(tree_root, change.path))
283
+ ? gitInTree(tree_root, ["hash-object", change.path]).trim()
284
+ : "deleted";
285
+ const diff = gitInTree(tree_root, ["diff", change.base, "--", change.path]);
286
+ const patch_sha256 = sha256Hex(diff);
287
+ const bytes = Buffer.byteLength(diff, "utf8");
288
+ if (change.blob_sha !== undefined && change.blob_sha !== blob_sha) {
289
+ throw new RuntimeError(`law_bytes_mismatch: the seat-supplied blob_sha for change ${change.path} (${change.blob_sha}) disagrees with the engine (${blob_sha}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
290
+ }
291
+ if (change.patch_sha256 !== undefined && change.patch_sha256 !== patch_sha256) {
292
+ throw new RuntimeError(`law_bytes_mismatch: the seat-supplied patch_sha256 for change ${change.path} disagrees with the sha256 of the real diff. A seat's claim is refused, never overwritten. Nothing is sealed.`);
293
+ }
294
+ if (change.bytes !== undefined && change.bytes !== bytes) {
295
+ throw new RuntimeError(`law_bytes_mismatch: the seat-supplied bytes for change ${change.path} (${change.bytes}) disagrees with the engine (${bytes}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
296
+ }
297
+ return { path: change.path, base: change.base, blob_sha, patch_sha256, bytes };
298
+ });
179
299
  }
180
300
  // Deterministic hash over the definitions a gig touches: the standard + its agents,
181
301
  // in a canonical (sorted, JCS) form. This is the reproducibility key — same defs,
@@ -372,8 +492,32 @@ export async function runGig(standard, gigInput, deps) {
372
492
  // only granularity that means anything. Returns whether THIS chair ever reported usage.
373
493
  const makeUsageSink = () => {
374
494
  let saw = false;
495
+ let sawCost = false;
496
+ // contract-spend-survives-v1 (O1/I1) — this chair's own settled usage, accumulated ALONGSIDE the
497
+ // gig-wide `usage` fold below so the per-chair rows reconcile to the gig total by construction.
498
+ const chairOwnUsage = { input_tokens: 0, output_tokens: 0, total_cost_usd: 0, by_model: {} };
499
+ // Per-chair, alongside the gig-level fold. The gig's `by_model` cannot separate two chairs in
500
+ // one run, which is exactly the question per-chair routing asks. Output tokens per model id,
501
+ // accumulated across this chair's `result` events; `workingModel` picks the one that did the
502
+ // work (the argmax) at report time rather than trusting the CLI's first key.
503
+ const chairOutputByModel = new Map();
504
+ let chairCost = 0;
505
+ let chairTokens = 0;
506
+ let chairSaw = false;
375
507
  return {
376
508
  attributed: () => saw,
509
+ reportedCost: () => sawCost,
510
+ usage: () => (chairSaw ? chairOwnUsage : undefined),
511
+ reported: () => {
512
+ if (!chairSaw)
513
+ return {};
514
+ const model = workingModel(chairOutputByModel);
515
+ return {
516
+ ...(model !== undefined ? { model } : {}),
517
+ cost_usd: chairCost,
518
+ tokens_used: chairTokens,
519
+ };
520
+ },
377
521
  fold(ev) {
378
522
  if (ev.type !== "result")
379
523
  return;
@@ -393,17 +537,42 @@ export async function runGig(standard, gigInput, deps) {
393
537
  // number this engine could produce about money.
394
538
  if (!hasCost && !hasTokens && !hasBreakdown)
395
539
  return;
540
+ chairSaw = true;
541
+ if (hasCost)
542
+ sawCost = true;
543
+ chairTokens +=
544
+ (typeof inRaw === "number" ? inRaw : 0) + (typeof outRaw === "number" ? outRaw : 0);
545
+ chairCost += hasCost ? costRaw : 0;
396
546
  usage.input_tokens += typeof inRaw === "number" ? inRaw : 0;
397
547
  usage.output_tokens += typeof outRaw === "number" ? outRaw : 0;
398
548
  usage.total_cost_usd += hasCost ? costRaw : 0;
549
+ // The SAME fold, kept per-chair for the durable chair_spend row (O1/I1). Summing every
550
+ // chair's own usage reconstructs the gig-wide `usage` above, which is what I1 pins.
551
+ chairOwnUsage.input_tokens += typeof inRaw === "number" ? inRaw : 0;
552
+ chairOwnUsage.output_tokens += typeof outRaw === "number" ? outRaw : 0;
553
+ chairOwnUsage.total_cost_usd += hasCost ? costRaw : 0;
399
554
  // Per-model breakdown keyed by the ACTUAL model id that ran (not the configured tier).
400
555
  if (hasBreakdown) {
401
556
  for (const [model, m] of Object.entries(mu)) {
557
+ const outTok = typeof m["outputTokens"] === "number" ? m["outputTokens"] : 0;
402
558
  const slot = usage.by_model[model] ?? { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
403
559
  slot.input_tokens += typeof m["inputTokens"] === "number" ? m["inputTokens"] : 0;
404
- slot.output_tokens += typeof m["outputTokens"] === "number" ? m["outputTokens"] : 0;
560
+ slot.output_tokens += outTok;
405
561
  slot.cost_usd += typeof m["costUSD"] === "number" ? m["costUSD"] : 0;
406
562
  usage.by_model[model] = slot;
563
+ // Per-chair by_model, keyed the same way, so the chair_spend row's own breakdown stands
564
+ // on its own rather than pointing back at the gig-wide total.
565
+ const cslot = chairOwnUsage.by_model[model] ?? { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
566
+ cslot.input_tokens += typeof m["inputTokens"] === "number" ? m["inputTokens"] : 0;
567
+ cslot.output_tokens += outTok;
568
+ cslot.cost_usd += typeof m["costUSD"] === "number" ? m["costUSD"] : 0;
569
+ chairOwnUsage.by_model[model] = cslot;
570
+ // The stamp is the model that WROTE this chair's output, decided at report time by
571
+ // `workingModel` (argmax over output tokens). The CLI lists a fast background call's
572
+ // model FIRST though it writes almost nothing, so the old first-key `??=` stamped a
573
+ // model that did none of the work. Accumulate per-model output; the gig-level `by_model`
574
+ // total above is untouched.
575
+ chairOutputByModel.set(model, (chairOutputByModel.get(model) ?? 0) + outTok);
407
576
  }
408
577
  }
409
578
  else {
@@ -550,41 +719,38 @@ export async function runGig(standard, gigInput, deps) {
550
719
  // idempotent, which is what makes it safe on this path. No merge conflict; tsc caught it.
551
720
  throw new GigAborted(gig_id, reason, finalizeUsage(), produced);
552
721
  };
553
- // Budget state. When deps.budget is undefined, enforcement is OFF (back-compat).
554
- // When present, we track an in-memory BudgetState mirroring budget-state.json.
555
- // #turn-budget the gig reserve pool opens from the dispatch payload FIRST, then the standard's
556
- // default, then 0. `??` (not max/sum) so the dispatch declaration wins deterministically when both
557
- // are present and absent stays distinct from a declared 0.
558
- const poolOpening = deps.budget?.pool ?? standard.reserve_pool ?? 0;
559
- const budget = deps.budget
722
+ // Budget state (budget-in-dollars). A DOLLAR ceiling is in play iff `deps.budget.max_usd` is set;
723
+ // a TURN POOL is in play iff `turn_pool` or `Standard.reserve_pool` names one. The snapshot exists
724
+ // whenever EITHER is present (O6): pool fields always, dollar fields only under a ceiling.
725
+ //
726
+ // O6 the pool opens from RunDeps.turn_pool FIRST, then the standard default. `??` (not max/sum)
727
+ // so the dispatch declaration wins deterministically, and absence stays distinct from a declared 0.
728
+ const maxUsd = deps.budget?.max_usd;
729
+ const hasCeiling = typeof maxUsd === "number";
730
+ const poolSource = deps.turn_pool ?? standard.reserve_pool; // undefined when neither names one
731
+ const poolInPlay = poolSource !== undefined;
732
+ const poolOpening = poolSource ?? 0;
733
+ const budget = (hasCeiling || poolInPlay)
560
734
  ? {
561
- opening: deps.budget.opening,
562
- spent: 0,
563
- credit: 0,
564
- balance: deps.budget.opening,
565
735
  agent_state: "active",
566
736
  depleted_agent: null,
567
737
  depleted_at: null,
568
- base_cost: deps.budget.base_cost ?? 1,
569
- k: deps.budget.k ?? 0.1,
570
- unit: "append-units",
571
- settled_usd: 0,
572
738
  pool_remaining: poolOpening,
573
739
  draws: [],
740
+ ...(hasCeiling ? { max_usd: maxUsd, spent_usd: 0, unit: "usd" } : {}),
574
741
  }
575
742
  : null;
576
- // #turn-budget — reserve turns HELD by prepared-but-not-yet-settled chairs, the pool's mirror of
577
- // the append-unit `reserved` above. `prepareChair` runs synchronously for the whole ready batch
578
- // before any invoke, so an offer computed against `pool_remaining - poolReserved` cannot let two
579
- // parallel chairs over-lend the same turns. A grant converts the hold to a real draw-down; a
580
- // no-draw or denial releases it. Conservation therefore holds for every ordering, not by luck.
743
+ // #turn-budget — reserve turns HELD by prepared-but-not-yet-settled chairs. `prepareChair` runs
744
+ // synchronously for the whole ready batch before any invoke, so an offer computed against
745
+ // `pool_remaining - poolReserved` cannot let two parallel chairs over-lend the same turns. A grant
746
+ // converts the hold to a real draw-down; a no-draw or denial releases it. Conservation therefore
747
+ // holds for every ordering, not by luck.
581
748
  let poolReserved = 0;
582
- // #232cost RESERVED by chairs that passed the gate but have not settled. `prepareChair`
583
- // runs eagerly for the whole ready batch, so the gate must see its batch siblings' holds;
584
- // but a hold is not spend. It converts to `spent` only when the invocation succeeds, and is
585
- // released (never charged) when it fails or when a later sibling trips the gate and the
586
- // batch is abandoned before a single invoker is called.
587
- let reserved = 0;
749
+ // F3under a ceiling, the agent slugs of chairs that SETTLED (reported usage) but reported NO
750
+ // usd. Their dollar spend is unknown, so the next batch cannot be verified affordable and must not
751
+ // start; this list is what BudgetUnverifiable names. A fully-stubbed chair that reports no usage at
752
+ // all is not here that is every no-cost test fixture, and it completes as before.
753
+ const unverifiedChairs = [];
588
754
  // Resolve agent-by-slug once.
589
755
  const agentBySlug = new Map(standard.agents.map((a) => [a.slug, a]));
590
756
  // ── dispatch preflight: the UNIFIED t=0 dead-reference sweep ────────────────
@@ -1036,7 +1202,11 @@ export async function runGig(standard, gigInput, deps) {
1036
1202
  const core = deps.outputs.coreTypeOf(domain_type) ?? domain_type;
1037
1203
  const primitive = CORE_TO_PRIMITIVE[core] ?? "JUDGE";
1038
1204
  const approvalInputs = hc.depends_on.flatMap((d) => producedByRole.get(d) ?? []);
1039
- const t0 = Date.now();
1205
+ // contract-seat-time-monotonic-v1 (O2) — seat time is measured on the monotonic clock
1206
+ // (performance.now), never Date.now: a wall-clock jump during the seat (lid sleep, NTP step)
1207
+ // is machine time, not seat time. Rounded to whole ms at the difference, since performance.now
1208
+ // is fractional.
1209
+ const t0 = performance.now();
1040
1210
  emit({ type: "chair_start", phase: phase.name, role: hc.role, producer: deps.approved_by ?? "human" });
1041
1211
  const rec = deps.outputs.write({
1042
1212
  core_type: core,
@@ -1057,7 +1227,9 @@ export async function runGig(standard, gigInput, deps) {
1057
1227
  produced.push(rec);
1058
1228
  emit({
1059
1229
  type: "chair_complete", phase: phase.name, role: hc.role, producer: deps.approved_by ?? "human",
1060
- output_types: [domain_type], duration_ms: Date.now() - t0,
1230
+ output_types: [domain_type], duration_ms: Math.round(performance.now() - t0),
1231
+ // A human seat forwards no agent write events, so there is nothing to measure: null, not 0.
1232
+ first_write_ms: null, context_tokens_at_first_write: null,
1061
1233
  });
1062
1234
  // A sealed lineage-verdict either grounds an institution or does not. Decide it here,
1063
1235
  // where the verdict and the record it approved are both in hand, and report the answer
@@ -1126,6 +1298,27 @@ export async function runGig(standard, gigInput, deps) {
1126
1298
  const chosenRoles = new Set(chosen.map((c) => c.role));
1127
1299
  ready = ready.filter((c) => chosenRoles.has(c.role));
1128
1300
  }
1301
+ // ── BUDGET BATCH-BOUNDARY GATE (O2/F3) ──────────────────────────────────────────────────
1302
+ // The dollar ceiling is checked HERE, before this ready batch starts, against SETTLED spend
1303
+ // (reconciled at each prior batch boundary). The batch already running is never interrupted;
1304
+ // it is the NEXT batch that does not start. Two fail-closed conditions, both naming the chair
1305
+ // that would have run next:
1306
+ // F3 — a prior settled invocation reported no usd, so affordability is UNVERIFIABLE; and
1307
+ // O2 — settled spend has reached max_usd.
1308
+ if (hasCeiling && budget) {
1309
+ if (unverifiedChairs.length > 0) {
1310
+ budget.agent_state = "depleted";
1311
+ budget.depleted_agent = ready[0]?.agent_slug ?? null;
1312
+ budget.depleted_at = new Date().toISOString();
1313
+ throw new BudgetUnverifiable([...unverifiedChairs], budget);
1314
+ }
1315
+ if ((budget.spent_usd ?? 0) >= maxUsd) {
1316
+ budget.agent_state = "depleted";
1317
+ budget.depleted_agent = ready[0]?.agent_slug ?? null;
1318
+ budget.depleted_at = new Date().toISOString();
1319
+ throw new BudgetExhausted(ready[0]?.agent_slug ?? "?", budget.spent_usd ?? 0, maxUsd, budget);
1320
+ }
1321
+ }
1129
1322
  // Per-chair work happens in two stages so non-invocation failures
1130
1323
  // (BudgetExhausted, contract violations, programming-level errors like
1131
1324
  // TypeError from a circular gig_input) propagate UNWRAPPED through the
@@ -1151,11 +1344,11 @@ export async function runGig(standard, gigInput, deps) {
1151
1344
  noteCheckpointRole(ch.role, phase.name, r.value);
1152
1345
  }
1153
1346
  }
1154
- // #233 — BATCH BOUNDARY is the only point at which real settled dollars can be
1155
- // reconciled into the budget: prepareChair ran for every chair in this batch before any
1156
- // of them was invoked, so no chair could have seen its siblings' cost. Reporting only.
1157
- if (budget)
1158
- budget.settled_usd = usage.total_cost_usd;
1347
+ // O2 — BATCH BOUNDARY reconcile: the settled dollars this batch added are folded into the
1348
+ // snapshot here, so the NEXT batch's gate (top of the while loop) sees them. prepareChair ran
1349
+ // for every chair in this batch before any was invoked, so no chair saw its siblings' cost.
1350
+ if (budget && hasCeiling)
1351
+ budget.spent_usd = usage.total_cost_usd;
1159
1352
  // Bank progress BEFORE the failure throw below. A batch whose siblings succeeded has
1160
1353
  // durable outputs either way; the checkpoint is what makes them reachable next time,
1161
1354
  // and writing it only on the happy path would forfeit exactly the runs that need it.
@@ -1186,7 +1379,14 @@ export async function runGig(standard, gigInput, deps) {
1186
1379
  if (examineRounds > 0) {
1187
1380
  const allChairs = standard.phases.flatMap((p) => p.chairs);
1188
1381
  const phaseNameOf = (role) => standard.phases.find((p) => p.chairs.some((c) => c.role === role))?.name ?? phase.name;
1189
- const primitivesOf = (slug) => standard.agents.find((a) => a.slug === slug)?.primitives ?? [];
1382
+ // O3 a verify SEAT is a chair whose output resolves to core type Verdict, whatever produces
1383
+ // it (agent or skill chair). Keying on the agent's VERIFY primitive (as this did) meant a
1384
+ // skill-backed verify chair sealing `pass: false` never triggered an amend: a skill chair
1385
+ // binds no agent, so it has no primitive to match.
1386
+ const producesVerdict = (c) => {
1387
+ const out = c.output_contract[0];
1388
+ return !!out && (deps.outputs.coreTypeOf(out) ?? "") === "Verdict";
1389
+ };
1190
1390
  const dropFromProduced = (recs) => {
1191
1391
  for (const r of recs) {
1192
1392
  const idx = produced.indexOf(r);
@@ -1197,7 +1397,7 @@ export async function runGig(standard, gigInput, deps) {
1197
1397
  const failingVerdict = (role) => (producedByRole.get(role) ?? []).find((r) => (deps.outputs.coreTypeOf(r.domain_type) ?? "") === "Verdict" &&
1198
1398
  r.data.pass === false);
1199
1399
  for (const vch of phase.chairs) {
1200
- if (!primitivesOf(vch.agent_slug).includes("VERIFY"))
1400
+ if (!producesVerdict(vch))
1201
1401
  continue;
1202
1402
  let verdict = failingVerdict(vch.role);
1203
1403
  if (!verdict)
@@ -1210,11 +1410,35 @@ export async function runGig(standard, gigInput, deps) {
1210
1410
  const out = c.output_contract[0];
1211
1411
  return !!out && (deps.outputs.coreTypeOf(out) ?? "") === "Artifact";
1212
1412
  };
1213
- const makers = vch.depends_on
1413
+ const makerSet = vch.depends_on
1214
1414
  .map((role) => allChairs.find((c) => c.role === role))
1215
1415
  .filter((c) => !!c && producesArtifact(c));
1216
- if (makers.length === 0)
1416
+ if (makerSet.length === 0)
1217
1417
  continue; // nothing to re-run — a verify with no maker to amend
1418
+ // contract-amend-nearest-makers-v1 (O1/I1/F1) — an amend round spends a seat ONLY where the
1419
+ // verdict's fix can land. Of today's maker set, re-invoke only the makers that no OTHER maker in
1420
+ // the set depends on, directly or transitively through depends_on. An upstream maker's inputs do
1421
+ // not change between rounds, so re-running it re-does settled work AND replaces the exact record
1422
+ // the downstream fix was built on (its sealed record must instead be carried unchanged — O2:
1423
+ // producedByRole still holds it, and the amend loop below never touches a skipped maker's role,
1424
+ // so prepareChair gathers it as-is). Independent makers keep no dependant in the set, so they all
1425
+ // re-run as today (I1). A composed standard's depends_on graph is acyclic, so the maker set always
1426
+ // has at least one sink — the narrowed selection is never empty and never the whole set (F1).
1427
+ const chairByRole = new Map(allChairs.map((c) => [c.role, c]));
1428
+ const transitiveDepsOf = (role) => {
1429
+ const seen = new Set();
1430
+ const stack = [...(chairByRole.get(role)?.depends_on ?? [])];
1431
+ while (stack.length > 0) {
1432
+ const r = stack.pop();
1433
+ if (seen.has(r))
1434
+ continue;
1435
+ seen.add(r);
1436
+ for (const d of chairByRole.get(r)?.depends_on ?? [])
1437
+ stack.push(d);
1438
+ }
1439
+ return seen;
1440
+ };
1441
+ const makers = makerSet.filter((m) => !makerSet.some((other) => other.role !== m.role && transitiveDepsOf(other.role).has(m.role)));
1218
1442
  for (let round = 1; round <= examineRounds && verdict; round++) {
1219
1443
  checkpoint();
1220
1444
  emit({ type: "phase_start", phase: `${phase.name}:amend#${round}`, roles: [...makers.map((m) => m.role), vch.role] });
@@ -1222,24 +1446,42 @@ export async function runGig(standard, gigInput, deps) {
1222
1446
  // AMEND: each maker re-runs with the failing verdict fed in as an extra input, so
1223
1447
  // the seat that built the change fixes the exact thing the verify caught.
1224
1448
  for (const mk of makers) {
1225
- const prep = prepareChair(mk, phaseNameOf(mk.role));
1226
- if (!prep.inputs.includes(feedback))
1227
- prep.inputs.push(feedback);
1449
+ // O1/I3 carry the maker's OWN work from the round just judged, plus the failing
1450
+ // verdict, INTO prepareChair, so both are among `inputs` when lookupReuse computes the
1451
+ // key. Pushing the verdict in AFTER prep (as this did) left the amend key identical to
1452
+ // round 1's, so a reuse-wired amend was served round 1's failing artifact from the
1453
+ // cache. producedByRole holds ONLY the round just judged, so the amend carries exactly
1454
+ // one prior artifact and one verdict, both the latest — never a pile of drafts.
1455
+ const priorWork = producedByRole.get(mk.role) ?? [];
1456
+ // contract-chair-session-continuity-v1 (O3/O5) — an amend RESUMES the maker's own session
1457
+ // (the invoker gets ctx.resume) and is never served from the reuse cache.
1458
+ // O1 — the initial invocation is round 1 (the default), so the amend loop's iteration
1459
+ // `round` (1-based) stamps round `round + 1`: each re-run of a seat gets a distinct,
1460
+ // monotonic round and no two chair_spend rows for one role collide.
1461
+ const prep = prepareChair(mk, phaseNameOf(mk.role), [...priorWork, feedback], { resume: true, round: round + 1 });
1228
1462
  const recs = await invokeAndWriteChair(prep);
1229
1463
  dropFromProduced(producedByRole.get(mk.role) ?? []);
1230
1464
  producedByRole.set(mk.role, recs);
1231
1465
  produced.push(...recs);
1232
1466
  noteCheckpointRole(mk.role, phaseNameOf(mk.role), recs);
1233
1467
  }
1234
- // RE-VERIFY the amended artifact.
1235
- const vprep = prepareChair(vch, phase.name);
1468
+ // RE-VERIFY the amended artifact. contract-resumed-gig-session-v1 (O1/I1) — the re-verify
1469
+ // RESUMES the verifier's own round-one session (`resume: true`), never re-opens `--session-id`
1470
+ // for a live id: the id is deterministic in (gig_id, role), so a second `--session-id` open
1471
+ // collides ("already in use"). Resuming carries `--resume <uuid>` instead. `keep_prompt` keeps
1472
+ // the FULL prompt rather than the maker's trimmed amend continuation: unlike a maker (whose
1473
+ // failing verdict IS the one new thing to send), a re-verify re-reads the amended tree from its
1474
+ // own identity, and a stateless door (chat-completions) that holds no conversation must still
1475
+ // carry the verify seat's identity — the trimmed continuation would strip it. buildInvokerArgs
1476
+ // still emits `--resume` (it keys on `resume`, not the prompt), so O1's arg law holds.
1477
+ const vprep = prepareChair(vch, phase.name, [], { resume: true, keep_prompt: true, round: round + 1 });
1236
1478
  const vrecs = await invokeAndWriteChair(vprep);
1237
1479
  dropFromProduced(producedByRole.get(vch.role) ?? []);
1238
1480
  producedByRole.set(vch.role, vrecs);
1239
1481
  produced.push(...vrecs);
1240
1482
  noteCheckpointRole(vch.role, phase.name, vrecs);
1241
- if (budget)
1242
- budget.settled_usd = usage.total_cost_usd;
1483
+ if (budget && hasCeiling)
1484
+ budget.spent_usd = usage.total_cost_usd;
1243
1485
  saveCheckpoint();
1244
1486
  verdict = failingVerdict(vch.role); // undefined once it passes → loop ends
1245
1487
  }
@@ -1350,6 +1592,28 @@ export async function runGig(standard, gigInput, deps) {
1350
1592
  }
1351
1593
  return { key, hit: { cache_key: key, source_gig_id: entry.source_gig_id, outputs: entry.outputs } };
1352
1594
  }
1595
+ /**
1596
+ * contract-seat-primer-v1 (O2/I3) — the MOST RECENT `seat-primer` this agent sealed for `area`,
1597
+ * across gigs. Agent-specific by construction: a chair never forks a primer sealed by a DIFFERENT
1598
+ * agent, even for the same area (I3), because the (agent_slug, area) filter admits only its own.
1599
+ * "Most recent" so re-priming an area updates every later fork — staleness is decided per blob at
1600
+ * fork time, not pinned to one primer id. Returns undefined when the agent has never primed the area.
1601
+ */
1602
+ function mostRecentSeatPrimer(agentSlug, area) {
1603
+ let best;
1604
+ for (const r of deps.outputs.all()) {
1605
+ if (r.domain_type !== "seat-primer")
1606
+ continue;
1607
+ const d = r.data;
1608
+ if (d["agent_slug"] !== agentSlug || d["area"] !== area)
1609
+ continue;
1610
+ // `>=` prefers the later-iterated record on an equal timestamp; all() is insertion-ordered, so
1611
+ // the newest primer wins even when two sealed in the same millisecond.
1612
+ if (!best || r.created_at >= best.created_at)
1613
+ best = r;
1614
+ }
1615
+ return best;
1616
+ }
1353
1617
  /**
1354
1618
  * Offer an ENTRY chair the seeds a chart edge carried in.
1355
1619
  *
@@ -1369,7 +1633,7 @@ export async function runGig(standard, gigInput, deps) {
1369
1633
  seedsConsumed.set(s.id, s);
1370
1634
  }
1371
1635
  }
1372
- function prepareChair(chair, phaseName) {
1636
+ function prepareChair(chair, phaseName, extraInputs = [], opts = {}) {
1373
1637
  // A skill-backed chair runs the skill's deterministic code half — no agent, no model.
1374
1638
  if (chair.skill_slug && (chair.agent_slug ?? "") === "") {
1375
1639
  const dir = deps.skill_dirs?.get(chair.skill_slug);
@@ -1388,6 +1652,11 @@ export async function runGig(standard, gigInput, deps) {
1388
1652
  inputs.push(...recs);
1389
1653
  }
1390
1654
  pullSeeds(chair, inputs, chair.input_contract);
1655
+ // Amend carriage: extra inputs (the maker's own prior work + the failing verdict) join the
1656
+ // frontier so they enter the reuse key — see the EXAMINE⇄AMEND block. Empty otherwise.
1657
+ for (const ex of extraInputs)
1658
+ if (!inputs.includes(ex))
1659
+ inputs.push(ex);
1391
1660
  if (chair.input_contract.length > 0) {
1392
1661
  for (const need of chair.input_contract) {
1393
1662
  // #156: a type satisfied by an upstream record OR by the gig payload (entry-chair seed).
@@ -1425,6 +1694,7 @@ export async function runGig(standard, gigInput, deps) {
1425
1694
  producer_slug: chair.skill_slug, domain: standard.domain,
1426
1695
  ...(skillReuse ? { reuse_key: skillReuse.key } : {}),
1427
1696
  ...(skillReuse?.hit ? { reuse_hit: skillReuse.hit } : {}),
1697
+ ...(opts.round !== undefined ? { round: opts.round } : {}),
1428
1698
  };
1429
1699
  }
1430
1700
  const agent = standard.agents.find((a) => a.slug === chair.agent_slug);
@@ -1477,6 +1747,14 @@ export async function runGig(standard, gigInput, deps) {
1477
1747
  // upstream record — by type, as records, so provenance survives the movement boundary.
1478
1748
  pullSeeds(chair, inputs, [...chair.input_contract, ...agent.input_types]);
1479
1749
  }
1750
+ // Amend carriage (O1/I3): the maker's own round-just-judged artifact and the failing verdict are
1751
+ // threaded in as extra inputs so they enter `inputs` BEFORE lookupReuse — the amend key then
1752
+ // describes what the maker actually receives, and a reuse-wired amend is no longer served round
1753
+ // 1's failing artifact from the cache. Empty on every non-amend prep, so round-1 keys stay
1754
+ // byte-identical (the I1 control).
1755
+ for (const ex of extraInputs)
1756
+ if (!inputs.includes(ex))
1757
+ inputs.push(ex);
1480
1758
  // Runtime input_contract check: every type the chair declares it expects
1481
1759
  // on input must be satisfied by its actual upstream inputs. Subtype-aware
1482
1760
  // (docs/genome-extension.md): a core-type requirement is met by any domain
@@ -1563,36 +1841,88 @@ export async function runGig(standard, gigInput, deps) {
1563
1841
  // context, so charging it (or worse, refusing it for lack of allowance) would be the budget
1564
1842
  // enforcing a cost that is not going to be incurred.
1565
1843
  const lookup = lookupReuse({ chair, phaseName, inputs, output_specs, agent, skills, producer_slug: agent.slug, domain });
1566
- // BUDGET GATEpre-invocation, and a RESERVATION only (#232). Synchronous so
1567
- // BudgetExhausted (and a TypeError thrown from JSON.stringify on a circular gig_input)
1568
- // propagate unwrapped to the caller rather than being aggregated as a chair failure.
1569
- //
1570
- // The gate compares against `balance - reserved` so a batch of parallel chairs cannot each
1571
- // spend the same balance; the hold converts to `spent` only in settleChairCost, after the
1572
- // invoker actually returns. Before this, `spent += cost` happened HERE — so when a later
1573
- // member of an eagerly-prepared batch tripped the gate, every earlier member was already
1574
- // charged and `invokeAndWriteChair` then ran for nobody. The operator saw spend for work
1575
- // that never started, and that inflated figure is what BudgetExhausted.state reported.
1576
- let reservedCost;
1577
- if (budget && !lookup?.hit) {
1578
- const cost = computeAppendCost({ agent, phase: phaseName, inputs, gig_input: gigInput }, budget.base_cost, budget.k);
1579
- const available = budget.balance - reserved;
1580
- if (available < cost) {
1581
- budget.agent_state = "depleted";
1582
- budget.depleted_agent = agent.slug;
1583
- budget.depleted_at = new Date().toISOString();
1584
- throw new BudgetExhausted(agent.slug, available, cost, budget);
1844
+ // contract-seat-primer-v1 (O2/O3/O4/F1)FORK WIRING. A chair with fork_from looks up the most
1845
+ // recent seat-primer for (its agent, area) across gigs and warm-starts from it. Resolved here so
1846
+ // the reuse gate below can withhold the hit (O5) and so the invocation carries the primer session
1847
+ // and the blob-stale paths. A fork with NO primer (F1) records `primer_missing` and runs cold.
1848
+ let fork;
1849
+ let fork_fallback;
1850
+ if (chair.fork_from) {
1851
+ const area = chair.fork_from.primer;
1852
+ const primer = mostRecentSeatPrimer(agent.slug, area);
1853
+ if (!primer) {
1854
+ fork_fallback = "primer_missing";
1855
+ }
1856
+ else {
1857
+ const pd = primer.data;
1858
+ // contract-rolling-seat-primer-v1 (O4/I1) a `max_context_tokens` ceiling REPLACES a bloated
1859
+ // primer instead of forking it: when the primer's recorded context size exceeds the ceiling, the
1860
+ // chair does NOT fork — it runs COLD (a fresh `--session-id`, its full prompt) and records
1861
+ // `primer_too_large`. Absent ceiling (I1) → the fork happens whatever the size, so size gates the
1862
+ // fork ONLY through this ceiling.
1863
+ const ceiling = chair.fork_from.max_context_tokens;
1864
+ const primer_context = typeof pd["context_tokens"] === "number" ? pd["context_tokens"] : 0;
1865
+ if (ceiling !== undefined && primer_context > ceiling) {
1866
+ fork_fallback = "primer_too_large";
1867
+ }
1868
+ else {
1869
+ // O4 — staleness is decided by BLOBS against the working tree, through the SAME gitInTree seam
1870
+ // the law/change stampers use. A file git can no longer hash (removed since priming) is stale.
1871
+ const files = Array.isArray(pd["files"]) ? pd["files"] : [];
1872
+ const stale_paths = files
1873
+ .filter((f) => {
1874
+ if (deps.tree_root === undefined)
1875
+ return false;
1876
+ let current;
1877
+ try {
1878
+ current = gitInTree(deps.tree_root, ["hash-object", f.path]).trim();
1879
+ }
1880
+ catch {
1881
+ return true;
1882
+ }
1883
+ return current !== f.blob_sha;
1884
+ })
1885
+ .map((f) => f.path);
1886
+ fork = {
1887
+ primer_session_id: String(pd["session_id"] ?? ""),
1888
+ primer_id: primer.id,
1889
+ primer_commit: String(pd["commit"] ?? ""),
1890
+ stale_paths,
1891
+ // contract-rolling-seat-primer-v1 (O2) — carry the forked primer's own files so a prime+fork
1892
+ // chair can seal a fresher primer unioning them with this seat's reads.
1893
+ primer_files: files,
1894
+ // contract-primer-reading-frontier-v1 (O2/F2) — the primer's recorded reading frontier, so the
1895
+ // fork's first spawn cuts the resumed conversation there. Absent ⇒ the whole session resumes.
1896
+ ...(typeof pd["frontier"] === "string" ? { frontier: pd["frontier"] } : {}),
1897
+ };
1898
+ }
1585
1899
  }
1586
- reserved += cost;
1587
- reservedCost = cost;
1588
1900
  }
1901
+ // contract-chair-session-continuity-v1 (O5) — a RESUMED (amend-round) invocation is NEVER served
1902
+ // from the reuse cache: the seat resumes its own conversation, which carries what the failing
1903
+ // verdict prompted it to reconsider, and none of that is in the reuse key. Serving the cached
1904
+ // artifact would replay the very work the amend exists to redo. The key is still kept below (the
1905
+ // amend's own output is cacheable on the round-one terms), only the HIT is withheld.
1906
+ // contract-seat-primer-v1 (O5) — the SAME withholding for a FORK: a warm-started conversation the
1907
+ // reuse key cannot describe would replay the very reading the fork exists to reuse. The hit is
1908
+ // withheld here AND recorded (below, in executeChair) so the cache-was-live signal survives — a
1909
+ // fork always invokes, even on a key that would otherwise hit.
1910
+ const effectiveHit = (opts.resume || fork) ? undefined : lookup?.hit;
1911
+ // O5 — record the withheld fork hit so executeChair marks the cache LIVE without serving it.
1912
+ const fork_reuse_withheld = fork && lookup?.hit
1913
+ ? { cache_key: lookup.hit.cache_key, source_gig_id: lookup.hit.source_gig_id, output_types: lookup.hit.outputs.map((o) => o.domain_type) }
1914
+ : undefined;
1915
+ // BUDGET GATE — the append-unit pre-invocation gate is GONE (O3). Payload size / base_cost / k
1916
+ // no longer decide whether a chair runs. The dollar ceiling is enforced against SETTLED spend at
1917
+ // BATCH BOUNDARIES (O2, in the dispatch loop), not per-chair here. This block now only computes
1918
+ // the reserve OFFER against the gig pool.
1589
1919
  // #turn-budget — RESERVE OFFER. Only a chair that DECLARED a `turn_reserve` reaches for the pool;
1590
1920
  // one that declared none threads no ctx.turn_reserve, so the invoker's own opts-level reserve is
1591
- // undisturbed (the #329 continuation path). When a budget is enforced the offer is capped to what
1592
- // the pool can still lend (min(own reserve, pool_remaining - poolReserved)) and HELD; with no
1593
- // budget there is no pool to cap against, so the declared reserve threads through directly.
1921
+ // undisturbed (the #329 continuation path). When a pool is in play the offer is capped to what it
1922
+ // can still lend (min(own reserve, pool_remaining - poolReserved)) and HELD; with no pool at all
1923
+ // (`budget` null) the declared reserve threads through directly.
1594
1924
  let reserveOffer;
1595
- if (chair.turn_reserve !== undefined && !lookup?.hit) {
1925
+ if (chair.turn_reserve !== undefined && !effectiveHit) {
1596
1926
  if (budget) {
1597
1927
  const poolAvailable = Math.max(0, budget.pool_remaining - poolReserved);
1598
1928
  reserveOffer = Math.min(chair.turn_reserve, poolAvailable);
@@ -1605,42 +1935,23 @@ export async function runGig(standard, gigInput, deps) {
1605
1935
  return {
1606
1936
  chair, phaseName, agent, primitive, domain_type, output_specs, inputs, skills,
1607
1937
  missing_skills: missing, producer_slug: agent.slug, domain,
1608
- ...(reservedCost !== undefined ? { cost: reservedCost } : {}),
1609
1938
  ...(reserveOffer !== undefined ? { reserve_offer: reserveOffer } : {}),
1610
1939
  ...(lookup ? { reuse_key: lookup.key } : {}),
1611
- ...(lookup?.hit ? { reuse_hit: lookup.hit } : {}),
1940
+ ...(effectiveHit ? { reuse_hit: effectiveHit } : {}),
1941
+ ...(opts.resume ? { resume: true } : {}),
1942
+ ...(opts.keep_prompt ? { resume_keep_prompt: true } : {}),
1943
+ ...(opts.round !== undefined ? { round: opts.round } : {}),
1944
+ ...(fork ? { fork } : {}),
1945
+ ...(fork_fallback !== undefined ? { fork_fallback } : {}),
1946
+ ...(fork_reuse_withheld ? { fork_reuse_withheld } : {}),
1612
1947
  };
1613
1948
  }
1614
- // #232 — convert a chair's reservation into settled spend, or release it. `spent` moves ONLY
1615
- // for a chair whose invocation actually returned, which is what the budget contract always
1616
- // claimed. A chair that was prepared and then never invoked (its batch sibling tripped the
1617
- // gate) never reaches here at all — so it is never charged, which is the point.
1618
- function settleChairCost(p, succeeded) {
1619
- if (!budget || p.cost === undefined)
1620
- return;
1621
- reserved -= p.cost;
1622
- if (!succeeded)
1623
- return;
1624
- budget.spent += p.cost;
1625
- budget.balance = budget.opening - budget.spent + budget.credit;
1626
- }
1627
1949
  // Stage 2 — actual invocation + post-invocation output_contract check + write.
1628
1950
  // Errors here ARE aggregated by Promise.allSettled and surfaced as a phase-
1629
- // level RuntimeError naming every failing chair role.
1630
- //
1631
- // The thin wrapper is where a chair's budget RESERVATION settles (#232): a hold becomes
1632
- // `spent` on success and is released on failure. Both paths must run, so the accounting
1633
- // cannot drift no matter how the chair ends.
1951
+ // level RuntimeError naming every failing chair role. There is no per-chair budget
1952
+ // reservation to settle any more — the ceiling is a batch-boundary check on settled USD.
1634
1953
  async function invokeAndWriteChair(p) {
1635
- try {
1636
- const written = await executeChair(p);
1637
- settleChairCost(p, true);
1638
- return written;
1639
- }
1640
- catch (e) {
1641
- settleChairCost(p, false);
1642
- throw e;
1643
- }
1954
+ return executeChair(p);
1644
1955
  // THE ROOM IS NOT TORN DOWN HERE. It used to be, in this chair-level `finally`, described as
1645
1956
  // "idempotent and a no-op when no venue was named". Idempotent yes; a no-op no —
1646
1957
  // `src/venue_realize.ts` sets `torn = true`, and `canReach()` is `!torn && egress.includes(...)`.
@@ -1655,8 +1966,55 @@ export async function runGig(standard, gigInput, deps) {
1655
1966
  // on both the success and failure paths — see the `finally` on runGig's outer try.
1656
1967
  }
1657
1968
  async function executeChair(p) {
1969
+ // What the transport SAID about this chair, hoisted out of the invocation block so the seal
1970
+ // can prefer a measurement over the tier table's guess. Empty for a skill-backed chair.
1971
+ let chairReport = {};
1658
1972
  const { chair, phaseName, inputs, skills, output_specs, producer_slug, domain } = p;
1659
- const t0 = Date.now();
1973
+ // contract-seat-time-monotonic-v1 (O1) — the chair's timings (first_write_ms, duration_ms below)
1974
+ // are monotonic differences (performance.now), never Date.now: a wall-clock jump mid-chair is
1975
+ // machine time, not seat time. Rounded to whole ms at each difference, since performance.now is
1976
+ // fractional.
1977
+ const t0 = performance.now();
1978
+ // #seat-metrics — the seat's FIRST write and the context it carried then, measured from the
1979
+ // chair's forwarded agent events (below). Null until a write happens; a chair that never writes
1980
+ // (or forwards no events, like a skill chair) leaves both null.
1981
+ let firstWriteMs = null;
1982
+ let contextAtFirstWrite = null;
1983
+ let lastAssistantContext = null;
1984
+ // #seat-effort (O5) — the effort the model chair resolved to, captured at the invoke ctx site
1985
+ // (below) so the chair_complete emit can record what the seat ran at. Stays undefined for a skill
1986
+ // chair, which runs no model at an effort.
1987
+ let resolvedEffort;
1988
+ // contract-amend-resume-prompt-v1 (F1) — set when the invoker reports a resume whose session was
1989
+ // gone and fell back cold (the `resume_fallback` stream event). Recorded on chair_complete so the
1990
+ // fallback is observable rather than a resume the record falsely claims happened.
1991
+ let resumeFellBack = false;
1992
+ // contract-resumed-gig-session-v1 (O3) — set when the invoker RESUMED a collided --session-id open
1993
+ // (the `resume_on_collision` stream event): a first spawn whose deterministic session id was already
1994
+ // in use continued the conversation instead of failing. `p.resume` is false on such a spawn (it is a
1995
+ // first open, not an amend), so this is what makes chair_complete record the continuation truthfully.
1996
+ let resumedOnCollision = false;
1997
+ // contract-seat-primer-v1 (O1/I2) — the files a PRIME seat Read, captured from the invoker's
1998
+ // forwarded `seat_reads` event, so the seat-primer is sealed from exactly what the seat read.
1999
+ let primerReads;
2000
+ // contract-rolling-seat-primer-v1 (O3) — the context size (input+cache_read+cache_creation) of the
2001
+ // LAST usage the seat reported, captured from the invoker's forwarded `seat_context` event. Under an
2002
+ // injected `run` seam the streamed assistant usages never reach onEvent, so the invoker parses the
2003
+ // last one from the returned stdout and forwards it, the same way it forwards `seat_reads`.
2004
+ let primerContextTokens;
2005
+ // contract-primer-reading-frontier-v1 (O1) — the reading frontier the invoker derived from the seat's
2006
+ // stream (the last user line before its first write), forwarded on the `seat_reads` event and sealed
2007
+ // onto the seat-primer. Absent when the seat called no write tool (I3) or had no user line before it (F1).
2008
+ let primerFrontier;
2009
+ // contract-primer-reading-frontier-v1 (O4/I1) — a PRIME chair's tree snapshot at chair START (git
2010
+ // stash create, or HEAD when clean), so a file Read before the frontier is sealed at the blob it had
2011
+ // when the chair began — not the post-edit blob the seat may have written after reading it. Undefined
2012
+ // when there is no tree_root (no git resolution) or the snapshot could not be taken.
2013
+ let primerStartSnapshot;
2014
+ // contract-seat-primer-v1 (F2) — set when the invoker reported the primer's session could not be
2015
+ // resumed and fell back cold (the `fork_fallback` stream event). Recorded on chair_complete.
2016
+ let forkFellBackReason;
2017
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
1660
2018
  // ── REUSE HIT ────────────────────────────────────────────────────────────────────────
1661
2019
  // Everything that could refuse this was decided at prep, before a byte was written. What
1662
2020
  // is left is a normal seal: the record is written through the SAME `deps.outputs.write`
@@ -1673,6 +2031,12 @@ export async function runGig(standard, gigInput, deps) {
1673
2031
  const rec = deps.outputs.write({
1674
2032
  core_type: spec.core_type,
1675
2033
  domain_type: o.domain_type,
2034
+ // O4 — a recall keeps the version it was SEALED at. Omitting this let outputs.write default
2035
+ // to 1, so a record sealed against a v2 type recalled as v1 — a DIFFERENT content_sha than
2036
+ // the record it recalls. A pre-migration entry carries no version; on a HIT the current
2037
+ // version equals the sealed one (else lookupReuse's re-hash would already have refused the
2038
+ // entry), so the fallback re-hashes identically.
2039
+ domain_type_version: o.domain_type_version ?? deps.outputs.typeVersionOf(o.domain_type),
1676
2040
  domain,
1677
2041
  gig_id,
1678
2042
  agent_slug: producer_slug,
@@ -1713,6 +2077,16 @@ export async function runGig(standard, gigInput, deps) {
1713
2077
  }
1714
2078
  const producerHint = chair.skill_slug || p.agent?.slug || chair.agent_slug || chair.role;
1715
2079
  emit({ type: "chair_start", phase: phaseName, role: chair.role, producer: producerHint });
2080
+ // contract-seat-primer-v1 (O5) — a fork whose reuse key WOULD hit: the cache is LIVE (record the
2081
+ // hit so the signal survives) but the fork is NEVER served — it invokes below, carrying warm
2082
+ // conversation the reuse key cannot describe.
2083
+ if (p.fork_reuse_withheld) {
2084
+ reuseReport.hits.push({
2085
+ phase: phaseName, role: chair.role,
2086
+ cache_key: p.fork_reuse_withheld.cache_key, source_gig_id: p.fork_reuse_withheld.source_gig_id,
2087
+ output_types: p.fork_reuse_withheld.output_types,
2088
+ });
2089
+ }
1716
2090
  let data;
1717
2091
  // Skill-backed chairs record which skill (version + verified code_hash + tier) sealed the
1718
2092
  // output, so the ledger entry traces back to the exact SkillChainEvent. Undefined for agents.
@@ -1729,7 +2103,13 @@ export async function runGig(standard, gigInput, deps) {
1729
2103
  // even be DELIVERED. `gig_abort` during a skill chair was a promise the engine could
1730
2104
  // not keep — #249's shape again, but a missing opportunity to kill rather than a
1731
2105
  // missing kill.
1732
- const r = await executeSkillAsync(p.skill_dir, skillInput, 120_000, { signal: deps.signal });
2106
+ // contract-skill-chair-runs-in-tree-v1 (O1/I1) a skill chair's code half runs in the GIG'S
2107
+ // tree: forward RunDeps.tree_root as the child's working directory when the run has one. A run
2108
+ // with no tree_root passes no cwd, so the child inherits the engine process's directory (I2).
2109
+ const r = await executeSkillAsync(p.skill_dir, skillInput, 120_000, {
2110
+ signal: deps.signal,
2111
+ ...(deps.tree_root !== undefined ? { cwd: deps.tree_root } : {}),
2112
+ });
1733
2113
  if (!r.ok)
1734
2114
  throw new RuntimeError(`skill chair "${chair.role}" ("${chair.skill_slug}") failed: ${r.error}`);
1735
2115
  data = (r.output && typeof r.output === "object" ? r.output : {});
@@ -1793,8 +2173,30 @@ export async function runGig(standard, gigInput, deps) {
1793
2173
  }
1794
2174
  placedHydration = decision.hydration;
1795
2175
  }
2176
+ // #seat-effort (O2) — resolve precedence ONCE here and set the RESOLVED value on the ctx, the
2177
+ // same seam `depth` threads through. Captured so chair_complete records what the seat ran at.
2178
+ resolvedEffort = resolveEffort(deps.effort, agent);
2179
+ // contract-seat-context-ceiling-v1 (O2) — resolve the context ceiling ONCE here (dispatch ▷
2180
+ // agent ▷ none) and set the RESOLVED value on the ctx only when one exists, so an undeclared
2181
+ // seat carries no ceiling and the completions invoker runs it uncapped (I1).
2182
+ const resolvedMaxContext = resolveMaxContextTokens(deps.max_context_tokens, agent);
2183
+ // contract-primer-reading-frontier-v1 (O4/I1) — snapshot the tree the moment BEFORE a PRIME seat
2184
+ // runs (git stash create captures the working tree without touching it; empty output ⇒ a clean
2185
+ // tree, so HEAD is the snapshot). A file the seat reads then edits is sealed at this pre-edit blob
2186
+ // (git rev-parse <snapshot>:<path>), and a carried file the seat never re-reads keeps its forked
2187
+ // blob — so a fork remembers exactly what the primer READ, not what it then DID. Best-effort: a
2188
+ // tree that cannot be snapshotted leaves this undefined and the seal falls back to hash-object.
2189
+ if (chair.prime && deps.tree_root !== undefined) {
2190
+ try {
2191
+ const created = gitInTree(deps.tree_root, ["stash", "create"]).trim();
2192
+ primerStartSnapshot = created.length > 0 ? created : gitInTree(deps.tree_root, ["rev-parse", "HEAD"]).trim();
2193
+ }
2194
+ catch {
2195
+ primerStartSnapshot = undefined;
2196
+ }
2197
+ }
1796
2198
  data = await deps.invoke({
1797
- agent, phase: phaseName, gig_id, inputs, gig_input: gigInput, skills,
2199
+ agent, phase: phaseName, role: chair.role, gig_id, inputs, gig_input: gigInput, skills,
1798
2200
  missing_skills: p.missing_skills, // #241 — what did NOT resolve, so the prompt can't assert it
1799
2201
  // THE SEAT IS WHERE THE INSTITUTION'S DATA ENTERS. Validated at compose time (the dead-slot
1800
2202
  // refusal) and, until now, dropped on the floor immediately afterwards.
@@ -1809,12 +2211,31 @@ export async function runGig(standard, gigInput, deps) {
1809
2211
  // itself, so an invoker can kill its child and shape what it asks the model for.
1810
2212
  ...(deps.signal ? { signal: deps.signal } : {}),
1811
2213
  ...(deps.depth ? { depth: deps.depth } : {}),
2214
+ // #seat-effort (O2) — the RESOLVED effort reaches the invoker on the ctx, always present
2215
+ // (resolveEffort floors to medium), so both invokers carry it without re-deriving.
2216
+ effort: resolvedEffort,
2217
+ // contract-seat-context-ceiling-v1 (O2/O3) — the RESOLVED ceiling reaches the invoker on the
2218
+ // ctx, present ONLY when declared (no floor), so an undeclared seat stays uncapped (I1).
2219
+ ...(resolvedMaxContext !== undefined ? { max_context_tokens: resolvedMaxContext } : {}),
1812
2220
  // #turn-budget — the chair's own turn budget threads through exactly as `depth` does; the
1813
2221
  // reserve is the pool-capped OFFER, not the raw declaration, and is present only when the
1814
2222
  // chair declared a reserve (so a reserve-less chair leaves the invoker's opts-level default
1815
2223
  // untouched — the #329 continuation path stays byte-identical).
1816
2224
  ...(p.chair.turn_budget !== undefined ? { turn_budget: p.chair.turn_budget } : {}),
1817
2225
  ...(p.reserve_offer !== undefined ? { turn_reserve: p.reserve_offer } : {}),
2226
+ // contract-chair-session-continuity-v1 (O3) — an amend re-invocation resumes the maker's
2227
+ // own prior-round session; the invoker reads this to pass --resume instead of --session-id.
2228
+ ...(p.resume ? { resume: true } : {}),
2229
+ // contract-resumed-gig-session-v1 (O1) — a re-verify resumes its session but keeps the full
2230
+ // prompt; thread it so buildPrompt skips the maker's trimmed continuation for this seat.
2231
+ ...(p.resume_keep_prompt ? { resume_keep_prompt: true } : {}),
2232
+ // contract-seat-primer-v1 (O1/I2) — a PRIME chair: the invoker parses its Read events and
2233
+ // emits them so the runtime seals the seat-primer from exactly what this seat read.
2234
+ ...(chair.prime ? { prime: chair.prime } : {}),
2235
+ // contract-seat-primer-v1 (O2/O4) — a FORK chair with a primer: the invoker warm-starts from
2236
+ // the primer's session and names the stale paths in the prompt. Absent on a plain chair or a
2237
+ // fork whose primer is missing (F1), so both spawn a fresh session and never --fork-session.
2238
+ ...(p.fork ? { fork: { primer_session_id: p.fork.primer_session_id, stale_paths: p.fork.stale_paths, ...(p.fork.frontier !== undefined ? { frontier: p.fork.frontier } : {}) } } : {}),
1818
2239
  // The venue → dispatch wire: thread the realized room onto the chair's ctx ONLY when a
1819
2240
  // venue resolved, so the invoker narrows the spawn by construction; both fields stay
1820
2241
  // absent otherwise (the venue-less path is unchanged).
@@ -1831,6 +2252,53 @@ export async function runGig(standard, gigInput, deps) {
1831
2252
  onEvent: (ev) => {
1832
2253
  sink.fold(ev);
1833
2254
  emit({ type: "agent_event", phase: phaseName, role: chair.role, event: ev });
2255
+ // contract-amend-resume-prompt-v1 (F1) — the invoker's cold-fallback signal. Captured here
2256
+ // (the one seam every chair event flows through) so chair_complete can record it.
2257
+ if (ev.type === "resume_fallback")
2258
+ resumeFellBack = true;
2259
+ // contract-resumed-gig-session-v1 (O3) — the invoker's collision-resume signal, captured on
2260
+ // the same seam so chair_complete can record the chair CONTINUED its session.
2261
+ if (ev.type === "resume_on_collision")
2262
+ resumedOnCollision = true;
2263
+ // contract-seat-primer-v1 (O1/I2) — a PRIME seat's forwarded reads, so the seat-primer is
2264
+ // sealed (below) from exactly the files it Read.
2265
+ if (ev.type === "seat_reads") {
2266
+ const r = ev.raw?.reads;
2267
+ primerReads = Array.isArray(r) ? r.filter((x) => typeof x === "string") : [];
2268
+ // contract-primer-reading-frontier-v1 (O1) — the frontier travels on the same event.
2269
+ const f = ev.raw?.frontier;
2270
+ primerFrontier = typeof f === "string" ? f : undefined;
2271
+ }
2272
+ // contract-rolling-seat-primer-v1 (O3) — the seat's context size at seal, forwarded by the
2273
+ // invoker (the injected `run` seam bypasses the streamed usages onEvent would otherwise fold).
2274
+ if (ev.type === "seat_context") {
2275
+ const c = ev.raw?.context_tokens;
2276
+ if (typeof c === "number")
2277
+ primerContextTokens = c;
2278
+ }
2279
+ // contract-seat-primer-v1 (F2) — the fork's primer session could not be resumed and it fell
2280
+ // back cold. Captured so chair_complete records the fallback (naming the session), not a
2281
+ // fork that never happened.
2282
+ if (ev.type === "fork_fallback") {
2283
+ const reason = ev.raw?.reason;
2284
+ forkFellBackReason = typeof reason === "string" ? reason : "the primer session could not be resumed";
2285
+ }
2286
+ // #seat-metrics — track the last assistant context and the FIRST write, BEFORE the
2287
+ // budget early-return below (which fires whenever no budget is wired). An assistant
2288
+ // event's raw.message.usage carries the context; the first Write/Edit/MultiEdit/
2289
+ // NotebookEdit tool_use marks the first write and snapshots the context as of the last
2290
+ // assistant usage before it.
2291
+ if (ev.type === "assistant") {
2292
+ const u = ev.raw?.message?.usage;
2293
+ if (u) {
2294
+ lastAssistantContext =
2295
+ (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
2296
+ }
2297
+ }
2298
+ else if (firstWriteMs === null && ev.type === "tool_use" && ev.tool !== undefined && WRITE_TOOLS.has(ev.tool)) {
2299
+ firstWriteMs = Math.round(performance.now() - t0);
2300
+ contextAtFirstWrite = lastAssistantContext;
2301
+ }
1834
2302
  if (!budget)
1835
2303
  return;
1836
2304
  // A chair crossed its budget into a granted reserve. Set `yielding` (D1: one condition
@@ -1871,6 +2339,12 @@ export async function runGig(standard, gigInput, deps) {
1871
2339
  finally {
1872
2340
  if (sink.attributed())
1873
2341
  attributedInvocations++;
2342
+ // F3 — under a ceiling, a chair that reported usage but NO settled usd leaves the next
2343
+ // batch unverifiable: the runtime cannot know whether it is affordable. Record it (never
2344
+ // silent). A chair that reported nothing at all (a plain stub) is not here.
2345
+ if (hasCeiling && sink.attributed() && !sink.reportedCost())
2346
+ unverifiedChairs.push(agent.slug);
2347
+ chairReport = sink.reported();
1874
2348
  }
1875
2349
  // The drawing chair LANDED within its reserve → clear yielding back to `active` (O12/INV16).
1876
2350
  // The gig-end success path then settles it; an idle chair that held but never drew releases here.
@@ -1879,6 +2353,29 @@ export async function runGig(standard, gigInput, deps) {
1879
2353
  emit({ type: "budget_state", phase: phaseName, role: chair.role, agent_state: "active", pool_remaining: budget.pool_remaining });
1880
2354
  }
1881
2355
  releaseHold();
2356
+ // ── contract-spend-survives-v1 (O1/I2/O2/F1) — the DURABLE chair_spend row ─────────────────
2357
+ // Appended the moment THIS chair's invocation settled — before the next chair is invoked, and
2358
+ // before the output_contract check below (which can throw) — so a kill or a failure on a later
2359
+ // chair leaves this one's captured spend behind. An attributed chair carries its own usage; an
2360
+ // unattributed one seals captured:false with NO cost, never a $0 row (#235). The gig-wide fold
2361
+ // still writes the single gig row on success; these rows are the per-chair record that survives
2362
+ // when that row is never sealed. Appended only for a model invocation — a skill chair runs no
2363
+ // model and settles no spend — and never for a reuse hit (which returns before reaching here).
2364
+ const chairSettledUsage = sink.usage();
2365
+ deps.ledger.append({
2366
+ kind: "chair_spend",
2367
+ schema_version: LEDGER_SCHEMA_VERSION,
2368
+ entry_id: `chair_spend:${gig_id}:${chair.role}:${p.round ?? 1}:${randomUUID()}`,
2369
+ gig_id,
2370
+ role: chair.role,
2371
+ phase: phaseName,
2372
+ round: p.round ?? 1,
2373
+ captured: sink.attributed(),
2374
+ output_hashes: [],
2375
+ started_at: new Date(t0).toISOString(),
2376
+ finished_at: new Date().toISOString(),
2377
+ ...(chairSettledUsage ? { usage: chairSettledUsage } : {}),
2378
+ });
1882
2379
  // Runtime output_contract check: every type the chair promised must be covered by the
1883
2380
  // bound agent's declared output_types (compose-time mirror; a hand-rolled literal could
1884
2381
  // still ship a mismatch).
@@ -1990,6 +2487,25 @@ export async function runGig(standard, gigInput, deps) {
1990
2487
  // #243 — DECIDE BEFORE SEALING. Every check that can throw now runs against resolved
1991
2488
  // slices while nothing has been written yet.
1992
2489
  //
2490
+ // AN INVOKER'S TYPED REFUSAL IS A REASON, NOT A MALFORMED OUTPUT. An invoker that declines —
2491
+ // a chair granting a tool that port does not carry, a tier the deployment never mapped, an
2492
+ // upstream that could not be reached — returns `{ok:false, refusal, message}` rather than
2493
+ // throwing, so the refusal is a value the engine can act on. Without this it fell straight
2494
+ // through to the seal path and the operator was told
2495
+ //
2496
+ // cannot seal "p": ... additionalProperties: must NOT have additional properties 'refusal'
2497
+ //
2498
+ // which is the shape complaint of the very object carrying the answer. The reason was present
2499
+ // and nothing read it — a refusal typed and then discarded is the same defect as a mechanism
2500
+ // nothing reaches, one seam over.
2501
+ //
2502
+ // Narrow on purpose: `ok === false` with BOTH a string refusal and a string message. A domain
2503
+ // type is free to have an `ok` field; it is not plausibly carrying all three.
2504
+ const refusal = data["refusal"];
2505
+ const refusalWhy = data["message"];
2506
+ if (data["ok"] === false && typeof refusal === "string" && typeof refusalWhy === "string") {
2507
+ throw new RuntimeError(`chair "${chair.role}" refused: ${refusal} — ${refusalWhy}`);
2508
+ }
1993
2509
  // The floor check used to sit AFTER the write loop, which created a failure class that did
1994
2510
  // not previously exist: a chair delivering part of its contract sealed those records,
1995
2511
  // append-flushed them to `outputs/<gig_id>.jsonl`, and only THEN threw — so the gig failed
@@ -2000,7 +2516,37 @@ export async function runGig(standard, gigInput, deps) {
2000
2516
  const resolved = [];
2001
2517
  for (const spec of output_specs) {
2002
2518
  const keyed = data[spec.domain_type];
2003
- const raw = keyed !== undefined && keyed !== null ? keyed : single ? data : undefined;
2519
+ // WRAPPER vs FIELD THE TYPE'S OWN SCHEMA DECIDES, never the data's shape. A chair may return
2520
+ // its record bare, or keyed under its type slug (`{ <type>: <record> }`). But a record whose
2521
+ // OWN schema declares a field named like its type (a type `line` with a `line` field) puts a
2522
+ // value under `data[<type>]` that is a FIELD, not a wrapper — and the shape alone cannot tell
2523
+ // the two apart (law 2: an OBJECT field looks exactly like a wrapped record). The schema can:
2524
+ // the keyed value is a wrapper only if it VALIDATES as a record of the type while the whole
2525
+ // `data` does not. So when `data[<type>]` cannot itself be a record of this type but the whole
2526
+ // `data` can, `data[<type>]` is a field and the whole record seals; otherwise the keyed wrapper
2527
+ // is honoured (control law 3), and multi-output (`single` false) is untouched — its blob is
2528
+ // keyed by construction. An array under the key is the multi-record seal list; honour it as-is.
2529
+ let raw;
2530
+ if (keyed === undefined || keyed === null) {
2531
+ raw = single ? data : undefined;
2532
+ }
2533
+ else if (single && !Array.isArray(keyed)) {
2534
+ const keyedIsRecord = typeof keyed === "object" &&
2535
+ deps.outputs.validateWrite({
2536
+ core_type: spec.core_type,
2537
+ domain_type: spec.domain_type,
2538
+ data: keyed,
2539
+ }).valid;
2540
+ const wholeIsRecord = deps.outputs.validateWrite({
2541
+ core_type: spec.core_type,
2542
+ domain_type: spec.domain_type,
2543
+ data,
2544
+ }).valid;
2545
+ raw = !keyedIsRecord && wholeIsRecord ? data : keyed;
2546
+ }
2547
+ else {
2548
+ raw = keyed;
2549
+ }
2004
2550
  if (raw === undefined || raw === null)
2005
2551
  continue;
2006
2552
  // MULTI-RECORD SEAL. captureOutputWrites hands the runtime a LIST of records per declared type
@@ -2020,6 +2566,21 @@ export async function runGig(standard, gigInput, deps) {
2020
2566
  // that throw also lands before the first write, rather than midway through them.
2021
2567
  for (const { slice } of resolved)
2022
2568
  backfillShas(slice);
2569
+ // RECORDS BY ADDRESS — the seal stamps law/change addresses from git, beside the *_sha backfill
2570
+ // and before any validateWrite or write, so a stamping refusal fails the chair with its typed
2571
+ // reason and nothing is sealed. A sealed `red-spec` carrying `laws` (or a `change-set` carrying
2572
+ // `changes`) has those entries REPLACED with the engine-stamped ones; a record carrying only
2573
+ // `diffs` and no `laws`/`changes` is left exactly as today (the pre-migration shape this gig's
2574
+ // own attester and builder still seal). tree_root is required only once a record actually carries
2575
+ // an address — a stamp with no tree_root refuses (`tree_root_unknown`), never reads process.cwd().
2576
+ for (const { spec, slice } of resolved) {
2577
+ if (spec.domain_type === "red-spec" && Array.isArray(slice["laws"])) {
2578
+ slice["laws"] = stampLawAddresses(slice["laws"], deps.tree_root);
2579
+ }
2580
+ else if (spec.domain_type === "change-set" && Array.isArray(slice["changes"])) {
2581
+ slice["changes"] = stampChangeAddresses(slice["changes"], deps.tree_root);
2582
+ }
2583
+ }
2023
2584
  // The output_contract is a FLOOR, not merely a selector. `written.length === 0` alone let a
2024
2585
  // chair that promised two types and sealed one complete silently. The old in-code
2025
2586
  // justification — a keyed type may be conditional, and a downstream input_contract check
@@ -2078,6 +2639,15 @@ export async function runGig(standard, gigInput, deps) {
2078
2639
  // Only now does anything become durable.
2079
2640
  const written = [];
2080
2641
  for (const { spec, slice } of resolved) {
2642
+ // contract-chair-cost-once-v1 — one chair invocation is settled ONCE (its `result` event
2643
+ // reports a single total_cost_usd), so its spend is attributed to exactly ONE sealed record.
2644
+ // The FIRST record this invocation seals carries `chairReport.cost_usd` / `tokens_used`; every
2645
+ // later record omits both and carries `cost_on`: the first record's id. Stamping the whole
2646
+ // chairReport on every record made a chair of width N report its spend N times, so summing
2647
+ // `cost_usd` over a gig double-counted every multi-output chair (coltrane-ui#242). `written` is
2648
+ // this invocation's records in seal order, so it is empty exactly at the first record and its
2649
+ // head is the cost carrier for the rest.
2650
+ const costCarrier = written[0];
2081
2651
  const rec = deps.outputs.write({
2082
2652
  core_type: spec.core_type,
2083
2653
  domain_type: spec.domain_type,
@@ -2097,10 +2667,28 @@ export async function runGig(standard, gigInput, deps) {
2097
2667
  // WHICH model produced this, resolved through the invoker's own function so the stamp
2098
2668
  // and the spawn cannot disagree. Absent for a skill-backed chair — no model ran, and
2099
2669
  // absent must mean unknown rather than "the default".
2670
+ // WHICH model produced this. The transport's own word wins: `resolveModel` is one
2671
+ // invoker's tier table, and the runtime calls it for EVERY invoker — so a chair served by
2672
+ // any other port used to seal a stamp naming a model it never touched. The fallback stays
2673
+ // (a transport may report nothing) but it no longer masquerades as a measurement:
2674
+ // `model_reported` says which of the two this is. Absent for skill-backed chairs — no
2675
+ // model ran, and absent must mean unknown rather than "the default".
2100
2676
  ...(p.agent
2101
2677
  ? {
2102
- model: resolveModel(p.agent.model_tier, deps.model_version),
2678
+ model: chairReport.model ?? resolveModel(p.agent.model_tier, deps.model_version),
2679
+ ...(chairReport.model !== undefined ? { model_reported: true } : {}),
2103
2680
  ...(p.agent.model_tier ? { model_tier: p.agent.model_tier } : {}),
2681
+ // Per-chair spend, declared in the record's own schema since it was written and
2682
+ // populated by nothing. The gig total cannot separate two chairs on two tiers,
2683
+ // which is the only question per-chair routing asks. Attributed ONCE per invocation
2684
+ // (contract-chair-cost-once-v1): the first record carries the settled cost + tokens;
2685
+ // every later record carries `cost_on` at the carrier and no cost of its own.
2686
+ ...(costCarrier === undefined
2687
+ ? {
2688
+ ...(chairReport.cost_usd !== undefined ? { cost_usd: chairReport.cost_usd } : {}),
2689
+ ...(chairReport.tokens_used !== undefined ? { tokens_used: chairReport.tokens_used } : {}),
2690
+ }
2691
+ : { cost_on: costCarrier.id }),
2104
2692
  }
2105
2693
  : {}),
2106
2694
  skill_provenance,
@@ -2131,6 +2719,8 @@ export async function runGig(standard, gigInput, deps) {
2131
2719
  core_type: w.core_type, domain_type: w.domain_type, domain: w.domain,
2132
2720
  primitive: w.primitive, agent_slug: w.agent_slug, phase: phaseName,
2133
2721
  data: w.data, content_sha: w.content_sha, type_fingerprint: fp, source_output_id: w.id,
2722
+ // O4 — carry the sealed version so a recall re-stamps it and hashes as the record it recalls.
2723
+ domain_type_version: w.domain_type_version,
2134
2724
  ...(w.skill_provenance ? { skill_provenance: w.skill_provenance } : {}),
2135
2725
  });
2136
2726
  }
@@ -2148,16 +2738,180 @@ export async function runGig(standard, gigInput, deps) {
2148
2738
  }
2149
2739
  }
2150
2740
  }
2741
+ // contract-seat-primer-v1 (O1/I2) — a PRIME chair seals a `seat-primer` record DERIVED by the
2742
+ // engine (never typed by the model): its own (gig, role) session, HEAD at seal, and the files its
2743
+ // seat Read, each with the `git hash-object` blob in the tree at seal. The reads arrive through the
2744
+ // invoker's `seat_reads` event (captured above); the record is sealed HERE, through the same write
2745
+ // boundary a derived output crosses, so it enters this gig's store and any later fork can find it.
2746
+ if (chair.prime) {
2747
+ const area = chair.prime.area;
2748
+ const session_id = sessionUuidFor(gig_id, chair.role);
2749
+ const reads = primerReads ?? [];
2750
+ const commit = deps.tree_root ? gitInTree(deps.tree_root, ["rev-parse", "HEAD"]).trim() : "";
2751
+ // contract-rolling-seat-primer-v1 (O2) — a chair that ALSO forked a primer (p.fork resolved) seals
2752
+ // a FRESHER primer: the forked primer's files UNIONed with this seat's own reads, de-duped by path
2753
+ // (forked first, then own reads), each RE-BLOBBED against the tree at seal. A chair that fell back
2754
+ // cold (primer_too_large / primer_missing → no p.fork) unions nothing and seals only its own reads,
2755
+ // which is the fresh, small primer the ceiling/first-primer path calls for. The `session_id` stays
2756
+ // this build's own (gig, role) uuid — the fork branch the next build resumes.
2757
+ const forkedFiles = p.fork?.primer_files ?? [];
2758
+ // contract-seat-primer-paths-v1 (O1/I1/F1) — a seat-primer must name its files the way the
2759
+ // repository does, so ANY checkout of this commit can use it. A seat's Read event carries an
2760
+ // ABSOLUTE checkout path, which ties the primer to one machine's location. Normalize each read to
2761
+ // its tree_root-relative POSIX path (an already-relative read is resolved against tree_root first,
2762
+ // so both spellings of the same file collapse to one key — I1), and DROP any path that resolves
2763
+ // OUTSIDE tree_root — it is not part of the area and must never be stored, above all not as an
2764
+ // absolute path escaping it (F1). With paths stored relative, the fork-time staleness hash
2765
+ // (gitInTree hash-object) resolves them in the FORKING run's own checkout, so a primer sealed
2766
+ // under one checkout is fresh in another of the same content (O2). Without a tree_root there is no
2767
+ // anchor (and no git resolution downstream), so the raw path is kept unchanged.
2768
+ const toTreeRelative = (raw) => {
2769
+ if (deps.tree_root === undefined)
2770
+ return raw;
2771
+ const abs = isAbsPath(raw) ? raw : joinPath(deps.tree_root, raw);
2772
+ const rel = relPath(deps.tree_root, abs);
2773
+ if (rel === "" || rel.startsWith("..") || isAbsPath(rel))
2774
+ return undefined; // outside tree_root
2775
+ return rel.split(pathSep).join("/");
2776
+ };
2777
+ // The seat's OWN reads — already CUT to those before the reading frontier by the invoker
2778
+ // (contract-primer-reading-frontier-v1 O4) — normalized to tree-relative and de-duped.
2779
+ const readRel = [];
2780
+ const readSet = new Set();
2781
+ for (const raw of reads) {
2782
+ const rel = toTreeRelative(raw);
2783
+ if (rel !== undefined && !readSet.has(rel)) {
2784
+ readSet.add(rel);
2785
+ readRel.push(rel);
2786
+ }
2787
+ }
2788
+ // contract-carried-primer-paths-v1 (O1/I1/F1) — a CARRIED file obeys the SAME path rules as a
2789
+ // read: normalize each forked path through the SAME toTreeRelative (absolute-under-tree_root →
2790
+ // tree_root-relative POSIX; a path resolving OUTSIDE tree_root is dropped, never stored — F1)
2791
+ // BEFORE de-duping, so a carried absolute spelling and a relative read of the same file collapse
2792
+ // to one tree-relative key (I1). The blob map is keyed by the NORMALIZED path (first carried
2793
+ // entry wins), so a carried file the seat did NOT re-read keeps the blob the forked primer
2794
+ // recorded — never re-blobbed against the tree (O1; contract-primer-reading-frontier-v1 I1).
2795
+ const carriedRel = [];
2796
+ const forkedBlob = new Map();
2797
+ for (const f of forkedFiles) {
2798
+ const rel = toTreeRelative(f.path);
2799
+ if (rel === undefined)
2800
+ continue; // outside tree_root — never stored (F1)
2801
+ if (!forkedBlob.has(rel))
2802
+ forkedBlob.set(rel, f.blob_sha);
2803
+ carriedRel.push(rel);
2804
+ }
2805
+ // Union: carried (forked) first, then the seat's own reads, de-duped by NORMALIZED path.
2806
+ const orderedPaths = [];
2807
+ const seenPaths = new Set();
2808
+ for (const path of [...carriedRel, ...readRel]) {
2809
+ if (!seenPaths.has(path)) {
2810
+ seenPaths.add(path);
2811
+ orderedPaths.push(path);
2812
+ }
2813
+ }
2814
+ // contract-primer-reading-frontier-v1 (O4/I1) — a file the seat READ before its frontier is sealed
2815
+ // at its CHAIR-START blob: `git rev-parse <snapshot>:<path>` against the pre-invoke snapshot, so a
2816
+ // file read-then-edited keeps its pre-edit blob (what the fork remembers), and a path the snapshot
2817
+ // does not hold (untracked at chair start) is hashed at seal. A carried file the seat did NOT
2818
+ // re-read keeps the blob the forked primer recorded — the reseal never launders a stale file fresh.
2819
+ const blobFor = (path) => {
2820
+ if (readSet.has(path)) {
2821
+ if (deps.tree_root === undefined)
2822
+ return "";
2823
+ if (primerStartSnapshot !== undefined) {
2824
+ try {
2825
+ return gitInTree(deps.tree_root, ["rev-parse", `${primerStartSnapshot}:${path}`]).trim();
2826
+ }
2827
+ catch { /* untracked at chair start — hash at seal below */ }
2828
+ }
2829
+ try {
2830
+ return gitInTree(deps.tree_root, ["hash-object", path]).trim();
2831
+ }
2832
+ catch {
2833
+ return "";
2834
+ }
2835
+ }
2836
+ return forkedBlob.get(path) ?? "";
2837
+ };
2838
+ const files = orderedPaths.map((path) => ({ path, blob_sha: blobFor(path) }));
2839
+ // contract-rolling-seat-primer-v1 (O3) — the context size of the LAST usage the seat reported:
2840
+ // the invoker-forwarded value under an injected `run` seam, else the streamed fold. Recorded ONLY
2841
+ // when a usage was actually reported — a seat that reported none has no "last usage" to stamp, so
2842
+ // the field is omitted (the same conditional discipline as `session_id`), which also keeps a seat-
2843
+ // primer type that never declared context_tokens sealing cleanly.
2844
+ const contextTokens = primerContextTokens ?? (lastAssistantContext ?? undefined);
2845
+ deps.outputs.write({
2846
+ core_type: "Signal",
2847
+ domain_type: "seat-primer",
2848
+ domain,
2849
+ gig_id,
2850
+ agent_slug: producer_slug,
2851
+ from_role: chair.role,
2852
+ phase: phaseName,
2853
+ primitive: CORE_TO_PRIMITIVE["Signal"] ?? "SENSE",
2854
+ // `source` is the Signal core's substance floor; the rest is the derived primer.
2855
+ data: {
2856
+ agent_slug: producer_slug, area,
2857
+ ...(session_id !== undefined ? { session_id } : {}),
2858
+ commit, files,
2859
+ // contract-rolling-seat-primer-v1 (O3) — the context size the seat carried at seal
2860
+ // (input+cache_read+cache_creation of its LAST usage): what the next build's ceiling weighs.
2861
+ ...(contextTokens !== undefined ? { context_tokens: contextTokens } : {}),
2862
+ // contract-primer-reading-frontier-v1 (O1) — seal the reading frontier ONLY when the seat wrote
2863
+ // (the invoker derived one); a read-only or user-less-write seat records none (I3/F1), so a
2864
+ // frontier is never guessed.
2865
+ ...(primerFrontier !== undefined ? { frontier: primerFrontier } : {}),
2866
+ source: `seat-primer://${producer_slug}/${area}`,
2867
+ },
2868
+ });
2869
+ }
2151
2870
  // A DECLARED-optional absence is still a fact about this run. Legitimising a shortfall is
2152
2871
  // not the same as hiding it, so it keeps its row in the manifest.
2153
2872
  if (missing.length > 0)
2154
2873
  unfulfilledOutputs.push({ role: chair.role, phase: phaseName, missing });
2874
+ // contract-seat-primer-v1 (O3/O4/F1/F2) — a fork that WARM-STARTED records where it forked from and
2875
+ // the paths stale since priming; a fork that FELL BACK cold (no primer, or an unresumable session)
2876
+ // records the fallback instead. `forkFellBackReason` is set by the invoker's cold-fallback event
2877
+ // (F2); `p.fork_fallback` is the prep-time "primer_missing" (F1). A cold fallback did NOT fork, so
2878
+ // it records neither forked_from nor primer_stale_paths.
2879
+ const forkFellBack = forkFellBackReason !== undefined;
2155
2880
  emit({
2156
2881
  type: "chair_complete", phase: phaseName, role: chair.role, producer: producer_slug,
2157
- output_types: written.map((w) => w.domain_type), duration_ms: Date.now() - t0,
2882
+ output_types: written.map((w) => w.domain_type), duration_ms: Math.round(performance.now() - t0),
2883
+ first_write_ms: firstWriteMs,
2884
+ context_tokens_at_first_write: contextAtFirstWrite,
2158
2885
  promised_output_types: output_specs.map((s) => s.domain_type),
2159
2886
  missing_output_types: missing,
2160
2887
  ...(unresolvedShaFields.length > 0 ? { unresolved_sha_fields: unresolvedShaFields } : {}),
2888
+ // #seat-effort (O5) — record the effort the seat ran at (a model chair only; a skill chair
2889
+ // leaves it undefined and the field stays absent).
2890
+ ...(resolvedEffort !== undefined ? { effort: resolvedEffort } : {}),
2891
+ // contract-chair-session-continuity-v1 (O4) — record the seat's session id (the uuid derived
2892
+ // from (gig_id, role)) and whether THIS invocation resumed it. A model chair only; a skill
2893
+ // chair (no p.agent) runs no session, so both fields stay absent. Computed here from the same
2894
+ // (gig_id, role) the invoker derives the spawn's --session-id from, so the record and the
2895
+ // spawn name one session.
2896
+ // contract-resumed-gig-session-v1 (O3) — `resumed` is true when THIS invocation continued its
2897
+ // session: either an amend round (`p.resume`) OR a first open whose deterministic id collided and
2898
+ // was resumed on the spot (`resumedOnCollision`). The second case is a first spawn, so `p.resume`
2899
+ // is false and only the collision signal tells the truth of it.
2900
+ ...(p.agent && sessionUuidFor(gig_id, chair.role) !== undefined
2901
+ ? { session_id: sessionUuidFor(gig_id, chair.role), resumed: p.resume === true || resumedOnCollision }
2902
+ : {}),
2903
+ // contract-amend-resume-prompt-v1 (F1) — a resume that fell back cold is recorded (never silent),
2904
+ // and only then, so a normal spawn's chair_complete is byte-identical to before.
2905
+ ...(resumeFellBack ? { resume_fallback: true } : {}),
2906
+ // contract-seat-primer-v1 (O3/O4) — a fork that warm-started names its primer and the stale paths.
2907
+ ...(p.fork && !forkFellBack
2908
+ ? {
2909
+ forked_from: { id: p.fork.primer_id, session_id: p.fork.primer_session_id, commit: p.fork.primer_commit },
2910
+ primer_stale_paths: p.fork.stale_paths,
2911
+ }
2912
+ : {}),
2913
+ // contract-seat-primer-v1 (F1/F2) — a fork that could not warm-start records why (never silent).
2914
+ ...(forkFellBack ? { fork_fallback: forkFellBackReason } : p.fork_fallback !== undefined ? { fork_fallback: p.fork_fallback } : {}),
2161
2915
  });
2162
2916
  return written;
2163
2917
  }
@@ -2219,6 +2973,10 @@ export async function runGig(standard, gigInput, deps) {
2219
2973
  // invokers, or a run whose every invocation reported no usage payload). #235: an absent
2220
2974
  // usage block means "not captured", never "$0.00".
2221
2975
  ...(settledUsage ? { usage: settledUsage } : {}),
2976
+ // contract-spend-survives-v1 (O4) — a RESUMED gig's row carries what the killed attempt spent,
2977
+ // read from the checkpoint (resumedFrom.prior_usage) and kept BESIDE this run's own `usage`,
2978
+ // never folded in (#235/#236). Absent on a cold run.
2979
+ ...(resumedFrom?.prior_usage !== undefined ? { prior_usage: resumedFrom.prior_usage } : {}),
2222
2980
  });
2223
2981
  // Drain the gig HEADER to the sink (fire-and-forget, like every output before it) — the
2224
2982
  // stub row the drain service fabricated for FK integrity is replaced by the run's own record.
@@ -2236,12 +2994,13 @@ export async function runGig(standard, gigInput, deps) {
2236
2994
  if (process.env["COLTRANE_DRAIN_DEBUG"])
2237
2995
  console.error(`[drain] gig header ${gig_id}: ${String(e)}`);
2238
2996
  });
2239
- // Cycle complete — when a budget was supplied, mark it `settled` and
2240
- // surface the final state in the manifest. `settled` mirrors the
2241
- // budget-state.json cycle terminal-state semantics for a closed cycle.
2997
+ // Cycle complete — when a snapshot exists (a ceiling OR a pool), mark it `settled` and surface
2998
+ // the final state in the manifest. Under a ceiling, the settled dollars are reconciled one last
2999
+ // time; a pool-only snapshot carries no dollar figure.
2242
3000
  if (budget) {
2243
3001
  budget.agent_state = "settled";
2244
- budget.settled_usd = usage.total_cost_usd; // #233 — final reconciliation of REAL dollars
3002
+ if (hasCeiling)
3003
+ budget.spent_usd = usage.total_cost_usd;
2245
3004
  }
2246
3005
  // The gig finished, so there is nothing left to resume — drop its checkpoint. Without this
2247
3006
  // every gig a deployment ever runs leaves a file behind forever. Only the SUCCESS path clears