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
@@ -148,6 +148,15 @@ async function handleCouncilRun(args) {
148
148
  gateway: resolveGatewayMode(args.gateway),
149
149
  noValidateModel: !!args['no-validate-model'],
150
150
  date: new Date().toISOString().slice(0, 10),
151
+ // v4.1 §4.5b/§4.5d. `--claude-review` is resolved here but VALIDATED by the
152
+ // engine's preflightClaudeReview (run-assemble.js): the reserved-seat and
153
+ // 'claude may not chair' guards live there on purpose so MCP, the GitHub
154
+ // Action and direct `require('./council/run')` callers hit the same rule and
155
+ // the same COUNCIL_CLAUDE_REVIEW_INVALID code. Re-checking them here would
156
+ // give the identical mistake two different error codes by entry point.
157
+ debate: !!args.debate,
158
+ claudeReviewFile: args['claude-review'] ? path.resolve(args['claude-review']) : null,
159
+ noCostGate: !!args['no-cost-gate'],
151
160
  });
152
161
 
153
162
  if (useJson) {
@@ -1,13 +1,14 @@
1
1
  // src/cli-handlers-council.js
2
2
  'use strict';
3
3
  const fs = require('fs');
4
+ const path = require('path');
4
5
  const { tally } = require('./council/tally');
5
6
  const { deriveReliability, appendRun, buildStatsDoc } = require('./council/ledger');
6
7
  const { sumWaveUsage, formatCost } = require('./utils/pricing');
7
8
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
8
9
  const { buildReport } = require('./council/report');
9
10
  const { validateFindings, buildValidateDoc } = require('./council/findings');
10
- const { buildVerdict, writeVerdictAtomic } = require('./council/verdict');
11
+ const { buildVerdict, readOverallVerdict, writeVerdictAtomic } = require('./council/verdict');
11
12
  const {
12
13
  runSave: runCouncilSave,
13
14
  runList: runCouncilList,
@@ -163,12 +164,29 @@ function runVerdict(args, useJson) {
163
164
  }
164
165
  const outPath = args.out || './verdict.json';
165
166
  let verdict;
166
- try { verdict = buildVerdict(record, decisions); }
167
+ try {
168
+ // The Stage-5 replacement overwrites the engine's verdict.json, which is
169
+ // one of only two homes of the chair's synthesis (the other is
170
+ // chair-output.md); tally.json carries no copy. Recover it from the RUN
171
+ // folder — the tally's own directory, not `-o` — before rebuilding.
172
+ const overallVerdict = readOverallVerdict(path.dirname(path.resolve(tallyPath)), record.meta.runId);
173
+ verdict = buildVerdict(record, decisions, { overallVerdict });
174
+ }
167
175
  catch (e) {
168
176
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot build verdict: ${e.message}`,
169
177
  hint: 'either tally.json needs meta, findings[], streetCred[], runStats, tierCounts, or decisions.json must be a JSON array of {id, decision, …} objects' });
170
178
  }
171
179
  writeVerdictAtomic(outPath, verdict);
180
+ if (args.render) {
181
+ // v4.1 §4.5c: refresh report.html next to the decided verdict.
182
+ try {
183
+ const html = buildReport({ verdict }, { format: 'html' });
184
+ fs.writeFileSync(path.join(path.dirname(outPath), 'report.html'), html);
185
+ } catch (e) {
186
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `verdict written but render failed: ${e.message}`,
187
+ hint: 'the verdict.json is valid; re-run `amicus council report <verdict.json> --html` manually' });
188
+ }
189
+ }
172
190
  process.stdout.write(useJson ? JSON.stringify(verdict, null, 2) + '\n' : renderVerdict(verdict, outPath));
173
191
  return 0;
174
192
  }
package/src/cli.js CHANGED
@@ -134,11 +134,13 @@ function isBooleanFlag(key) {
134
134
  'no-validate-model',
135
135
  'remove', // used by 'key' command only; other handlers ignore it
136
136
  'no-cost-gate', // disable the budget gate for this run
137
+ 'debate', // council run: enable the Stage-2.5 rebuttal round
137
138
  'no-ledger', // council tally: compute the record without appending to the reliability ledger
138
139
  'html', // council report: emit a self-contained HTML page
139
140
  'md', // council report: emit Markdown (default)
140
141
  'fix', // doctor: self-heal fixable checks in place (#56)
141
142
  'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
143
+ 'render', // council verdict: also refresh report.html next to the decided verdict
142
144
  ];
143
145
  return booleanFlags.includes(key);
144
146
  }
@@ -508,13 +510,19 @@ Subcommands for 'council':
508
510
  --decisions <d.json> Optional. Stage-4 decisions array (default [])
509
511
  -o, --out <out.json> Output path (default ./verdict.json)
510
512
  --json Print the full verdict document
513
+ --render Also refresh report.html next to the decided verdict
511
514
  run --prompt-file <briefing.md> (--models a,b,c | --council <name>)
512
515
  [--chair <model>] [--critic <model>] [--lenses s1,s2,...]
513
516
  [--out-dir <dir>] [--json] [--max-cost <usd>] [--timeout <min>]
514
517
  [--gateway auto|direct|openrouter] [--no-validate-model]
518
+ [--debate] [--claude-review <file>] [--no-cost-gate]
515
519
  Run the full headless council engine (v4.0).
516
520
  Chair default: deepseek (must NOT be a bench seat).
517
521
  --critic and --lenses are mutually exclusive.
522
+ --debate adds a Stage-2.5 rebuttal round.
523
+ --claude-review <file> enters Claude's own review as
524
+ a judged entry; --no-cost-gate disables the per-leg
525
+ price gate for the whole run (repairs + chair).
518
526
  Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
519
527
  save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
520
528
  --json Machine-readable output
@@ -0,0 +1,158 @@
1
+ // src/council/briefings-debate.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/briefings-debate
6
+ * Pure Stage-2.5 (debate) template builders for the headless council engine
7
+ * (spec §5.3). Adapted from skills/second-opinion/SEAT-BRIEFS.md § Rebuttal-round
8
+ * templates, hardened from the line-based contract to the v4.0 Stage-2
9
+ * trailing-JSON style (prose first, one fenced ```json block last, nothing
10
+ * after it). Every brief opens with the no-tools preamble and the date stamp
11
+ * (spec §4.3). No IO, no model calls.
12
+ */
13
+
14
+ const { dateLine } = require('./briefings-stage2');
15
+
16
+ const DEBATE_NO_TOOLS_PREAMBLE =
17
+ 'Do NOT use any tools or read any files; everything is in this message; ' +
18
+ 'begin immediately with the JSON block.';
19
+
20
+ /** Defense trailing-JSON contract (spec §5.3a). */
21
+ const DEFENSE_CONTRACT = [
22
+ 'End your response with a fenced ```json block, with no text after it:',
23
+ '',
24
+ '```json',
25
+ '{',
26
+ ' "responses": [',
27
+ ' { "id": "A1", "action": "defend", "argument": "<strongest evidence-based defense, one paragraph max>" },',
28
+ ' { "id": "A3", "action": "amend", "claim": "<full corrected replacement claim>", "argument": "<why, one sentence>" },',
29
+ ' { "id": "B2", "action": "withdraw" }',
30
+ ' ]',
31
+ '}',
32
+ '```',
33
+ '',
34
+ 'Every listed finding id must appear exactly once. `defend` requires `argument`; ' +
35
+ '`amend` requires `claim`; `withdraw` requires neither.',
36
+ ].join('\n');
37
+
38
+ /** Re-vote trailing-JSON contract (spec §5.3b). */
39
+ const REVOTE_CONTRACT = [
40
+ 'End your response with a fenced ```json block, with no text after it:',
41
+ '',
42
+ '```json',
43
+ '{',
44
+ ' "revotes": [',
45
+ ' { "id": "A1", "verdict": "agree", "reason": "<one line>" },',
46
+ ' { "id": "A3", "verdict": "dispute", "reason": "<one line>" }',
47
+ ' ]',
48
+ '}',
49
+ '```',
50
+ '',
51
+ '`verdict` is one of agree | dispute | neutral. Every listed finding id must ' +
52
+ 'appear exactly once.',
53
+ ].join('\n');
54
+
55
+ /** Count the anonymized peer verdicts carried on a finding. */
56
+ function verdictCounts(list) {
57
+ const c = { dispute: 0, agree: 0, neutral: 0 };
58
+ for (const v of list || []) { if (c[v] !== undefined) { c[v] += 1; } }
59
+ return c;
60
+ }
61
+
62
+ function findingBlockDefense(f) {
63
+ const c = verdictCounts(f.peerVerdicts);
64
+ const reasons = (f.disputeReasons || []).filter(Boolean);
65
+ const loc = f.location ? ` @ ${f.location}` : '';
66
+ // Stage-2's adjudication JSON has no `reason` field, so written reasons are usually
67
+ // absent. Report the REAL anonymized peer split instead of inventing a reason string.
68
+ const why = reasons.length
69
+ ? ` Peers disputed it for:\n${reasons.map(r => ` - ${r}`).join('\n')}`
70
+ : ' No written dispute reasons were captured.';
71
+ return `- ${f.id} [${f.severity}]${loc}: ${f.claim}\n`
72
+ + ` Peer verdicts (anonymized): ${c.dispute} dispute, ${c.agree} agree, ${c.neutral} neutral.\n`
73
+ + why;
74
+ }
75
+
76
+ function findingBlockRevote(f) {
77
+ const mark = f.amended ? ' **AMENDED**' : '';
78
+ return `- ${f.id} [${f.severity}]${mark}: ${f.claim}\n Raiser's response: ${f.argument}`;
79
+ }
80
+
81
+ /** One defense solo per raiser (spec §5.3a). */
82
+ function buildDefenseBrief({ findings, date }) {
83
+ const parts = [DEBATE_NO_TOOLS_PREAMBLE];
84
+ if (date) { parts.push(dateLine(date)); }
85
+ parts.push(
86
+ 'You reviewed an artifact and raised the findings below. Peer reviewers ' +
87
+ '(anonymous) disputed them for the stated reasons. For EACH finding decide: ' +
88
+ 'DEFEND it with evidence, AMEND it with corrected replacement text, or WITHDRAW ' +
89
+ 'it. Withdraw anything you cannot defend with evidence — an unsupported repeat ' +
90
+ 'of the original claim is weaker than a withdrawal.',
91
+ findings.map(findingBlockDefense).join('\n\n'),
92
+ DEFENSE_CONTRACT,
93
+ );
94
+ return parts.join('\n\n');
95
+ }
96
+
97
+ /** One shared re-vote bundle, fanned out to disputing judges (spec §5.3b). */
98
+ function buildRevoteBundle({ findings, date }) {
99
+ const parts = [DEBATE_NO_TOOLS_PREAMBLE];
100
+ if (date) { parts.push(dateLine(date)); }
101
+ parts.push(
102
+ 'You previously adjudicated findings on this artifact and disputed at least ' +
103
+ 'one of those below. The (anonymous) raiser has now responded. Re-adjudicate ' +
104
+ 'ONLY the findings listed, in light of each response. Changing your verdict ' +
105
+ 'when the defense is convincing is good judging, not weakness; so is holding ' +
106
+ 'your dispute when it isn\'t.',
107
+ findings.map(findingBlockRevote).join('\n\n'),
108
+ REVOTE_CONTRACT,
109
+ );
110
+ return parts.join('\n\n');
111
+ }
112
+
113
+ function repair(kind, contract, errors) {
114
+ const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
115
+ return [
116
+ 'Do NOT use any tools or read any files; everything is in this message; begin ' +
117
+ 'immediately with the JSON block.',
118
+ `Your previous ${kind} response's trailing JSON failed validation with these errors:`,
119
+ lines,
120
+ 'Re-emit ONLY the corrected JSON block as a single fenced ```json block:',
121
+ contract,
122
+ ].join('\n\n');
123
+ }
124
+
125
+ function buildDefenseRepairPrompt({ errors }) { return repair('defense', DEFENSE_CONTRACT, errors); }
126
+ function buildRevoteRepairPrompt({ errors }) { return repair('re-vote', REVOTE_CONTRACT, errors); }
127
+
128
+ /**
129
+ * Chair-packet "Debate round outcomes" section (spec §5.3c). De-anonymized —
130
+ * built by run.js from debate.json + both tally records. `priorVerdicts` is the
131
+ * finding's ACTUAL Stage-2 adjudication map from the provisional tally record and
132
+ * `revotes` the map of judges who re-voted; `before` is derived here so no caller
133
+ * can assume it. A re-vote recipient qualified by disputing SOME bundled finding,
134
+ * so its prior verdict on THIS finding is often 'agree' or 'neutral'.
135
+ * @param {{outcomes: Array<{id, originalClaim, action, amendedClaim,
136
+ * priorVerdicts: Object<string,string>, revotes: Object<string,string>}>}} args
137
+ */
138
+ function buildDebateAddendum({ outcomes }) {
139
+ const blocks = outcomes.map(o => {
140
+ const head = `- ${o.id}: "${o.originalClaim}" → ${o.action}`;
141
+ const amended = o.action === 'amended' && o.amendedClaim ? `\n Amended claim: "${o.amendedClaim}"` : '';
142
+ const prior = o.priorVerdicts || {};
143
+ const revotes = o.revotes || {};
144
+ const judges = Object.keys(revotes);
145
+ const changes = judges.length
146
+ ? '\n Re-vote changes: ' + judges.map(
147
+ j => `${j}: ${prior[j] || 'no prior verdict'} → ${revotes[j]}`).join('; ')
148
+ : '\n Re-vote changes: none';
149
+ return head + amended + changes;
150
+ }).join('\n');
151
+ return ['--- Debate round outcomes ---', blocks].join('\n\n');
152
+ }
153
+
154
+ module.exports = {
155
+ DEBATE_NO_TOOLS_PREAMBLE, DEFENSE_CONTRACT, REVOTE_CONTRACT,
156
+ buildDefenseBrief, buildRevoteBundle,
157
+ buildDefenseRepairPrompt, buildRevoteRepairPrompt, buildDebateAddendum,
158
+ };
@@ -19,6 +19,9 @@ const CHAIR_NO_TOOLS_PREAMBLE =
19
19
 
20
20
  const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink'];
21
21
 
22
+ /** Shared date line (spec §4.3) — prepended to every model-facing briefing. */
23
+ function dateLine(date) { return `Today's date is ${date}.`; }
24
+
22
25
  /** Stage-2 headless output contract (spec §5, embedded in the judge bundle). */
23
26
  const JUDGE_OUTPUT_CONTRACT = [
24
27
  'End your response with a trailing fenced ```json block — no text after it — in',
@@ -45,13 +48,14 @@ const JUDGE_OUTPUT_CONTRACT = [
45
48
  * @param {{reviews: Array<{label: string, text: string}>,
46
49
  * findings: Array<{id: string, severity: string, claim: string}>}} args
47
50
  */
48
- function buildJudgeBundle({ reviews, findings }) {
51
+ function buildJudgeBundle({ reviews, findings, date }) {
49
52
  const findingLines = findings.map(f => `${f.id} [${f.severity}] ${f.claim}`).join('\n');
50
53
  const reviewBlocks = reviews
51
54
  .map(r => `--- ${r.label} ---\n${r.text}`)
52
55
  .join('\n\n');
53
- return [
54
- JUDGE_NO_TOOLS_PREAMBLE,
56
+ const parts = [JUDGE_NO_TOOLS_PREAMBLE];
57
+ if (date) { parts.push(dateLine(date)); }
58
+ parts.push(
55
59
  'You are judging the anonymized peer reviews below. Do two things:',
56
60
  'Task A — Rank: order the reviews from most to least accurate and insightful.',
57
61
  'Task B — Adjudicate: for EVERY finding id listed below, state agree, dispute, or ' +
@@ -60,7 +64,8 @@ function buildJudgeBundle({ reviews, findings }) {
60
64
  '--- FINDINGS INDEX (run-global ids) ---',
61
65
  findingLines,
62
66
  reviewBlocks,
63
- ].join('\n\n');
67
+ );
68
+ return parts.join('\n\n');
64
69
  }
65
70
 
66
71
  /** Bounded judge-repair re-prompt (solo; ≤ 2 per judge — spec §5). */
@@ -103,7 +108,7 @@ const VERDICT_SCALE_ADDENDUM = [
103
108
  * adjudications: Array<{findingId: string, judge: string, verdict: string}>,
104
109
  * tierCounts: object}} args
105
110
  */
106
- function buildChairPacket({ reviews, rankings, adjudications, tierCounts }) {
111
+ function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date }) {
107
112
  const reviewBlocks = reviews.map(r => `--- Review by ${r.model} ---\n${r.text}`).join('\n\n');
108
113
  const rankingLines = rankings
109
114
  .map(r => `${r.judge}: ${JSON.stringify(r.order)}`)
@@ -112,8 +117,9 @@ function buildChairPacket({ reviews, rankings, adjudications, tierCounts }) {
112
117
  .map(a => `${a.findingId} — ${a.judge}: ${a.verdict}`)
113
118
  .join('\n');
114
119
  const tiers = JSON.stringify(tierCounts);
115
- return [
116
- CHAIR_NO_TOOLS_PREAMBLE,
120
+ const parts = [CHAIR_NO_TOOLS_PREAMBLE];
121
+ if (date) { parts.push(dateLine(date)); }
122
+ parts.push(
117
123
  'You are the council chair. Write the synthesized verdict across the reviews, ' +
118
124
  'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
119
125
  'peer-validated standing (rank position and adjudication pattern), distinguish ' +
@@ -127,7 +133,8 @@ function buildChairPacket({ reviews, rankings, adjudications, tierCounts }) {
127
133
  '--- PER-FINDING ADJUDICATIONS ---',
128
134
  adjLines,
129
135
  VERDICT_SCALE_ADDENDUM,
130
- ].join('\n\n');
136
+ );
137
+ return parts.join('\n\n');
131
138
  }
132
139
 
133
140
  /** One-shot chair repair: the VERDICT line was missing (spec §5 chair contract). */
@@ -145,6 +152,6 @@ function buildChairRepairPrompt() {
145
152
 
146
153
  module.exports = {
147
154
  JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE, CHAIR_VERDICT_VALUES,
148
- JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM,
155
+ JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM, dateLine,
149
156
  buildJudgeBundle, buildJudgeRepairPrompt, buildChairPacket, buildChairRepairPrompt,
150
157
  };
@@ -0,0 +1,98 @@
1
+ // src/council/debate.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/debate
6
+ * PURE final-tally reassembly for headless debate mode (spec §5.5). DI-free —
7
+ * takes the provisional tally input + parsed defense/re-vote maps and returns a
8
+ * new tally input plus the debate.json findings rows. run.js then re-runs
9
+ * tally() on the returned input and decorates the record. Keeps run.js under the
10
+ * line gate.
11
+ */
12
+
13
+ const PAST_TENSE = { defend: 'defended', amend: 'amended', withdraw: 'withdrawn', 'no-response': 'no-response' };
14
+
15
+ // runStats roles the ledger join must skip (ledger.js): a debate leg is an extra leg by an
16
+ // already-benched model, never an extra ledger row and never that model's ledger identity.
17
+ const DEBATE_ROLES = new Set(['rebuttal', 'revote']);
18
+
19
+ /**
20
+ * Reassemble the tally input after the debate round.
21
+ * @param {{tallyInput: object, provisionalRecord: object|null,
22
+ * defenseByRaiser: Object<string, Object<string, object>>,
23
+ * revoteByJudge: Object<string, Object<string, {verdict, reason?}>>}} args
24
+ * @returns {{input: object, debateFindings: Array}}
25
+ */
26
+ function applyDebate({ tallyInput, defenseByRaiser, revoteByJudge }) {
27
+ // Deep-ish clone the mutable arrays we touch (findings + adjudications).
28
+ const findings = tallyInput.findings.map(f => ({ ...f }));
29
+ const adjudications = tallyInput.adjudications.map(a => ({ ...a }));
30
+
31
+ // Flatten the per-raiser defense map to a per-id lookup + debate.json rows.
32
+ const byId = {};
33
+ const debateFindings = [];
34
+ for (const [raiser, perId] of Object.entries(defenseByRaiser || {})) {
35
+ for (const [id, resp] of Object.entries(perId)) {
36
+ byId[id] = resp;
37
+ const src = findings.find(f => f.id === id) || {};
38
+ const row = { id, raiser, action: resp.action, previousTier: src.previousTier || null };
39
+ if (resp.argument) { row.argument = resp.argument; }
40
+ if (resp.claim) { row.claim = resp.claim; }
41
+ debateFindings.push(row);
42
+ }
43
+ }
44
+
45
+ // Amend: swap claim text in place. Withdraw/defend/no-response: findings[] unchanged.
46
+ for (const f of findings) {
47
+ const resp = byId[f.id];
48
+ if (resp && resp.action === 'amend' && typeof resp.claim === 'string') { f.claim = resp.claim; }
49
+ delete f.previousTier; // provisional-only scratch field, never written to tally input
50
+ }
51
+
52
+ // Re-vote replacement: replace the wave judge's entry on each bundled id.
53
+ for (const [judge, perId] of Object.entries(revoteByJudge || {})) {
54
+ for (const [id, rv] of Object.entries(perId)) {
55
+ const entry = adjudications.find(a => a.findingId === id && a.judge === judge);
56
+ if (entry) { entry.verdict = rv.verdict; }
57
+ else { adjudications.push({ findingId: id, judge, verdict: rv.verdict }); }
58
+ }
59
+ }
60
+
61
+ return { input: { ...tallyInput, findings, adjudications }, debateFindings };
62
+ }
63
+
64
+ /**
65
+ * Inject the additive past-tense debate decoration onto the tally record's
66
+ * findings (spec §5.6). Mutates + returns the record.
67
+ * @param {object} record tally() output
68
+ * @param {Array<{id, action, previousTier}>} debateFindings
69
+ * @returns {object} record
70
+ */
71
+ function decorateRecord(record, debateFindings) {
72
+ const byId = new Map((debateFindings || []).map(d => [d.id, d]));
73
+ for (const f of record.findings) {
74
+ const d = byId.get(f.id);
75
+ if (d) { f.debate = { action: PAST_TENSE[d.action] || 'no-response', previousTier: d.previousTier }; }
76
+ }
77
+ return record;
78
+ }
79
+
80
+ /**
81
+ * runStats rows for the debate legs (spec §5.5). role is 'rebuttal' | 'revote';
82
+ * these legs never enter meta.models, so the ledger stays one row per (run×model),
83
+ * and ledger.js skips DEBATE_ROLES when joining runStats so a debate leg cannot
84
+ * overwrite the bench row's role/wasChair/conformance on that model's ledger row.
85
+ * @param {{defenseLegs: Array, revoteLegs: Array}} args leg metadata
86
+ * @returns {Array<object>}
87
+ */
88
+ function debateRunStatsRows({ defenseLegs, revoteLegs }) {
89
+ const mk = (role) => (l) => ({
90
+ model: l.model, role, wasChair: false, conformance: l.conformance || 'clean',
91
+ status: l.status || 'unknown',
92
+ durationMs: typeof l.durationMs === 'number' ? l.durationMs : null,
93
+ usage: l.usage || null,
94
+ });
95
+ return [...(defenseLegs || []).map(mk('rebuttal')), ...(revoteLegs || []).map(mk('revote'))];
96
+ }
97
+
98
+ module.exports = { applyDebate, decorateRecord, debateRunStatsRows, PAST_TENSE, DEBATE_ROLES };
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getConfigDir } = require('../utils/config');
6
+ const { DEBATE_ROLES } = require('./debate');
6
7
 
7
8
  const LEDGER_SCHEMA_VERSION = 1;
8
9
  const LEDGER_FILE = 'council-ledger.jsonl';
@@ -17,7 +18,7 @@ function countSeverity(findings) {
17
18
  function buildLedgerRows(record) {
18
19
  const { meta, findings, streetCred, runStats, judged } = record;
19
20
  const sc = new Map(streetCred.map(s => [s.model, s]));
20
- const rs = new Map(runStats.map(r => [r.model, r]));
21
+ const rs = new Map(runStats.filter(r => !DEBATE_ROLES.has(r.role)).map(r => [r.model, r]));
21
22
  return meta.models.map(model => {
22
23
  const raised = findings.filter(f => f.raiser === model);
23
24
  const s = sc.get(model) || {};
@@ -100,4 +100,86 @@ function parseChairVerdict(text) {
100
100
  return found;
101
101
  }
102
102
 
103
- module.exports = { parseJudgeOutput, parseChairVerdict, CHAIR_VERDICTS, JUDGE_VERDICTS };
103
+ const DEBATE_ACTIONS = ['defend', 'amend', 'withdraw'];
104
+ const REVOTE_VERDICTS = ['agree', 'dispute', 'neutral'];
105
+
106
+ /**
107
+ * Parse a debate defense solo (spec §5.4). Every expected id ends up in byId:
108
+ * a present-and-valid entry keeps its action/argument/claim; anything absent or
109
+ * individually invalid becomes {action:'no-response'} (the spec-mandated
110
+ * "original stands" fallback). A block-level failure sets ok:false so the
111
+ * caller issues exactly ONE repair; after that, every id is 'no-response'.
112
+ * @param {string} text raw defense output (prose + trailing ```json block)
113
+ * @param {string[]} expectedIds run-global ids sent to this raiser
114
+ * @returns {{byId: Object, ok: boolean, errors: Array<{code, detail}>}}
115
+ */
116
+ function parseDebateDefense(text, expectedIds) {
117
+ const allNoResponse = () => {
118
+ const byId = {};
119
+ for (const id of expectedIds) { byId[id] = { action: 'no-response' }; }
120
+ return byId;
121
+ };
122
+ const body = lastJsonBlock(text || '');
123
+ if (body === null) {
124
+ return { byId: allNoResponse(), ok: false, errors: [{ code: 'NO_FENCED_BLOCK', detail: 'no ```json block found' }] };
125
+ }
126
+ let parsed;
127
+ try { parsed = JSON.parse(body); }
128
+ catch (e) { return { byId: allNoResponse(), ok: false, errors: [{ code: 'NOT_PARSEABLE', detail: e.message }] }; }
129
+ if (!parsed || !Array.isArray(parsed.responses)) {
130
+ return { byId: allNoResponse(), ok: false, errors: [{ code: 'BAD_RESPONSES', detail: 'responses must be an array' }] };
131
+ }
132
+ const expected = new Set(expectedIds);
133
+ const byId = allNoResponse();
134
+ for (const r of parsed.responses) {
135
+ const id = r && r.id;
136
+ if (!expected.has(id)) { continue; } // unknown id ignored
137
+ if (r.action === 'defend' && typeof r.argument === 'string' && r.argument.trim()) {
138
+ byId[id] = { action: 'defend', argument: r.argument };
139
+ } else if (r.action === 'amend' && typeof r.claim === 'string' && r.claim.trim()) {
140
+ const entry = { action: 'amend', claim: r.claim };
141
+ if (typeof r.argument === 'string' && r.argument.trim()) { entry.argument = r.argument; }
142
+ byId[id] = entry;
143
+ } else if (r.action === 'withdraw') {
144
+ byId[id] = { action: 'withdraw' };
145
+ } // else leave as no-response
146
+ }
147
+ return { byId, ok: true, errors: [] };
148
+ }
149
+
150
+ /**
151
+ * Parse a re-vote leg (spec §5.4). byId holds ONLY present-and-valid ids; an
152
+ * absent or invalid entry is omitted so the judge's original verdict stands.
153
+ * Block-level failure sets ok:false (caller issues one repair, then originals
154
+ * stand for all bundled ids).
155
+ * @param {string} text raw re-vote output
156
+ * @param {string[]} expectedIds bundled run-global ids
157
+ * @returns {{byId: Object, ok: boolean, errors: Array<{code, detail}>}}
158
+ */
159
+ function parseRevote(text, expectedIds) {
160
+ const body = lastJsonBlock(text || '');
161
+ if (body === null) {
162
+ return { byId: {}, ok: false, errors: [{ code: 'NO_FENCED_BLOCK', detail: 'no ```json block found' }] };
163
+ }
164
+ let parsed;
165
+ try { parsed = JSON.parse(body); }
166
+ catch (e) { return { byId: {}, ok: false, errors: [{ code: 'NOT_PARSEABLE', detail: e.message }] }; }
167
+ if (!parsed || !Array.isArray(parsed.revotes)) {
168
+ return { byId: {}, ok: false, errors: [{ code: 'BAD_REVOTES', detail: 'revotes must be an array' }] };
169
+ }
170
+ const expected = new Set(expectedIds);
171
+ const byId = {};
172
+ for (const r of parsed.revotes) {
173
+ const id = r && r.id;
174
+ if (!expected.has(id) || !REVOTE_VERDICTS.includes(r.verdict)) { continue; }
175
+ const entry = { verdict: r.verdict };
176
+ if (typeof r.reason === 'string' && r.reason.trim()) { entry.reason = r.reason; }
177
+ byId[id] = entry;
178
+ }
179
+ return { byId, ok: true, errors: [] };
180
+ }
181
+
182
+ module.exports = {
183
+ parseJudgeOutput, parseChairVerdict, CHAIR_VERDICTS, JUDGE_VERDICTS,
184
+ parseDebateDefense, parseRevote, DEBATE_ACTIONS, REVOTE_VERDICTS,
185
+ };
@@ -55,6 +55,33 @@ function renderHtml(m) {
55
55
  const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
56
56
  h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).map(esc).join(' · ');
57
57
 
58
+ // m.debate is absent on hand-built models (tests/council/report.test.js calls
59
+ // renderHtml directly with no debate key) — the guard must tolerate that, and
60
+ // absent/empty ⇒ no section at all so a no-debate report stays byte-identical
61
+ // to v4.0's HTML output. no-response findings get their own list (same
62
+ // reasoning as report.js's renderMd) so the heading never dangles over
63
+ // nothing when a run's only debating raiser never responded.
64
+ let debateSection = '';
65
+ if (m.debate && m.debate.present) {
66
+ const withdrawnItems = (m.debate.withdrawn || []).map((w) => {
67
+ const arrow = w.previousTier && w.previousTier !== w.tier ? `${esc(w.previousTier)} → ${esc(w.tier)}` : esc(w.previousTier || w.tier);
68
+ return `<li>${esc(w.id)}: ${arrow} (withdrawn — no longer live)</li>`;
69
+ }).join('');
70
+ const movementItems = (m.debate.movements || []).map(mv =>
71
+ `<li>${esc(mv.id)}: ${esc(mv.previousTier)} → ${esc(mv.tier)} (${esc(mv.action)})</li>`).join('');
72
+ const noResponseItems = (m.debate.noResponse || []).map((nr) => {
73
+ const arrow = nr.previousTier && nr.previousTier !== nr.tier ? `${esc(nr.previousTier)} → ${esc(nr.tier)}` : esc(nr.previousTier || nr.tier);
74
+ return `<li>${esc(nr.id)}: ${arrow} (no response — original stands)</li>`;
75
+ }).join('');
76
+ // Defensive: never emit the heading unless at least one grouping has content.
77
+ if (withdrawnItems || movementItems || noResponseItems) {
78
+ debateSection = '\n<h2>Debate round</h2>' +
79
+ (withdrawnItems ? `<p><strong>Withdrawn by raiser:</strong></p><ul>${withdrawnItems}</ul>` : '') +
80
+ (movementItems ? `<p><strong>Tier movements after re-vote:</strong></p><ul>${movementItems}</ul>` : '') +
81
+ (noResponseItems ? `<p><strong>No response (raiser did not defend):</strong></p><ul>${noResponseItems}</ul>` : '');
82
+ }
83
+ }
84
+
58
85
  return `<!DOCTYPE html>
59
86
  <html lang="en"><head><meta charset="utf-8">
60
87
  <title>Council Report — ${esc(h.runId)}</title>
@@ -91,7 +118,7 @@ td.c { text-align: center; }
91
118
  <table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
92
119
  <p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>
93
120
  <h2>Street-cred <span class="meta">(peers-only; lower = better)</span></h2>
94
- <table><tr><th>Model</th><th>peers-only</th><th>with-self</th></tr>${credRows}</table>
121
+ <table><tr><th>Model</th><th>peers-only</th><th>with-self</th></tr>${credRows}</table>${debateSection}
95
122
  <h2>Cost</h2>
96
123
  <table><tr><th>Model</th><th>Status</th><th>Duration</th><th>Cost</th></tr>${costRows}
97
124
  <tr><td><strong>Wave total</strong></td><td></td><td></td><td>${esc(formatCost(m.cost.total))}</td></tr></table>
@@ -28,9 +28,30 @@ function toModel(verdict, wave) {
28
28
  return {
29
29
  id: f.id, severity: f.severity, raiser: f.raiser, tier: f.tier,
30
30
  basis: f.basis || { a: 0, d: 0, n: 0 }, decision: f.decision || null,
31
- applied: f.applied === true, byJudge,
31
+ applied: f.applied === true, byJudge, debate: f.debate || null,
32
32
  };
33
33
  });
34
+ // 'movements' is deliberately re-vote-only (defended/amended): a withdrawn or
35
+ // no-response finding is never bundled into the re-vote (run-debate.js's
36
+ // bundleFor()), so its tier — even if it happens to differ from previousTier —
37
+ // was never "moved after re-vote". Listing it there would read as "still live,
38
+ // just downgraded" when it was actually retracted; withdrawn findings get their
39
+ // own list below so a reader can tell the two apart.
40
+ // no-response findings (spec §5.7: a dead defense leg, or one still
41
+ // unstructured after its single repair, makes that raiser's bundled
42
+ // findings all 'no-response') get their own list, same idiom as withdrawn —
43
+ // silently dropping them would leave a "## Debate round" heading with
44
+ // nothing beneath it whenever a run's only debating raiser never responded.
45
+ const debate = {
46
+ present: findings.some(f => f.debate) === true,
47
+ withdrawn: verdict.findings.filter(f => f.debate && f.debate.action === 'withdrawn')
48
+ .map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
49
+ movements: verdict.findings.filter(f => f.debate && f.debate.action !== 'withdrawn' && f.debate.action !== 'no-response'
50
+ && f.debate.previousTier && f.debate.previousTier !== f.tier)
51
+ .map(f => ({ id: f.id, action: f.debate.action, previousTier: f.debate.previousTier, tier: f.tier })),
52
+ noResponse: verdict.findings.filter(f => f.debate && f.debate.action === 'no-response')
53
+ .map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
54
+ };
34
55
  const runStats = verdict.runStats || [];
35
56
  const costRows = runStats.map(r => ({
36
57
  model: r.model, status: r.status, durationMs: r.durationMs,
@@ -43,7 +64,7 @@ function toModel(verdict, wave) {
43
64
  chair: verdict.chair, council: judges, claudeInCouncil: verdict.claudeInCouncil === true,
44
65
  },
45
66
  tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
46
- judges, findings,
67
+ judges, findings, debate,
47
68
  streetCred: verdict.streetCred || [],
48
69
  cost: { rows: costRows, total },
49
70
  };
@@ -92,6 +113,33 @@ function renderMd(m) {
92
113
  out.push('');
93
114
  }
94
115
 
116
+ // Defensive: never emit the heading unless at least one grouping has
117
+ // content — a heading over nothing is worse than no heading.
118
+ if (m.debate.present && (m.debate.withdrawn.length || m.debate.movements.length || m.debate.noResponse.length)) {
119
+ out.push('\n## Debate round\n');
120
+ if (m.debate.withdrawn.length) {
121
+ out.push('**Withdrawn by raiser:**');
122
+ for (const w of m.debate.withdrawn) {
123
+ const arrow = w.previousTier && w.previousTier !== w.tier ? `${w.previousTier} → ${w.tier}` : (w.previousTier || w.tier);
124
+ out.push(`- ${w.id}: ${arrow} (withdrawn — no longer live)`);
125
+ }
126
+ out.push('');
127
+ }
128
+ if (m.debate.movements.length) {
129
+ out.push('**Tier movements after re-vote:**');
130
+ for (const mv of m.debate.movements) { out.push(`- ${mv.id}: ${mv.previousTier} → ${mv.tier} (${mv.action})`); }
131
+ out.push('');
132
+ }
133
+ if (m.debate.noResponse.length) {
134
+ out.push('**No response (raiser did not defend):**');
135
+ for (const nr of m.debate.noResponse) {
136
+ const arrow = nr.previousTier && nr.previousTier !== nr.tier ? `${nr.previousTier} → ${nr.tier}` : (nr.previousTier || nr.tier);
137
+ out.push(`- ${nr.id}: ${arrow} (no response — original stands)`);
138
+ }
139
+ out.push('');
140
+ }
141
+ }
142
+
95
143
  out.push('## Cost\n');
96
144
  out.push('| Model | Status | Duration | Cost |\n|---|---|---|---|');
97
145
  for (const r of m.cost.rows) { out.push(`| ${r.model} | ${r.status} | ${fmtDur(r.durationMs)} | ${formatCost(r.cost)} |`); }