amicus 4.2.0 → 4.3.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +37 -1
  3. package/README.md +8 -5
  4. package/bin/amicus.js +5 -0
  5. package/package.json +1 -1
  6. package/schemas/council-run-live.schema.json +33 -0
  7. package/schemas/event.schema.json +15 -0
  8. package/schemas/progress.schema.json +24 -0
  9. package/schemas/run-live.schema.json +15 -0
  10. package/schemas/spend.schema.json +26 -1
  11. package/schemas/wave-live.schema.json +15 -0
  12. package/src/cli-handlers-council-run.js +61 -5
  13. package/src/cli-handlers-run.js +26 -0
  14. package/src/cli-handlers-spend.js +62 -27
  15. package/src/cli-handlers-watch.js +89 -0
  16. package/src/cli.js +58 -1
  17. package/src/council/run-chair.js +10 -2
  18. package/src/council/run-debate.js +5 -1
  19. package/src/council/run-launch.js +14 -1
  20. package/src/council/run-stages.js +13 -0
  21. package/src/council/run.js +32 -4
  22. package/src/headless.js +9 -1
  23. package/src/mcp-council-awareness.js +46 -1
  24. package/src/mcp-council-run.js +28 -4
  25. package/src/mcp-notify.js +54 -0
  26. package/src/mcp-server.js +51 -1
  27. package/src/mcp-spend.js +125 -0
  28. package/src/mcp-tools.js +39 -0
  29. package/src/mcp-wait.js +28 -2
  30. package/src/observe/events.js +156 -0
  31. package/src/observe/follow.js +26 -0
  32. package/src/observe/live-doc.js +38 -0
  33. package/src/observe/on-complete.js +117 -0
  34. package/src/observe/watch-render.js +149 -0
  35. package/src/sidecar/continue.js +32 -0
  36. package/src/sidecar/fallback-chains.js +65 -0
  37. package/src/sidecar/fanout-leg-fallback.js +189 -0
  38. package/src/sidecar/fanout-leg.js +58 -26
  39. package/src/sidecar/fanout-retry.js +208 -0
  40. package/src/sidecar/fanout-validate.js +42 -4
  41. package/src/sidecar/fanout.js +50 -30
  42. package/src/sidecar/progress.js +5 -0
  43. package/src/sidecar/resume.js +12 -0
  44. package/src/sidecar/start.js +13 -1
  45. package/src/spend-query.js +104 -0
  46. package/src/utils/api-key-store.js +7 -4
  47. package/src/utils/env-loader.js +0 -1
  48. package/src/utils/env-raw-store.js +13 -4
  49. package/src/utils/error-classify.js +31 -0
  50. package/src/utils/model-tiers.js +1 -1
  51. package/src/utils/spend-ledger.js +24 -1
@@ -0,0 +1,89 @@
1
+ // src/cli-handlers-watch.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * `amicus watch <id>` (spec 5.1) — render any in-flight (or terminal) run from
6
+ * any process, reading only the data layer (Surfaces A/B/C). This file owns id
7
+ * resolution + the command entry; the pure renderers live in
8
+ * src/observe/watch-render.js (Task 12). No fs.watch — a poll loop over the
9
+ * composed doc (via handlers.amicus_status) + the events tail.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { validateTaskId } = require('./utils/validators');
15
+
16
+ /**
17
+ * Resolve a watch id to a wave / council / solo target (pure over disk).
18
+ * Resolution order (spec 5.1): council pointer file -> council; else session
19
+ * metadata (type:'wave' -> wave, else -> solo); nothing readable -> unknown.
20
+ * Uses the SAME canonical path builders the rest of the codebase resolves
21
+ * sessions/pointers with — readPointer (council/run-state.js) and
22
+ * getSessionDir (session-manager.js) — rather than hand-rolling disk paths,
23
+ * so watch can never drift from how start/status/council resolve ids.
24
+ * @param {string} id
25
+ * @param {string} project
26
+ * @returns {{kind:'wave'|'council'|'solo'|'unknown', id: string, runDir?: string}}
27
+ */
28
+ function resolveWatchTarget(id, project) {
29
+ const clean = String(id).replace(/^council-/, '');
30
+
31
+ const { readPointer } = require('./council/run-state');
32
+ const ptr = readPointer(project, clean);
33
+ if (ptr) { return { kind: 'council', id: clean, runDir: ptr.runDir }; }
34
+
35
+ // getSessionDir THROWS on a path-traversal id ('..' / separators) — this
36
+ // resolver is exported and docblocked "pure over disk", so it must be
37
+ // total for arbitrary input, not just for ids that already passed
38
+ // validateTaskId in the wired CLI path (handleWatch, below). A throw here
39
+ // (traversal or otherwise) falls through to 'unknown' like any other
40
+ // unreadable id, rather than propagating out of a "pure" resolver.
41
+ try {
42
+ const { getSessionDir } = require('./session-manager');
43
+ const metaPath = path.join(getSessionDir(project, clean), 'metadata.json');
44
+ if (fs.existsSync(metaPath)) {
45
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
46
+ return { kind: meta.type === 'wave' ? 'wave' : 'solo', id: clean };
47
+ }
48
+ } catch { /* fall through to unknown */ }
49
+ return { kind: 'unknown', id: clean };
50
+ }
51
+
52
+ /**
53
+ * `amicus watch <id> [--json|--plain] [--interval <sec>] [--project <p>] [--ui]`
54
+ * @param {object} args parsed CLI args
55
+ * @returns {Promise<number>} exit code (render loop: Task 12)
56
+ */
57
+ async function handleWatch(args) {
58
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
59
+ const id = args._[1];
60
+ if (!id || id === true) {
61
+ process.stderr.write('Error: id is required for watch\nUsage: amicus watch <id> [--json] [--plain] [--interval <sec>]\n');
62
+ return 1;
63
+ }
64
+ const check = validateTaskId(String(id));
65
+ if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
66
+
67
+ // --ui is registered as the v4.4 Council Workspace seam only; this rev
68
+ // (v4.3) registers the flag + this fail-fast, the GUI itself is out of
69
+ // scope. --ui alone is accepted and falls through to the loop.
70
+ if (args.ui && args.json) {
71
+ process.stderr.write('Error: --ui is interactive-only and cannot be combined with --json\n');
72
+ return 1;
73
+ }
74
+
75
+ const project = args.project || args.cwd || process.cwd();
76
+ const target = resolveWatchTarget(String(id), project);
77
+ if (target.kind === 'unknown') {
78
+ return failJson(!!args.json, {
79
+ code: ERROR_CODES.BAD_SESSION,
80
+ message: `watch: id '${id}' not found or unreadable in ${project}`,
81
+ hint: 'Pass --project if the run was launched elsewhere.',
82
+ });
83
+ }
84
+
85
+ const { runWatchLoop } = require('./observe/watch-render');
86
+ return runWatchLoop(target, args, project);
87
+ }
88
+
89
+ module.exports = { handleWatch, resolveWatchTarget };
package/src/cli.js CHANGED
@@ -143,6 +143,12 @@ function isBooleanFlag(key) {
143
143
  'render', // council verdict: also refresh report.html next to the decided verdict
144
144
  'claude', // init: register for Claude Code only (Task 15)
145
145
  'desktop', // init: register for Claude Desktop only (Task 15)
146
+ 'failed', // spend: only non-complete (wasted) rows (v4.3 Task 4, spec §7.3)
147
+ 'rows', // spend: include matching raw rows, capped at 1000 (v4.3 Task 4)
148
+ 'plain', // watch: milestone log lines instead of the refresh table (v4.3 Task 11)
149
+ 'ui', // watch: open the Council Workspace window; v4.4 seam (v4.3 Task 11)
150
+ 'follow', // fanout / council run: stream this run's own events to stderr (v4.3 Task 13)
151
+ 'fallback', // fanout / council run: opt-in cheaper-model substitution (v4.3 Task 18, spec 6.2); --no-fallback negates via the generic no-* catch-all below
146
152
  ];
147
153
  return booleanFlags.includes(key);
148
154
  }
@@ -369,6 +375,7 @@ Commands:
369
375
  council verdict <tally.json> [--decisions <d.json>] [-o <out.json>] Build + write verdict.json
370
376
  doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
371
377
  spend [--since 7d] [--json] Cross-run cost rollup from the spend ledger
378
+ watch <id> [--json] [--plain] [--interval <sec>] Live-render a run from any terminal
372
379
  abort Abort a running session (or --all)
373
380
  setup Configure default model and aliases
374
381
  --api-keys Open API key setup window
@@ -431,10 +438,27 @@ Options for 'fanout':
431
438
  ~32KB Windows argument cap). Mutually exclusive
432
439
  with --prompt. Also works with 'start'.
433
440
  --wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
441
+ --retry-failed <waveId> Relaunch ONLY that wave's failed/timed-out/crashed/
442
+ aborted legs as a NEW linked wave, using each leg's
443
+ own saved context (byte-identical retry). Skips
444
+ --prompt/--models; --models filters which failed
445
+ legs to retry. wave.json is never modified.
434
446
  --json Emit the wave result as stable JSON on stdout
435
447
  --max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
436
448
  --no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
449
+ --fallback / --no-fallback Opt-in cheaper-model substitution on a classified
450
+ rate-limit/overload leg failure (spec 6.2). Overrides
451
+ config fallbacks.enabled when passed; default: config, else off.
437
452
  --gateway <mode> Routing: auto (direct-first), direct, or openrouter
453
+ --follow Stream this run's events to stderr as they happen (--json -> NDJSON)
454
+ --on-complete <cmd> Run a shell command once, at terminal state, after
455
+ wave.json is durable. The command is user-authored
456
+ on THIS command line (CLI-only — never sourced from
457
+ config/briefings/model output); payload rides via
458
+ env only (AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/
459
+ RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
460
+ text. Child stdout/stderr go to amicus stderr.
461
+ Never changes the wave's exit code, docs, or events.
438
462
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
439
463
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
440
464
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -519,7 +543,8 @@ Subcommands for 'council':
519
543
  [--chair <model>] [--critic <model>] [--lenses s1,s2,...]
520
544
  [--out-dir <dir>] [--json] [--max-cost <usd>] [--timeout <min>]
521
545
  [--gateway auto|direct|openrouter] [--no-validate-model]
522
- [--debate] [--claude-review <file>] [--no-cost-gate]
546
+ [--debate] [--claude-review <file>] [--no-cost-gate] [--follow]
547
+ [--fallback] [--no-fallback] [--on-complete <cmd>]
523
548
  Run the full headless council engine (v4.0).
524
549
  Chair default: deepseek (must NOT be a bench seat).
525
550
  --critic and --lenses are mutually exclusive.
@@ -527,6 +552,21 @@ Subcommands for 'council':
527
552
  --claude-review <file> enters Claude's own review as
528
553
  a judged entry; --no-cost-gate disables the per-leg
529
554
  price gate for the whole run (repairs + chair).
555
+ --fallback/--no-fallback opts stage legs (Stage-1 +
556
+ Stage-2) into cheaper-model substitution on a
557
+ classified rate-limit/overload failure (spec 6.2);
558
+ the chair never substitutes via chains.
559
+ --follow streams run events to stderr as they
560
+ happen (--json -> NDJSON).
561
+ --on-complete <cmd> runs a shell command once, at
562
+ terminal state, after run.json is durable. The
563
+ command is user-authored on THIS command line
564
+ (CLI-only — never sourced from config/briefings/
565
+ model output); payload rides via env only
566
+ (AMICUS_TASK_ID/TYPE/STATUS/EXIT_CODE/RESULT_FILE/
567
+ EVENTS_FILE/COST/PROJECT), never model text. Child
568
+ stdout/stderr go to amicus stderr. Never changes
569
+ the run's exit code, docs, or events.
530
570
  Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
531
571
  save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
532
572
  --json Machine-readable output
@@ -544,9 +584,26 @@ Options for 'doctor':
544
584
  spend: `
545
585
  Options for 'spend':
546
586
  --since <Nd> Restrict to the last N days (e.g. --since 7d)
587
+ --wave <id> Only rows from this fan-out wave
588
+ --council <runId|name> Only rows from this council run (id or preset name)
589
+ --project <path|.> Only rows from this project ('.' = cwd)
590
+ --model <id-or-prefix> Only rows whose model starts with this
591
+ --op <start|continue|resume|leg> Only rows with this operation
592
+ --failed Only non-complete (wasted) rows
593
+ --group-by <model|wave|council|project|op|day> Rollup dimension (default model)
594
+ --rows Include matching raw rows (capped at 1000)
547
595
  --json Machine-readable output (versioned spend doc)
548
596
  Reads ~/.config/amicus/spend-ledger.jsonl (one row per completed run/leg).
549
597
  Shows remaining OpenRouter credit when a key is configured.
598
+ `,
599
+ watch: `
600
+ Options for 'watch':
601
+ <id> A fan-out wave id, council run id, or session id
602
+ --project <path> Project the run was launched in (default cwd)
603
+ --interval <sec> Refresh interval (default 2, floor 0.5)
604
+ --plain Milestone log lines instead of the refresh table
605
+ --json NDJSON: tailed events + composed doc on change + final doc
606
+ --ui Open the Council Workspace window (v4.4; interactive-only)
550
607
  `,
551
608
  setup: `
552
609
  Options for 'setup':
@@ -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);
@@ -26,7 +26,13 @@ function createLaunchers(deps = {}) {
26
26
 
27
27
  /**
28
28
  * @param {{models: string[], prompt: string, project: string, waveId: string,
29
- * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string}} opts
29
+ * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
30
+ * councilRunId?: string, councilName?: string, fallback?: object, catalog?: Array}} opts
31
+ * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
32
+ * ids forwarded verbatim into the runFanout call so it can stamp them onto
33
+ * every leg. fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
34
+ * additive/opt-in — omitted by callers that must never substitute (the
35
+ * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
30
36
  * @returns {Promise<{wave: object|null, exitCode: number}>}
31
37
  */
32
38
  async function launchWave(opts) {
@@ -43,6 +49,13 @@ function createLaunchers(deps = {}) {
43
49
  includeContext: false,
44
50
  gatewayMode: opts.gateway,
45
51
  noValidateModel: opts.noValidateModel,
52
+ councilRunId: opts.councilRunId,
53
+ councilName: opts.councilName,
54
+ // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
55
+ // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
56
+ // fallback path only activates when `fallback.enabled` is true.
57
+ fallback: opts.fallback,
58
+ catalog: opts.catalog,
46
59
  // v4.1 §4.5d: `--no-cost-gate` is a WHOLE-RUN opt-out (an intentional
47
60
  // o3-class council), so it has to ride every council launch — otherwise
48
61
  // fanout's per-$/Mtok gate refuses the first repair or the chair
@@ -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
@@ -121,6 +128,8 @@ async function runStage1(ctx) {
121
128
  model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
122
129
  project: o.runDir, waveId, timeout: o.timeout,
123
130
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
131
+ councilRunId: o.runId, councilName: o.councilName,
132
+ fallback: o.fallback, catalog: o.catalog,
124
133
  });
125
134
  ctx.addWave(solo.wave);
126
135
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
@@ -170,6 +179,8 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
170
179
  models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
171
180
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
172
181
  noCostGate: o.noCostGate,
182
+ councilRunId: o.runId, councilName: o.councilName,
183
+ fallback: o.fallback, catalog: o.catalog,
173
184
  });
174
185
  ctx.addWave(wave);
175
186
  if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
@@ -195,6 +206,8 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
195
206
  model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
196
207
  project: ctx.scratchDir, waveId, timeout: o.timeout,
197
208
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
209
+ councilRunId: o.runId, councilName: o.councilName,
210
+ fallback: o.fallback, catalog: o.catalog,
198
211
  });
199
212
  ctx.addWave(solo.wave);
200
213
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
@@ -32,18 +32,26 @@ const { buildDebateAddendum } = require('./briefings-debate');
32
32
  const { decorateRecord } = require('./debate');
33
33
  const asm = require('./run-assemble');
34
34
  const { sumWaveUsage } = require('../utils/pricing');
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 };
53
+ noCostGate: false, councilName: null, ...options };
54
+ o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
47
55
  const launchers = deps.launchers || createLaunchers();
48
56
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
49
57
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
@@ -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({
@@ -81,7 +90,7 @@ async function runCouncil(options, deps = {}) {
81
90
  });
82
91
 
83
92
  const degraded = { value: false };
84
- const finalize = (exitCode, error) => {
93
+ const finalize = async (exitCode, error) => {
85
94
  uninstall();
86
95
  const code = signalled || exitCode;
87
96
  const status = (code === 130 || code === 143) ? 'aborted'
@@ -91,6 +100,8 @@ async function runCouncil(options, deps = {}) {
91
100
  usage: { cost: sumWaveUsage(allLegs).cost },
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) {
@@ -151,9 +164,11 @@ async function runCouncil(options, deps = {}) {
151
164
  .concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
152
165
  runState.updateStage(o.runDir, 'stage2',
153
166
  { status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
167
+ emitStageStarted(o.runDir, o.runId, 'stage2', `${o.runId}-s2`, o.follow);
154
168
  const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings,
155
169
  extraLabeled: claudeReview ? [{ label: claudeReview.label, text: claudeReview.text }] : [] });
156
170
  runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
171
+ emitStageTerminal(o.runDir, o.runId, 'stage2', 'complete', `${o.runId}-s2`, o.follow);
157
172
  if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
158
173
  if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
159
174
 
@@ -183,9 +198,12 @@ async function runCouncil(options, deps = {}) {
183
198
  // checkpoint — no ledger append, written before any debate leg launches.
184
199
  fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
185
200
  runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
201
+ emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
202
+ emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
186
203
  const worthDebating = !runDebateMod.nothingToDebate(provisional);
187
204
  if (worthDebating && !overBudget()) {
188
205
  runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
206
+ emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
189
207
  const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
190
208
  // A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
191
209
  // the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
@@ -195,6 +213,7 @@ async function runCouncil(options, deps = {}) {
195
213
  return finalize(dbg.aborted);
196
214
  }
197
215
  runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
216
+ emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
198
217
  // run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
199
218
  // knows whether the wave launched. Never advertise a `-rv` id here: a skipped
200
219
  // re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
@@ -203,6 +222,10 @@ async function runCouncil(options, deps = {}) {
203
222
  // work that never happened.
204
223
  runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
205
224
  ? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
225
+ // debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
226
+ // v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
227
+ emitStageTerminal(o.runDir, o.runId, 'debate-revote',
228
+ dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
206
229
  ({ debatedInput, debateFindings, debateSummary } = dbg);
207
230
  debatedRecord = tally(debatedInput);
208
231
  // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
@@ -258,9 +281,14 @@ async function runCouncil(options, deps = {}) {
258
281
  catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
259
282
  }
260
283
  asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
261
- runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
284
+ const tallyStage = o.debate ? 'tally-final' : 'tally';
285
+ runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
286
+ emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
287
+ emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
262
288
  asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
263
289
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
290
+ emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
291
+ emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
264
292
 
265
293
  return finalize(degraded.value ? 2 : 0);
266
294
  } catch (err) {
package/src/headless.js CHANGED
@@ -448,7 +448,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
448
448
 
449
449
  const mr = mirrorMessages(messages, mirror);
450
450
  mr.appendLines.forEach(line => logMessage(conversationPath, line));
451
- mr.progressUpdates.forEach(p => writeProgress(sessionDir, p.stage, p.extra));
451
+ // Surface A (spec §4.1): stamp raw usage on each 'receiving' flush from the
452
+ // PERSISTENT mirror.usageByMsg Map (accumulated across polls) — NOT
453
+ // mr.usageByMsg, which doesn't exist on mirrorMessages()'s per-poll delta.
454
+ // Cost resolution happens at read time (Task 9); the writer stays cheap.
455
+ const { sumPerMessageUsage } = require('./utils/pricing');
456
+ mr.progressUpdates.forEach(p => writeProgress(
457
+ sessionDir, p.stage,
458
+ p.stage === 'receiving' ? { ...p.extra, usage: sumPerMessageUsage(mirror.usageByMsg) } : p.extra,
459
+ ));
452
460
  const currentAssistantMsgId = mr.currentAssistantMsgId;
453
461
  const assistantFinished = mr.assistantFinished;
454
462
  if (mr.sessionError) {
@@ -16,6 +16,7 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
  const runState = require('./council/run-state');
18
18
  const { RUNNING_VERSION } = require('./utils/version-info');
19
+ const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
19
20
 
20
21
  /**
21
22
  * Every wave a stage launched: the primary `waveId` plus the recorded
@@ -58,6 +59,41 @@ function countWaveLegs(project, waveId) {
58
59
  return { total: legs.length, complete };
59
60
  }
60
61
 
62
+ /**
63
+ * Leg ids recorded on a sub-wave's metadata.json, or [] when the wave record
64
+ * is absent/malformed. A small sibling to countWaveLegs — kept separate so
65
+ * that helper's {total, complete} contract (other callers depend on it) isn't
66
+ * overloaded into returning ids too.
67
+ * @returns {string[]}
68
+ */
69
+ function waveLegIds(project, waveId) {
70
+ const { getSessionDir } = require('./session-manager');
71
+ let legs;
72
+ try {
73
+ legs = JSON.parse(fs.readFileSync(
74
+ path.join(getSessionDir(project, waveId), 'metadata.json'), 'utf-8')).legs;
75
+ } catch { return []; }
76
+ return Array.isArray(legs) ? legs : [];
77
+ }
78
+
79
+ /**
80
+ * Read-time cost-by-seat for one leg (A8: progress.json only, never a ledger).
81
+ * Tolerates a leg with no progress.usage yet — contributes nothing (N3).
82
+ */
83
+ function legUsage(project, legId) {
84
+ const { getSessionDir } = require('./session-manager');
85
+ const { readProgress } = require('./sidecar/progress');
86
+ let model = null;
87
+ try {
88
+ model = JSON.parse(fs.readFileSync(
89
+ path.join(getSessionDir(project, legId), 'metadata.json'), 'utf-8')).model || null;
90
+ } catch { /* leg metadata not written yet */ }
91
+ let progressUsage;
92
+ try { progressUsage = readProgress(getSessionDir(project, legId)).usage; }
93
+ catch { /* no progress.json yet */ }
94
+ return enrichLegUsage({ model }, progressUsage);
95
+ }
96
+
61
97
  function elapsedOf(run) {
62
98
  const end = run.completedAt || new Date().toISOString();
63
99
  const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
@@ -94,11 +130,19 @@ function buildCouncilStatusPayload(project, taskId) {
94
130
  // Sum across every sub-wave the active stage launched: a lens stage1 has no
95
131
  // seat wave at all, and a critic solo runs beside one. Stays null until at
96
132
  // least one sub-wave record exists on disk.
133
+ // Cost-by-seat rides the same loop, read-time from progress.json only (A8) —
134
+ // usageLegs stays empty (no `usage` on the payload) until a leg has actually
135
+ // flushed usage; a leg with none yet contributes nothing (N3).
136
+ const usageLegs = [];
97
137
  for (const waveId of active && active.project ? subWaveIds(active) : []) {
98
138
  const c = countWaveLegs(active.project, waveId);
99
139
  if (!c) { continue; }
100
140
  legsTotal = (legsTotal || 0) + c.total;
101
141
  legsComplete = (legsComplete || 0) + c.complete;
142
+ for (const legId of waveLegIds(active.project, waveId)) {
143
+ const enriched = legUsage(active.project, legId);
144
+ if (enriched.usage) { usageLegs.push(enriched); }
145
+ }
102
146
  }
103
147
  const payload = {
104
148
  taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
@@ -107,8 +151,9 @@ function buildCouncilStatusPayload(project, taskId) {
107
151
  exitCode: run.exitCode !== undefined ? run.exitCode : null,
108
152
  version: RUNNING_VERSION,
109
153
  };
154
+ if (usageLegs.length) { payload.usage = rollupWaveUsage(usageLegs); }
110
155
  if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
111
- return payload;
156
+ return markLive(payload);
112
157
  }
113
158
 
114
159
  /** amicus_list entries for every council pointer in the project. */
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const runState = require('./council/run-state');
17
17
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
18
  const { isPathInside } = require('./project-root-allowlist');
19
+ const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
19
20
 
20
21
  function textResult(text, isError) {
21
22
  const result = { content: [{ type: 'text', text }] };
@@ -23,7 +24,14 @@ function textResult(text, isError) {
23
24
  return result;
24
25
  }
25
26
 
26
- /** Resolve the bench: models XOR council preset (amicus_fanout parity). */
27
+ /**
28
+ * Resolve the bench: models XOR council preset (amicus_fanout parity).
29
+ * Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
30
+ * preset name when that branch was taken, else null — this handler always
31
+ * spawns the CLI child with an already-expanded `--models` list (never
32
+ * `--council`), so the preset name would otherwise be lost; the caller
33
+ * forwards it via the internal `--council-name` passthrough instead.
34
+ */
27
35
  function resolveBenchInput(input) {
28
36
  const inputModels = Array.isArray(input.models) ? input.models : [];
29
37
  const hasModels = inputModels.length > 0;
@@ -34,11 +42,12 @@ function resolveBenchInput(input) {
34
42
  const { resolveCouncilMembers } = require('./utils/config');
35
43
  const { readCache } = require('./utils/model-catalog');
36
44
  const catalog = (readCache() || {}).models || [];
37
- const expanded = resolveCouncilMembers(input.council.trim(), catalog);
45
+ const presetName = input.council.trim();
46
+ const expanded = resolveCouncilMembers(presetName, catalog);
38
47
  if (expanded.error) { return { error: expanded.error }; }
39
- return { bench: expanded.models };
48
+ return { bench: expanded.models, presetName };
40
49
  }
41
- return { bench: inputModels };
50
+ return { bench: inputModels, presetName: null };
42
51
  }
43
52
 
44
53
  /**
@@ -49,6 +58,12 @@ function resolveBenchInput(input) {
49
58
  * @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
50
59
  */
51
60
  async function handleCouncilRunTool(input, project, helpers) {
61
+ // Task 15 (spec §5.3): validate onComplete FIRST, before any run dir is
62
+ // prepared — exec strings are rejected over MCP (the Zod enum on the tool
63
+ // def already rejects them at the call boundary; this is defense-in-depth
64
+ // for any caller that bypasses schema validation).
65
+ const oc = validateOnComplete(input.onComplete);
66
+ if (!oc.ok) { return textResult(oc.error, true); }
52
67
  const CHAIR_DEFAULT = 'deepseek';
53
68
  if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
54
69
  return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
@@ -62,6 +77,7 @@ async function handleCouncilRunTool(input, project, helpers) {
62
77
  const benchRes = resolveBenchInput(input);
63
78
  if (benchRes.error) { return textResult(benchRes.error, true); }
64
79
  const bench = benchRes.bench;
80
+ const presetName = benchRes.presetName;
65
81
  if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
66
82
  const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
67
83
  if (bench.includes(chair)) {
@@ -123,6 +139,10 @@ async function handleCouncilRunTool(input, project, helpers) {
123
139
  if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
124
140
  if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
125
141
  if (input.gateway) { args.push('--gateway', input.gateway); }
142
+ // v4.3 Task 3 (spec §7.1): the bench above is already expanded, so `--council`
143
+ // itself is never spawned (it would collide with `--models`) — this internal,
144
+ // undocumented flag carries the preset NAME through for attribution only.
145
+ if (presetName) { args.push('--council-name', presetName); }
126
146
  // v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
127
147
  // reason outDir is — an MCP client may send a relative path, and the child's cwd
128
148
  // is the run dir. Validation of the file itself stays in the spawned engine's
@@ -145,6 +165,10 @@ async function handleCouncilRunTool(input, project, helpers) {
145
165
  // read-merge-write has no lock (see run-state.writeSpawnPid).
146
166
  try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
147
167
  catch { /* best-effort */ }
168
+ // Task 15 (spec §5.3): the run is now known-launched under runId — mark it
169
+ // for a best-effort terminal notify. runWait's poll loop (mcp-wait.js) is
170
+ // the only code that later sees this council run reach terminal state.
171
+ if (oc.mode === 'mcp-notify') { requestMcpNotify(runId); }
148
172
 
149
173
  const body = JSON.stringify({
150
174
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',