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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +113 -0
  3. package/README.md +1 -1
  4. package/commands/council.md +1 -1
  5. package/docs/DISTRIBUTION.md +38 -11
  6. package/docs/ROADMAP.md +40 -8
  7. package/docs/publishing.md +1 -1
  8. package/docs/usage.md +1 -1
  9. package/package.json +3 -2
  10. package/schemas/council-run.schema.json +20 -0
  11. package/schemas/council-verdict.schema.json +20 -0
  12. package/schemas/doctor.schema.json +23 -1
  13. package/skills/second-opinion/MODEL-NOTES.md +182 -35
  14. package/src/cli-council-run-render.js +51 -0
  15. package/src/cli-handlers-council-run.js +45 -44
  16. package/src/cli-handlers-council.js +9 -3
  17. package/src/cli-handlers-doctor.js +16 -37
  18. package/src/cli-handlers-watch.js +1 -1
  19. package/src/cli.js +1 -1
  20. package/src/council/ledger.js +5 -1
  21. package/src/council/report-html.js +16 -1
  22. package/src/council/report.js +25 -1
  23. package/src/council/run-assemble.js +25 -7
  24. package/src/council/run-budget.js +14 -8
  25. package/src/council/run-chair.js +21 -4
  26. package/src/council/run-debate-stage.js +115 -0
  27. package/src/council/run-degrade.js +44 -0
  28. package/src/council/run-finalize.js +18 -3
  29. package/src/council/run-launch.js +4 -0
  30. package/src/council/run-retry-notes.js +74 -0
  31. package/src/council/run-retry.js +280 -0
  32. package/src/council/run-server.js +24 -7
  33. package/src/council/run-stage2.js +10 -2
  34. package/src/council/run-stages.js +59 -27
  35. package/src/council/run.js +39 -67
  36. package/src/council/verdict.js +81 -8
  37. package/src/mcp-council-bench.js +45 -0
  38. package/src/mcp-council-run.js +11 -28
  39. package/src/mcp-server.js +22 -3
  40. package/src/mcp-tools.js +13 -1
  41. package/src/utils/degrade.js +69 -0
  42. package/src/utils/doctor-degrade.js +51 -0
  43. package/src/utils/doctor-electron-mcp-check.js +64 -5
  44. package/src/utils/doctor-engine-check.js +14 -3
  45. package/src/utils/doctor-mcp-checks.js +10 -3
  46. package/src/utils/known-flags.js +2 -1
  47. package/src/utils/remediation-hints.js +20 -14
  48. package/src/utils/result-schema.js +6 -2
  49. package/src/utils/session-index-tmp-sweep.js +2 -1
  50. package/src/utils/update-notice.js +171 -0
  51. package/src/workspace/run-scan.js +5 -1
@@ -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 runDebateMod = require('./run-debate');
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, degraded });
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
- if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
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
- let debatedInput = provisionalInput, debatedRecord = provisional;
197
- let debateOutcomes = null, debateFindings = null;
198
- let debateSummary = o.debate ? { enabled: true, outcome: 'nothing-to-debate',
199
- contested: 0, disputed: 0, defended: 0, amended: 0, withdrawn: 0, noResponse: 0,
200
- revoteJudges: 0, revoteApplied: 0, verdictChanges: 0 } : null;
201
- if (o.debate) {
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, degraded, statsFn, isSignalled: () => signalled,
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);
@@ -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, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
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 };
@@ -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 };