amicus 4.0.1 → 4.1.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +169 -0
  3. package/README.md +4 -4
  4. package/commands/council.md +6 -6
  5. package/package.json +3 -2
  6. package/schemas/council-run.schema.json +15 -1
  7. package/schemas/council-tally.schema.json +10 -1
  8. package/schemas/council-verdict.schema.json +10 -1
  9. package/schemas/error.schema.json +1 -1
  10. package/scripts/postinstall.js +6 -3
  11. package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
  12. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
  13. package/skills/second-opinion/MODEL-NOTES.md +21 -0
  14. package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
  15. package/skills/second-opinion/SKILL.md +319 -333
  16. package/skills/sidecar/SKILL.md +5 -5
  17. package/src/cli-handlers-council-run.js +9 -0
  18. package/src/cli-handlers-council.js +20 -2
  19. package/src/cli.js +8 -0
  20. package/src/council/briefings-debate.js +158 -0
  21. package/src/council/briefings-stage2.js +16 -9
  22. package/src/council/debate.js +98 -0
  23. package/src/council/ledger.js +2 -1
  24. package/src/council/parse-stage2.js +83 -1
  25. package/src/council/report-html.js +28 -1
  26. package/src/council/report.js +64 -4
  27. package/src/council/run-assemble.js +91 -9
  28. package/src/council/run-chair.js +145 -0
  29. package/src/council/run-debate.js +293 -0
  30. package/src/council/run-launch.js +27 -1
  31. package/src/council/run-stages.js +19 -7
  32. package/src/council/run.js +104 -110
  33. package/src/council/verdict.js +43 -2
  34. package/src/mcp-council-run.js +7 -0
  35. package/src/mcp-server.js +28 -3
  36. package/src/mcp-tools.js +24 -4
  37. package/src/utils/curated-models.js +22 -20
  38. package/src/utils/error-doc.js +2 -0
  39. package/src/utils/model-fetcher.js +6 -0
@@ -3,16 +3,16 @@
3
3
 
4
4
  /**
5
5
  * @module council/run
6
- * Headless council driver (spec §5): stage state machine over the DI launch
7
- * wrappers — Stage-1 reviews → anonymized Stage-2 cross-review → tally
8
- * chair synthesis → verdict/report — checkpointing run.json after every
9
- * stage (run-state) and consuming the existing pure primitives unchanged
6
+ * Headless council driver (spec §5): stage state machine over the DI launch wrappers —
7
+ * Stage-1 reviews → anonymized Stage-2 cross-review → optional Stage-2.5 debate
8
+ * (run-debate) → tally → chair synthesis → verdict/report — checkpointing run.json after
9
+ * every stage (run-state) and consuming the existing pure primitives unchanged
10
10
  * (tally, buildVerdict via run-assemble, report renderers, ledger).
11
11
  *
12
- * Tally sequencing: an in-memory provisional tally feeds the chair packet;
13
- * the on-disk tally-input.json/tally.json are FINAL (chair runStats row
14
- * included, actual chair in meta) and only the final record is ledgered —
15
- * the skill's debate-mode provisional/final precedent.
12
+ * Tally sequencing: a provisional tally feeds the chair packet (and, under --debate, the
13
+ * debate round + tally-provisional.json); the on-disk tally-input.json/tally.json are
14
+ * FINAL (chair runStats row included, actual chair in meta) and only the final record is
15
+ * ledgered — the skill's debate-mode provisional/final precedent.
16
16
  *
17
17
  * Never rejects for run errors: always resolves {exitCode, run}.
18
18
  */
@@ -23,39 +23,27 @@ const { tally } = require('./tally');
23
23
  const { assignLabels, toGlobalFindings } = require('./anonymize');
24
24
  const briefings = require('./briefings');
25
25
  const stage2 = require('./briefings-stage2');
26
- const { parseChairVerdict } = require('./parse-stage2');
27
26
  const runState = require('./run-state');
28
27
  const { createLaunchers } = require('./run-launch');
29
- const { runStage1, runStage2, isAbortExit } = require('./run-stages');
28
+ const { runStage1, runStage2 } = require('./run-stages');
29
+ const { runChair, pickFallbackChair } = require('./run-chair');
30
+ const runDebateMod = require('./run-debate');
31
+ const { buildDebateAddendum } = require('./briefings-debate');
32
+ const { decorateRecord } = require('./debate');
30
33
  const asm = require('./run-assemble');
31
34
  const { sumWaveUsage } = require('../utils/pricing');
32
35
 
33
36
  const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
34
37
 
35
38
  /**
36
- * Chair fallback promotion (spec §4): the highest peers-only street-cred
37
- * model from `council stats` that is not a bench seat and not the failed
38
- * chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
39
- * (deriveReliability's avgStreetCredPeersOnly; lower is better).
40
- * @returns {string|null}
41
- */
42
- function pickFallbackChair(statsRows, bench, failedChair) {
43
- const benchSet = new Set(bench);
44
- const candidates = (statsRows || [])
45
- .filter(r => !benchSet.has(r.model) && r.model !== failedChair
46
- && typeof r.avgStreetCredPeersOnly === 'number')
47
- .sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
48
- return candidates.length ? candidates[0].model : null;
49
- }
50
-
51
- /**
52
- * @param {object} options {briefing, models, chair, critic?, lenses?, project,
53
- * runId, runDir, timeout?, maxCost?, gateway?, noValidateModel?, date}
39
+ * @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
40
+ * runDir, timeout?, maxCost?, gateway?, noValidateModel?, date, debate?, noCostGate?}
54
41
  * @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?}
55
42
  * @returns {Promise<{exitCode: number, run: object}>}
56
43
  */
57
44
  async function runCouncil(options, deps = {}) {
58
- const o = { critic: null, lenses: null, maxCost: null, ...options };
45
+ const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
46
+ noCostGate: false, ...options };
59
47
  const launchers = deps.launchers || createLaunchers();
60
48
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
61
49
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
@@ -75,6 +63,10 @@ async function runCouncil(options, deps = {}) {
75
63
  schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
76
64
  bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
77
65
  labelMap: null,
66
+ // Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
67
+ // "no debate key" contract and fail the object-typed schema), and with a VALID
68
+ // outcome from the first write so a run killed mid-debate stays schema-valid.
69
+ ...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
78
70
  options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
79
71
  usage: null, pid: process.pid, createdAt: now(),
80
72
  });
@@ -105,6 +97,12 @@ async function runCouncil(options, deps = {}) {
105
97
  const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
106
98
 
107
99
  try {
100
+ // v4.1 §4.4: Claude-in-council is a FILE input — validated after initRun (so the
101
+ // error doc lands in a run dir that exists) and before any launch (zero spend).
102
+ const pre = asm.preflightClaudeReview(o);
103
+ if (pre.error) { return finalize(1, pre.error); }
104
+ const claudeReview = pre.claudeReview;
105
+
108
106
  // Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
109
107
  fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
110
108
  briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
@@ -141,17 +139,20 @@ async function runCouncil(options, deps = {}) {
141
139
  }
142
140
 
143
141
  // ---- Stage 2: anonymized cross-review ----
144
- const labels = assignLabels(s1.reviews.map(r => r.model));
142
+ // A file-sourced Claude review is ALWAYS the last label — review N+1 (§4.4).
143
+ const labels = assignLabels(s1.reviews.map(r => r.model).concat(claudeReview ? ['claude'] : []));
145
144
  runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
146
145
  // Attach each review's run-global findings (buildTallyInput reads
147
146
  // r.globalFindings per review, not a bare parallel array).
148
147
  s1.reviews.forEach((r, i) => {
149
148
  r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
150
149
  });
151
- const globalFindings = s1.reviews.flatMap(r => r.globalFindings);
150
+ const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
151
+ .concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
152
152
  runState.updateStage(o.runDir, 'stage2',
153
153
  { status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
154
- const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings });
154
+ const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings,
155
+ extraLabeled: claudeReview ? [{ label: claudeReview.label, text: claudeReview.text }] : [] });
155
156
  runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
156
157
  if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
157
158
  if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
@@ -166,105 +167,98 @@ async function runCouncil(options, deps = {}) {
166
167
  // ---- Chair synthesis (provisional tally feeds the packet) ----
167
168
  const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
168
169
  runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
169
- reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
170
+ reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
170
171
  });
171
172
  const provisionalInput = mkInput(null, o.chair);
172
173
  const provisional = tally(provisionalInput);
173
174
 
174
- const packet = stage2.buildChairPacket({
175
- reviews: s1.reviews.map(r => ({ model: r.model, text: r.text })),
176
- rankings: provisionalInput.rankings,
177
- adjudications: provisionalInput.adjudications,
178
- tierCounts: provisional.tierCounts,
179
- });
180
- fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
181
- const attemptChair = async (model, waveId) => {
182
- runState.appendStageWave(o.runDir, 'chair', waveId);
183
- const solo = await launchers.launchSolo({
184
- model, prompt: packet, project: o.runDir, waveId,
185
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
186
- });
187
- addWave(solo.wave);
188
- const ok = solo.leg && solo.leg.status === 'complete'
189
- && solo.leg.summary && solo.leg.summary.trim();
190
- return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
191
- };
192
-
193
- let chairLeg = null;
194
- let actualChair = null;
195
- if (overBudget()) {
196
- // Ceiling hit after the tally is computable: skip the chair, write the
197
- // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
198
- // Never abort in-flight legs for cost — this only stops NEW launches.
199
- degraded.value = true;
200
- runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
201
- } else {
202
- runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
203
- // Fallback chain (spec §4): retry same chair once → promote best
204
- // non-bench model from the ledger → give up (no Claude fallback headless).
205
- let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
206
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
207
- if (!attempt.leg && !overBudget()) {
208
- attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
209
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
210
- }
211
- if (attempt.leg) { actualChair = o.chair; }
212
- else if (!overBudget()) {
213
- let statsRows = [];
214
- try { statsRows = statsFn(); } catch { /* no ledger yet */ }
215
- const fallback = pickFallbackChair(statsRows, o.models, o.chair);
216
- if (fallback) {
217
- attempt = await attemptChair(fallback, `${o.runId}-ch3`);
218
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
219
- if (attempt.leg) { actualChair = fallback; }
175
+ // ---- Stage 2.5: debate (optional, spec §5.1) ----
176
+ let debatedInput = provisionalInput, debatedRecord = provisional;
177
+ let debateOutcomes = null, debateFindings = null;
178
+ let debateSummary = o.debate ? { enabled: true, outcome: 'nothing-to-debate',
179
+ contested: 0, disputed: 0, defended: 0, amended: 0, withdrawn: 0, noResponse: 0,
180
+ revoteJudges: 0, revoteApplied: 0, verdictChanges: 0 } : null;
181
+ if (o.debate) {
182
+ // spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
183
+ // checkpoint — no ledger append, written before any debate leg launches.
184
+ fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
185
+ runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
186
+ const worthDebating = !runDebateMod.nothingToDebate(provisional);
187
+ if (worthDebating && !overBudget()) {
188
+ runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
189
+ const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
190
+ // A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
191
+ // the summary FIRST the writer contract requires a valid `outcome` whenever the key exists.
192
+ if (dbg.aborted) {
193
+ runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
194
+ contested: dbg.contested, disputed: dbg.disputed } });
195
+ return finalize(dbg.aborted);
220
196
  }
197
+ runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
198
+ // run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
199
+ // knows whether the wave launched. Never advertise a `-rv` id here: a skipped
200
+ // re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
201
+ // Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
202
+ // defended/amended or the cost ceiling skipped it — 'complete' would report
203
+ // work that never happened.
204
+ runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
205
+ ? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
206
+ ({ debatedInput, debateFindings, debateSummary } = dbg);
207
+ debatedRecord = tally(debatedInput);
208
+ // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
209
+ // list must be normalized to null here — otherwise the packet-assembly
210
+ // ternary below still calls buildDebateAddendum({outcomes: []}), which
211
+ // emits a bare "--- Debate round outcomes ---" heading with nothing
212
+ // under it (same defect class ee447b6 fixed on the report renderer).
213
+ debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
214
+ ? dbg.addendumOutcomes : null;
215
+ // Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
216
+ // each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
217
+ if (dbg.degraded) { degraded.value = true; }
218
+ } else if (worthDebating) {
219
+ // Budget gone before the defense wave launched, but there WAS something to debate — the
220
+ // other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
221
+ debateSummary.outcome = 'skipped-cost-ceiling';
222
+ degraded.value = true;
221
223
  }
222
- chairLeg = attempt.leg;
223
- runState.updateStage(o.runDir, 'chair',
224
- { status: chairLeg ? 'complete' : 'error', completedAt: now() });
225
- // The chair chain may have promoted a fallback (or given up) — checkpoint
226
- // the ACTUAL chair into run.json now so status/`--json`/the human summary
227
- // never report the originally-requested chair after a promotion. Mirrors
228
- // mkInput's actualChair || o.chair (a give-up with no actual chair keeps
229
- // the requested chair).
230
- runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
224
+ runState.checkpoint(o.runDir, { debate: debateSummary });
231
225
  }
232
- const chairText = chairLeg ? chairLeg.summary : null;
233
- let chairConformance = 'clean';
234
226
 
235
- // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
236
- let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
237
- if (chairText && !overallVerdict && !overBudget()) {
238
- runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
239
- const repair = await launchers.launchSolo({
240
- model: actualChair, prompt: stage2.buildChairRepairPrompt(),
241
- project: o.runDir, waveId: `${o.runId}-ch4`,
242
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
243
- });
244
- addWave(repair.wave);
245
- if (isAbortExit(repair.exitCode) || signalled) { return finalize(repair.exitCode || signalled); }
246
- overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
247
- chairConformance = overallVerdict ? 'repaired' : 'unstructured';
248
- }
249
- // A completed chair whose verdict never parsed is 'unstructured' even when
250
- // the repair was skipped (e.g. the chair leg itself tripped --max-cost).
251
- if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
252
- if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
227
+ const packet = stage2.buildChairPacket({
228
+ // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
229
+ // no rankings/adjudications, so it appears ONLY as one more review block.
230
+ reviews: s1.reviews.map(r => ({ model: r.model, text: r.text }))
231
+ .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
232
+ rankings: debatedInput.rankings,
233
+ adjudications: debatedInput.adjudications,
234
+ tierCounts: debatedRecord.tierCounts, date: o.date,
235
+ }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
236
+ fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
237
+
238
+ const chairRes = await runChair(ctx, {
239
+ packet, degraded, statsFn, isSignalled: () => signalled,
240
+ });
241
+ if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
242
+ const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
253
243
 
254
244
  // ---- Final tally (chair row included) + ledger + artifacts ----
255
245
  const chairStats = chairLeg ? asm.buildRunStatsEntry({
256
246
  leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
257
247
  conformance: chairConformance,
258
248
  }) : null;
259
- const finalInput = mkInput(chairStats, actualChair || o.chair);
249
+ // Built on the (possibly debated) input so the debate's amended claims, replaced
250
+ // adjudications and rebuttal/revote runStats rows all reach the final record.
251
+ const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
252
+ if (chairStats) { finalInput.runStats = [...(finalInput.runStats || []), chairStats]; }
260
253
  const record = tally(finalInput);
254
+ if (debateFindings) { decorateRecord(record, debateFindings); }
261
255
  if (!o.lenses) {
262
256
  // Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
263
257
  try { appendRunFn(record); }
264
258
  catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
265
259
  }
266
260
  asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
267
- runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
261
+ runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
268
262
  asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
269
263
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
270
264
 
@@ -1,6 +1,8 @@
1
1
  // src/council/verdict.js
2
2
  'use strict';
3
3
  const fs = require('fs');
4
+ const path = require('path');
5
+ const { parseChairVerdict } = require('./parse-stage2');
4
6
 
5
7
  // v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
6
8
  // nullable overallVerdict (the chair's Ship-it line; populated by the headless
@@ -29,7 +31,7 @@ function buildVerdict(record, decisions = [], opts = {}) {
29
31
  findings: record.findings.map(f => {
30
32
  const d = byId.get(f.id) || {};
31
33
  const tierOverride = d.tierOverride || f.tierOverride || null;
32
- return {
34
+ const out = {
33
35
  id: f.id, raiser: f.raiser, severity: f.severity,
34
36
  tier: tierOverride ? tierOverride.to : f.tier,
35
37
  basis: f.basis, confidence: f.confidence, tierOverride,
@@ -38,6 +40,8 @@ function buildVerdict(record, decisions = [], opts = {}) {
38
40
  decision: d.decision || null,
39
41
  applied: d.applied === true,
40
42
  };
43
+ if (f.debate) { out.debate = f.debate; } // v4.1: additive debate decoration carry-through (spec §5.6)
44
+ return out;
41
45
  }),
42
46
  streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
43
47
  runStats: record.runStats,
@@ -45,6 +49,43 @@ function buildVerdict(record, decisions = [], opts = {}) {
45
49
  };
46
50
  }
47
51
 
52
+ /**
53
+ * Recover the chair's overall verdict for a run folder.
54
+ *
55
+ * The chair's synthesis is the council's most valuable output and it is stored
56
+ * in exactly two places: the engine's `verdict.json` (parsed) and
57
+ * `chair-output.md` (prose). Neither `tally.json` nor `run.json` carries a
58
+ * copy — so the Stage-5 step that REPLACES `verdict.json` from `tally.json`
59
+ * must read the verdict back out of the run folder first, or it destroys it.
60
+ *
61
+ * Preference order, both anchored on the run dir (never on the `-o` path — the
62
+ * verdict belongs to the run, not to wherever the caller writes the result):
63
+ * 1. `<runDir>/verdict.json` `overallVerdict` — the value the engine already
64
+ * parsed. Guarded by `runId`: a stale or foreign verdict.json sitting in
65
+ * the folder must never inject another run's chair line.
66
+ * 2. `<runDir>/chair-output.md`, re-parsed with the engine's own
67
+ * `parseChairVerdict`, so there is no second parser to drift. This also
68
+ * recovers runs whose verdict.json was already nulled by the defect.
69
+ *
70
+ * Never invents: an absent, skipped, or unstructured chair yields null.
71
+ * @param {string} runDir
72
+ * @param {string} [runId] record.meta.runId — the run being rebuilt
73
+ * @returns {string|null} a canonical chair verdict phrase, or null
74
+ */
75
+ function readOverallVerdict(runDir, runId) {
76
+ try {
77
+ const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
78
+ if (typeof prior.overallVerdict === 'string' && prior.overallVerdict
79
+ && (!runId || prior.runId === runId)) {
80
+ return prior.overallVerdict;
81
+ }
82
+ } catch { /* no prior verdict.json, or unreadable — try the chair prose */ }
83
+ try {
84
+ return parseChairVerdict(fs.readFileSync(path.join(runDir, 'chair-output.md'), 'utf-8'));
85
+ } catch { /* no chair-output.md — the chair genuinely produced nothing */ }
86
+ return null;
87
+ }
88
+
48
89
  /** Atomic write: tmp + rename (matches the repo's wave.json convention). */
49
90
  function writeVerdictAtomic(filePath, verdict) {
50
91
  const tmp = `${filePath}.tmp-${process.pid}`;
@@ -52,4 +93,4 @@ function writeVerdictAtomic(filePath, verdict) {
52
93
  fs.renameSync(tmp, filePath);
53
94
  }
54
95
 
55
- module.exports = { buildVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
96
+ module.exports = { buildVerdict, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
@@ -123,6 +123,13 @@ async function handleCouncilRunTool(input, project, helpers) {
123
123
  if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
124
124
  if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
125
125
  if (input.gateway) { args.push('--gateway', input.gateway); }
126
+ // v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
127
+ // reason outDir is — an MCP client may send a relative path, and the child's cwd
128
+ // is the run dir. Validation of the file itself stays in the spawned engine's
129
+ // pre-flight (run-assemble.preflightClaudeReview), so every entry point shares it.
130
+ if (input.debate) { args.push('--debate'); }
131
+ if (input.claudeReviewFile) { args.push('--claude-review', path.resolve(project, String(input.claudeReviewFile))); }
132
+ if (input.noCostGate) { args.push('--no-cost-gate'); }
126
133
 
127
134
  let child;
128
135
  try { child = helpers.spawnFn(args, runDir); } catch (err) {
package/src/mcp-server.js CHANGED
@@ -13,7 +13,7 @@ const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
13
13
  const { SharedServerManager } = require('./utils/shared-server');
14
14
  const { durationBetween } = require('./utils/result-schema');
15
15
  const { canonicalProjectPath } = require('./utils/project-path');
16
- const { isAllowedProjectRoot } = require('./project-root-allowlist');
16
+ const { isAllowedProjectRoot, isPathInside } = require('./project-root-allowlist');
17
17
  const { recordSession } = require('./utils/session-index');
18
18
  const { fileURLToPath } = require('url');
19
19
  const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
@@ -1141,10 +1141,35 @@ const handlers = {
1141
1141
  } catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
1142
1142
  },
1143
1143
 
1144
- async amicus_verdict(input) {
1144
+ async amicus_verdict(input, project) {
1145
1145
  try {
1146
1146
  const { buildVerdict } = require('./council/verdict');
1147
- return textResult(fenceSidecarOutput(JSON.stringify(buildVerdict(input.record, input.decisions || []))));
1147
+ // The chair's synthesis lives only in the engine's verdict.json /
1148
+ // chair-output.md, and this tool's output replaces verdict.json — so it
1149
+ // must be carried through or it is destroyed. Unlike the CLI there is no
1150
+ // run-folder path to anchor on (`record` arrives inline), so it is an
1151
+ // explicit input; omitted → null, never fabricated.
1152
+ const verdict = buildVerdict(input.record, input.decisions || [],
1153
+ { overallVerdict: input.overallVerdict });
1154
+ if (!input.render) {
1155
+ return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
1156
+ }
1157
+ // v4.1 §4.5c: return the markdown rendering (so Cowork can assemble report.md
1158
+ // without Bash) and, when an outDir is given, refresh report.html on disk.
1159
+ const { buildReport } = require('./council/report');
1160
+ const md = buildReport({ verdict }, { format: 'md' });
1161
+ if (input.outDir) {
1162
+ // Containment parity with amicus_council_run (mcp-council-run.js): an
1163
+ // MCP-supplied outDir must not write outside the project directory.
1164
+ const cwd = project || getProjectDir(input.project);
1165
+ const outDir = path.resolve(cwd, String(input.outDir));
1166
+ if (!isPathInside(outDir, cwd)) {
1167
+ return textResult(`outDir must resolve to a path inside the project directory (${cwd}).`, true);
1168
+ }
1169
+ fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
1170
+ fs.writeFileSync(path.join(outDir, 'report.html'), buildReport({ verdict }, { format: 'html' }), { mode: 0o600 });
1171
+ }
1172
+ return textResult(fenceSidecarOutput(md));
1148
1173
  } catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
1149
1174
  },
1150
1175
 
package/src/mcp-tools.js CHANGED
@@ -57,7 +57,7 @@ function getTools() {
57
57
  inputSchema: {
58
58
  model: safeModel.optional().describe(
59
59
  `Short alias (${aliasNames}) or full model ID ` +
60
- '(bare provider/model is canonical and routes direct-first, e.g. anthropic/claude-opus-4.8; ' +
60
+ '(bare provider/model is canonical and routes direct-first, e.g. anthropic/claude-opus-4-8; ' +
61
61
  'openrouter/provider/model forces OpenRouter). ' +
62
62
  'If omitted, uses the configured default. Call amicus_guide to see all aliases.'
63
63
  ),
@@ -406,11 +406,12 @@ function getTools() {
406
406
  },
407
407
  {
408
408
  name: 'amicus_verdict',
409
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
409
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
410
410
  description:
411
411
  "Merge a tally record with Claude's Stage-4 decisions into the verdict " +
412
412
  'object (final tiers after overrides, decisions, applied flags). Pure + ' +
413
- 'synchronous; returns the verdict does NOT write it to disk.',
413
+ 'synchronous; returns the verdict. Writes nothing unless render:true AND ' +
414
+ 'outDir are given — then it also refreshes <outDir>/report.html.',
414
415
  inputSchema: {
415
416
  record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
416
417
  decisions: z.array(z.object({
@@ -418,6 +419,13 @@ function getTools() {
418
419
  duplicateOf: z.string().nullable().optional(),
419
420
  tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
420
421
  })).optional().describe('Stage-4 per-finding decisions (default []).'),
422
+ overallVerdict: z.string().nullable().optional().describe(
423
+ "The chair's VERDICT line, read from the engine-written <runDir>/verdict.json " +
424
+ '(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
425
+ 'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
426
+ 'chair was skipped; never author one yourself.'),
427
+ render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
428
+ 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.'),
421
429
  project: z.string().optional().describe('Optional project directory path.'),
422
430
  },
423
431
  },
@@ -466,6 +474,18 @@ function getTools() {
466
474
  gateway: z.enum(GATEWAY_MODES).optional().describe(
467
475
  'Routing preference: auto (default), direct, or openrouter.'
468
476
  ),
477
+ debate: z.boolean().optional().describe(
478
+ 'Add a Stage-2.5 rebuttal round: raisers defend Contested/Disputed findings and ' +
479
+ 'disputing judges re-vote before the chair synthesizes.'
480
+ ),
481
+ claudeReviewFile: z.string().optional().describe(
482
+ "Path to Claude's own review file (prose + findings JSON) to include as a judged " +
483
+ 'entry. Claude is reviewed and ranked like a seat, but never judges or chairs.'
484
+ ),
485
+ noCostGate: z.boolean().optional().describe(
486
+ 'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
487
+ 'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
488
+ ),
469
489
  project: z.string().optional().describe(
470
490
  'Optional project directory path. Auto-detected from working directory if omitted.'
471
491
  ),
@@ -561,7 +581,7 @@ Include: Objective, Background, Files of interest, Success criteria, Constraints
561
581
  |-------|-------|
562
582
  ${aliasRows}
563
583
 
564
- Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4.8) is the canonical,
584
+ Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4-8) is the canonical,
565
585
  policy-routed form — it routes direct-first (your direct provider key if configured, else
566
586
  OpenRouter). \`openrouter/provider/model\` is an explicit override that forces OpenRouter. The
567
587
  \`gateway\` param (or \`routing.prefer\` in config.json) controls this per call or globally.
@@ -30,8 +30,8 @@ const FAMILIES = [
30
30
  vendorPath: 'google',
31
31
  idPattern: /^gemini-[\d.]+-flash(-preview|-exp|-latest)?$/,
32
32
  directProviders: ['google'],
33
- fallback: { openrouter: 'openrouter/google/gemini-3.5-flash',
34
- google: 'google/gemini-3.5-flash' } },
33
+ fallback: { openrouter: 'openrouter/google/gemini-3.6-flash',
34
+ google: 'google/gemini-3.6-flash' } },
35
35
  { alias: 'gemini-pro', label: 'Gemini Pro-class', blurb: 'advanced reasoning',
36
36
  vendorPath: 'google',
37
37
  idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
@@ -119,24 +119,6 @@ function toCanonicalDefault(route) {
119
119
  return route;
120
120
  }
121
121
 
122
- /**
123
- * @returns {Object<string,string>} alias → pinned route, direct-first for
124
- * direct-capable vendors (bare `vendor/model`), openrouter-prefixed for
125
- * gateway-only vendors. STATIC — runtime-safe.
126
- */
127
- function toDefaultAliases() {
128
- const out = {};
129
- for (const f of FAMILIES) {
130
- const route = f.fallback.openrouter || Object.values(f.fallback)[0];
131
- out[f.alias] = toCanonicalDefault(route);
132
- }
133
- for (const e of CARDLESS) {
134
- const route = e.routes.openrouter || Object.values(e.routes)[0];
135
- out[e.alias] = toCanonicalDefault(route);
136
- }
137
- return out;
138
- }
139
-
140
122
  /**
141
123
  * @returns {Array<{alias,provider,model}>} every pinned route, flattened (for the alias audit).
142
124
  */
@@ -213,6 +195,26 @@ function toGatewayRoutes() {
213
195
  return out;
214
196
  }
215
197
 
198
+ /**
199
+ * @returns {Object<string,string>} alias → the SINGLE pinned route used for
200
+ * display and `config.default`: the alias's authored direct form when one
201
+ * exists, else its OpenRouter route. STATIC — runtime-safe, never networks.
202
+ *
203
+ * Derived from `toGatewayRoutes()` on purpose, so the two builders can never
204
+ * disagree. It previously string-stripped `openrouter/` itself, which emitted
205
+ * OpenRouter's dot ids for divergent vendors (`anthropic/claude-opus-4.8` —
206
+ * the direct API only serves the dash form) and invented a bare direct id for
207
+ * OpenRouter-only models (`fable`). Both made `amicus doctor` and `amicus
208
+ * models --check` warn about the product's own shipped defaults.
209
+ */
210
+ function toDefaultAliases() {
211
+ const out = {};
212
+ for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
213
+ out[alias] = routes.direct || routes.openrouter;
214
+ }
215
+ return out;
216
+ }
217
+
216
218
  module.exports = {
217
219
  getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
218
220
  };
@@ -22,6 +22,8 @@ const ERROR_CODES = Object.freeze({
22
22
  INTERNAL: 'INTERNAL', // unexpected pre-flight throw
23
23
  COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
24
24
  COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
25
+ // council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
26
+ COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
25
27
  });
26
28
 
27
29
  /**
@@ -19,6 +19,12 @@ const ANTHROPIC_MODELS = [
19
19
  { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
20
20
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
21
21
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
22
+ // Dated snapshot: the id Anthropic's /v1/models actually lists, and the
23
+ // `haiku` direct route curated-models.js authors. Without it the floor
24
+ // (the only anthropic/ rows a keyless or OpenRouter-only user ever has)
25
+ // reports the shipped `haiku` default as stale.
26
+ { id: 'anthropic/claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5 (2025-10-01)',
27
+ contextLength: null, pricing: null },
22
28
  { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null }
23
29
  ];
24
30