amicus 4.5.4 → 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +113 -0
- package/README.md +1 -1
- package/commands/council.md +1 -1
- package/docs/DISTRIBUTION.md +38 -11
- package/docs/ROADMAP.md +40 -8
- package/docs/publishing.md +1 -1
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-run.schema.json +20 -0
- package/schemas/council-verdict.schema.json +20 -0
- package/schemas/doctor.schema.json +23 -1
- package/skills/second-opinion/MODEL-NOTES.md +182 -35
- package/src/cli-council-run-render.js +51 -0
- package/src/cli-handlers-council-run.js +45 -44
- package/src/cli-handlers-council.js +9 -3
- package/src/cli-handlers-doctor.js +16 -37
- package/src/cli-handlers-watch.js +1 -1
- package/src/cli.js +1 -1
- package/src/council/ledger.js +5 -1
- package/src/council/report-html.js +16 -1
- package/src/council/report.js +25 -1
- package/src/council/run-assemble.js +25 -7
- package/src/council/run-budget.js +14 -8
- package/src/council/run-chair.js +21 -4
- package/src/council/run-debate-stage.js +115 -0
- package/src/council/run-degrade.js +44 -0
- package/src/council/run-finalize.js +18 -3
- package/src/council/run-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-server.js +24 -7
- package/src/council/run-stage2.js +10 -2
- package/src/council/run-stages.js +59 -27
- package/src/council/run.js +39 -67
- package/src/council/verdict.js +81 -8
- package/src/mcp-council-bench.js +45 -0
- package/src/mcp-council-run.js +11 -28
- package/src/mcp-server.js +22 -3
- package/src/mcp-tools.js +13 -1
- package/src/utils/degrade.js +69 -0
- package/src/utils/doctor-degrade.js +51 -0
- package/src/utils/doctor-electron-mcp-check.js +64 -5
- package/src/utils/doctor-engine-check.js +14 -3
- package/src/utils/doctor-mcp-checks.js +10 -3
- package/src/utils/known-flags.js +2 -1
- package/src/utils/remediation-hints.js +20 -14
- package/src/utils/result-schema.js +6 -2
- package/src/utils/session-index-tmp-sweep.js +2 -1
- package/src/utils/update-notice.js +171 -0
- package/src/workspace/run-scan.js +5 -1
package/src/council/run.js
CHANGED
|
@@ -26,7 +26,7 @@ const runState = require('./run-state');
|
|
|
26
26
|
const { createLaunchers } = require('./run-launch');
|
|
27
27
|
const { runStage1, runStage2 } = require('./run-stages'); // stage 2 lives in ./run-stage2 (300-line gate), re-exported there
|
|
28
28
|
const { runChair, pickFallbackChair } = require('./run-chair');
|
|
29
|
-
const
|
|
29
|
+
const { runDebateStage } = require('./run-debate-stage'); // debate orchestration lives there (300-line gate), extracted from here (v4.6 Plan 1 Task 1)
|
|
30
30
|
const { decorateRecord } = require('./debate');
|
|
31
31
|
const asm = require('./run-assemble');
|
|
32
32
|
const { createBudget } = require('./run-budget');
|
|
@@ -62,8 +62,9 @@ async function runCouncil(options, deps = {}) {
|
|
|
62
62
|
// across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
|
|
63
63
|
// why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
|
|
64
64
|
const degraded = { value: false };
|
|
65
|
+
const degrade = require('./run-degrade').createDegradeSink({ runDir: o.runDir, degraded });
|
|
65
66
|
const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
|
|
66
|
-
noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir,
|
|
67
|
+
noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degrade });
|
|
67
68
|
// v4.4.1 Task 0.5: ONE OpenCode server for the whole run — ./run-server carries
|
|
68
69
|
// the why and the evidence that `_scratch` judge isolation survives it. Acquired
|
|
69
70
|
// below (a getter, because the launchers are built first); null = as before.
|
|
@@ -72,6 +73,20 @@ async function runCouncil(options, deps = {}) {
|
|
|
72
73
|
|| createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer });
|
|
73
74
|
|
|
74
75
|
runState.initCouncilRun(o); // run.json seed + sessions-dir pointer (run-state.js)
|
|
76
|
+
|
|
77
|
+
// dropped-members (spec §5, Plan 4): a seat the user's preset requested that
|
|
78
|
+
// never resolved is a lost seat — announced like every other loss. Fires
|
|
79
|
+
// once per member, before any launch (zero spend), for BOTH transports.
|
|
80
|
+
for (const dm of o.droppedMembers || []) {
|
|
81
|
+
degrade.note({
|
|
82
|
+
channel: 'dropped-members',
|
|
83
|
+
what: `seat ${dm.member} was not seated`,
|
|
84
|
+
why: dm.reason,
|
|
85
|
+
effect: 'the bench is smaller than the preset requested; the run will exit degraded (2)',
|
|
86
|
+
data: { member: dm.member, reason: dm.reason },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
|
|
76
91
|
|
|
77
92
|
let signalled = null;
|
|
@@ -93,15 +108,15 @@ async function runCouncil(options, deps = {}) {
|
|
|
93
108
|
const claimed = sharedServer;
|
|
94
109
|
sharedServer = null;
|
|
95
110
|
await require('./run-server').releaseRunServer(claimed);
|
|
96
|
-
const code = resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling });
|
|
111
|
+
const code = resolveTerminalExit({ signalled, exitCode, degraded, degrade, inexactUnderCeiling });
|
|
97
112
|
const run = await writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock });
|
|
98
113
|
return { exitCode: code, run };
|
|
99
114
|
};
|
|
100
115
|
|
|
101
116
|
// Injected launchers bring their own transport. Never throws — degrades to null.
|
|
102
|
-
if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer(o, deps); }
|
|
117
|
+
if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer({ ...o, degrade }, deps); }
|
|
103
118
|
|
|
104
|
-
const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
|
|
119
|
+
const ctx = { o, launchers, addWave, overBudget, degrade, scratchDir: path.join(o.runDir, '_scratch') };
|
|
105
120
|
|
|
106
121
|
try {
|
|
107
122
|
// v4.1 §4.4: Claude-in-council is a FILE input — validated after initRun (so the
|
|
@@ -139,7 +154,6 @@ async function runCouncil(options, deps = {}) {
|
|
|
139
154
|
});
|
|
140
155
|
emitStageTerminal(o.runDir, o.runId, 'stage1', s1Status, o.lenses ? null : `${o.runId}-s1`, o.follow);
|
|
141
156
|
if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
|
|
142
|
-
if (s1.degraded) { degraded.value = true; } // bench shrank → never a "full run"
|
|
143
157
|
if (s1.reviews.length < 2) {
|
|
144
158
|
return finalize(1, {
|
|
145
159
|
code: 'COUNCIL_QUORUM',
|
|
@@ -175,7 +189,15 @@ async function runCouncil(options, deps = {}) {
|
|
|
175
189
|
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
176
190
|
emitStageTerminal(o.runDir, o.runId, 'stage2', 'complete', `${o.runId}-s2`, o.follow);
|
|
177
191
|
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
178
|
-
|
|
192
|
+
const usableJudges = s2.judgeResults.filter(j => j.ok).length;
|
|
193
|
+
if (usableJudges < 2) {
|
|
194
|
+
degrade.note({
|
|
195
|
+
channel: 'thin-cross-review',
|
|
196
|
+
what: `only ${usableJudges} of ${s2.judgeResults.length} judges returned a usable cross-review`,
|
|
197
|
+
why: 'the other judges produced no parseable Stage-2 block',
|
|
198
|
+
effect: 'findings were tiered on a thinner cross-review than the bench size implies; will exit degraded (2)',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
179
201
|
|
|
180
202
|
// Merge Stage-2 judging conformance into each seat's row (worst wins).
|
|
181
203
|
const byJudge = new Map(s2.judgeResults.map(j => [j.judge, j]));
|
|
@@ -193,64 +215,12 @@ async function runCouncil(options, deps = {}) {
|
|
|
193
215
|
const provisional = tally(provisionalInput);
|
|
194
216
|
|
|
195
217
|
// ---- Stage 2.5: debate (optional, spec §5.1) ----
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
// spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
|
|
203
|
-
// checkpoint — no ledger append, written before any debate leg launches.
|
|
204
|
-
fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
|
|
205
|
-
runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
|
|
206
|
-
emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
|
|
207
|
-
emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
|
|
208
|
-
const worthDebating = !runDebateMod.nothingToDebate(provisional);
|
|
209
|
-
if (worthDebating && !overBudget()) {
|
|
210
|
-
runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
|
|
211
|
-
emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
|
|
212
|
-
const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
|
|
213
|
-
// A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
|
|
214
|
-
// the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
|
|
215
|
-
if (dbg.aborted) {
|
|
216
|
-
runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
|
|
217
|
-
contested: dbg.contested, disputed: dbg.disputed } });
|
|
218
|
-
return finalize(dbg.aborted);
|
|
219
|
-
}
|
|
220
|
-
runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
|
|
221
|
-
emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
|
|
222
|
-
// run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
|
|
223
|
-
// knows whether the wave launched. Never advertise a `-rv` id here: a skipped
|
|
224
|
-
// re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
|
|
225
|
-
// Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
|
|
226
|
-
// defended/amended or the cost ceiling skipped it — 'complete' would report
|
|
227
|
-
// work that never happened.
|
|
228
|
-
runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
|
|
229
|
-
? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
|
|
230
|
-
// debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
|
|
231
|
-
// v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
|
|
232
|
-
emitStageTerminal(o.runDir, o.runId, 'debate-revote',
|
|
233
|
-
dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
|
|
234
|
-
({ debatedInput, debateFindings, debateSummary } = dbg);
|
|
235
|
-
debatedRecord = tally(debatedInput);
|
|
236
|
-
// Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
|
|
237
|
-
// list must be normalized to null here — otherwise the packet-assembly
|
|
238
|
-
// ternary below still calls buildDebateAddendum({outcomes: []}), which
|
|
239
|
-
// emits a bare "--- Debate round outcomes ---" heading with nothing
|
|
240
|
-
// under it (same defect class ee447b6 fixed on the report renderer).
|
|
241
|
-
debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
|
|
242
|
-
? dbg.addendumOutcomes : null;
|
|
243
|
-
// Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
|
|
244
|
-
// each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
|
|
245
|
-
if (dbg.degraded) { degraded.value = true; }
|
|
246
|
-
} else if (worthDebating) {
|
|
247
|
-
// Budget gone before the defense wave launched, but there WAS something to debate — the
|
|
248
|
-
// other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
|
|
249
|
-
debateSummary.outcome = 'skipped-cost-ceiling';
|
|
250
|
-
degraded.value = true;
|
|
251
|
-
}
|
|
252
|
-
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
253
|
-
}
|
|
218
|
+
const { debatedInput, debatedRecord, debateOutcomes, debateFindings, aborted: debateAborted } =
|
|
219
|
+
await runDebateStage(ctx, { provisional, provisionalInput, overBudget });
|
|
220
|
+
// Mirrors the `if (signalled || s1.aborted)` / `if (signalled || s2.aborted)` guards
|
|
221
|
+
// above: run-debate-stage.js can't reach this closure's `finalize`, so it hands the
|
|
222
|
+
// signal back here instead (see its docblock) and we finalize on its behalf.
|
|
223
|
+
if (debateAborted) { return finalize(debateAborted); }
|
|
254
224
|
|
|
255
225
|
const packet = asm.buildChairPacketFile({
|
|
256
226
|
runDir: o.runDir, reviews: s1.reviews, claudeReview, date: o.date,
|
|
@@ -258,7 +228,7 @@ async function runCouncil(options, deps = {}) {
|
|
|
258
228
|
});
|
|
259
229
|
|
|
260
230
|
const chairRes = await runChair(ctx, {
|
|
261
|
-
packet,
|
|
231
|
+
packet, degrade, statsFn, isSignalled: () => signalled,
|
|
262
232
|
});
|
|
263
233
|
if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
|
|
264
234
|
const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
|
|
@@ -284,8 +254,10 @@ async function runCouncil(options, deps = {}) {
|
|
|
284
254
|
runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
|
|
285
255
|
emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
|
|
286
256
|
emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
|
|
257
|
+
// Verdict assembly is the degrade cut-off: anything noted after this line
|
|
258
|
+
// reaches stderr + run.json but not verdict.json (spec §6 rule 1).
|
|
287
259
|
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
288
|
-
critic: o.critic, deadWaves });
|
|
260
|
+
critic: o.critic, deadWaves, degrades: degrade.all() });
|
|
289
261
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
290
262
|
emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
|
|
291
263
|
emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
|
package/src/council/verdict.js
CHANGED
|
@@ -9,13 +9,6 @@ const { parseChairVerdict } = require('./parse-stage2');
|
|
|
9
9
|
// engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
|
|
10
10
|
const VERDICT_SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
14
|
-
* @param {object} record tally() output
|
|
15
|
-
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
16
|
-
* @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
|
|
17
|
-
* parsed chair `VERDICT:` line; omitted/undefined → null.
|
|
18
|
-
*/
|
|
19
12
|
/**
|
|
20
13
|
* Describe which requested seats actually reviewed, for the verdict's own face.
|
|
21
14
|
*
|
|
@@ -54,6 +47,57 @@ function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
|
|
|
54
47
|
};
|
|
55
48
|
}
|
|
56
49
|
|
|
50
|
+
/**
|
|
51
|
+
* seatLoss, derived from the sink's records (v4.6 Plan 2, spec D3 — closes #84).
|
|
52
|
+
*
|
|
53
|
+
* WHY A DERIVATION: two fields reporting lost seats can disagree; deriving one
|
|
54
|
+
* from the other makes contradiction inexpressible. `summarizeSeatLoss` stays
|
|
55
|
+
* exactly as v4.5.2 shipped it (its tests pass unedited — that is the proof the
|
|
56
|
+
* shape survived); this function rebuilds its wave input from `dead-wave`
|
|
57
|
+
* records and then adds the losses waves can never show: `dead-leg` records —
|
|
58
|
+
* a solo critic wave that STARTED but whose one leg died is invisible to
|
|
59
|
+
* deadWaves, which is #84's second half.
|
|
60
|
+
*
|
|
61
|
+
* Reads ONLY record.data (Task 1's machine surface) — never the prose fields.
|
|
62
|
+
* @param {{runId: string, critic: ?string, degrades: Array<object>}} o
|
|
63
|
+
* @returns {?object} the summarizeSeatLoss shape, or null when no critic was requested
|
|
64
|
+
*/
|
|
65
|
+
function deriveSeatLoss({ runId, critic, degrades = [] } = {}) {
|
|
66
|
+
if (!critic) { return null; }
|
|
67
|
+
const real = degrades.filter(d => d.kind !== 'heal' && d.data);
|
|
68
|
+
const waves = real.filter(d => d.channel === 'dead-wave')
|
|
69
|
+
.map(d => ({ waveId: d.data.waveId, models: d.data.models || [], reason: d.data.reason }));
|
|
70
|
+
const base = summarizeSeatLoss({ runId, critic, deadWaves: waves });
|
|
71
|
+
const legs = real.filter(d => d.channel === 'dead-leg');
|
|
72
|
+
const criticLeg = legs.find(l => l.data.seat === critic) || null;
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
criticSeated: base.criticSeated && !criticLeg,
|
|
76
|
+
// SL-2 handoff: a reconciliation note (run-retry-notes.js's
|
|
77
|
+
// missingLegStillDeadNote) carries data.status: null when the retry
|
|
78
|
+
// produced no leg for the seat at all — there is no status to name, so
|
|
79
|
+
// the old `ended '${status}'` template rendered the literal string
|
|
80
|
+
// "ended 'null'". A status-carrying record keeps the original text.
|
|
81
|
+
reason: base.reason || (criticLeg
|
|
82
|
+
? (criticLeg.data.reason || (criticLeg.data.status
|
|
83
|
+
? `the critic leg ended '${criticLeg.data.status}' with no usable output`
|
|
84
|
+
: 'the critic leg produced no usable output'))
|
|
85
|
+
: null),
|
|
86
|
+
deadBenchSeats: [...base.deadBenchSeats,
|
|
87
|
+
...legs.filter(l => l.data.seat !== critic).map(l => l.data.seat)],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
93
|
+
* @param {object} record tally() output
|
|
94
|
+
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
95
|
+
* @param {{overallVerdict?: (string|null), seatLoss?: object, degrades?: Array<object>}} [opts]
|
|
96
|
+
* `overallVerdict` is the engine hook (Plan B): the parsed chair `VERDICT:`
|
|
97
|
+
* line; omitted/undefined → null. `seatLoss` (v4.5.2) and `degrades` (v4.6
|
|
98
|
+
* Plan 2) are additive and OPTIONAL — each lands on the verdict only when
|
|
99
|
+
* truthy/non-empty, absent otherwise (never fabricated).
|
|
100
|
+
*/
|
|
57
101
|
function buildVerdict(record, decisions = [], opts = {}) {
|
|
58
102
|
const byId = new Map(decisions.map(d => [d.id, d]));
|
|
59
103
|
return {
|
|
@@ -87,6 +131,11 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
87
131
|
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
|
88
132
|
// was requested, so its absence never has to be interpreted.
|
|
89
133
|
...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
|
|
134
|
+
// v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
|
|
135
|
+
// OPTIONAL — present only when the run actually degraded, so a clean run's
|
|
136
|
+
// verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
|
|
137
|
+
// seatLoss precedent).
|
|
138
|
+
...(opts.degrades && opts.degrades.length ? { degrades: opts.degrades } : {}),
|
|
90
139
|
};
|
|
91
140
|
}
|
|
92
141
|
|
|
@@ -127,6 +176,29 @@ function readOverallVerdict(runDir, runId) {
|
|
|
127
176
|
return null;
|
|
128
177
|
}
|
|
129
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Recover the additive loss surfaces for a Stage-5 rebuild (#87, v4.6 Plan 4).
|
|
181
|
+
* Same contract as readOverallVerdict directly above: the run folder's own
|
|
182
|
+
* verdict.json is the only source, a foreign runId never leaks, and absence
|
|
183
|
+
* yields nulls — the rebuild preserves, never invents. tally.json carries
|
|
184
|
+
* neither field, which is why the pre-#87 rebuild silently destroyed both.
|
|
185
|
+
* @param {string} runDir
|
|
186
|
+
* @param {string} [runId]
|
|
187
|
+
* @returns {{seatLoss: (object|null), degrades: (Array<object>|null)}}
|
|
188
|
+
*/
|
|
189
|
+
function readPriorVerdictSurfaces(runDir, runId) {
|
|
190
|
+
try {
|
|
191
|
+
const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
|
|
192
|
+
if (!runId || prior.runId === runId) {
|
|
193
|
+
return {
|
|
194
|
+
seatLoss: (prior.seatLoss && typeof prior.seatLoss === 'object') ? prior.seatLoss : null,
|
|
195
|
+
degrades: Array.isArray(prior.degrades) && prior.degrades.length ? prior.degrades : null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
} catch { /* no prior verdict.json, or unreadable — nothing to preserve */ }
|
|
199
|
+
return { seatLoss: null, degrades: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
130
202
|
/** Atomic write: tmp + rename (matches the repo's wave.json convention). */
|
|
131
203
|
function writeVerdictAtomic(filePath, verdict) {
|
|
132
204
|
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
@@ -135,5 +207,6 @@ function writeVerdictAtomic(filePath, verdict) {
|
|
|
135
207
|
}
|
|
136
208
|
|
|
137
209
|
module.exports = {
|
|
138
|
-
buildVerdict, summarizeSeatLoss,
|
|
210
|
+
buildVerdict, summarizeSeatLoss, deriveSeatLoss, readOverallVerdict, readPriorVerdictSurfaces,
|
|
211
|
+
writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
|
|
139
212
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/mcp-council-bench.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module mcp-council-bench
|
|
6
|
+
* Bench resolution for `amicus_council_run` (models XOR council preset). Split
|
|
7
|
+
* out of mcp-council-run.js (v4.6 Plan 4 Task 4b): that file sat at 298/300
|
|
8
|
+
* lines and the --dropped-members producer (the MCP→child transport-parity
|
|
9
|
+
* fix) needed the room. `resolveBenchInput` is self-contained — no dependency
|
|
10
|
+
* on the handler's validation/spawn-argv logic — so it moves verbatim to its
|
|
11
|
+
* own leaf; the old home (mcp-council-run.js) requires it back, so its one
|
|
12
|
+
* call site keeps working unchanged. Never part of mcp-council-run.js's
|
|
13
|
+
* module.exports (an internal helper, not a re-exported API), so no re-export
|
|
14
|
+
* shim is needed there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
19
|
+
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
20
|
+
* preset name when that branch was taken, else null — this handler always
|
|
21
|
+
* spawns the CLI child with an already-expanded `--models` list (never
|
|
22
|
+
* `--council`), so the preset name would otherwise be lost; the caller
|
|
23
|
+
* forwards it via the internal `--council-name` passthrough instead.
|
|
24
|
+
*/
|
|
25
|
+
function resolveBenchInput(input) {
|
|
26
|
+
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
27
|
+
const hasModels = inputModels.length > 0;
|
|
28
|
+
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
29
|
+
if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
|
|
30
|
+
if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
|
|
31
|
+
if (hasCouncil) {
|
|
32
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
33
|
+
const { readCache } = require('./utils/model-catalog');
|
|
34
|
+
const catalog = (readCache() || {}).models || [];
|
|
35
|
+
const presetName = input.council.trim();
|
|
36
|
+
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
37
|
+
if (expanded.error) { return { error: expanded.error }; }
|
|
38
|
+
// v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
|
|
39
|
+
// to --models) — the pre-seed below is the only place this is recorded.
|
|
40
|
+
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
41
|
+
}
|
|
42
|
+
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { resolveBenchInput };
|
package/src/mcp-council-run.js
CHANGED
|
@@ -17,6 +17,9 @@ const runState = require('./council/run-state');
|
|
|
17
17
|
const { fenceSidecarOutput } = require('./utils/untrusted-fence');
|
|
18
18
|
const { isPathInside } = require('./project-root-allowlist');
|
|
19
19
|
const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
|
|
20
|
+
// v4.6 Plan 4 Task 4b: resolveBenchInput moved to its own leaf (size gate) —
|
|
21
|
+
// see mcp-council-bench.js's module docblock for why.
|
|
22
|
+
const { resolveBenchInput } = require('./mcp-council-bench');
|
|
20
23
|
|
|
21
24
|
function textResult(text, isError) {
|
|
22
25
|
const result = { content: [{ type: 'text', text }] };
|
|
@@ -40,34 +43,6 @@ const COUNCIL_PACK_PARAM_MAP = {
|
|
|
40
43
|
template: 'template',
|
|
41
44
|
};
|
|
42
45
|
|
|
43
|
-
/**
|
|
44
|
-
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
45
|
-
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
46
|
-
* preset name when that branch was taken, else null — this handler always
|
|
47
|
-
* spawns the CLI child with an already-expanded `--models` list (never
|
|
48
|
-
* `--council`), so the preset name would otherwise be lost; the caller
|
|
49
|
-
* forwards it via the internal `--council-name` passthrough instead.
|
|
50
|
-
*/
|
|
51
|
-
function resolveBenchInput(input) {
|
|
52
|
-
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
53
|
-
const hasModels = inputModels.length > 0;
|
|
54
|
-
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
55
|
-
if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
|
|
56
|
-
if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
|
|
57
|
-
if (hasCouncil) {
|
|
58
|
-
const { resolveCouncilMembers } = require('./utils/config');
|
|
59
|
-
const { readCache } = require('./utils/model-catalog');
|
|
60
|
-
const catalog = (readCache() || {}).models || [];
|
|
61
|
-
const presetName = input.council.trim();
|
|
62
|
-
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
63
|
-
if (expanded.error) { return { error: expanded.error }; }
|
|
64
|
-
// v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
|
|
65
|
-
// to --models) — the pre-seed below is the only place this is recorded.
|
|
66
|
-
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
67
|
-
}
|
|
68
|
-
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
46
|
/**
|
|
72
47
|
* amicus_council_run: validate → prep run dir → spawn CLI child → return
|
|
73
48
|
* {runId, runDir} immediately (fenced).
|
|
@@ -205,6 +180,14 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
205
180
|
// itself is never spawned (it would collide with `--models`) — this internal,
|
|
206
181
|
// undocumented flag carries the preset NAME through for attribution only.
|
|
207
182
|
if (presetName) { args.push('--council-name', presetName); }
|
|
183
|
+
// v4.6 Plan 4 Task 4b: same precedent as --council-name above — an internal,
|
|
184
|
+
// undocumented passthrough. The child's own resolveBench has no literal
|
|
185
|
+
// --council to re-resolve from, so without this it always resolves
|
|
186
|
+
// droppedMembers: [] and the sink's dropped-members channel (Task 4) never
|
|
187
|
+
// fires — a CLI `--council <preset>` run exits 2 on a dropped member while
|
|
188
|
+
// an identical MCP run exits 0. Omitted when nothing dropped (and always
|
|
189
|
+
// for bare `models` input) — argv stays byte-identical to today otherwise.
|
|
190
|
+
if (droppedMembers.length) { args.push('--dropped-members', JSON.stringify(droppedMembers)); }
|
|
208
191
|
// v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
|
|
209
192
|
// reason outDir is — an MCP client may send a relative path, and the child's cwd
|
|
210
193
|
// is the run dir. Validation of the file itself stays in the spawned engine's
|
package/src/mcp-server.js
CHANGED
|
@@ -1388,8 +1388,12 @@ const handlers = {
|
|
|
1388
1388
|
// must be carried through or it is destroyed. Unlike the CLI there is no
|
|
1389
1389
|
// run-folder path to anchor on (`record` arrives inline), so it is an
|
|
1390
1390
|
// explicit input; omitted → null, never fabricated.
|
|
1391
|
+
// #87: seatLoss/degrades get the same additive-passthrough treatment —
|
|
1392
|
+
// present only when the caller supplies them, never fabricated here.
|
|
1391
1393
|
const verdict = buildVerdict(input.record, input.decisions || [],
|
|
1392
|
-
{ overallVerdict: input.overallVerdict
|
|
1394
|
+
{ overallVerdict: input.overallVerdict,
|
|
1395
|
+
...(input.seatLoss ? { seatLoss: input.seatLoss } : {}),
|
|
1396
|
+
...(Array.isArray(input.degrades) && input.degrades.length ? { degrades: input.degrades } : {}) });
|
|
1393
1397
|
if (!input.render) {
|
|
1394
1398
|
return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
|
|
1395
1399
|
}
|
|
@@ -1455,6 +1459,21 @@ async function startMcpServer() {
|
|
|
1455
1459
|
{ capabilities: { roots: {} } }
|
|
1456
1460
|
);
|
|
1457
1461
|
|
|
1462
|
+
// MCP update notice (spec 2026-08-03): the MCP server replaces the CLI's
|
|
1463
|
+
// deliberately-skipped pre-command update check. Fire-and-forget — startup
|
|
1464
|
+
// is never delayed; when the async init resolves with an update known, one
|
|
1465
|
+
// stderr line lands in the client's MCP log. The per-result notice itself
|
|
1466
|
+
// is appended by maybeAppendUpdateNotice in the registration wrapper below.
|
|
1467
|
+
const { initUpdateCheck, getUpdateInfo } = require('./utils/updater');
|
|
1468
|
+
const { maybeAppendUpdateNotice } = require('./utils/update-notice');
|
|
1469
|
+
initUpdateCheck().then(() => {
|
|
1470
|
+
const updateInfo = getUpdateInfo();
|
|
1471
|
+
if (updateInfo && updateInfo.hasUpdate) {
|
|
1472
|
+
process.stderr.write(
|
|
1473
|
+
`[amicus] update available: v${updateInfo.current} -> v${updateInfo.latest}\n`);
|
|
1474
|
+
}
|
|
1475
|
+
}).catch(() => { /* advisory only */ });
|
|
1476
|
+
|
|
1458
1477
|
for (const tool of getTools()) {
|
|
1459
1478
|
const register = (name) => server.registerTool(
|
|
1460
1479
|
name,
|
|
@@ -1462,11 +1481,11 @@ async function startMcpServer() {
|
|
|
1462
1481
|
async (input) => {
|
|
1463
1482
|
try {
|
|
1464
1483
|
const project = await resolveProjectDir(input.project, server);
|
|
1465
|
-
return await handlers[tool.name](input, project, server);
|
|
1484
|
+
return maybeAppendUpdateNotice(await handlers[tool.name](input, project, server));
|
|
1466
1485
|
}
|
|
1467
1486
|
catch (err) {
|
|
1468
1487
|
logger.error(`MCP tool error: ${name}`, { error: err.message });
|
|
1469
|
-
return textResult(`Error: ${err.message}`, true);
|
|
1488
|
+
return maybeAppendUpdateNotice(textResult(`Error: ${err.message}`, true));
|
|
1470
1489
|
}
|
|
1471
1490
|
}
|
|
1472
1491
|
);
|
package/src/mcp-tools.js
CHANGED
|
@@ -433,6 +433,14 @@ function getTools() {
|
|
|
433
433
|
'(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
|
|
434
434
|
'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
|
|
435
435
|
'chair was skipped; never author one yourself.'),
|
|
436
|
+
seatLoss: z.record(z.any()).nullable().optional().describe(
|
|
437
|
+
'The engine-written <runDir>/verdict.json seatLoss block (v4.5.2 — critic seating). ' +
|
|
438
|
+
'Additive passthrough — preserved onto the rebuilt verdict; omitted → absent, never ' +
|
|
439
|
+
'fabricated (#87).'),
|
|
440
|
+
degrades: z.array(z.record(z.any())).nullable().optional().describe(
|
|
441
|
+
'The engine-written <runDir>/verdict.json degrades[] (v4.6 Plan 2 — what was lost). ' +
|
|
442
|
+
'Additive passthrough — preserved onto the rebuilt verdict; omitted → absent, never ' +
|
|
443
|
+
'fabricated (#87).'),
|
|
436
444
|
render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
|
|
437
445
|
outDir: z.string().optional().describe('Dir to write report.html into when render:true — resolved against the project dir and rejected if it escapes it. Omit to write nothing.'),
|
|
438
446
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
@@ -566,9 +574,13 @@ function getGuideText() {
|
|
|
566
574
|
.map(([name, model]) => `| ${name} | ${model} |`)
|
|
567
575
|
.join('\n');
|
|
568
576
|
// #33: surface the running version (and a call-time staleness warning) so a
|
|
569
|
-
// post-upgrade agent session can tell it's running old code.
|
|
577
|
+
// post-upgrade agent session can tell it's running old code. Spec 2026-08-03
|
|
578
|
+
// adds the registry-side sibling: a newer release exists (not latched here —
|
|
579
|
+
// the guide is the on-demand surface).
|
|
570
580
|
const warn = versionWarning();
|
|
581
|
+
const updateLine = require('./utils/update-notice').guideUpdateLine();
|
|
571
582
|
const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
|
|
583
|
+
+ (updateLine ? `\n\n> ${updateLine}` : '')
|
|
572
584
|
+ (warn ? `\n\n> ⚠️ ${warn}` : '');
|
|
573
585
|
|
|
574
586
|
return `# Amicus Usage Guide
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module utils/degrade
|
|
5
|
+
* The degrade/heal record: one shape, one vocabulary, shared by the council
|
|
6
|
+
* runtime and `doctor`. Pure — no I/O, no council knowledge.
|
|
7
|
+
*
|
|
8
|
+
* WHY validation lives here and THROWS: it is what makes the announcement
|
|
9
|
+
* contract real. A degrade that does not say what was lost, why, and what it
|
|
10
|
+
* cost cannot be constructed. Callers never see the throw — run-degrade.js's
|
|
11
|
+
* sink catches it and converts it into an `internal` degrade (spec §7).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
15
|
+
// council runtime channels
|
|
16
|
+
'dead-leg', 'dead-wave', 'budget-refusal', 'shared-server-unavailable',
|
|
17
|
+
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
|
+
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
|
+
'stage1-retry',
|
|
20
|
+
'internal',
|
|
21
|
+
// doctor channels
|
|
22
|
+
'doctor-check-failed', 'doctor-fix',
|
|
23
|
+
]));
|
|
24
|
+
|
|
25
|
+
const KINDS = Object.freeze(new Set(['degrade', 'heal']));
|
|
26
|
+
const REQUIRED = ['what', 'why', 'effect'];
|
|
27
|
+
|
|
28
|
+
function makeDegrade(input = {}) {
|
|
29
|
+
const kind = input.kind === undefined ? 'degrade' : input.kind;
|
|
30
|
+
if (!KINDS.has(kind)) {
|
|
31
|
+
throw new Error(`degrade: unknown kind '${kind}' (expected 'degrade' or 'heal')`);
|
|
32
|
+
}
|
|
33
|
+
if (!DEGRADE_CHANNELS.has(input.channel)) {
|
|
34
|
+
throw new Error(`degrade: unknown channel '${input.channel}'`);
|
|
35
|
+
}
|
|
36
|
+
for (const f of REQUIRED) {
|
|
37
|
+
if (typeof input[f] !== 'string' || !input[f].trim()) {
|
|
38
|
+
throw new Error(`degrade: '${f}' is required and must be a non-blank string`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const record = {
|
|
42
|
+
kind, channel: input.channel,
|
|
43
|
+
what: input.what.trim(), why: input.why.trim(), effect: input.effect.trim(),
|
|
44
|
+
};
|
|
45
|
+
if (typeof input.remedy === 'string' && input.remedy.trim()) {
|
|
46
|
+
record.remedy = input.remedy.trim();
|
|
47
|
+
}
|
|
48
|
+
if (input.data !== undefined) {
|
|
49
|
+
if (typeof input.data !== 'object' || input.data === null || Array.isArray(input.data)) {
|
|
50
|
+
throw new Error("degrade: 'data' must be a plain object when provided");
|
|
51
|
+
}
|
|
52
|
+
record.data = Object.freeze({ ...input.data });
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze(record);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The ONE voice for every channel. Kept here rather than at call sites so ten
|
|
59
|
+
* channels cannot drift into ten dialects.
|
|
60
|
+
* @param {object} record from makeDegrade
|
|
61
|
+
* @returns {string} one line, newline-terminated
|
|
62
|
+
*/
|
|
63
|
+
function formatDegrade(record) {
|
|
64
|
+
const lead = record.kind === 'heal' ? 'Recovered' : 'Notice';
|
|
65
|
+
const remedy = record.remedy ? ` Try: ${record.remedy}.` : '';
|
|
66
|
+
return `${lead}: ${record.what} — ${record.why}. ${record.effect}.${remedy}\n`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { makeDegrade, formatDegrade, DEGRADE_CHANNELS };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module utils/doctor-degrade
|
|
5
|
+
* The doctor-side collector (spec §4): maps doctor's check rows onto the shared
|
|
6
|
+
* degrade/heal vocabulary. Pure — reads row STATUS and the structured `fixed`
|
|
7
|
+
* flag only, never message prose (the Plan 2 rule: prose is the human surface).
|
|
8
|
+
*
|
|
9
|
+
* Mapping (spec §6, with the recorded interpretation):
|
|
10
|
+
* status 'error' → kind 'degrade', channel 'doctor-check-failed'
|
|
11
|
+
* row.fixed === true → kind 'heal', channel 'doctor-fix'
|
|
12
|
+
* 'ok'/'warn' rows → no record — doctor's exit code ignores warns, and the
|
|
13
|
+
* §6 equivalence (exit derives from any degrade) must
|
|
14
|
+
* hold without changing exit behavior.
|
|
15
|
+
* A row can produce both (a partial self-heal that still fails).
|
|
16
|
+
* Never throws: a malformed row degrades honestly, mirroring the council sink.
|
|
17
|
+
*/
|
|
18
|
+
const { makeDegrade } = require('./degrade');
|
|
19
|
+
|
|
20
|
+
function collectDoctorDegrades(checks) {
|
|
21
|
+
const records = [];
|
|
22
|
+
for (const c of Array.isArray(checks) ? checks : []) {
|
|
23
|
+
if (!c) { continue; }
|
|
24
|
+
const name = c.name || c.id || 'unnamed';
|
|
25
|
+
if (c.status === 'error') {
|
|
26
|
+
records.push(makeDegrade({
|
|
27
|
+
channel: 'doctor-check-failed',
|
|
28
|
+
what: `the '${name}' check failed`,
|
|
29
|
+
why: (typeof c.message === 'string' && c.message.trim()) ? c.message : 'the check produced no message',
|
|
30
|
+
effect: 'amicus may not work correctly until this is fixed; doctor exits 1',
|
|
31
|
+
...(typeof c.hint === 'string' && c.hint.trim() ? { remedy: c.hint } : {}),
|
|
32
|
+
data: { checkId: c.id || null },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
if (c.fixed === true) {
|
|
36
|
+
records.push(makeDegrade({
|
|
37
|
+
kind: 'heal',
|
|
38
|
+
channel: 'doctor-fix',
|
|
39
|
+
what: `the '${name}' check was repaired in place`,
|
|
40
|
+
why: (typeof c.fixDetail === 'string' && c.fixDetail.trim())
|
|
41
|
+
? `doctor --fix ${c.fixDetail}`
|
|
42
|
+
: "doctor --fix applied the check's self-heal",
|
|
43
|
+
effect: 'no further action needed; the repair already ran',
|
|
44
|
+
data: { checkId: c.id || null },
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return records;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { collectDoctorDegrades };
|