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
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @module council/run-assemble
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* chair-output.md)
|
|
12
|
-
*
|
|
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
|
-
|
|
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 };
|