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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +75 -0
- package/README.md +3 -3
- package/commands/council.md +6 -6
- package/package.json +1 -1
- package/schemas/council-run.schema.json +15 -1
- package/schemas/council-tally.schema.json +10 -1
- package/schemas/council-verdict.schema.json +10 -1
- package/schemas/error.schema.json +1 -1
- package/scripts/postinstall.js +6 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
- package/skills/second-opinion/MODEL-NOTES.md +21 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
- package/skills/second-opinion/SKILL.md +319 -333
- package/src/cli-handlers-council-run.js +9 -0
- package/src/cli-handlers-council.js +20 -2
- package/src/cli.js +8 -0
- package/src/council/briefings-debate.js +158 -0
- package/src/council/briefings-stage2.js +16 -9
- package/src/council/debate.js +98 -0
- package/src/council/ledger.js +2 -1
- package/src/council/parse-stage2.js +83 -1
- package/src/council/report-html.js +28 -1
- package/src/council/report.js +50 -2
- package/src/council/run-assemble.js +91 -9
- package/src/council/run-chair.js +145 -0
- package/src/council/run-debate.js +289 -0
- package/src/council/run-launch.js +27 -1
- package/src/council/run-stages.js +19 -7
- package/src/council/run.js +100 -110
- package/src/council/verdict.js +43 -2
- package/src/mcp-council-run.js +7 -0
- package/src/mcp-server.js +28 -3
- package/src/mcp-tools.js +22 -2
- 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 {
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 };
|
package/src/council/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/council/report.js
CHANGED
|
@@ -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)} |`); }
|