amicus 4.2.1 → 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 (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. 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 };
@@ -19,6 +19,7 @@ const stage2 = require('./briefings-stage2');
19
19
  const { parseChairVerdict } = require('./parse-stage2');
20
20
  const runState = require('./run-state');
21
21
  const { isAbortExit } = require('./run-stages');
22
+ const { emitStageStarted, emitStageTerminal } = require('../observe/events');
22
23
 
23
24
  /**
24
25
  * Chair fallback promotion (spec §4): the highest peers-only street-cred
@@ -68,6 +69,9 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
68
69
  // below are the launches a re-armed price gate would refuse LAST, after
69
70
  // the whole bench has already been paid for.
70
71
  noCostGate: o.noCostGate,
72
+ // v4.3 Task 3 (spec §7.2 named defect): without this, chair spend is
73
+ // ledgered with councilRunId:null and is unattributable.
74
+ councilRunId: o.runId, councilName: o.councilName,
71
75
  });
72
76
  addWave(solo.wave);
73
77
  const ok = solo.leg && solo.leg.status === 'complete'
@@ -83,8 +87,10 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
83
87
  // Never abort in-flight legs for cost — this only stops NEW launches.
84
88
  degraded.value = true;
85
89
  runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
90
+ emitStageTerminal(o.runDir, o.runId, 'chair', 'skipped', null, o.follow);
86
91
  } else {
87
92
  runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
93
+ emitStageStarted(o.runDir, o.runId, 'chair', null, o.follow);
88
94
  // Fallback chain (spec §4): retry same chair once → promote best
89
95
  // non-bench model from the ledger → give up (no Claude fallback headless).
90
96
  let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
@@ -105,8 +111,9 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
105
111
  }
106
112
  }
107
113
  chairLeg = attempt.leg;
108
- runState.updateStage(o.runDir, 'chair',
109
- { status: chairLeg ? 'complete' : 'error', completedAt: now() });
114
+ const chairStatus = chairLeg ? 'complete' : 'error';
115
+ runState.updateStage(o.runDir, 'chair', { status: chairStatus, completedAt: now() });
116
+ emitStageTerminal(o.runDir, o.runId, 'chair', chairStatus, null, o.follow);
110
117
  // The chair chain may have promoted a fallback (or given up) — checkpoint
111
118
  // the ACTUAL chair into run.json now so status/`--json`/the human summary
112
119
  // never report the originally-requested chair after a promotion. Mirrors
@@ -126,6 +133,7 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
126
133
  project: o.runDir, waveId: `${o.runId}-ch4`,
127
134
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
128
135
  noCostGate: o.noCostGate,
136
+ councilRunId: o.runId, councilName: o.councilName,
129
137
  });
130
138
  addWave(repair.wave);
131
139
  if (isAbortExit(repair.exitCode) || isSignalled()) { return bail(repair.exitCode || isSignalled()); }
@@ -18,6 +18,7 @@ const { materializeDebate } = require('./run-launch');
18
18
  const { tally } = require('./tally');
19
19
  const { isAbortExit } = require('./run-stages');
20
20
  const runState = require('./run-state');
21
+ const { emitStageStarted } = require('../observe/events');
21
22
 
22
23
  /** Spec §5.7 fallback: a dead/unparseable defense means every bundled id's original stands. */
23
24
  function allNoResponse(ids) {
@@ -65,7 +66,9 @@ function debateTargets(provisionalRecord, tallyInput) {
65
66
  /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
66
67
  function legOpts(ctx, waveId) {
67
68
  return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
68
- noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate };
69
+ noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
70
+ // v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
71
+ councilRunId: ctx.o.runId, councilName: ctx.o.councilName };
69
72
  }
70
73
 
71
74
  async function runDefenseSolo(ctx, raiser, findings, idx) {
@@ -121,6 +124,7 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
121
124
  // nothing was defended/amended, or the cost ceiling hit).
122
125
  runState.updateStage(ctx.o.runDir, 'debate-revote',
123
126
  { status: 'running', startedAt: new Date().toISOString(), project: ctx.scratchDir, waveId });
127
+ emitStageStarted(ctx.o.runDir, ctx.o.runId, 'debate-revote', waveId, ctx.o.follow);
124
128
  runState.appendStageWave(ctx.o.runDir, 'debate-revote', waveId);
125
129
  const res = await ctx.launchers.launchWave({ ...legOpts(ctx, waveId), models: judges, prompt: bundle });
126
130
  ctx.addWave(res.wave);
@@ -18,20 +18,56 @@ 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,
29
- * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string}} opts
45
+ * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
46
+ * councilRunId?: string, councilName?: string, fallback?: object, catalog?: Array}} opts
47
+ * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
48
+ * ids forwarded verbatim into the runFanout call so it can stamp them onto
49
+ * every leg. fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
50
+ * additive/opt-in — omitted by callers that must never substitute (the
51
+ * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
30
52
  * @returns {Promise<{wave: object|null, exitCode: number}>}
31
53
  */
32
54
  async function launchWave(opts) {
33
55
  fs.mkdirSync(opts.project, { recursive: true });
34
- 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) } : {}),
35
71
  models: opts.models.join(','),
36
72
  prompt: opts.prompt,
37
73
  promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
@@ -43,6 +79,13 @@ function createLaunchers(deps = {}) {
43
79
  includeContext: false,
44
80
  gatewayMode: opts.gateway,
45
81
  noValidateModel: opts.noValidateModel,
82
+ councilRunId: opts.councilRunId,
83
+ councilName: opts.councilName,
84
+ // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
85
+ // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
86
+ // fallback path only activates when `fallback.enabled` is true.
87
+ fallback: opts.fallback,
88
+ catalog: opts.catalog,
46
89
  // v4.1 §4.5d: `--no-cost-gate` is a WHOLE-RUN opt-out (an intentional
47
90
  // o3-class council), so it has to ride every council launch — otherwise
48
91
  // fanout's per-$/Mtok gate refuses the first repair or the chair
@@ -60,17 +103,25 @@ function createLaunchers(deps = {}) {
60
103
  directory: opts.project,
61
104
  noMcp: true,
62
105
  });
63
- 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 };
64
114
  }
65
115
 
66
116
  /**
67
117
  * One-model launch (critic/lens legs, repairs, the chair) as a 1-leg wave.
68
- * @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}>}
69
120
  */
70
121
  async function launchSolo(opts) {
71
- const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
122
+ const { wave, exitCode, errorDoc } = await launchWave({ ...opts, models: [opts.model] });
72
123
  const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
73
- return { wave, exitCode, leg };
124
+ return { wave, exitCode, leg, errorDoc };
74
125
  }
75
126
 
76
127
  return { launchWave, launchSolo };
@@ -41,6 +41,13 @@ async function launchStage1(ctx) {
41
41
  const common = {
42
42
  project: o.runDir, timeout: o.timeout, gateway: o.gateway,
43
43
  noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
44
+ // v4.3 Task 3 (spec §7.2): attribution ids, forwarded verbatim to runFanout
45
+ // via run-launch.js so every Stage-1 leg's ledger row carries them.
46
+ councilRunId: o.runId, councilName: o.councilName,
47
+ // v4.3 Task 18 (spec §6.2): fallback chains apply to STAGE legs only —
48
+ // the chair (run-chair.js) and debate legs (run-debate.js) never receive
49
+ // this, so they never substitute via chains.
50
+ fallback: o.fallback, catalog: o.catalog,
44
51
  };
45
52
  const launches = [];
46
53
  // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
@@ -112,19 +119,35 @@ async function runStage1(ctx) {
112
119
  let conformance = 'clean';
113
120
  let res = validateFindings(m.text);
114
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;
115
133
  while (!res.ok && attempts < 2 && !ctx.overBudget()) {
116
134
  attempts += 1;
117
135
  repairSeq += 1;
118
136
  const waveId = `${o.runId}-p${repairSeq}`;
119
137
  runState.appendStageWave(o.runDir, 'stage1', waveId);
120
138
  const solo = await ctx.launchers.launchSolo({
121
- model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
139
+ model: m.modelInput,
140
+ prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
122
141
  project: o.runDir, waveId, timeout: o.timeout,
123
142
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
143
+ councilRunId: o.runId, councilName: o.councilName,
144
+ fallback: o.fallback, catalog: o.catalog,
124
145
  });
125
146
  ctx.addWave(solo.wave);
126
147
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
127
- 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);
128
151
  if (res.ok) { conformance = 'repaired'; }
129
152
  }
130
153
  if (!res.ok) { conformance = 'unstructured'; }
@@ -170,6 +193,8 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
170
193
  models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
171
194
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
172
195
  noCostGate: o.noCostGate,
196
+ councilRunId: o.runId, councilName: o.councilName,
197
+ fallback: o.fallback, catalog: o.catalog,
173
198
  });
174
199
  ctx.addWave(wave);
175
200
  if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
@@ -195,6 +220,8 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
195
220
  model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
196
221
  project: ctx.scratchDir, waveId, timeout: o.timeout,
197
222
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
223
+ councilRunId: o.runId, councilName: o.councilName,
224
+ fallback: o.fallback, catalog: o.catalog,
198
225
  });
199
226
  ctx.addWave(solo.wave);
200
227
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
@@ -212,4 +239,4 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
212
239
  return { aborted: null, judgeResults };
213
240
  }
214
241
 
215
- module.exports = { runStage1, runStage2, isAbortExit, slug };
242
+ module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
@@ -31,33 +31,41 @@ 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
+ const { emitRunStarted, emitRunTerminal, emitStageStarted, emitStageTerminal } = require('../observe/events');
36
+ const { fireCouncilOnComplete } = require('../observe/on-complete');
35
37
 
36
38
  const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
37
39
 
38
40
  /**
39
41
  * @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
40
- * runDir, timeout?, maxCost?, gateway?, noValidateModel?, date, debate?, noCostGate?}
42
+ * runDir, timeout?, maxCost?, gateway?, noValidateModel?, date, debate?, noCostGate?,
43
+ * councilName?, fallback?, catalog?} councilName (v4.3 Task 3) = preset name when
44
+ * launched via `--council <preset>`, else null — threaded via ctx.o into every
45
+ * launchWave/launchSolo for leg ledger attribution. fallback/catalog (v4.3 Task 18
46
+ * §6.2): ctx.o carries both, but only run-stages.js's stage launches read them —
47
+ * the chair/debate legs never substitute via chains.
41
48
  * @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?}
42
49
  * @returns {Promise<{exitCode: number, run: object}>}
43
50
  */
44
51
  async function runCouncil(options, deps = {}) {
45
52
  const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
46
- noCostGate: false, ...options };
47
- const launchers = deps.launchers || createLaunchers();
53
+ noCostGate: false, councilName: null, ...options };
54
+ o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
48
55
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
49
56
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
50
57
  const installSignals = deps.installSignalAbortFn
51
58
  || require('../utils/session-abort').installSignalAbort;
52
59
  const now = () => new Date().toISOString();
53
60
 
54
- const allLegs = [];
55
- const addWave = (wave) => { if (wave && Array.isArray(wave.legs)) { allLegs.push(...wave.legs); } };
56
- const spent = () => {
57
- const c = sumWaveUsage(allLegs).cost;
58
- return typeof c.amount === 'number' ? c.amount : 0;
59
- };
60
- 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 });
61
69
 
62
70
  runState.initRun(o.runDir, {
63
71
  schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
@@ -71,6 +79,7 @@ async function runCouncil(options, deps = {}) {
71
79
  usage: null, pid: process.pid, createdAt: now(),
72
80
  });
73
81
  runState.writePointer(o.project, o.runId, o.runDir);
82
+ emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
74
83
 
75
84
  let signalled = null;
76
85
  const uninstall = installSignals({
@@ -80,17 +89,19 @@ async function runCouncil(options, deps = {}) {
80
89
  },
81
90
  });
82
91
 
83
- const degraded = { value: false };
84
- const finalize = (exitCode, error) => {
92
+ const finalize = async (exitCode, error) => {
85
93
  uninstall();
86
94
  const code = signalled || exitCode;
87
95
  const status = (code === 130 || code === 143) ? 'aborted'
88
96
  : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
97
+ noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
89
98
  const run = runState.checkpoint(o.runDir, {
90
99
  status, exitCode: code, error: error || null,
91
- usage: { cost: sumWaveUsage(allLegs).cost },
100
+ usage: usageBlock(),
92
101
  completedAt: now(),
93
102
  });
103
+ emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
104
+ await fireCouncilOnComplete(o.onComplete, run, { runId: o.runId, runDir: o.runDir, exitCode: code, project: o.project }, o.onCompleteDeps);
94
105
  return { exitCode: code, run };
95
106
  };
96
107
 
@@ -116,11 +127,13 @@ async function runCouncil(options, deps = {}) {
116
127
  status: 'running', startedAt: now(), project: o.runDir,
117
128
  ...(o.lenses ? {} : { waveId: `${o.runId}-s1` }),
118
129
  });
130
+ emitStageStarted(o.runDir, o.runId, 'stage1', o.lenses ? null : `${o.runId}-s1`, o.follow);
119
131
  const s1 = await runStage1(ctx);
120
132
  runState.updateStage(o.runDir, 'stage1', {
121
133
  status: 'complete', completedAt: now(),
122
134
  taskIds: s1.reviews.map(r => (r.leg && r.leg.taskId)).filter(Boolean),
123
135
  });
136
+ emitStageTerminal(o.runDir, o.runId, 'stage1', 'complete', o.lenses ? null : `${o.runId}-s1`, o.follow);
124
137
  if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
125
138
  if (s1.deadLegs.length > 0) { degraded.value = true; } // bench shrank → never a "full run"
126
139
  if (s1.reviews.length < 2) {
@@ -131,6 +144,7 @@ async function runCouncil(options, deps = {}) {
131
144
  }
132
145
 
133
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
134
148
  if (overBudget()) {
135
149
  return finalize(1, {
136
150
  code: 'COST_EXCEEDED',
@@ -151,9 +165,11 @@ async function runCouncil(options, deps = {}) {
151
165
  .concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
152
166
  runState.updateStage(o.runDir, 'stage2',
153
167
  { status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
168
+ emitStageStarted(o.runDir, o.runId, 'stage2', `${o.runId}-s2`, o.follow);
154
169
  const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings,
155
170
  extraLabeled: claudeReview ? [{ label: claudeReview.label, text: claudeReview.text }] : [] });
156
171
  runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
172
+ emitStageTerminal(o.runDir, o.runId, 'stage2', 'complete', `${o.runId}-s2`, o.follow);
157
173
  if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
158
174
  if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
159
175
 
@@ -183,9 +199,12 @@ async function runCouncil(options, deps = {}) {
183
199
  // checkpoint — no ledger append, written before any debate leg launches.
184
200
  fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
185
201
  runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
202
+ emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
203
+ emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
186
204
  const worthDebating = !runDebateMod.nothingToDebate(provisional);
187
205
  if (worthDebating && !overBudget()) {
188
206
  runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
207
+ emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
189
208
  const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
190
209
  // A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
191
210
  // the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
@@ -195,6 +214,7 @@ async function runCouncil(options, deps = {}) {
195
214
  return finalize(dbg.aborted);
196
215
  }
197
216
  runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
217
+ emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
198
218
  // run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
199
219
  // knows whether the wave launched. Never advertise a `-rv` id here: a skipped
200
220
  // re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
@@ -203,6 +223,10 @@ async function runCouncil(options, deps = {}) {
203
223
  // work that never happened.
204
224
  runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
205
225
  ? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
226
+ // debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
227
+ // v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
228
+ emitStageTerminal(o.runDir, o.runId, 'debate-revote',
229
+ dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
206
230
  ({ debatedInput, debateFindings, debateSummary } = dbg);
207
231
  debatedRecord = tally(debatedInput);
208
232
  // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
@@ -258,9 +282,14 @@ async function runCouncil(options, deps = {}) {
258
282
  catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
259
283
  }
260
284
  asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
261
- runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
285
+ const tallyStage = o.debate ? 'tally-final' : 'tally';
286
+ runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
287
+ emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
288
+ emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
262
289
  asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
263
290
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
291
+ emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
292
+ emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
264
293
 
265
294
  return finalize(degraded.value ? 2 : 0);
266
295
  } catch (err) {