amicus 4.3.0 → 4.4.1

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,277 @@
1
+ // src/council/run-budget.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-budget
6
+ * The council driver's budget position (v4.4). Split out of src/council/run.js
7
+ * to stay under the 300-line size gate; it is pure except for the one stderr
8
+ * notice, and every member is injectable/observable so run.js's behaviour is
9
+ * unchanged by the move.
10
+ *
11
+ * WHY THIS EXISTS AS ITS OWN CONCEPT. Before v4.4 the driver had a single
12
+ * `spent()` that mapped a null cost amount to `0` and discarded
13
+ * `sumWaveUsage`'s `unpricedLegs`/`source` entirely. Unknown spend was
14
+ * therefore invisible to `--max-cost` AND to every reader of run.json:
15
+ * `council-wsgate02` really spent $0.9859 against a $0.75 ceiling — a 131%
16
+ * overrun — while `spent()` believed $0.3720 and never emitted COST_EXCEEDED
17
+ * (.superpowers/sdd/v44/zero-usage-diagnosis.md §0/§9).
18
+ *
19
+ * OWNER'S RULING (Christian, v4.4): fail LOUD, not fail CLOSED —
20
+ * "I don't want hitting a ceiling to stop us from solving real problems."
21
+ * So an unknown-cost leg must NOT halt a run and must NOT by itself trip the
22
+ * ceiling; the ceiling trips on KNOWN spend only. The uncertainty is instead
23
+ * made impossible to miss. The failure mode being eliminated is SILENT
24
+ * UNDER-REPORTING, not "continuing in the presence of uncertainty" — and
25
+ * nothing here converts uncertainty into a fabricated number in either
26
+ * direction (no rounding unknown up to a guess, no rounding it down to zero).
27
+ *
28
+ * v4.4.1 CA-6 completes that posture at the EXIT CODE (see inexactUnderCeiling
29
+ * below): a ceiling never blocks, but a run under a ceiling no longer exits 0
30
+ * while publishing a total it knows is only a floor.
31
+ */
32
+
33
+ const { sumWaveUsage } = require('../utils/pricing');
34
+
35
+ /**
36
+ * @param {object} opts
37
+ * @param {Array<object>} [opts.allLegs] live array the driver pushes every wave's legs into
38
+ * @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
39
+ * @param {string} [opts.runDir] run directory; when given, budget refusals are
40
+ * checkpointed into run.json so the record outlives the stderr notice
41
+ * @param {{value: boolean}} [opts.degraded] the driver's degrade flag; a budget
42
+ * refusal sets it so the run can never exit 0 with a silently shrunken bench
43
+ * @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
44
+ * @returns {{spendState:Function, spent:Function, overBudget:Function,
45
+ * remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
46
+ * addWave:Function, reserveBudget:Function, releaseBudget:Function,
47
+ * noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
48
+ */
49
+ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
50
+ const legs = allLegs || [];
51
+ const emit = write || ((s) => process.stderr.write(s));
52
+ const hasCeiling = maxCost !== null && maxCost !== undefined;
53
+
54
+ /**
55
+ * The run's position, split into what we KNOW was spent and how many legs we
56
+ * cannot price at all. `known` is the sum of resolved amounts only — a leg
57
+ * whose cost is `unknown` contributes nothing, because inventing a number for
58
+ * it would be a fabrication. `unknownLegs` is what makes that omission
59
+ * visible instead of silent: it is the count the run summary, the envelope,
60
+ * `amicus spend` and the GUI all read to say "this total is a floor".
61
+ */
62
+ const spendState = () => {
63
+ const c = sumWaveUsage(legs).cost;
64
+ return {
65
+ known: typeof c.amount === 'number' ? c.amount : 0,
66
+ unknownLegs: c.unpricedLegs || 0,
67
+ subtreeUnknownLegs: c.subtreeUnknownLegs || 0,
68
+ cost: c,
69
+ };
70
+ };
71
+
72
+ const spent = () => spendState().known;
73
+
74
+ /** Trips on KNOWN spend only — see the ruling in the module docblock. */
75
+ const overBudget = () => hasCeiling && spent() >= maxCost;
76
+
77
+ // ---- Concurrency-safe reservations (v4.4 cost-council finding 1) ----------
78
+ /**
79
+ * THE DEFECT. `launchStage1()` builds the seat wave and the critic wave
80
+ * concurrently under one `Promise.all`, and each launcher called
81
+ * `remainingBudget()` BEFORE either wave's legs had been appended to `legs`.
82
+ * Both therefore observed the full, unreduced allowance, and both could pass
83
+ * a soft gate that — taken together — exceeded `--max-cost`. Only run.js's
84
+ * post-Stage-1 `overBudget()` noticed, by which point the money was committed.
85
+ * A read is not a claim.
86
+ *
87
+ * THE FIX. `reserveBudget` is a SYNCHRONOUS read-and-claim. Synchronicity is
88
+ * the entire guarantee: the JS event loop cannot interleave two callers inside
89
+ * a synchronous function, so a second concurrent wave necessarily observes the
90
+ * first wave's claim. No mutex, no async barrier, nothing to deadlock.
91
+ *
92
+ * WHY NOT SPLIT THE QUOTA. Handing each concurrent wave a fixed share is also
93
+ * safe but is strictly MORE refusing than the ceiling requires: three cheap
94
+ * seats plus one expensive critic can fit a ceiling that no proportional split
95
+ * admits. The owner's standing ruling is fail LOUD, not fail CLOSED — so the
96
+ * gate must refuse only what genuinely does not fit. First claim wins; a wave
97
+ * refused here does not stop the run (see noteBudgetRefusal).
98
+ */
99
+ const reservations = new Map(); // waveId -> pre-flight estimate ($)
100
+ const reserved = () => { let t = 0; for (const v of reservations.values()) { t += v; } return t; };
101
+
102
+ /** Ceiling minus known spend MINUS outstanding reservations, floored at 0;
103
+ * null when no ceiling is set. Threaded into each wave's fanout pre-flight
104
+ * estimate as its starting allowance (run-launch.js). */
105
+ const remainingBudget = () => (hasCeiling ? Math.max(maxCost - spent() - reserved(), 0) : null);
106
+
107
+ /**
108
+ * Atomically claim `estimate` against the allowance no sibling wave has taken.
109
+ * @returns {boolean} false ONLY when the estimate genuinely does not fit.
110
+ * A $0 / unpriced / non-numeric estimate always fits: unknown cost must
111
+ * never halt a run (the same ruling that keeps it out of `overBudget`).
112
+ */
113
+ const reserveBudget = (waveId, estimate) => {
114
+ if (!hasCeiling) { return true; }
115
+ const est = (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) ? estimate : 0;
116
+ if (est > remainingBudget()) { return false; }
117
+ reservations.set(waveId, (reservations.get(waveId) || 0) + est);
118
+ return true;
119
+ };
120
+
121
+ /** Drop a wave's outstanding claim (its real spend now speaks for it). */
122
+ const releaseBudget = (waveId) => { reservations.delete(waveId); };
123
+
124
+ /**
125
+ * Record a finished wave: its ESTIMATE stops counting and its MEASURED legs
126
+ * start counting, in one synchronous step so no concurrent launcher can ever
127
+ * observe a moment where the wave counts twice or not at all.
128
+ */
129
+ const addWave = (wave) => {
130
+ if (!wave) { return; }
131
+ if (wave.waveId) { releaseBudget(wave.waveId); }
132
+ if (Array.isArray(wave.legs)) { legs.push(...wave.legs); }
133
+ };
134
+
135
+ /**
136
+ * A wave the ceiling refused. POLICY (the owner's ruling applied to
137
+ * concurrency): the run CONTINUES with a partial bench — it never rolls back
138
+ * waves already launched (that would destroy paid work) and never aborts
139
+ * (that is fail-closed). What it must never do is lose the seat SILENTLY, so
140
+ * every refusal is announced on stderr, kept on run.json, and degrades the
141
+ * run's exit code. Stage 1's existing quorum gate still refuses to call a
142
+ * bench of fewer than two reviews a council.
143
+ */
144
+ const refusals = [];
145
+ const noteBudgetRefusal = (info) => {
146
+ const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
147
+ reason: 'max-cost', at: new Date().toISOString() };
148
+ refusals.push(rec);
149
+ if (degraded) { degraded.value = true; }
150
+ emit(`Notice: the $${maxCost} --max-cost ceiling refused wave ${rec.waveId} `
151
+ + `(${rec.models.join(', ') || 'no models'}) — those seats DID NOT LAUNCH and are missing from `
152
+ + 'this council. The run continues with the bench that did launch and will exit degraded (2). '
153
+ + 'Raise --max-cost, or pass --no-cost-gate, to seat them.\n');
154
+ if (runDir) {
155
+ // Never let bookkeeping sink a run that is otherwise fine.
156
+ try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
157
+ catch (e) { emit(`Notice: could not record the budget refusal in run.json: ${e.message}\n`); }
158
+ }
159
+ };
160
+ const budgetRefusals = () => refusals.slice();
161
+
162
+ // ⚠️ v4.4.1 CA-3: a plain `noticed` boolean announced the FIRST unknown leg and
163
+ // silently swallowed every one created afterwards (Stage 2, repairs, debate,
164
+ // chair) — run.json kept the correct final count, so the data was right and
165
+ // only the announcement was wrong, which is exactly the failure mode the
166
+ // fail-loud posture exists to prevent. Track the count that was last announced
167
+ // instead, so a GROWING total re-announces while an unchanged one stays quiet:
168
+ // the notice keeps its "once per new fact" character without going silent on
169
+ // the later stages. (The alternative — deferring every notice to finalize() —
170
+ // was rejected: the notice exists to inform a decision still in flight.)
171
+ let noticedAt = -1;
172
+ /**
173
+ * One prominent, un-missable notice each time the incomplete total GROWS (see
174
+ * `noticedAt` above; re-calling it with nothing new is silent) — for EITHER
175
+ * reason, which are different statements and are worded differently:
176
+ * - `unknownLegs` — the leg reported no usage at all.
177
+ * - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
178
+ * subagent whose CHILD session is billed separately and whose spend the
179
+ * walk could NOT account for. This is the one that made
180
+ * `council-wsgate01` report `costExact: true` while $0.0215 short — 100%
181
+ * of that gap was one `explore` child session.
182
+ *
183
+ * v4.4.1 CA-1: child sessions are now enumerated and their measured spend IS
184
+ * attributed (`cost.subtreeCost`), so this second bucket has narrowed to the
185
+ * subtrees the walk genuinely could not price. It is deliberately still a
186
+ * separate statement from an unpriced leg: "we could not see this leg at all"
187
+ * and "we saw this leg but not what it spawned" are different facts.
188
+ */
189
+ const noticeUnknownSpend = () => {
190
+ const s = spendState();
191
+ const n = s.unknownLegs + s.subtreeUnknownLegs;
192
+ if (n === 0 || n === noticedAt) { return; }
193
+ noticedAt = n;
194
+ const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
195
+ const parts = [];
196
+ if (s.unknownLegs > 0) {
197
+ parts.push(`${s.unknownLegs} council leg(s) reported NO usage — their cost is UNKNOWN`);
198
+ }
199
+ if (s.subtreeUnknownLegs > 0) {
200
+ parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
201
+ + 'is billed separately and could NOT be determined');
202
+ }
203
+ // v4.4.1 A3: the counts are CUMULATIVE, and re-announcing a grown total
204
+ // ("1 council leg(s)…" then "4 council leg(s)…") reads as two separate
205
+ // findings that a reader can reasonably add up to five. "so far this run"
206
+ // says once, for both clauses, that each number is a running total.
207
+ emit(`Notice: so far this run, ${parts.join('; and ')} and is NOT included in the `
208
+ + `$${s.known.toFixed(4)} total${ceiling}. Real spend is HIGHER than reported — this total `
209
+ + 'is at least, not exactly, what was spent. See run.json usage (unknownLegs / '
210
+ + 'subtreeUnknownLegs), or `amicus spend --json` (sourceMix.unknown).'
211
+ // v4.4.1 CA-6: with a ceiling set, an inexact total is not a clean run — say
212
+ // so where the uncertainty is announced, so exit 2 is never a surprise.
213
+ //
214
+ // ⚠️ Review F2: hedged to "is on track to" rather than "will". This notice
215
+ // fires from noticeUnknownSpend(), which run.js calls immediately BEFORE
216
+ // the overBudget() check — so a run whose KNOWN spend also crosses the
217
+ // ceiling right here prints this sentence and then exits 1
218
+ // (COST_EXCEEDED), not 2. A signalled run exits 130/143. "Will" would be a
219
+ // guarantee this code cannot make; "is on track to" states the tendency
220
+ // that holds in the common case without promising an outcome decided
221
+ // later, downstream of this call.
222
+ + (hasCeiling ? ' Because a ceiling is set and this total is inexact, the run is on track '
223
+ + 'to exit degraded (2); the ceiling itself still never halts a run.' : '') + '\n');
224
+ };
225
+
226
+ /**
227
+ * The run summary's `usage` block. `costExact`/`unknownLegs` sit at the TOP of
228
+ * it on purpose: `cost.unpricedLegs` always carried the count, but every reader
229
+ * of `usage` looked one level up and saw only a number that read as
230
+ * authoritative — the silent under-report the diagnosis measured at 62.3% on
231
+ * council-wsgate02. Consumers: src/workspace/run-detail.js costPanel (the GUI
232
+ * gauge + total), src/cli-handlers-council-run.js renderRunHuman, and the
233
+ * `--json` manifest, which emits run.json verbatim.
234
+ */
235
+ const usageBlock = () => {
236
+ const s = spendState();
237
+ return {
238
+ cost: s.cost,
239
+ unknownLegs: s.unknownLegs,
240
+ subtreeUnknownLegs: s.subtreeUnknownLegs,
241
+ // v4.4 Task 2: `costExact` used to be `unknownLegs === 0`, which asks "did
242
+ // every leg report tokens" — a statement about observation coverage of each
243
+ // leg's OWN session, NOT about whether the total is complete. That is how
244
+ // `council-wsgate01` asserted exactness while $0.0215 short: all 7 legs were
245
+ // `source: 'reported'`, and 100% of the gap was one unattributed `explore`
246
+ // child session. costExact must mean "this is the whole bill", so it now
247
+ // requires BOTH: every leg observed, AND no leg with an unattributed subtree.
248
+ costExact: s.unknownLegs === 0 && s.subtreeUnknownLegs === 0,
249
+ };
250
+ };
251
+
252
+ /**
253
+ * v4.4.1 CA-6 (OWNER RULING, 2026-07-26). Is a `--max-cost` ceiling in force
254
+ * over a total we already know to be incomplete?
255
+ *
256
+ * `--max-cost` bounds KNOWN spend: an unknown leg contributes nothing to it and
257
+ * never halts a run — see `overBudget` above, which this deliberately does NOT
258
+ * touch, and which must keep ignoring unknown legs. That policy is right and it
259
+ * stays. What was wrong was the REPORT. A run could exit 0 — read by every
260
+ * script and every human as "clean, and inside your ceiling" — while knowingly
261
+ * publishing a floor: `council-wsgate02` really spent $0.9859 against a $0.75
262
+ * ceiling (131%) while amicus believed $0.3720, and exited 0.
263
+ *
264
+ * So the exit code degrades to 2, through the SAME `degraded` channel
265
+ * `noteBudgetRefusal` already uses for a shrunken bench: the run finished, its
266
+ * answer is good, and the cost figure underneath it is not the whole bill.
267
+ * With NO ceiling there is nothing to be inexact against and the exit code is
268
+ * untouched — an unpriced leg on an unbounded run is a fact, not a degradation.
269
+ * @returns {boolean}
270
+ */
271
+ const inexactUnderCeiling = () => hasCeiling && !usageBlock().costExact;
272
+
273
+ return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
274
+ addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals, inexactUnderCeiling };
275
+ }
276
+
277
+ module.exports = { createBudget };
@@ -129,7 +129,10 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
129
129
  if (chairText && !overallVerdict && !overBudget()) {
130
130
  runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
131
131
  const repair = await launchers.launchSolo({
132
- model: actualChair, prompt: stage2.buildChairRepairPrompt(),
132
+ // ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
133
+ // VERDICT line is missing — so a fresh repair session that cannot see the
134
+ // synthesis is picking a verdict on an artifact it has never read.
135
+ model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
133
136
  project: o.runDir, waveId: `${o.runId}-ch4`,
134
137
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
135
138
  noCostGate: o.noCostGate,
@@ -95,7 +95,8 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
95
95
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
96
96
  const res2 = await ctx.launchers.launchSolo({
97
97
  ...legOpts(ctx, repairId), model: raiser,
98
- prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors }),
98
+ // ⚠️ LC-12: a repair solo is a fresh session — the defense that failed rides along.
99
+ prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors, defense: leg.summary }),
99
100
  });
100
101
  ctx.addWave(res2.wave);
101
102
  if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
@@ -144,7 +145,8 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
144
145
  const repairId = `${waveId}-${judge}r`;
145
146
  runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
146
147
  const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
147
- prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors }) });
148
+ // ⚠️ LC-12: ditto the re-vote output being repaired rides with its errors.
149
+ prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
148
150
  ctx.addWave(r2.wave);
149
151
  if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
150
152
  const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
@@ -0,0 +1,102 @@
1
+ // src/council/run-finalize.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-finalize
6
+ * The TERMINAL half of run.js's finalize(): the whole exit-code vocabulary
7
+ * (signal → code, code → status, and the degradation that resolves the run's
8
+ * FINAL code), the last-chance unknown-spend notice, run.json's terminal
9
+ * checkpoint, the run-terminal event and the on-complete hook. Extracted from
10
+ * run.js for the 300-line size gate (v4.4.1 fix wave) — run.js keeps the half
11
+ * that must stay in the closure (uninstalling signals and releasing the run's
12
+ * shared server).
13
+ *
14
+ * ⚠️ BOOKKEEPING MUST NEVER SINK A RUN THAT ALREADY FINISHED. run.js documents
15
+ * "Never rejects for run errors: always resolves {exitCode, run}", but every
16
+ * `return finalize(…)` in it is a bare `return` of a promise — which, by async
17
+ * semantics, does NOT route through the enclosing catch. So a throw from the
18
+ * terminal checkpoint (an unwritable run dir, a full disk) escaped runCouncil as
19
+ * a REJECTION, past its own contract, and past run.js's `catch`. Worse, the
20
+ * throw landed between the server release and the caller, so the caller had no
21
+ * result to act on. Everything here is therefore guarded: a failure is announced
22
+ * on stderr and the run still resolves with a document whose terminal fields are
23
+ * authoritative.
24
+ */
25
+
26
+ const { emitRunTerminal } = require('../observe/events');
27
+ const { fireCouncilOnComplete } = require('../observe/on-complete');
28
+ const runState = require('./run-state');
29
+
30
+ /** Abort signal → the run's exit code. Lives here with the rest of the exit-code
31
+ * vocabulary; re-exported from ./run (its long-standing public home). */
32
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
33
+
34
+ /**
35
+ * run.json status for a council exit code (spec §4 degradation table).
36
+ * @param {number} code @returns {string}
37
+ */
38
+ function statusForExit(code) {
39
+ return (code === 130 || code === 143) ? 'aborted'
40
+ : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
41
+ }
42
+
43
+ /**
44
+ * The run's FINAL exit code, and the ONE place a would-be-clean run degrades.
45
+ *
46
+ * Precedence, highest first:
47
+ * 1. a signal — an aborted run reports how it was killed, nothing else;
48
+ * 2. a non-zero code the driver already decided (1 = error, 2 = degraded) —
49
+ * never re-labelled, because a failure is not a degradation;
50
+ * 3. `degraded.value` — the flag a shrunken bench, a thin cross-review, a dead
51
+ * debate leg and (v4.4.1 CA-6) an inexact total under a `--max-cost`
52
+ * ceiling all already set. 0 becomes 2.
53
+ *
54
+ * ⚠️ CA-6 is wired here rather than at the ceiling itself ON PURPOSE. The
55
+ * standing owner ruling — "I don't want hitting a ceiling to stop us from
56
+ * solving real problems" — is absolute: `overBudget()` still trips on KNOWN
57
+ * spend only and a ceiling still never blocks a run. What changes is only what
58
+ * the run CLAIMS on the way out. Exit 0 means "clean, and inside your ceiling";
59
+ * a run that published a total it knows is a floor has not earned that.
60
+ *
61
+ * @param {{signalled: number|null, exitCode: number, degraded?: {value: boolean},
62
+ * inexactUnderCeiling?: () => boolean}} args
63
+ * @returns {number}
64
+ */
65
+ function resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling }) {
66
+ if (signalled) { return signalled; }
67
+ if (degraded && inexactUnderCeiling && inexactUnderCeiling()) { degraded.value = true; }
68
+ return (exitCode === 0 && degraded && degraded.value) ? 2 : exitCode;
69
+ }
70
+
71
+ /**
72
+ * Write the run's terminal record and fire its terminal observers.
73
+ *
74
+ * @param {{o: object, code: number, error?: object|null,
75
+ * noticeUnknownSpend: Function, usageBlock: Function,
76
+ * deps?: {fireOnCompleteFn?: Function, write?: Function}}} args
77
+ * @returns {Promise<object>} the run document — always usable, even when the
78
+ * write failed (the on-disk doc merged under the authoritative terminal fields).
79
+ */
80
+ async function writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock, deps = {} }) {
81
+ const status = statusForExit(code);
82
+ const terminal = { status, exitCode: code, error: error || null };
83
+ try {
84
+ noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
85
+ const run = runState.checkpoint(o.runDir, {
86
+ ...terminal, usage: usageBlock(), completedAt: new Date().toISOString(),
87
+ });
88
+ emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
89
+ await (deps.fireOnCompleteFn || fireCouncilOnComplete)(o.onComplete, run,
90
+ { runId: o.runId, runDir: o.runDir, exitCode: code, project: o.project }, o.onCompleteDeps);
91
+ return run;
92
+ } catch (err) {
93
+ const write = deps.write || ((s) => process.stderr.write(s));
94
+ write(`Notice: council run bookkeeping failed at finalize: ${err.message}. The run itself `
95
+ + `finished with exit ${code} (${status}); run.json may be incomplete.\n`);
96
+ let onDisk = {};
97
+ try { onDisk = runState.readRun(o.runDir) || {}; } catch { /* unreadable too */ }
98
+ return { runId: o.runId, ...onDisk, ...terminal };
99
+ }
100
+ }
101
+
102
+ module.exports = { statusForExit, resolveTerminalExit, writeRunTerminal, SIGNAL_EXIT };
@@ -18,11 +18,47 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
 
20
20
  /**
21
- * @param {{fanoutFn?: Function}} [deps] test seam; default = real runFanout
21
+ * Did a launch exit because a SIGNAL killed it (130 = SIGINT, 143 = SIGTERM)
22
+ * rather than because the work failed? Every stage loop short-circuits on this
23
+ * instead of treating the wave as a normal failure.
24
+ *
25
+ * It lives HERE, with the module that produces those exit codes, because every
26
+ * stage loop needs it — including run-stage2.js. Defining it in run-stages.js and
27
+ * importing it back from its own child made the two mutually circular, which is
28
+ * why runStage2 could not be re-exported from run-stages.js (v4.4.1 review F5).
29
+ * @param {number} code
30
+ * @returns {boolean}
31
+ */
32
+ function isAbortExit(code) { return code === 130 || code === 143; }
33
+
34
+ /**
35
+ * @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
36
+ * reserveBudget?: (waveId: string, estimate: number) => boolean,
37
+ * onBudgetRefusal?: (info: {waveId, models, message}) => void}} [deps]
38
+ * fanoutFn: test seam; default = real runFanout.
39
+ * remainingBudget (v4.4): supplies the council's REMAINING `--max-cost`
40
+ * allowance (ceiling − known spend − outstanding reservations) at launch time,
41
+ * threaded into the fanout pre-flight estimate gate. Omitted (or returning
42
+ * null) leaves `maxCost` off the transport call entirely, exactly as before.
43
+ * reserveBudget (v4.4 cost-council finding 1): the ATOMIC claim. Reading the
44
+ * remaining allowance is not enough when two waves launch concurrently — both
45
+ * read the same unreduced number and both pass. The transport calls this once,
46
+ * synchronously, with the estimate it just computed; see run-budget.js.
47
+ * onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
48
+ * never launched can never vanish silently.
49
+ * sharedServer (v4.4.1 Task 0.5): a GETTER returning the run's single
50
+ * {serverClient, server} pair, or null. A getter (not a value) because run.js
51
+ * builds the launchers before it acquires the server — see ./run-server for
52
+ * why one server per run, and why `_scratch` isolation survives it. Returning
53
+ * null leaves the transport call byte-identical: the wave owns its own server.
22
54
  * @returns {{launchWave: Function, launchSolo: Function}}
23
55
  */
24
56
  function createLaunchers(deps = {}) {
25
57
  const fanoutFn = deps.fanoutFn || require('../sidecar/fanout').runFanout;
58
+ const remainingBudget = deps.remainingBudget || null;
59
+ const reserveBudget = deps.reserveBudget || null;
60
+ const onBudgetRefusal = deps.onBudgetRefusal || null;
61
+ const sharedServer = deps.sharedServer || null;
26
62
 
27
63
  /**
28
64
  * @param {{models: string[], prompt: string, project: string, waveId: string,
@@ -37,7 +73,27 @@ function createLaunchers(deps = {}) {
37
73
  */
38
74
  async function launchWave(opts) {
39
75
  fs.mkdirSync(opts.project, { recursive: true });
40
- const { wave, exitCode } = await fanoutFn({
76
+ // v4.4: arm fanout's SOFT total-$ ceiling with the council's remaining
77
+ // allowance. Previously omitted, so fanout fell back to `cfg.maxCost` (a key
78
+ // src/utils/config.js never defines) and the pre-flight estimate gate was
79
+ // inert for every council run — run.js's post-hoc check was the only ceiling,
80
+ // and it can only refuse after the money is gone. Left OFF when there is no
81
+ // provider or no ceiling, so the transport call is byte-identical for
82
+ // non-council callers and for `--max-cost`-less runs.
83
+ const remaining = remainingBudget ? remainingBudget() : null;
84
+ // v4.4.1 Task 0.5: every launch in the run rides the SAME OpenCode server.
85
+ // NOT `client` — that key is fanout's client TYPE string; the SDK client is
86
+ // `serverClient` (see the seam comment in fanout.js). Absent → the wave
87
+ // starts and closes its own server, exactly as before.
88
+ const shared = sharedServer ? sharedServer() : null;
89
+ const { wave, exitCode, errorDoc } = await fanoutFn({
90
+ ...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
91
+ ...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
92
+ // v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
93
+ // transport resolved routing; a concurrently launching sibling can claim
94
+ // part of that allowance in the meantime. This is the CLAIM that settles
95
+ // it — synchronous by contract, so two callers can never interleave.
96
+ ...(reserveBudget ? { reserveBudget: (est) => reserveBudget(opts.waveId, est) } : {}),
41
97
  models: opts.models.join(','),
42
98
  prompt: opts.prompt,
43
99
  promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
@@ -73,17 +129,25 @@ function createLaunchers(deps = {}) {
73
129
  directory: opts.project,
74
130
  noMcp: true,
75
131
  });
76
- return { wave, exitCode };
132
+ // A ceiling refusal returns `wave: null`, which the council driver's
133
+ // addWave() treats as a no-op — so before v4.4 the seats simply vanished
134
+ // from the bench with nothing on stdout, stderr or run.json to say so. A
135
+ // partial bench is an acceptable outcome; an UNANNOUNCED one is not.
136
+ if (onBudgetRefusal && errorDoc && errorDoc.code === 'BUDGET_EXCEEDED') {
137
+ onBudgetRefusal({ waveId: opts.waveId, models: opts.models.slice(), message: errorDoc.message });
138
+ }
139
+ return { wave, exitCode, errorDoc: errorDoc || null };
77
140
  }
78
141
 
79
142
  /**
80
143
  * One-model launch (critic/lens legs, repairs, the chair) as a 1-leg wave.
81
- * @returns {Promise<{wave: object|null, exitCode: number, leg: object|null}>}
144
+ * @returns {Promise<{wave: object|null, exitCode: number, leg: object|null,
145
+ * errorDoc: object|null}>}
82
146
  */
83
147
  async function launchSolo(opts) {
84
- const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
148
+ const { wave, exitCode, errorDoc } = await launchWave({ ...opts, models: [opts.model] });
85
149
  const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
86
- return { wave, exitCode, leg };
150
+ return { wave, exitCode, leg, errorDoc };
87
151
  }
88
152
 
89
153
  return { launchWave, launchSolo };
@@ -135,4 +199,6 @@ function materializeDebate(runDir, legs, prefix) {
135
199
  return out;
136
200
  }
137
201
 
138
- module.exports = { createLaunchers, materializeReviews, materializeDebate, sanitizeName };
202
+ module.exports = {
203
+ createLaunchers, materializeReviews, materializeDebate, sanitizeName, isAbortExit,
204
+ };