amicus 4.2.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +7 -4
- package/bin/amicus.js +5 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +33 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +24 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/src/cli-handlers-council-run.js +61 -5
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +62 -27
- package/src/cli-handlers-watch.js +89 -0
- package/src/cli.js +58 -1
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +14 -1
- package/src/council/run-stages.js +13 -0
- package/src/council/run.js +32 -4
- package/src/headless.js +9 -1
- package/src/mcp-council-awareness.js +46 -1
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +38 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +149 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +58 -26
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +50 -30
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/spend-query.js +104 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- 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':
|
package/src/council/run-chair.js
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
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
|
|
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 }; }
|
package/src/council/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/mcp-council-run.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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',
|