amicus 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +4 -3
  4. package/electron/ipc-workspace.js +283 -0
  5. package/electron/main.js +27 -0
  6. package/electron/preload-workspace.js +40 -0
  7. package/electron/workspace-shell.js +85 -0
  8. package/electron/workspace-ui/index.html +111 -0
  9. package/electron/workspace-ui/live-model.js +101 -0
  10. package/electron/workspace-ui/md-lite.js +119 -0
  11. package/electron/workspace-ui/workspace-app.js +240 -0
  12. package/electron/workspace-ui/workspace-matrix.js +212 -0
  13. package/electron/workspace-ui/workspace-panels.js +226 -0
  14. package/electron/workspace-ui/workspace-render.js +271 -0
  15. package/electron/workspace-ui/workspace-verbs.js +247 -0
  16. package/electron/workspace-ui/workspace.css +172 -0
  17. package/package.json +1 -1
  18. package/schemas/council-run-live.schema.json +25 -1
  19. package/schemas/council-run.schema.json +14 -0
  20. package/schemas/progress.schema.json +14 -1
  21. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  22. package/src/cli-handlers-council-run.js +25 -3
  23. package/src/cli-handlers-spend.js +32 -5
  24. package/src/cli-handlers-watch.js +37 -10
  25. package/src/council/briefings.js +35 -2
  26. package/src/council/run-budget.js +224 -0
  27. package/src/council/run-launch.js +44 -6
  28. package/src/council/run-stages.js +17 -3
  29. package/src/council/run.js +12 -11
  30. package/src/headless.js +347 -14
  31. package/src/mcp-council-awareness.js +53 -3
  32. package/src/observe/council-legs.js +183 -0
  33. package/src/observe/live-doc.js +21 -3
  34. package/src/observe/watch-render.js +19 -0
  35. package/src/opencode-client.js +15 -3
  36. package/src/sidecar/child-sessions.js +198 -0
  37. package/src/sidecar/conversation-mirror.js +111 -37
  38. package/src/sidecar/fanout-budget.js +71 -0
  39. package/src/sidecar/fanout-leg.js +23 -1
  40. package/src/sidecar/fanout.js +4 -11
  41. package/src/sidecar/tool-part.js +196 -0
  42. package/src/sidecar/workspace-window.js +62 -0
  43. package/src/spend-query.js +21 -6
  44. package/src/utils/env-num.js +42 -0
  45. package/src/utils/path-fence.js +82 -0
  46. package/src/utils/pricing.js +98 -9
  47. package/src/workspace/artifact-guard.js +187 -0
  48. package/src/workspace/blind-mode.js +32 -0
  49. package/src/workspace/fold-format.js +95 -0
  50. package/src/workspace/live-normalize.js +156 -0
  51. package/src/workspace/matrix-model.js +94 -0
  52. package/src/workspace/run-detail.js +223 -0
  53. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,224 @@
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
+
29
+ const { sumWaveUsage } = require('../utils/pricing');
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {Array<object>} [opts.allLegs] live array the driver pushes every wave's legs into
34
+ * @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
35
+ * @param {string} [opts.runDir] run directory; when given, budget refusals are
36
+ * checkpointed into run.json so the record outlives the stderr notice
37
+ * @param {{value: boolean}} [opts.degraded] the driver's degrade flag; a budget
38
+ * refusal sets it so the run can never exit 0 with a silently shrunken bench
39
+ * @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
40
+ * @returns {{spendState:Function, spent:Function, overBudget:Function,
41
+ * remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
42
+ * addWave:Function, reserveBudget:Function, releaseBudget:Function,
43
+ * noteBudgetRefusal:Function, budgetRefusals:Function}}
44
+ */
45
+ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
46
+ const legs = allLegs || [];
47
+ const emit = write || ((s) => process.stderr.write(s));
48
+ const hasCeiling = maxCost !== null && maxCost !== undefined;
49
+
50
+ /**
51
+ * The run's position, split into what we KNOW was spent and how many legs we
52
+ * cannot price at all. `known` is the sum of resolved amounts only — a leg
53
+ * whose cost is `unknown` contributes nothing, because inventing a number for
54
+ * it would be a fabrication. `unknownLegs` is what makes that omission
55
+ * visible instead of silent: it is the count the run summary, the envelope,
56
+ * `amicus spend` and the GUI all read to say "this total is a floor".
57
+ */
58
+ const spendState = () => {
59
+ const c = sumWaveUsage(legs).cost;
60
+ return {
61
+ known: typeof c.amount === 'number' ? c.amount : 0,
62
+ unknownLegs: c.unpricedLegs || 0,
63
+ subtreeUnknownLegs: c.subtreeUnknownLegs || 0,
64
+ cost: c,
65
+ };
66
+ };
67
+
68
+ const spent = () => spendState().known;
69
+
70
+ /** Trips on KNOWN spend only — see the ruling in the module docblock. */
71
+ const overBudget = () => hasCeiling && spent() >= maxCost;
72
+
73
+ // ---- Concurrency-safe reservations (v4.4 cost-council finding 1) ----------
74
+ /**
75
+ * THE DEFECT. `launchStage1()` builds the seat wave and the critic wave
76
+ * concurrently under one `Promise.all`, and each launcher called
77
+ * `remainingBudget()` BEFORE either wave's legs had been appended to `legs`.
78
+ * Both therefore observed the full, unreduced allowance, and both could pass
79
+ * a soft gate that — taken together — exceeded `--max-cost`. Only run.js's
80
+ * post-Stage-1 `overBudget()` noticed, by which point the money was committed.
81
+ * A read is not a claim.
82
+ *
83
+ * THE FIX. `reserveBudget` is a SYNCHRONOUS read-and-claim. Synchronicity is
84
+ * the entire guarantee: the JS event loop cannot interleave two callers inside
85
+ * a synchronous function, so a second concurrent wave necessarily observes the
86
+ * first wave's claim. No mutex, no async barrier, nothing to deadlock.
87
+ *
88
+ * WHY NOT SPLIT THE QUOTA. Handing each concurrent wave a fixed share is also
89
+ * safe but is strictly MORE refusing than the ceiling requires: three cheap
90
+ * seats plus one expensive critic can fit a ceiling that no proportional split
91
+ * admits. The owner's standing ruling is fail LOUD, not fail CLOSED — so the
92
+ * gate must refuse only what genuinely does not fit. First claim wins; a wave
93
+ * refused here does not stop the run (see noteBudgetRefusal).
94
+ */
95
+ const reservations = new Map(); // waveId -> pre-flight estimate ($)
96
+ const reserved = () => { let t = 0; for (const v of reservations.values()) { t += v; } return t; };
97
+
98
+ /** Ceiling minus known spend MINUS outstanding reservations, floored at 0;
99
+ * null when no ceiling is set. Threaded into each wave's fanout pre-flight
100
+ * estimate as its starting allowance (run-launch.js). */
101
+ const remainingBudget = () => (hasCeiling ? Math.max(maxCost - spent() - reserved(), 0) : null);
102
+
103
+ /**
104
+ * Atomically claim `estimate` against the allowance no sibling wave has taken.
105
+ * @returns {boolean} false ONLY when the estimate genuinely does not fit.
106
+ * A $0 / unpriced / non-numeric estimate always fits: unknown cost must
107
+ * never halt a run (the same ruling that keeps it out of `overBudget`).
108
+ */
109
+ const reserveBudget = (waveId, estimate) => {
110
+ if (!hasCeiling) { return true; }
111
+ const est = (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) ? estimate : 0;
112
+ if (est > remainingBudget()) { return false; }
113
+ reservations.set(waveId, (reservations.get(waveId) || 0) + est);
114
+ return true;
115
+ };
116
+
117
+ /** Drop a wave's outstanding claim (its real spend now speaks for it). */
118
+ const releaseBudget = (waveId) => { reservations.delete(waveId); };
119
+
120
+ /**
121
+ * Record a finished wave: its ESTIMATE stops counting and its MEASURED legs
122
+ * start counting, in one synchronous step so no concurrent launcher can ever
123
+ * observe a moment where the wave counts twice or not at all.
124
+ */
125
+ const addWave = (wave) => {
126
+ if (!wave) { return; }
127
+ if (wave.waveId) { releaseBudget(wave.waveId); }
128
+ if (Array.isArray(wave.legs)) { legs.push(...wave.legs); }
129
+ };
130
+
131
+ /**
132
+ * A wave the ceiling refused. POLICY (the owner's ruling applied to
133
+ * concurrency): the run CONTINUES with a partial bench — it never rolls back
134
+ * waves already launched (that would destroy paid work) and never aborts
135
+ * (that is fail-closed). What it must never do is lose the seat SILENTLY, so
136
+ * every refusal is announced on stderr, kept on run.json, and degrades the
137
+ * run's exit code. Stage 1's existing quorum gate still refuses to call a
138
+ * bench of fewer than two reviews a council.
139
+ */
140
+ const refusals = [];
141
+ const noteBudgetRefusal = (info) => {
142
+ const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
143
+ reason: 'max-cost', at: new Date().toISOString() };
144
+ refusals.push(rec);
145
+ if (degraded) { degraded.value = true; }
146
+ emit(`Notice: the $${maxCost} --max-cost ceiling refused wave ${rec.waveId} `
147
+ + `(${rec.models.join(', ') || 'no models'}) — those seats DID NOT LAUNCH and are missing from `
148
+ + 'this council. The run continues with the bench that did launch and will exit degraded (2). '
149
+ + 'Raise --max-cost, or pass --no-cost-gate, to seat them.\n');
150
+ if (runDir) {
151
+ // Never let bookkeeping sink a run that is otherwise fine.
152
+ try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
153
+ catch (e) { emit(`Notice: could not record the budget refusal in run.json: ${e.message}\n`); }
154
+ }
155
+ };
156
+ const budgetRefusals = () => refusals.slice();
157
+
158
+ let noticed = false;
159
+ /**
160
+ * One prominent, un-missable notice per run when the total is incomplete — for
161
+ * EITHER reason, which are different statements and are worded differently:
162
+ * - `unknownLegs` — the leg reported no usage at all.
163
+ * - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
164
+ * subagent whose CHILD session is billed separately and whose spend the
165
+ * walk could NOT account for. This is the one that made
166
+ * `council-wsgate01` report `costExact: true` while $0.0215 short — 100%
167
+ * of that gap was one `explore` child session.
168
+ *
169
+ * v4.4.1 CA-1: child sessions are now enumerated and their measured spend IS
170
+ * attributed (`cost.subtreeCost`), so this second bucket has narrowed to the
171
+ * subtrees the walk genuinely could not price. It is deliberately still a
172
+ * separate statement from an unpriced leg: "we could not see this leg at all"
173
+ * and "we saw this leg but not what it spawned" are different facts.
174
+ */
175
+ const noticeUnknownSpend = () => {
176
+ const s = spendState();
177
+ if ((s.unknownLegs === 0 && s.subtreeUnknownLegs === 0) || noticed) { return; }
178
+ noticed = true;
179
+ const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
180
+ const parts = [];
181
+ if (s.unknownLegs > 0) {
182
+ parts.push(`${s.unknownLegs} council leg(s) reported NO usage — their cost is UNKNOWN`);
183
+ }
184
+ if (s.subtreeUnknownLegs > 0) {
185
+ parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
186
+ + 'is billed separately and could NOT be determined');
187
+ }
188
+ emit(`Notice: ${parts.join('; and ')} and is NOT included in the $${s.known.toFixed(4)} `
189
+ + `total${ceiling}. Real spend is HIGHER than reported — this total is at least, not exactly, `
190
+ + 'what was spent. See run.json usage (unknownLegs / subtreeUnknownLegs), or '
191
+ + '`amicus spend --json` (sourceMix.unknown).\n');
192
+ };
193
+
194
+ /**
195
+ * The run summary's `usage` block. `costExact`/`unknownLegs` sit at the TOP of
196
+ * it on purpose: `cost.unpricedLegs` always carried the count, but every reader
197
+ * of `usage` looked one level up and saw only a number that read as
198
+ * authoritative — the silent under-report the diagnosis measured at 62.3% on
199
+ * council-wsgate02. Consumers: src/workspace/run-detail.js costPanel (the GUI
200
+ * gauge + total), src/cli-handlers-council-run.js renderRunHuman, and the
201
+ * `--json` manifest, which emits run.json verbatim.
202
+ */
203
+ const usageBlock = () => {
204
+ const s = spendState();
205
+ return {
206
+ cost: s.cost,
207
+ unknownLegs: s.unknownLegs,
208
+ subtreeUnknownLegs: s.subtreeUnknownLegs,
209
+ // v4.4 Task 2: `costExact` used to be `unknownLegs === 0`, which asks "did
210
+ // every leg report tokens" — a statement about observation coverage of each
211
+ // leg's OWN session, NOT about whether the total is complete. That is how
212
+ // `council-wsgate01` asserted exactness while $0.0215 short: all 7 legs were
213
+ // `source: 'reported'`, and 100% of the gap was one unattributed `explore`
214
+ // child session. costExact must mean "this is the whole bill", so it now
215
+ // requires BOTH: every leg observed, AND no leg with an unattributed subtree.
216
+ costExact: s.unknownLegs === 0 && s.subtreeUnknownLegs === 0,
217
+ };
218
+ };
219
+
220
+ return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
221
+ addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals };
222
+ }
223
+
224
+ module.exports = { createBudget };
@@ -18,11 +18,27 @@ 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
+ * @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
22
+ * reserveBudget?: (waveId: string, estimate: number) => boolean,
23
+ * onBudgetRefusal?: (info: {waveId, models, message}) => void}} [deps]
24
+ * fanoutFn: test seam; default = real runFanout.
25
+ * remainingBudget (v4.4): supplies the council's REMAINING `--max-cost`
26
+ * allowance (ceiling − known spend − outstanding reservations) at launch time,
27
+ * threaded into the fanout pre-flight estimate gate. Omitted (or returning
28
+ * null) leaves `maxCost` off the transport call entirely, exactly as before.
29
+ * reserveBudget (v4.4 cost-council finding 1): the ATOMIC claim. Reading the
30
+ * remaining allowance is not enough when two waves launch concurrently — both
31
+ * read the same unreduced number and both pass. The transport calls this once,
32
+ * synchronously, with the estimate it just computed; see run-budget.js.
33
+ * onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
34
+ * never launched can never vanish silently.
22
35
  * @returns {{launchWave: Function, launchSolo: Function}}
23
36
  */
24
37
  function createLaunchers(deps = {}) {
25
38
  const fanoutFn = deps.fanoutFn || require('../sidecar/fanout').runFanout;
39
+ const remainingBudget = deps.remainingBudget || null;
40
+ const reserveBudget = deps.reserveBudget || null;
41
+ const onBudgetRefusal = deps.onBudgetRefusal || null;
26
42
 
27
43
  /**
28
44
  * @param {{models: string[], prompt: string, project: string, waveId: string,
@@ -37,7 +53,21 @@ function createLaunchers(deps = {}) {
37
53
  */
38
54
  async function launchWave(opts) {
39
55
  fs.mkdirSync(opts.project, { recursive: true });
40
- const { wave, exitCode } = await fanoutFn({
56
+ // v4.4: arm fanout's SOFT total-$ ceiling with the council's remaining
57
+ // allowance. Previously omitted, so fanout fell back to `cfg.maxCost` (a key
58
+ // src/utils/config.js never defines) and the pre-flight estimate gate was
59
+ // inert for every council run — run.js's post-hoc check was the only ceiling,
60
+ // and it can only refuse after the money is gone. Left OFF when there is no
61
+ // provider or no ceiling, so the transport call is byte-identical for
62
+ // non-council callers and for `--max-cost`-less runs.
63
+ const remaining = remainingBudget ? remainingBudget() : null;
64
+ const { wave, exitCode, errorDoc } = await fanoutFn({
65
+ ...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
66
+ // v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
67
+ // transport resolved routing; a concurrently launching sibling can claim
68
+ // part of that allowance in the meantime. This is the CLAIM that settles
69
+ // it — synchronous by contract, so two callers can never interleave.
70
+ ...(reserveBudget ? { reserveBudget: (est) => reserveBudget(opts.waveId, est) } : {}),
41
71
  models: opts.models.join(','),
42
72
  prompt: opts.prompt,
43
73
  promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
@@ -73,17 +103,25 @@ function createLaunchers(deps = {}) {
73
103
  directory: opts.project,
74
104
  noMcp: true,
75
105
  });
76
- return { wave, exitCode };
106
+ // A ceiling refusal returns `wave: null`, which the council driver's
107
+ // addWave() treats as a no-op — so before v4.4 the seats simply vanished
108
+ // from the bench with nothing on stdout, stderr or run.json to say so. A
109
+ // partial bench is an acceptable outcome; an UNANNOUNCED one is not.
110
+ if (onBudgetRefusal && errorDoc && errorDoc.code === 'BUDGET_EXCEEDED') {
111
+ onBudgetRefusal({ waveId: opts.waveId, models: opts.models.slice(), message: errorDoc.message });
112
+ }
113
+ return { wave, exitCode, errorDoc: errorDoc || null };
77
114
  }
78
115
 
79
116
  /**
80
117
  * 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}>}
118
+ * @returns {Promise<{wave: object|null, exitCode: number, leg: object|null,
119
+ * errorDoc: object|null}>}
82
120
  */
83
121
  async function launchSolo(opts) {
84
- const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
122
+ const { wave, exitCode, errorDoc } = await launchWave({ ...opts, models: [opts.model] });
85
123
  const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
86
- return { wave, exitCode, leg };
124
+ return { wave, exitCode, leg, errorDoc };
87
125
  }
88
126
 
89
127
  return { launchWave, launchSolo };
@@ -119,13 +119,25 @@ async function runStage1(ctx) {
119
119
  let conformance = 'clean';
120
120
  let res = validateFindings(m.text);
121
121
  let attempts = 0;
122
+ // ⚠️ LC-6: the text the repair prompt must carry. A repair solo is a FRESH
123
+ // session — it has no memory of the review turn — so shipping only
124
+ // res.errors asked the model to correct something it had never seen. Two
125
+ // paid models refused ("I don't have a previous review to correct") and one
126
+ // fabricated a finding, which reached tally.json and the chair's verdict.
127
+ // Tracked rather than pinned to m.text so `repairing` and `res.errors`
128
+ // always describe the SAME artifact: on attempt 2 the errors came from
129
+ // validating attempt 1's output, so attempt 1's output is what is being
130
+ // repaired. An empty/dead repair leg leaves it on the last real text
131
+ // (there is no newer artifact to name).
132
+ let repairing = m.text;
122
133
  while (!res.ok && attempts < 2 && !ctx.overBudget()) {
123
134
  attempts += 1;
124
135
  repairSeq += 1;
125
136
  const waveId = `${o.runId}-p${repairSeq}`;
126
137
  runState.appendStageWave(o.runDir, 'stage1', waveId);
127
138
  const solo = await ctx.launchers.launchSolo({
128
- model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
139
+ model: m.modelInput,
140
+ prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
129
141
  project: o.runDir, waveId, timeout: o.timeout,
130
142
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
131
143
  councilRunId: o.runId, councilName: o.councilName,
@@ -133,7 +145,9 @@ async function runStage1(ctx) {
133
145
  });
134
146
  ctx.addWave(solo.wave);
135
147
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
136
- res = validateFindings((solo.leg && solo.leg.summary) || '');
148
+ const repaired = (solo.leg && solo.leg.summary) || '';
149
+ if (repaired.trim()) { repairing = repaired; }
150
+ res = validateFindings(repaired);
137
151
  if (res.ok) { conformance = 'repaired'; }
138
152
  }
139
153
  if (!res.ok) { conformance = 'unstructured'; }
@@ -225,4 +239,4 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
225
239
  return { aborted: null, judgeResults };
226
240
  }
227
241
 
228
- module.exports = { runStage1, runStage2, isAbortExit, slug };
242
+ module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
@@ -31,7 +31,7 @@ const runDebateMod = require('./run-debate');
31
31
  const { buildDebateAddendum } = require('./briefings-debate');
32
32
  const { decorateRecord } = require('./debate');
33
33
  const asm = require('./run-assemble');
34
- const { sumWaveUsage } = require('../utils/pricing');
34
+ const { createBudget } = require('./run-budget');
35
35
  const { emitRunStarted, emitRunTerminal, emitStageStarted, emitStageTerminal } = require('../observe/events');
36
36
  const { fireCouncilOnComplete } = require('../observe/on-complete');
37
37
 
@@ -52,20 +52,20 @@ async function runCouncil(options, deps = {}) {
52
52
  const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
53
53
  noCostGate: false, councilName: null, ...options };
54
54
  o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
55
- const launchers = deps.launchers || createLaunchers();
56
55
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
57
56
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
58
57
  const installSignals = deps.installSignalAbortFn
59
58
  || require('../utils/session-abort').installSignalAbort;
60
59
  const now = () => new Date().toISOString();
61
60
 
62
- const allLegs = [];
63
- const addWave = (wave) => { if (wave && Array.isArray(wave.legs)) { allLegs.push(...wave.legs); } };
64
- const spent = () => {
65
- const c = sumWaveUsage(allLegs).cost;
66
- return typeof c.amount === 'number' ? c.amount : 0;
67
- };
68
- const overBudget = () => o.maxCost !== null && o.maxCost !== undefined && spent() >= o.maxCost;
61
+ // v4.4: the whole budget position lives in ./run-budget — its docblock carries the "fail LOUD,
62
+ // not CLOSED" ruling, why reserveBudget (not merely remainingBudget) is what holds the ceiling
63
+ // across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
64
+ // why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
65
+ const degraded = { value: false };
66
+ const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
67
+ noteBudgetRefusal } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degraded });
68
+ const launchers = deps.launchers || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal });
69
69
 
70
70
  runState.initRun(o.runDir, {
71
71
  schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
@@ -89,15 +89,15 @@ async function runCouncil(options, deps = {}) {
89
89
  },
90
90
  });
91
91
 
92
- const degraded = { value: false };
93
92
  const finalize = async (exitCode, error) => {
94
93
  uninstall();
95
94
  const code = signalled || exitCode;
96
95
  const status = (code === 130 || code === 143) ? 'aborted'
97
96
  : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
97
+ noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
98
98
  const run = runState.checkpoint(o.runDir, {
99
99
  status, exitCode: code, error: error || null,
100
- usage: { cost: sumWaveUsage(allLegs).cost },
100
+ usage: usageBlock(),
101
101
  completedAt: now(),
102
102
  });
103
103
  emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
@@ -144,6 +144,7 @@ async function runCouncil(options, deps = {}) {
144
144
  }
145
145
 
146
146
  // ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
147
+ noticeUnknownSpend(); // v4.4: warn EARLY on a long run, not only at finalize
147
148
  if (overBudget()) {
148
149
  return finalize(1, {
149
150
  code: 'COST_EXCEEDED',