amicus 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +3 -3
  4. package/commands/council.md +6 -6
  5. package/package.json +1 -1
  6. package/schemas/council-run.schema.json +16 -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/src/cli-handlers-council-run.js +9 -0
  17. package/src/cli-handlers-council.js +20 -2
  18. package/src/cli.js +8 -0
  19. package/src/council/briefings-debate.js +158 -0
  20. package/src/council/briefings-stage2.js +16 -9
  21. package/src/council/debate.js +98 -0
  22. package/src/council/ledger.js +2 -1
  23. package/src/council/parse-stage2.js +83 -1
  24. package/src/council/report-html.js +28 -1
  25. package/src/council/report.js +50 -2
  26. package/src/council/run-assemble.js +91 -9
  27. package/src/council/run-chair.js +145 -0
  28. package/src/council/run-debate.js +289 -0
  29. package/src/council/run-launch.js +27 -1
  30. package/src/council/run-stages.js +41 -13
  31. package/src/council/run-state.js +40 -1
  32. package/src/council/run.js +108 -110
  33. package/src/council/verdict.js +43 -2
  34. package/src/mcp-council-awareness.js +187 -0
  35. package/src/mcp-council-run.js +29 -128
  36. package/src/mcp-server.js +28 -3
  37. package/src/mcp-tools.js +22 -2
  38. package/src/utils/error-doc.js +2 -0
@@ -25,6 +25,7 @@ const briefings = require('./briefings');
25
25
  const stage2 = require('./briefings-stage2');
26
26
  const { parseJudgeOutput } = require('./parse-stage2');
27
27
  const { materializeReviews, sanitizeName } = require('./run-launch');
28
+ const runState = require('./run-state');
28
29
 
29
30
  function isAbortExit(code) { return code === 130 || code === 143; }
30
31
 
@@ -35,25 +36,37 @@ function slug(text) {
35
36
  /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
36
37
  async function launchStage1(ctx) {
37
38
  const { o, launchers } = ctx;
39
+ // `noCostGate` rides EVERY launch object in this file (here, the findings
40
+ // repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
38
41
  const common = {
39
42
  project: o.runDir, timeout: o.timeout, gateway: o.gateway,
40
- noValidateModel: o.noValidateModel,
43
+ noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
41
44
  };
42
45
  const launches = [];
46
+ // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
47
+ // stages[].waveIds, so an id written after the launch leaves that leg
48
+ // reachable only by the pid kill (no per-leg abort marker).
49
+ const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
43
50
  if (o.lenses) {
44
- o.models.forEach((m, i) => launches.push(launchers.launchSolo({
45
- ...common, model: m, waveId: `${o.runId}-l${i + 1}`,
46
- prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
47
- })));
51
+ o.models.forEach((m, i) => {
52
+ const waveId = `${o.runId}-l${i + 1}`;
53
+ record(waveId);
54
+ launches.push(launchers.launchSolo({
55
+ ...common, model: m, waveId,
56
+ prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
57
+ }));
58
+ });
48
59
  } else {
49
60
  const seats = o.models.filter(m => m !== o.critic);
50
61
  if (seats.length > 0) {
62
+ record(`${o.runId}-s1`);
51
63
  launches.push(launchers.launchWave({
52
64
  ...common, models: seats, waveId: `${o.runId}-s1`,
53
65
  prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
66
  }));
55
67
  }
56
68
  if (o.critic) {
69
+ record(`${o.runId}-c1`);
57
70
  launches.push(launchers.launchSolo({
58
71
  ...common, model: o.critic, waveId: `${o.runId}-c1`,
59
72
  prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
@@ -102,10 +115,12 @@ async function runStage1(ctx) {
102
115
  while (!res.ok && attempts < 2 && !ctx.overBudget()) {
103
116
  attempts += 1;
104
117
  repairSeq += 1;
118
+ const waveId = `${o.runId}-p${repairSeq}`;
119
+ runState.appendStageWave(o.runDir, 'stage1', waveId);
105
120
  const solo = await ctx.launchers.launchSolo({
106
121
  model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
107
- project: o.runDir, waveId: `${o.runId}-p${repairSeq}`, timeout: o.timeout,
108
- gateway: o.gateway, noValidateModel: o.noValidateModel,
122
+ project: o.runDir, waveId, timeout: o.timeout,
123
+ gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
109
124
  });
110
125
  ctx.addWave(solo.wave);
111
126
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
@@ -124,26 +139,37 @@ async function runStage1(ctx) {
124
139
  /**
125
140
  * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
126
141
  * @param {object} ctx
127
- * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array}} args
142
+ * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
143
+ * extraLabeled?: Array<{label: string, text: string}>}} args
144
+ * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
145
+ * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
128
146
  * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
129
147
  */
130
- async function runStage2(ctx, { reviews, labels, globalFindings }) {
148
+ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
131
149
  const { o } = ctx;
132
150
  const { rankingToOrder } = require('./anonymize');
133
151
  fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
134
152
 
135
- const labeled = labels.entries.map((e, i) => ({ label: e.label, text: reviews[i].text }));
136
- const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings });
153
+ // Zip off `reviews` (never off `labels.entries`, which may be one longer than
154
+ // reviews when a file-sourced review is present) and append the extras.
155
+ const labeled = reviews
156
+ .map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
157
+ .concat(extraLabeled);
158
+ const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
137
159
  fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
138
160
 
161
+ // ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
162
+ // review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
139
163
  const judges = reviews.map(r => r.modelInput);
140
164
  const parseCtx = {
141
165
  labels: labels.entries.map(e => e.label),
142
166
  findingIds: globalFindings.map(f => f.id),
143
167
  };
168
+ runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
144
169
  const { wave, exitCode } = await ctx.launchers.launchWave({
145
170
  models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
146
171
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
172
+ noCostGate: o.noCostGate,
147
173
  });
148
174
  ctx.addWave(wave);
149
175
  if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
@@ -163,10 +189,12 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
163
189
  while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
164
190
  attempts += 1;
165
191
  repairSeq += 1;
192
+ const waveId = `${o.runId}-q${repairSeq}`;
193
+ runState.appendStageWave(o.runDir, 'stage2', waveId);
166
194
  const solo = await ctx.launchers.launchSolo({
167
195
  model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
168
- project: ctx.scratchDir, waveId: `${o.runId}-q${repairSeq}`, timeout: o.timeout,
169
- gateway: o.gateway, noValidateModel: o.noValidateModel,
196
+ project: ctx.scratchDir, waveId, timeout: o.timeout,
197
+ gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
170
198
  });
171
199
  ctx.addWave(solo.wave);
172
200
  if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
@@ -20,8 +20,31 @@ const { writeFileAtomic } = require('../utils/atomic-write');
20
20
  const { SESSIONS_DIR } = require('../session-manager');
21
21
 
22
22
  const RUN_FILE = 'run.json';
23
+ const SPAWN_PID_FILE = 'spawn.pid';
23
24
 
24
25
  function runPath(runDir) { return path.join(runDir, RUN_FILE); }
26
+ function spawnPidPath(runDir) { return path.join(runDir, SPAWN_PID_FILE); }
27
+
28
+ /**
29
+ * Record the engine child's pid in its own file rather than patching run.json.
30
+ * The spawning process (the MCP handler) and the engine child both write
31
+ * run.json, and `checkpoint` is a read-merge-write with no cross-process lock —
32
+ * so a pid patch from the parent can clobber, or be clobbered by, whatever the
33
+ * child wrote in the same window. A standalone single-write file has no read
34
+ * side, so there is no race to lose. Readers fall back to it whenever run.json
35
+ * carries no pid (see readSpawnPid).
36
+ */
37
+ function writeSpawnPid(runDir, pid) {
38
+ writeFileAtomic(spawnPidPath(runDir), String(pid), { mode: 0o600 });
39
+ }
40
+
41
+ /** @returns {number|null} the recorded spawn pid, or null when absent/corrupt */
42
+ function readSpawnPid(runDir) {
43
+ try {
44
+ const pid = Number.parseInt(fs.readFileSync(spawnPidPath(runDir), 'utf-8').trim(), 10);
45
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
46
+ } catch { return null; }
47
+ }
25
48
 
26
49
  /** @returns {object|null} parsed run.json, or null when missing/corrupt */
27
50
  function readRun(runDir) {
@@ -78,6 +101,21 @@ function updateStage(runDir, name, patch) {
78
101
  return checkpoint(runDir, { stages });
79
102
  }
80
103
 
104
+ /**
105
+ * Append a sub-wave id to one stage's `waveIds` (dedup, launch order preserved).
106
+ * A stage can have several sub-waves in flight at once (lens solos, a critic
107
+ * solo alongside the seat wave) or in sequence (the chair's ch1..ch4 chain), so
108
+ * the single `waveId` field cannot describe them. `amicus abort` cascades over
109
+ * this list to mark every in-flight leg instead of relying on the pid kill.
110
+ */
111
+ function appendStageWave(runDir, name, waveId) {
112
+ const existing = readRun(runDir) || {};
113
+ const stage = (existing.stages || []).find(s => s && s.name === name) || {};
114
+ const waveIds = Array.isArray(stage.waveIds) ? stage.waveIds : [];
115
+ if (waveIds.includes(waveId)) { return existing; }
116
+ return updateStage(runDir, name, { waveIds: [...waveIds, waveId] });
117
+ }
118
+
81
119
  function stripPrefix(runId) { return String(runId).replace(/^council-/, ''); }
82
120
 
83
121
  /** `<project>/.claude/amicus_sessions/council-<runId>.json` */
@@ -117,6 +155,7 @@ function listPointers(project) {
117
155
  }
118
156
 
119
157
  module.exports = {
120
- RUN_FILE, readRun, initRun, checkpoint, updateStage,
158
+ RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
159
+ writeSpawnPid, readSpawnPid,
121
160
  pointerPath, writePointer, readPointer, listPointers,
122
161
  };
@@ -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,13 +97,25 @@ 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 });
111
109
 
112
110
  // ---- Stage 1: independent reviews ----
113
- runState.updateStage(o.runDir, 'stage1',
114
- { status: 'running', startedAt: now(), waveId: `${o.runId}-s1`, project: o.runDir });
111
+ // Lens mode launches one solo per seat instead of a `-s1` seat wave, so it
112
+ // has no primary wave to name — run-stages records each real sub-wave into
113
+ // waveIds at launch. Advertising a `-s1` that never exists made both the
114
+ // abort cascade and the status leg rollup chase a phantom.
115
+ runState.updateStage(o.runDir, 'stage1', {
116
+ status: 'running', startedAt: now(), project: o.runDir,
117
+ ...(o.lenses ? {} : { waveId: `${o.runId}-s1` }),
118
+ });
115
119
  const s1 = await runStage1(ctx);
116
120
  runState.updateStage(o.runDir, 'stage1', {
117
121
  status: 'complete', completedAt: now(),
@@ -135,17 +139,20 @@ async function runCouncil(options, deps = {}) {
135
139
  }
136
140
 
137
141
  // ---- Stage 2: anonymized cross-review ----
138
- 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'] : []));
139
144
  runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
140
145
  // Attach each review's run-global findings (buildTallyInput reads
141
146
  // r.globalFindings per review, not a bare parallel array).
142
147
  s1.reviews.forEach((r, i) => {
143
148
  r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
144
149
  });
145
- const globalFindings = s1.reviews.flatMap(r => r.globalFindings);
150
+ const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
151
+ .concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
146
152
  runState.updateStage(o.runDir, 'stage2',
147
153
  { status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
148
- 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 }] : [] });
149
156
  runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
150
157
  if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
151
158
  if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
@@ -160,103 +167,94 @@ async function runCouncil(options, deps = {}) {
160
167
  // ---- Chair synthesis (provisional tally feeds the packet) ----
161
168
  const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
162
169
  runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
163
- reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
170
+ reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
164
171
  });
165
172
  const provisionalInput = mkInput(null, o.chair);
166
173
  const provisional = tally(provisionalInput);
167
174
 
168
- const packet = stage2.buildChairPacket({
169
- reviews: s1.reviews.map(r => ({ model: r.model, text: r.text })),
170
- rankings: provisionalInput.rankings,
171
- adjudications: provisionalInput.adjudications,
172
- tierCounts: provisional.tierCounts,
173
- });
174
- fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
175
- const attemptChair = async (model, waveId) => {
176
- const solo = await launchers.launchSolo({
177
- model, prompt: packet, project: o.runDir, waveId,
178
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
179
- });
180
- addWave(solo.wave);
181
- const ok = solo.leg && solo.leg.status === 'complete'
182
- && solo.leg.summary && solo.leg.summary.trim();
183
- return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
184
- };
185
-
186
- let chairLeg = null;
187
- let actualChair = null;
188
- if (overBudget()) {
189
- // Ceiling hit after the tally is computable: skip the chair, write the
190
- // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
191
- // Never abort in-flight legs for cost — this only stops NEW launches.
192
- degraded.value = true;
193
- runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
194
- } else {
195
- runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
196
- // Fallback chain (spec §4): retry same chair once → promote best
197
- // non-bench model from the ledger → give up (no Claude fallback headless).
198
- let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
199
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
200
- if (!attempt.leg && !overBudget()) {
201
- attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
202
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
203
- }
204
- if (attempt.leg) { actualChair = o.chair; }
205
- else if (!overBudget()) {
206
- let statsRows = [];
207
- try { statsRows = statsFn(); } catch { /* no ledger yet */ }
208
- const fallback = pickFallbackChair(statsRows, o.models, o.chair);
209
- if (fallback) {
210
- attempt = await attemptChair(fallback, `${o.runId}-ch3`);
211
- if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
212
- 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);
213
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
+ runState.updateStage(o.runDir, 'debate-revote', { status: 'complete', completedAt: now() });
202
+ ({ debatedInput, debateFindings, debateSummary } = dbg);
203
+ debatedRecord = tally(debatedInput);
204
+ // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
205
+ // list must be normalized to null here — otherwise the packet-assembly
206
+ // ternary below still calls buildDebateAddendum({outcomes: []}), which
207
+ // emits a bare "--- Debate round outcomes ---" heading with nothing
208
+ // under it (same defect class ee447b6 fixed on the report renderer).
209
+ debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
210
+ ? dbg.addendumOutcomes : null;
211
+ // Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
212
+ // each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
213
+ if (dbg.degraded) { degraded.value = true; }
214
+ } else if (worthDebating) {
215
+ // Budget gone before the defense wave launched, but there WAS something to debate — the
216
+ // other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
217
+ debateSummary.outcome = 'skipped-cost-ceiling';
218
+ degraded.value = true;
214
219
  }
215
- chairLeg = attempt.leg;
216
- runState.updateStage(o.runDir, 'chair',
217
- { status: chairLeg ? 'complete' : 'error', completedAt: now() });
218
- // The chair chain may have promoted a fallback (or given up) — checkpoint
219
- // the ACTUAL chair into run.json now so status/`--json`/the human summary
220
- // never report the originally-requested chair after a promotion. Mirrors
221
- // mkInput's actualChair || o.chair (a give-up with no actual chair keeps
222
- // the requested chair).
223
- runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
220
+ runState.checkpoint(o.runDir, { debate: debateSummary });
224
221
  }
225
- const chairText = chairLeg ? chairLeg.summary : null;
226
- let chairConformance = 'clean';
227
222
 
228
- // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
229
- let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
230
- if (chairText && !overallVerdict && !overBudget()) {
231
- const repair = await launchers.launchSolo({
232
- model: actualChair, prompt: stage2.buildChairRepairPrompt(),
233
- project: o.runDir, waveId: `${o.runId}-ch4`,
234
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
235
- });
236
- addWave(repair.wave);
237
- if (isAbortExit(repair.exitCode) || signalled) { return finalize(repair.exitCode || signalled); }
238
- overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
239
- chairConformance = overallVerdict ? 'repaired' : 'unstructured';
240
- }
241
- // A completed chair whose verdict never parsed is 'unstructured' even when
242
- // the repair was skipped (e.g. the chair leg itself tripped --max-cost).
243
- if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
244
- if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
223
+ const packet = stage2.buildChairPacket({
224
+ // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
225
+ // no rankings/adjudications, so it appears ONLY as one more review block.
226
+ reviews: s1.reviews.map(r => ({ model: r.model, text: r.text }))
227
+ .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
228
+ rankings: debatedInput.rankings,
229
+ adjudications: debatedInput.adjudications,
230
+ tierCounts: debatedRecord.tierCounts, date: o.date,
231
+ }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
232
+ fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
233
+
234
+ const chairRes = await runChair(ctx, {
235
+ packet, degraded, statsFn, isSignalled: () => signalled,
236
+ });
237
+ if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
238
+ const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
245
239
 
246
240
  // ---- Final tally (chair row included) + ledger + artifacts ----
247
241
  const chairStats = chairLeg ? asm.buildRunStatsEntry({
248
242
  leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
249
243
  conformance: chairConformance,
250
244
  }) : null;
251
- const finalInput = mkInput(chairStats, actualChair || o.chair);
245
+ // Built on the (possibly debated) input so the debate's amended claims, replaced
246
+ // adjudications and rebuttal/revote runStats rows all reach the final record.
247
+ const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
248
+ if (chairStats) { finalInput.runStats = [...(finalInput.runStats || []), chairStats]; }
252
249
  const record = tally(finalInput);
250
+ if (debateFindings) { decorateRecord(record, debateFindings); }
253
251
  if (!o.lenses) {
254
252
  // Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
255
253
  try { appendRunFn(record); }
256
254
  catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
257
255
  }
258
256
  asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
259
- runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
257
+ runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
260
258
  asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
261
259
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
262
260
 
@@ -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 };