amicus 4.0.1 → 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 (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +75 -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 +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/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 +19 -7
  31. package/src/council/run.js +100 -110
  32. package/src/council/verdict.js +43 -2
  33. package/src/mcp-council-run.js +7 -0
  34. package/src/mcp-server.js +28 -3
  35. package/src/mcp-tools.js +22 -2
  36. package/src/utils/error-doc.js +2 -0
@@ -3,13 +3,17 @@
3
3
 
4
4
  /**
5
5
  * @module council/run-assemble
6
- * Pure assembly + artifact emission for the headless council engine (spec §5):
7
- * the five-keys tally input (meta pins: claudeInCouncil false, models = bench
8
- * seats exactly critic included, chair excludedrunType 'headless'),
9
- * runStats rows copied verbatim from leg docs, and the run-dir artifact set
10
- * (tally-input.json, tally.json, verdict.json with overallVerdict, report.html,
11
- * chair-output.md). Raiser self-votes are INCLUDED in adjudications — exclusion
12
- * is tally's job (tally.js:95); judged is tally's job (tally.js:110).
6
+ * Assembly + artifact emission for the headless council engine (spec §5), plus
7
+ * the `--claude-review` pre-flight it now also owns: the five-keys tally input
8
+ * (meta pins: claudeInCouncil false, models = bench seats exactly critic
9
+ * included, chair excluded runType 'headless'), runStats rows copied
10
+ * verbatim from leg docs, the run-dir artifact set (tally-input.json,
11
+ * tally.json, verdict.json with overallVerdict, report.html, chair-output.md),
12
+ * v4.1 §4.4 pre-flight validation of the file-sourced Claude review
13
+ * (preflightClaudeReview — the reserved-seat/chair/critic guards), its review-N+1
14
+ * labelling (labelClaudeReview), and its synthesized null-usage runStats row
15
+ * (claudeRunStatsRow). Raiser self-votes are INCLUDED in adjudications —
16
+ * exclusion is tally's job (tally.js:95); judged is tally's job (tally.js:110).
13
17
  */
14
18
 
15
19
  const fs = require('fs');
@@ -17,9 +21,15 @@ const path = require('path');
17
21
  const { writeFileAtomic } = require('../utils/atomic-write');
18
22
  const { buildVerdict, writeVerdictAtomic } = require('./verdict');
19
23
  const { buildReport } = require('./report');
24
+ const { validateFindings } = require('./findings');
25
+ const { toGlobalFindings } = require('./anonymize');
20
26
 
21
27
  const CONFORMANCE_RANK = { clean: 0, repaired: 1, unstructured: 2 };
22
28
 
29
+ /** v4.1 §4.4: the reserved seat name for the file-sourced Claude review. */
30
+ const CLAUDE_SEAT = 'claude';
31
+ const CLAUDE_REVIEW_ERROR = 'COUNCIL_CLAUDE_REVIEW_INVALID';
32
+
23
33
  /** Worst-wins merge of Stage-1 findings conformance and Stage-2 judge conformance. */
24
34
  function worseConformance(a, b) {
25
35
  return (CONFORMANCE_RANK[a] || 0) >= (CONFORMANCE_RANK[b] || 0) ? a : b;
@@ -43,14 +53,79 @@ function buildRunStatsEntry({ leg, model, role, wasChair, conformance }) {
43
53
  };
44
54
  }
45
55
 
56
+ /**
57
+ * Pre-flight for `--claude-review <path>` (v4.1 §4.4). Runs AFTER initRun (so the
58
+ * error doc lands in a run dir that exists) and BEFORE any launch, so an invalid
59
+ * file costs zero spend. The orchestrator authored the file, so there is no repair
60
+ * loop — fix and relaunch is free.
61
+ * @param {{claudeReviewFile: ?string, chair: ?string, critic: ?string, models: ?Array<string>}} o run options
62
+ * @returns {{claudeReview: object|null, error: ?{code: string, message: string}}}
63
+ */
64
+ function preflightClaudeReview(o) {
65
+ if (!o.claudeReviewFile) { return { claudeReview: null, error: null }; }
66
+ const bad = (detail) => ({ claudeReview: null,
67
+ error: { code: CLAUDE_REVIEW_ERROR, message: `council_claude_review_invalid: ${detail}` } });
68
+ if (o.chair === CLAUDE_SEAT) {
69
+ return bad('claude may not chair (it is judged, never votes or chairs)');
70
+ }
71
+ if (o.critic === CLAUDE_SEAT) {
72
+ return bad("'claude' is a reserved seat name and cannot be the critic");
73
+ }
74
+ if (Array.isArray(o.models) && o.models.includes(CLAUDE_SEAT)) {
75
+ // 'claude' is a reserved seat name for the file-sourced review N+1 (it
76
+ // joins meta.models synthetically — see buildTallyInput). A real bench
77
+ // leg ALSO named 'claude' would collide on that same key: labelMap gets
78
+ // two 'claude' entries, and ledger.js's Map join lets the synthesized
79
+ // claude row overwrite the real leg's role/conformance/wasChair and
80
+ // double-count findingsRaised — permanently, since the ledger is
81
+ // append-only. Reject it here so every entry point (CLI, MCP, GitHub
82
+ // Action, direct require('./council/run')) is covered, not just the
83
+ // CLI's option whitelist.
84
+ return bad("'claude' is a reserved seat name and cannot also appear in --models");
85
+ }
86
+ let text = '';
87
+ try { text = fs.readFileSync(o.claudeReviewFile, 'utf-8'); }
88
+ catch (e) { return bad(`cannot read ${o.claudeReviewFile}: ${e.message}`); }
89
+ const v = validateFindings(text);
90
+ if (!v.ok) { return bad(v.errors.map(e => `${e.code}: ${e.detail}`).join('; ')); }
91
+ return { claudeReview: { model: CLAUDE_SEAT, text, findings: v.findings }, error: null };
92
+ }
93
+
94
+ /**
95
+ * Stamp the file-sourced Claude review with its label (always the LAST entry —
96
+ * review N+1) and its run-global finding ids. Mutates + returns the new ids so
97
+ * run.js can concat them onto the bench's globalFindings in one expression.
98
+ * @returns {Array<object>} claude's run-global findings
99
+ */
100
+ function labelClaudeReview(claudeReview, labels) {
101
+ const e = labels.entries[labels.entries.length - 1];
102
+ claudeReview.label = e.label;
103
+ claudeReview.globalFindings = toGlobalFindings(e.letter, CLAUDE_SEAT, claudeReview.findings);
104
+ return claudeReview.globalFindings;
105
+ }
106
+
107
+ /**
108
+ * The synthesized runStats row for a review that never ran a leg (v4.1 §4.4).
109
+ * durationMs/usage are null per the never-invent rule — nothing was launched.
110
+ */
111
+ function claudeRunStatsRow() {
112
+ return { model: CLAUDE_SEAT, role: CLAUDE_SEAT, wasChair: false, conformance: 'clean',
113
+ status: 'complete', durationMs: null, usage: null };
114
+ }
115
+
46
116
  /**
47
117
  * Assemble the five-keys tally input (spec §5 / SKILL.md Stage-2 recipe).
48
118
  * @param {{runId: string, date: string, bench: string[], chair: string,
49
119
  * reviews: Array<{model, role, conformance, leg, globalFindings}>,
50
120
  * judgeResults: Array<{judge, ok, order, adjudications}>,
51
- * chairStats: object|null}} args
121
+ * chairStats: object|null, claudeReview?: object|null}} args
122
+ * `claudeReview` (v4.1 §4.4) amends the v4.0 meta pin: present ⇒ claudeInCouncil
123
+ * true, 'claude' joins meta.models (the street-cred universe), its findings join
124
+ * the pool and it gets the synthesized null-usage runStats row. Absent ⇒ v4.0
125
+ * output byte-for-byte.
52
126
  */
53
- function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats }) {
127
+ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats,
128
+ claudeReview }) {
54
129
  const meta = {
55
130
  runId, date, runType: 'headless',
56
131
  models: bench.slice(), // bench seats exactly: critic included, chair excluded
@@ -65,6 +140,12 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
65
140
  const runStats = reviews.map(r => buildRunStatsEntry({
66
141
  leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
67
142
  }));
143
+ if (claudeReview) {
144
+ meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
145
+ meta.claudeInCouncil = true;
146
+ findings.push(...claudeReview.globalFindings);
147
+ runStats.push(claudeRunStatsRow());
148
+ }
68
149
  if (chairStats) { runStats.push(chairStats); }
69
150
  return { meta, findings, adjudications, rankings, runStats };
70
151
  }
@@ -97,4 +178,5 @@ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
97
178
 
98
179
  module.exports = {
99
180
  buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
181
+ preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
100
182
  };
@@ -0,0 +1,145 @@
1
+ // src/council/run-chair.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-chair
6
+ * Chair synthesis + VERDICT-line repair for the headless council driver,
7
+ * lifted VERBATIM out of run.js for the 300-line gate (v4.1 Task 0.5). Pure
8
+ * refactor: same launches, same waveIds, same run.json checkpoints, same
9
+ * degradation rules. Launchers come in through `ctx` exactly as run-stages.js
10
+ * takes them; run.js keeps the chair-packet build, the tally sequencing, the
11
+ * signal bookkeeping and finalize().
12
+ *
13
+ * `isSignalled` is a GETTER, not a snapshot: run.js's signal handler mutates
14
+ * its `signalled` local between awaits and the v4.0 code re-read it after every
15
+ * chair launch. Passing the value instead would silently drop those aborts.
16
+ */
17
+
18
+ const stage2 = require('./briefings-stage2');
19
+ const { parseChairVerdict } = require('./parse-stage2');
20
+ const runState = require('./run-state');
21
+ const { isAbortExit } = require('./run-stages');
22
+
23
+ /**
24
+ * Chair fallback promotion (spec §4): the highest peers-only street-cred
25
+ * model from `council stats` that is not a bench seat and not the failed
26
+ * chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
27
+ * (deriveReliability's avgStreetCredPeersOnly; lower is better).
28
+ *
29
+ * The reserved seat name 'claude' is never eligible (v4.1 §4.4 "never chairs"):
30
+ * a --claude-review run puts a real 'claude' row in the ledger, so without this
31
+ * filter a LATER run could promote it and walk straight past the pre-flight
32
+ * --chair claude guard — with no Claude leg to launch.
33
+ * @returns {string|null}
34
+ */
35
+ function pickFallbackChair(statsRows, bench, failedChair) {
36
+ const benchSet = new Set(bench);
37
+ const candidates = (statsRows || [])
38
+ .filter(r => r.model !== 'claude' && !benchSet.has(r.model) && r.model !== failedChair
39
+ && typeof r.avgStreetCredPeersOnly === 'number')
40
+ .sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
41
+ return candidates.length ? candidates[0].model : null;
42
+ }
43
+
44
+ /**
45
+ * Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
46
+ * single VERDICT-line repair re-prompt.
47
+ * @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
48
+ * @param {{packet: string, degraded: {value: boolean}, statsFn: Function,
49
+ * isSignalled: function(): (number|null)}} args
50
+ * @returns {Promise<{aborted: number|null, chairLeg: object|null,
51
+ * actualChair: string|null, chairText: string|null,
52
+ * chairConformance: string, overallVerdict: string|null}>}
53
+ */
54
+ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
55
+ const { o, launchers, addWave, overBudget } = ctx;
56
+ const now = () => new Date().toISOString();
57
+ const bail = (code) => ({
58
+ aborted: code, chairLeg: null, actualChair: null, chairText: null,
59
+ chairConformance: 'clean', overallVerdict: null,
60
+ });
61
+
62
+ const attemptChair = async (model, waveId) => {
63
+ runState.appendStageWave(o.runDir, 'chair', waveId);
64
+ const solo = await launchers.launchSolo({
65
+ model, prompt: packet, project: o.runDir, waveId,
66
+ timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
67
+ // v4.1 §4.5d: the chair chain (ch1/ch2/ch3) and the ch4 VERDICT repair
68
+ // below are the launches a re-armed price gate would refuse LAST, after
69
+ // the whole bench has already been paid for.
70
+ noCostGate: o.noCostGate,
71
+ });
72
+ addWave(solo.wave);
73
+ const ok = solo.leg && solo.leg.status === 'complete'
74
+ && solo.leg.summary && solo.leg.summary.trim();
75
+ return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
76
+ };
77
+
78
+ let chairLeg = null;
79
+ let actualChair = null;
80
+ if (overBudget()) {
81
+ // Ceiling hit after the tally is computable: skip the chair, write the
82
+ // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
83
+ // Never abort in-flight legs for cost — this only stops NEW launches.
84
+ degraded.value = true;
85
+ runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
86
+ } else {
87
+ runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
88
+ // Fallback chain (spec §4): retry same chair once → promote best
89
+ // non-bench model from the ledger → give up (no Claude fallback headless).
90
+ let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
91
+ if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
92
+ if (!attempt.leg && !overBudget()) {
93
+ attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
94
+ if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
95
+ }
96
+ if (attempt.leg) { actualChair = o.chair; }
97
+ else if (!overBudget()) {
98
+ let statsRows = [];
99
+ try { statsRows = statsFn(); } catch { /* no ledger yet */ }
100
+ const fallback = pickFallbackChair(statsRows, o.models, o.chair);
101
+ if (fallback) {
102
+ attempt = await attemptChair(fallback, `${o.runId}-ch3`);
103
+ if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
104
+ if (attempt.leg) { actualChair = fallback; }
105
+ }
106
+ }
107
+ chairLeg = attempt.leg;
108
+ runState.updateStage(o.runDir, 'chair',
109
+ { status: chairLeg ? 'complete' : 'error', completedAt: now() });
110
+ // The chair chain may have promoted a fallback (or given up) — checkpoint
111
+ // the ACTUAL chair into run.json now so status/`--json`/the human summary
112
+ // never report the originally-requested chair after a promotion. Mirrors
113
+ // mkInput's actualChair || o.chair (a give-up with no actual chair keeps
114
+ // the requested chair).
115
+ runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
116
+ }
117
+ const chairText = chairLeg ? chairLeg.summary : null;
118
+ let chairConformance = 'clean';
119
+
120
+ // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
121
+ let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
122
+ if (chairText && !overallVerdict && !overBudget()) {
123
+ runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
124
+ const repair = await launchers.launchSolo({
125
+ model: actualChair, prompt: stage2.buildChairRepairPrompt(),
126
+ project: o.runDir, waveId: `${o.runId}-ch4`,
127
+ timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
128
+ noCostGate: o.noCostGate,
129
+ });
130
+ addWave(repair.wave);
131
+ if (isAbortExit(repair.exitCode) || isSignalled()) { return bail(repair.exitCode || isSignalled()); }
132
+ overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
133
+ chairConformance = overallVerdict ? 'repaired' : 'unstructured';
134
+ }
135
+ // A completed chair whose verdict never parsed is 'unstructured' even when
136
+ // the repair was skipped (e.g. the chair leg itself tripped --max-cost).
137
+ if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
138
+ if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
139
+
140
+ return {
141
+ aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
142
+ };
143
+ }
144
+
145
+ module.exports = { runChair, pickFallbackChair };
@@ -0,0 +1,289 @@
1
+ // src/council/run-debate.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-debate
6
+ * Impure Stage-2.5 orchestration for headless debate mode (spec §5.1). Launches the
7
+ * defense mini-wave (one solo per raiser) and the re-vote mini-wave (one fanout to
8
+ * disputing judges), parses each with one bounded repair, then hands off to the pure
9
+ * reassembly in ./debate.js. Launchers are injected via ctx (repo DI pattern).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const dbrief = require('./briefings-debate');
15
+ const { parseDebateDefense, parseRevote } = require('./parse-stage2');
16
+ const { applyDebate, debateRunStatsRows, PAST_TENSE } = require('./debate');
17
+ const { materializeDebate } = require('./run-launch');
18
+ const { tally } = require('./tally');
19
+ const { isAbortExit } = require('./run-stages');
20
+ const runState = require('./run-state');
21
+
22
+ /** Spec §5.7 fallback: a dead/unparseable defense means every bundled id's original stands. */
23
+ function allNoResponse(ids) {
24
+ const byId = {};
25
+ for (const id of ids) { byId[id] = { action: 'no-response' }; }
26
+ return byId;
27
+ }
28
+
29
+ /** True when there is nothing to challenge (spec §5.1). */
30
+ function nothingToDebate(provisionalRecord) {
31
+ if (!provisionalRecord || provisionalRecord.judged === false) { return true; }
32
+ const n = provisionalRecord.findings.filter(f => f.tier === 'Contested' || f.tier === 'Disputed').length;
33
+ return n === 0;
34
+ }
35
+
36
+ /** Judges whose provisional adjudications dispute at least one bundled id. */
37
+ function disputingJudges(provisionalRecord, bundledIds) {
38
+ const ids = new Set(bundledIds);
39
+ const judges = new Set();
40
+ for (const f of provisionalRecord.findings) {
41
+ if (!ids.has(f.id)) { continue; }
42
+ for (const adj of f.adjudications || []) {
43
+ if (adj.verdict === 'dispute') { judges.add(adj.judge); }
44
+ }
45
+ }
46
+ return [...judges];
47
+ }
48
+
49
+ /** Group Contested+Disputed findings by raiser (defense targets). */
50
+ function debateTargets(provisionalRecord, tallyInput) {
51
+ const claimById = new Map(tallyInput.findings.map(f => [f.id, f]));
52
+ const byRaiser = {};
53
+ const previousTier = {};
54
+ for (const f of provisionalRecord.findings) {
55
+ if (f.tier !== 'Contested' && f.tier !== 'Disputed') { continue; }
56
+ previousTier[f.id] = f.tier;
57
+ const src = claimById.get(f.id) || {};
58
+ const peerVerdicts = (f.adjudications || []).filter(a => a.judge !== f.raiser).map(a => a.verdict);
59
+ (byRaiser[f.raiser] = byRaiser[f.raiser] || []).push({ id: f.id, claim: src.claim,
60
+ severity: f.severity, location: src.location, peerVerdicts, disputeReasons: [] });
61
+ }
62
+ return { byRaiser, previousTier };
63
+ }
64
+
65
+ /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
66
+ function legOpts(ctx, waveId) {
67
+ return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
68
+ noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate };
69
+ }
70
+
71
+ async function runDefenseSolo(ctx, raiser, findings, idx) {
72
+ const brief = dbrief.buildDefenseBrief({ findings, date: ctx.o.date });
73
+ const waveId = `${ctx.o.runId}-d${idx + 1}`;
74
+ const expectedIds = findings.map(f => f.id);
75
+ // Record the sub-wave BEFORE launching: `amicus abort` cascades over stages[].waveIds
76
+ // (run-stages.js's record(), run-chair.js's chair chain), so an id written after the
77
+ // launch leaves an in-flight leg reachable only by the pid kill. The v4.0.1
78
+ // abort-cascade fix must hold for debate stages too.
79
+ runState.appendStageWave(ctx.o.runDir, 'debate-defense', waveId);
80
+ const res = await ctx.launchers.launchSolo({ ...legOpts(ctx, waveId), model: raiser, prompt: brief });
81
+ ctx.addWave(res.wave);
82
+ if (isAbortExit(res.exitCode)) { return { raiser, aborted: res.exitCode }; }
83
+ let leg = res.leg && res.leg.status === 'complete' ? res.leg : null;
84
+ // A dead leg gets the SAME spec §5.7 fallback the parser applies to a block-level
85
+ // failure — every expected id 'no-response', never an empty map, so the
86
+ // originals-stand outcome still reaches debate.json and the record decoration.
87
+ let parsed = leg ? parseDebateDefense(leg.summary, expectedIds)
88
+ : { ok: false, byId: allNoResponse(expectedIds), errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
89
+ let conformance = leg ? 'clean' : 'unstructured';
90
+ if (leg && !parsed.ok) {
91
+ const repairId = `${waveId}r`;
92
+ runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
93
+ const res2 = await ctx.launchers.launchSolo({
94
+ ...legOpts(ctx, repairId), model: raiser,
95
+ prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors }),
96
+ });
97
+ ctx.addWave(res2.wave);
98
+ if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
99
+ const leg2 = res2.leg && res2.leg.status === 'complete' ? res2.leg : null;
100
+ parsed = leg2 ? parseDebateDefense(leg2.summary, expectedIds) : parsed;
101
+ conformance = parsed.ok ? 'repaired' : 'unstructured';
102
+ if (leg2) { leg = leg2; }
103
+ }
104
+ // A dead leg (no complete summary) OR an 'unstructured' conformance after the one
105
+ // repair is a debate degradation (spec §5.7) — surfaced via the returned leg.
106
+ const stub = { model: raiser, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
107
+ return { raiser, byId: parsed.byId,
108
+ leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage, conformance, summary: leg.summary } : stub };
109
+ }
110
+
111
+ async function runRevoteWave(ctx, judges, bundleFindings) {
112
+ const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date });
113
+ // spec §5.1 names `revote-bundle.md` a run-dir artifact: the shared re-vote prompt goes to
114
+ // disk exactly like Stage 2's bundle-stage2.md, so the round's model-facing input is
115
+ // auditable alongside briefing-stage1.md and chair-packet.md.
116
+ fs.writeFileSync(path.join(ctx.o.runDir, 'revote-bundle.md'), bundle, { mode: 0o600 });
117
+ const waveId = `${ctx.o.runId}-rv`;
118
+ const expectedIds = bundleFindings.map(f => f.id);
119
+ // run-debate — not run.js — owns this stage's `running` checkpoint AND its abort-cascade
120
+ // id: only this function knows whether the wave actually launched (it is skipped when
121
+ // nothing was defended/amended, or the cost ceiling hit).
122
+ runState.updateStage(ctx.o.runDir, 'debate-revote',
123
+ { status: 'running', startedAt: new Date().toISOString(), project: ctx.scratchDir, waveId });
124
+ runState.appendStageWave(ctx.o.runDir, 'debate-revote', waveId);
125
+ const res = await ctx.launchers.launchWave({ ...legOpts(ctx, waveId), models: judges, prompt: bundle });
126
+ ctx.addWave(res.wave);
127
+ if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
128
+ const byJudge = {}, legs = [];
129
+ for (const leg of ((res.wave && res.wave.legs) || [])) {
130
+ // The council ALIAS, not the resolved executable id — runStats rows join
131
+ // meta.models by exact string (run-assemble.js's buildRunStatsEntry).
132
+ const judge = leg.modelInput || leg.model;
133
+ const alive = leg.status === 'complete' && leg.summary;
134
+ let outLeg = leg; // the leg actually recorded (post-repair when there is one)
135
+ let parsed = alive ? parseRevote(leg.summary, expectedIds)
136
+ : { ok: false, byId: {}, errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
137
+ let conformance = alive ? 'clean' : 'unstructured';
138
+ if (alive && !parsed.ok) {
139
+ // One repair, solo, to that judge.
140
+ const repairId = `${waveId}-${judge}r`;
141
+ runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
142
+ const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
143
+ prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors }) });
144
+ ctx.addWave(r2.wave);
145
+ if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
146
+ const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
147
+ parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
148
+ conformance = parsed.ok ? 'repaired' : 'unstructured';
149
+ // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
150
+ // revote-<model>.md and the runStats row keep the PRE-repair output.
151
+ if (leg2) { outLeg = leg2; }
152
+ }
153
+ byJudge[judge] = parsed.byId;
154
+ legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage, conformance, summary: outLeg.summary || '' });
155
+ }
156
+ return { byJudge, legs };
157
+ }
158
+
159
+ /** The re-vote bundle: defended-or-amended findings ONLY (spec §5.1 — withdrawn never appear). */
160
+ function bundleFor(defenseResults, tallyInput) {
161
+ const out = [];
162
+ for (const dr of defenseResults) {
163
+ for (const [id, resp] of Object.entries(dr.byId)) {
164
+ if (resp.action !== 'defend' && resp.action !== 'amend') { continue; }
165
+ const src = tallyInput.findings.find(f => f.id === id) || {};
166
+ out.push({ id, severity: src.severity, amended: resp.action === 'amend',
167
+ claim: resp.action === 'amend' ? resp.claim : src.claim,
168
+ argument: resp.argument || 'defended without extra argument' });
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Full Stage-2.5 sequence (spec §5.1). Returns everything run.js needs. Cost gate: run.js
176
+ * checks overBudget before invoking; this checks again before the re-vote wave (spec §5.7).
177
+ * @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
178
+ * @param {{provisionalRecord: object, tallyInput: object}} args
179
+ */
180
+ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
181
+ const { byRaiser, previousTier } = debateTargets(provisionalRecord, tallyInput);
182
+ const contested = provisionalRecord.findings.filter(f => f.tier === 'Contested').length;
183
+ const disputed = provisionalRecord.findings.filter(f => f.tier === 'Disputed').length;
184
+
185
+ // ---- Defense mini-wave: ONE CONCURRENT solo per raiser (spec §5.1) ----
186
+ // Concurrent, not sequential: every raiser gets its OWN briefing, so this is N independent
187
+ // solos rather than one fanout wave. No per-leg budget check interleaves between them — the
188
+ // cost ceiling is a WHOLE-ROUND gate run.js applies BEFORE calling runDebate
189
+ // ('skipped-cost-ceiling' is a round-level outcome in spec §5.1's enum, not a per-leg one).
190
+ // `appendStageWave` is sync fs and each solo registers its waveId before its first await,
191
+ // so concurrency cannot interleave a read-modify-write of run.json.
192
+ // v4.1 §4.4: the reserved seat 'claude' is a FILE-sourced review with no leg to
193
+ // launch, so it is never asked to defend — its contested findings simply stand
194
+ // (the same "originals stand" outcome as a no-response).
195
+ const raisers = Object.keys(byRaiser).filter(m => m !== 'claude');
196
+ const defenseResults = await Promise.all(
197
+ raisers.map((raiser, i) => runDefenseSolo(ctx, raiser, byRaiser[raiser], i)));
198
+ // A signal during the defense wave aborts the whole finalization (spec §5.7):
199
+ // return the abort code so run.js finalizes 'aborted' with NO tally-final / NO ledger.
200
+ const abortedDefense = defenseResults.find(d => d.aborted);
201
+ if (abortedDefense) { return { aborted: abortedDefense.aborted, contested, disputed }; }
202
+ materializeDebate(ctx.o.runDir, defenseResults.map(d => ({ model: d.raiser, summary: d.leg.summary })), 'rebuttal');
203
+
204
+ const defenseByRaiser = {};
205
+ for (const dr of defenseResults) { defenseByRaiser[dr.raiser] = { ...dr.byId }; }
206
+ // v4.1 §4.4: claude never gets a defense leg (raisers filter above), but its
207
+ // contested/disputed findings still need an audit trail — the SAME spec §5.7
208
+ // "originals stand" fallback a dead/unrepaired defense leg gets. Seeded into
209
+ // defenseByRaiser ONLY (never defenseResults, which feeds the `bad(l)`
210
+ // degraded check below — a claude entry there would wrongly flip a clean run
211
+ // to degraded/exit 2).
212
+ if (byRaiser.claude) { defenseByRaiser.claude = allNoResponse(byRaiser.claude.map(f => f.id)); }
213
+ // Stamp previousTier onto the tally input: applyDebate reads it off tallyInput.findings[]
214
+ // (it ignores the provisional record), so without this every row's previousTier is null.
215
+ const stampedInput = { ...tallyInput, findings: tallyInput.findings.map(f => ({ ...f, previousTier: previousTier[f.id] })) };
216
+
217
+ // ---- Re-vote mini-wave (disputing judges only) ----
218
+ let revoteByJudge = {}, revoteLegs = [];
219
+ const defendedOrAmended = bundleFor(defenseResults, tallyInput);
220
+ const judges = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
221
+ // A re-vote is warranted only when something was defended/amended AND ≥1 judge disputed it.
222
+ // Skipping THAT case because the whole-run budget is spent is the 'skipped-cost-ceiling'
223
+ // degradation branch (spec §5.7); skipping because there is simply nothing to re-vote is NOT.
224
+ const wouldRevote = defendedOrAmended.length > 0 && judges.length > 0;
225
+ const costCeiling = ctx.overBudget() && wouldRevote;
226
+ if (wouldRevote && !costCeiling) {
227
+ const rv = await runRevoteWave(ctx, judges, defendedOrAmended);
228
+ if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
229
+ revoteByJudge = rv.byJudge;
230
+ revoteLegs = rv.legs;
231
+ // revote-<model>.md per surviving judge leg, mirroring rebuttal-<model>.md
232
+ // (spec §5.1 'raw outputs revote-<model>.md').
233
+ materializeDebate(ctx.o.runDir, revoteLegs, 'revote');
234
+ }
235
+
236
+ // ---- Pure reassembly ----
237
+ const { input: debatedInput, debateFindings } = applyDebate({
238
+ tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
239
+ debatedInput.runStats = [...(debatedInput.runStats || []),
240
+ ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs })];
241
+
242
+ // verdictChanges: findings whose tier moved from provisional to debated.
243
+ const provTierById = new Map(provisionalRecord.findings.map(f => [f.id, f.tier]));
244
+ const debatedRec = tally(debatedInput);
245
+ let verdictChanges = 0;
246
+ for (const f of debatedRec.findings) { if (provTierById.get(f.id) !== f.tier) { verdictChanges += 1; } }
247
+
248
+ // ---- Artifacts + summary ----
249
+ const revotesJson = [];
250
+ for (const [judge, perId] of Object.entries(revoteByJudge)) {
251
+ for (const [id, rv] of Object.entries(perId)) { revotesJson.push({ judge, id, verdict: rv.verdict, reason: rv.reason || null, applied: true }); }
252
+ }
253
+ fs.writeFileSync(path.join(ctx.o.runDir, 'debate.json'),
254
+ JSON.stringify({ findings: debateFindings, revotes: revotesJson }, null, 2), { mode: 0o600 });
255
+
256
+ const counts = { defended: 0, amended: 0, withdrawn: 0, noResponse: 0 };
257
+ const COUNT_KEY = { defend: 'defended', amend: 'amended', withdraw: 'withdrawn' };
258
+ for (const df of debateFindings) { counts[COUNT_KEY[df.action] || 'noResponse'] += 1; }
259
+ const debateSummary = {
260
+ enabled: true, outcome: costCeiling ? 'skipped-cost-ceiling' : 'ran',
261
+ contested, disputed, ...counts,
262
+ revoteJudges: revoteLegs.length, revoteApplied: revotesJson.length, verdictChanges,
263
+ };
264
+
265
+ // ---- Degradation (spec §5.7) → run.js maps this to exit code 2 ----
266
+ // A dead/unstructured-after-repair defense solo, a partial or fully-dead re-vote wave, or a
267
+ // cost-ceiling skip of a warranted re-vote each degrade the run. (Abort short-circuits above;
268
+ // nothing-to-debate and a clean run are NOT degradations.)
269
+ const bad = (l) => l.status !== 'complete' || l.conformance === 'unstructured';
270
+ const degraded = defenseResults.some(d => bad(d.leg)) || revoteLegs.some(bad) || costCeiling;
271
+
272
+ // Chair-addendum outcomes (spec §5.3c). `action` is the PAST_TENSE form
273
+ // buildDebateAddendum renders verbatim — only the four valid values ever reach it.
274
+ const priorById = new Map(provisionalRecord.findings.map(
275
+ f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.judge, a.verdict]))]));
276
+ const addendumOutcomes = debateFindings.map(df => ({
277
+ id: df.id, originalClaim: (tallyInput.findings.find(f => f.id === df.id) || {}).claim,
278
+ action: PAST_TENSE[df.action] || PAST_TENSE['no-response'],
279
+ amendedClaim: df.action === 'amend' ? df.claim : null,
280
+ priorVerdicts: priorById.get(df.id) || {},
281
+ revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.judge, r.verdict])),
282
+ }));
283
+
284
+ return { debatedInput, debateFindings, debateSummary, addendumOutcomes,
285
+ defenseLegs: defenseResults.map(d => d.leg), revoteLegs, verdictChanges,
286
+ degraded, aborted: null };
287
+ }
288
+
289
+ module.exports = { runDebate, nothingToDebate, disputingJudges, debateTargets };
@@ -43,6 +43,13 @@ function createLaunchers(deps = {}) {
43
43
  includeContext: false,
44
44
  gatewayMode: opts.gateway,
45
45
  noValidateModel: opts.noValidateModel,
46
+ // v4.1 §4.5d: `--no-cost-gate` is a WHOLE-RUN opt-out (an intentional
47
+ // o3-class council), so it has to ride every council launch — otherwise
48
+ // fanout's per-$/Mtok gate refuses the first repair or the chair
49
+ // mid-council. Transport key is literally `noCostGate` (fanout.js
50
+ // guards with `if (!options.noCostGate)`); the CALLERS assemble these
51
+ // option objects, so run-stages/run-chair/run-debate each set it.
52
+ noCostGate: !!opts.noCostGate,
46
53
  json: false,
47
54
  quiet: true,
48
55
  // Spec §6 judge isolation: pin every leg's OpenCode tool-exec cwd to its
@@ -96,4 +103,23 @@ function materializeReviews(runDir, legs) {
96
103
  return out;
97
104
  }
98
105
 
99
- module.exports = { createLaunchers, materializeReviews, sanitizeName };
106
+ /**
107
+ * Write per-leg debate artifacts: `<prefix>-<sanitizeName(model)>.md` for each
108
+ * leg with a non-empty summary. Mirrors materializeReviews.
109
+ * @param {string} runDir
110
+ * @param {Array<{model: string, summary: string}>} legs
111
+ * @param {string} prefix 'rebuttal' | 'revote'
112
+ * @returns {Array<{model: string, file: string}>}
113
+ */
114
+ function materializeDebate(runDir, legs, prefix) {
115
+ const out = [];
116
+ for (const leg of legs) {
117
+ if (!leg || !leg.summary || !leg.summary.trim()) { continue; }
118
+ const file = path.join(runDir, `${prefix}-${sanitizeName(leg.model)}.md`);
119
+ fs.writeFileSync(file, leg.summary, { mode: 0o600 });
120
+ out.push({ model: leg.model, file });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ module.exports = { createLaunchers, materializeReviews, materializeDebate, sanitizeName };