amicus 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +118 -0
- package/README.md +3 -3
- package/commands/council.md +6 -6
- package/package.json +1 -1
- package/schemas/council-run.schema.json +16 -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 +41 -13
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +108 -110
- package/src/council/verdict.js +43 -2
- package/src/mcp-council-awareness.js +187 -0
- package/src/mcp-council-run.js +29 -128
- package/src/mcp-server.js +28 -3
- package/src/mcp-tools.js +22 -2
- package/src/utils/error-doc.js +2 -0
|
@@ -25,6 +25,7 @@ const briefings = require('./briefings');
|
|
|
25
25
|
const stage2 = require('./briefings-stage2');
|
|
26
26
|
const { parseJudgeOutput } = require('./parse-stage2');
|
|
27
27
|
const { materializeReviews, sanitizeName } = require('./run-launch');
|
|
28
|
+
const runState = require('./run-state');
|
|
28
29
|
|
|
29
30
|
function isAbortExit(code) { return code === 130 || code === 143; }
|
|
30
31
|
|
|
@@ -35,25 +36,37 @@ function slug(text) {
|
|
|
35
36
|
/** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
|
|
36
37
|
async function launchStage1(ctx) {
|
|
37
38
|
const { o, launchers } = ctx;
|
|
39
|
+
// `noCostGate` rides EVERY launch object in this file (here, the findings
|
|
40
|
+
// repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
|
|
38
41
|
const common = {
|
|
39
42
|
project: o.runDir, timeout: o.timeout, gateway: o.gateway,
|
|
40
|
-
noValidateModel: o.noValidateModel,
|
|
43
|
+
noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
41
44
|
};
|
|
42
45
|
const launches = [];
|
|
46
|
+
// Record every sub-wave BEFORE it launches: `amicus abort` cascades over
|
|
47
|
+
// stages[].waveIds, so an id written after the launch leaves that leg
|
|
48
|
+
// reachable only by the pid kill (no per-leg abort marker).
|
|
49
|
+
const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
43
50
|
if (o.lenses) {
|
|
44
|
-
o.models.forEach((m, i) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
o.models.forEach((m, i) => {
|
|
52
|
+
const waveId = `${o.runId}-l${i + 1}`;
|
|
53
|
+
record(waveId);
|
|
54
|
+
launches.push(launchers.launchSolo({
|
|
55
|
+
...common, model: m, waveId,
|
|
56
|
+
prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
48
59
|
} else {
|
|
49
60
|
const seats = o.models.filter(m => m !== o.critic);
|
|
50
61
|
if (seats.length > 0) {
|
|
62
|
+
record(`${o.runId}-s1`);
|
|
51
63
|
launches.push(launchers.launchWave({
|
|
52
64
|
...common, models: seats, waveId: `${o.runId}-s1`,
|
|
53
65
|
prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
|
|
54
66
|
}));
|
|
55
67
|
}
|
|
56
68
|
if (o.critic) {
|
|
69
|
+
record(`${o.runId}-c1`);
|
|
57
70
|
launches.push(launchers.launchSolo({
|
|
58
71
|
...common, model: o.critic, waveId: `${o.runId}-c1`,
|
|
59
72
|
prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
|
|
@@ -102,10 +115,12 @@ async function runStage1(ctx) {
|
|
|
102
115
|
while (!res.ok && attempts < 2 && !ctx.overBudget()) {
|
|
103
116
|
attempts += 1;
|
|
104
117
|
repairSeq += 1;
|
|
118
|
+
const waveId = `${o.runId}-p${repairSeq}`;
|
|
119
|
+
runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
105
120
|
const solo = await ctx.launchers.launchSolo({
|
|
106
121
|
model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
|
|
107
|
-
project: o.runDir, waveId
|
|
108
|
-
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
122
|
+
project: o.runDir, waveId, timeout: o.timeout,
|
|
123
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
109
124
|
});
|
|
110
125
|
ctx.addWave(solo.wave);
|
|
111
126
|
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
|
|
@@ -124,26 +139,37 @@ async function runStage1(ctx) {
|
|
|
124
139
|
/**
|
|
125
140
|
* Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
|
|
126
141
|
* @param {object} ctx
|
|
127
|
-
* @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array
|
|
142
|
+
* @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
|
|
143
|
+
* extraLabeled?: Array<{label: string, text: string}>}} args
|
|
144
|
+
* `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
|
|
145
|
+
* a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
|
|
128
146
|
* @returns {Promise<{aborted: number|null, judgeResults: Array}>}
|
|
129
147
|
*/
|
|
130
|
-
async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
148
|
+
async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
|
|
131
149
|
const { o } = ctx;
|
|
132
150
|
const { rankingToOrder } = require('./anonymize');
|
|
133
151
|
fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
|
|
134
152
|
|
|
135
|
-
|
|
136
|
-
|
|
153
|
+
// Zip off `reviews` (never off `labels.entries`, which may be one longer than
|
|
154
|
+
// reviews when a file-sourced review is present) and append the extras.
|
|
155
|
+
const labeled = reviews
|
|
156
|
+
.map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
|
|
157
|
+
.concat(extraLabeled);
|
|
158
|
+
const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
|
|
137
159
|
fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
|
|
138
160
|
|
|
161
|
+
// ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
|
|
162
|
+
// review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
|
|
139
163
|
const judges = reviews.map(r => r.modelInput);
|
|
140
164
|
const parseCtx = {
|
|
141
165
|
labels: labels.entries.map(e => e.label),
|
|
142
166
|
findingIds: globalFindings.map(f => f.id),
|
|
143
167
|
};
|
|
168
|
+
runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
|
|
144
169
|
const { wave, exitCode } = await ctx.launchers.launchWave({
|
|
145
170
|
models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
|
|
146
171
|
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
172
|
+
noCostGate: o.noCostGate,
|
|
147
173
|
});
|
|
148
174
|
ctx.addWave(wave);
|
|
149
175
|
if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
|
|
@@ -163,10 +189,12 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
|
163
189
|
while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
|
|
164
190
|
attempts += 1;
|
|
165
191
|
repairSeq += 1;
|
|
192
|
+
const waveId = `${o.runId}-q${repairSeq}`;
|
|
193
|
+
runState.appendStageWave(o.runDir, 'stage2', waveId);
|
|
166
194
|
const solo = await ctx.launchers.launchSolo({
|
|
167
195
|
model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
|
|
168
|
-
project: ctx.scratchDir, waveId
|
|
169
|
-
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
196
|
+
project: ctx.scratchDir, waveId, timeout: o.timeout,
|
|
197
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
170
198
|
});
|
|
171
199
|
ctx.addWave(solo.wave);
|
|
172
200
|
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
|
package/src/council/run-state.js
CHANGED
|
@@ -20,8 +20,31 @@ const { writeFileAtomic } = require('../utils/atomic-write');
|
|
|
20
20
|
const { SESSIONS_DIR } = require('../session-manager');
|
|
21
21
|
|
|
22
22
|
const RUN_FILE = 'run.json';
|
|
23
|
+
const SPAWN_PID_FILE = 'spawn.pid';
|
|
23
24
|
|
|
24
25
|
function runPath(runDir) { return path.join(runDir, RUN_FILE); }
|
|
26
|
+
function spawnPidPath(runDir) { return path.join(runDir, SPAWN_PID_FILE); }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record the engine child's pid in its own file rather than patching run.json.
|
|
30
|
+
* The spawning process (the MCP handler) and the engine child both write
|
|
31
|
+
* run.json, and `checkpoint` is a read-merge-write with no cross-process lock —
|
|
32
|
+
* so a pid patch from the parent can clobber, or be clobbered by, whatever the
|
|
33
|
+
* child wrote in the same window. A standalone single-write file has no read
|
|
34
|
+
* side, so there is no race to lose. Readers fall back to it whenever run.json
|
|
35
|
+
* carries no pid (see readSpawnPid).
|
|
36
|
+
*/
|
|
37
|
+
function writeSpawnPid(runDir, pid) {
|
|
38
|
+
writeFileAtomic(spawnPidPath(runDir), String(pid), { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {number|null} the recorded spawn pid, or null when absent/corrupt */
|
|
42
|
+
function readSpawnPid(runDir) {
|
|
43
|
+
try {
|
|
44
|
+
const pid = Number.parseInt(fs.readFileSync(spawnPidPath(runDir), 'utf-8').trim(), 10);
|
|
45
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
/** @returns {object|null} parsed run.json, or null when missing/corrupt */
|
|
27
50
|
function readRun(runDir) {
|
|
@@ -78,6 +101,21 @@ function updateStage(runDir, name, patch) {
|
|
|
78
101
|
return checkpoint(runDir, { stages });
|
|
79
102
|
}
|
|
80
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Append a sub-wave id to one stage's `waveIds` (dedup, launch order preserved).
|
|
106
|
+
* A stage can have several sub-waves in flight at once (lens solos, a critic
|
|
107
|
+
* solo alongside the seat wave) or in sequence (the chair's ch1..ch4 chain), so
|
|
108
|
+
* the single `waveId` field cannot describe them. `amicus abort` cascades over
|
|
109
|
+
* this list to mark every in-flight leg instead of relying on the pid kill.
|
|
110
|
+
*/
|
|
111
|
+
function appendStageWave(runDir, name, waveId) {
|
|
112
|
+
const existing = readRun(runDir) || {};
|
|
113
|
+
const stage = (existing.stages || []).find(s => s && s.name === name) || {};
|
|
114
|
+
const waveIds = Array.isArray(stage.waveIds) ? stage.waveIds : [];
|
|
115
|
+
if (waveIds.includes(waveId)) { return existing; }
|
|
116
|
+
return updateStage(runDir, name, { waveIds: [...waveIds, waveId] });
|
|
117
|
+
}
|
|
118
|
+
|
|
81
119
|
function stripPrefix(runId) { return String(runId).replace(/^council-/, ''); }
|
|
82
120
|
|
|
83
121
|
/** `<project>/.claude/amicus_sessions/council-<runId>.json` */
|
|
@@ -117,6 +155,7 @@ function listPointers(project) {
|
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
module.exports = {
|
|
120
|
-
RUN_FILE, readRun, initRun, checkpoint, updateStage,
|
|
158
|
+
RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
|
|
159
|
+
writeSpawnPid, readSpawnPid,
|
|
121
160
|
pointerPath, writePointer, readPointer, listPointers,
|
|
122
161
|
};
|
package/src/council/run.js
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @module council/run
|
|
6
|
-
* Headless council driver (spec §5): stage state machine over the DI launch
|
|
7
|
-
*
|
|
8
|
-
* chair synthesis → verdict/report — checkpointing run.json after
|
|
9
|
-
* stage (run-state) and consuming the existing pure primitives unchanged
|
|
6
|
+
* Headless council driver (spec §5): stage state machine over the DI launch wrappers —
|
|
7
|
+
* Stage-1 reviews → anonymized Stage-2 cross-review → optional Stage-2.5 debate
|
|
8
|
+
* (run-debate) → tally → chair synthesis → verdict/report — checkpointing run.json after
|
|
9
|
+
* every stage (run-state) and consuming the existing pure primitives unchanged
|
|
10
10
|
* (tally, buildVerdict via run-assemble, report renderers, ledger).
|
|
11
11
|
*
|
|
12
|
-
* Tally sequencing:
|
|
13
|
-
* the on-disk tally-input.json/tally.json are
|
|
14
|
-
* included, actual chair in meta) and only the final record is
|
|
15
|
-
* the skill's debate-mode provisional/final precedent.
|
|
12
|
+
* Tally sequencing: a provisional tally feeds the chair packet (and, under --debate, the
|
|
13
|
+
* debate round + tally-provisional.json); the on-disk tally-input.json/tally.json are
|
|
14
|
+
* FINAL (chair runStats row included, actual chair in meta) and only the final record is
|
|
15
|
+
* ledgered — the skill's debate-mode provisional/final precedent.
|
|
16
16
|
*
|
|
17
17
|
* Never rejects for run errors: always resolves {exitCode, run}.
|
|
18
18
|
*/
|
|
@@ -23,39 +23,27 @@ const { tally } = require('./tally');
|
|
|
23
23
|
const { assignLabels, toGlobalFindings } = require('./anonymize');
|
|
24
24
|
const briefings = require('./briefings');
|
|
25
25
|
const stage2 = require('./briefings-stage2');
|
|
26
|
-
const { parseChairVerdict } = require('./parse-stage2');
|
|
27
26
|
const runState = require('./run-state');
|
|
28
27
|
const { createLaunchers } = require('./run-launch');
|
|
29
|
-
const { runStage1, runStage2
|
|
28
|
+
const { runStage1, runStage2 } = require('./run-stages');
|
|
29
|
+
const { runChair, pickFallbackChair } = require('./run-chair');
|
|
30
|
+
const runDebateMod = require('./run-debate');
|
|
31
|
+
const { buildDebateAddendum } = require('./briefings-debate');
|
|
32
|
+
const { decorateRecord } = require('./debate');
|
|
30
33
|
const asm = require('./run-assemble');
|
|
31
34
|
const { sumWaveUsage } = require('../utils/pricing');
|
|
32
35
|
|
|
33
36
|
const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
|
|
39
|
-
* (deriveReliability's avgStreetCredPeersOnly; lower is better).
|
|
40
|
-
* @returns {string|null}
|
|
41
|
-
*/
|
|
42
|
-
function pickFallbackChair(statsRows, bench, failedChair) {
|
|
43
|
-
const benchSet = new Set(bench);
|
|
44
|
-
const candidates = (statsRows || [])
|
|
45
|
-
.filter(r => !benchSet.has(r.model) && r.model !== failedChair
|
|
46
|
-
&& typeof r.avgStreetCredPeersOnly === 'number')
|
|
47
|
-
.sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
|
|
48
|
-
return candidates.length ? candidates[0].model : null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {object} options {briefing, models, chair, critic?, lenses?, project,
|
|
53
|
-
* runId, runDir, timeout?, maxCost?, gateway?, noValidateModel?, date}
|
|
39
|
+
* @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
|
|
40
|
+
* runDir, timeout?, maxCost?, gateway?, noValidateModel?, date, debate?, noCostGate?}
|
|
54
41
|
* @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?}
|
|
55
42
|
* @returns {Promise<{exitCode: number, run: object}>}
|
|
56
43
|
*/
|
|
57
44
|
async function runCouncil(options, deps = {}) {
|
|
58
|
-
const o = { critic: null, lenses: null, maxCost: null,
|
|
45
|
+
const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
|
|
46
|
+
noCostGate: false, ...options };
|
|
59
47
|
const launchers = deps.launchers || createLaunchers();
|
|
60
48
|
const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
|
|
61
49
|
const statsFn = deps.statsFn || require('./ledger').deriveReliability;
|
|
@@ -75,6 +63,10 @@ async function runCouncil(options, deps = {}) {
|
|
|
75
63
|
schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
|
|
76
64
|
bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
|
|
77
65
|
labelMap: null,
|
|
66
|
+
// Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
|
|
67
|
+
// "no debate key" contract and fail the object-typed schema), and with a VALID
|
|
68
|
+
// outcome from the first write so a run killed mid-debate stays schema-valid.
|
|
69
|
+
...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
|
|
78
70
|
options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
|
|
79
71
|
usage: null, pid: process.pid, createdAt: now(),
|
|
80
72
|
});
|
|
@@ -105,13 +97,25 @@ async function runCouncil(options, deps = {}) {
|
|
|
105
97
|
const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
|
|
106
98
|
|
|
107
99
|
try {
|
|
100
|
+
// v4.1 §4.4: Claude-in-council is a FILE input — validated after initRun (so the
|
|
101
|
+
// error doc lands in a run dir that exists) and before any launch (zero spend).
|
|
102
|
+
const pre = asm.preflightClaudeReview(o);
|
|
103
|
+
if (pre.error) { return finalize(1, pre.error); }
|
|
104
|
+
const claudeReview = pre.claudeReview;
|
|
105
|
+
|
|
108
106
|
// Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
|
|
109
107
|
fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
|
|
110
108
|
briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
|
|
111
109
|
|
|
112
110
|
// ---- Stage 1: independent reviews ----
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
// Lens mode launches one solo per seat instead of a `-s1` seat wave, so it
|
|
112
|
+
// has no primary wave to name — run-stages records each real sub-wave into
|
|
113
|
+
// waveIds at launch. Advertising a `-s1` that never exists made both the
|
|
114
|
+
// abort cascade and the status leg rollup chase a phantom.
|
|
115
|
+
runState.updateStage(o.runDir, 'stage1', {
|
|
116
|
+
status: 'running', startedAt: now(), project: o.runDir,
|
|
117
|
+
...(o.lenses ? {} : { waveId: `${o.runId}-s1` }),
|
|
118
|
+
});
|
|
115
119
|
const s1 = await runStage1(ctx);
|
|
116
120
|
runState.updateStage(o.runDir, 'stage1', {
|
|
117
121
|
status: 'complete', completedAt: now(),
|
|
@@ -135,17 +139,20 @@ async function runCouncil(options, deps = {}) {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
// ---- Stage 2: anonymized cross-review ----
|
|
138
|
-
|
|
142
|
+
// A file-sourced Claude review is ALWAYS the last label — review N+1 (§4.4).
|
|
143
|
+
const labels = assignLabels(s1.reviews.map(r => r.model).concat(claudeReview ? ['claude'] : []));
|
|
139
144
|
runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
|
|
140
145
|
// Attach each review's run-global findings (buildTallyInput reads
|
|
141
146
|
// r.globalFindings per review, not a bare parallel array).
|
|
142
147
|
s1.reviews.forEach((r, i) => {
|
|
143
148
|
r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
|
|
144
149
|
});
|
|
145
|
-
const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
|
|
150
|
+
const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
|
|
151
|
+
.concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
|
|
146
152
|
runState.updateStage(o.runDir, 'stage2',
|
|
147
153
|
{ status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
|
|
148
|
-
const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings
|
|
154
|
+
const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings,
|
|
155
|
+
extraLabeled: claudeReview ? [{ label: claudeReview.label, text: claudeReview.text }] : [] });
|
|
149
156
|
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
150
157
|
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
151
158
|
if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
|
|
@@ -160,103 +167,94 @@ async function runCouncil(options, deps = {}) {
|
|
|
160
167
|
// ---- Chair synthesis (provisional tally feeds the packet) ----
|
|
161
168
|
const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
|
|
162
169
|
runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
|
|
163
|
-
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
|
|
170
|
+
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
|
|
164
171
|
});
|
|
165
172
|
const provisionalInput = mkInput(null, o.chair);
|
|
166
173
|
const provisional = tally(provisionalInput);
|
|
167
174
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
190
|
-
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
191
|
-
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
192
|
-
degraded.value = true;
|
|
193
|
-
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
194
|
-
} else {
|
|
195
|
-
runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
|
|
196
|
-
// Fallback chain (spec §4): retry same chair once → promote best
|
|
197
|
-
// non-bench model from the ledger → give up (no Claude fallback headless).
|
|
198
|
-
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
199
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
200
|
-
if (!attempt.leg && !overBudget()) {
|
|
201
|
-
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
202
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
203
|
-
}
|
|
204
|
-
if (attempt.leg) { actualChair = o.chair; }
|
|
205
|
-
else if (!overBudget()) {
|
|
206
|
-
let statsRows = [];
|
|
207
|
-
try { statsRows = statsFn(); } catch { /* no ledger yet */ }
|
|
208
|
-
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
209
|
-
if (fallback) {
|
|
210
|
-
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
211
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
212
|
-
if (attempt.leg) { actualChair = fallback; }
|
|
175
|
+
// ---- Stage 2.5: debate (optional, spec §5.1) ----
|
|
176
|
+
let debatedInput = provisionalInput, debatedRecord = provisional;
|
|
177
|
+
let debateOutcomes = null, debateFindings = null;
|
|
178
|
+
let debateSummary = o.debate ? { enabled: true, outcome: 'nothing-to-debate',
|
|
179
|
+
contested: 0, disputed: 0, defended: 0, amended: 0, withdrawn: 0, noResponse: 0,
|
|
180
|
+
revoteJudges: 0, revoteApplied: 0, verdictChanges: 0 } : null;
|
|
181
|
+
if (o.debate) {
|
|
182
|
+
// spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
|
|
183
|
+
// checkpoint — no ledger append, written before any debate leg launches.
|
|
184
|
+
fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
|
|
185
|
+
runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
|
|
186
|
+
const worthDebating = !runDebateMod.nothingToDebate(provisional);
|
|
187
|
+
if (worthDebating && !overBudget()) {
|
|
188
|
+
runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
|
|
189
|
+
const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
|
|
190
|
+
// A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
|
|
191
|
+
// the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
|
|
192
|
+
if (dbg.aborted) {
|
|
193
|
+
runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
|
|
194
|
+
contested: dbg.contested, disputed: dbg.disputed } });
|
|
195
|
+
return finalize(dbg.aborted);
|
|
213
196
|
}
|
|
197
|
+
runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
|
|
198
|
+
// run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
|
|
199
|
+
// knows whether the wave launched. Never advertise a `-rv` id here: a skipped
|
|
200
|
+
// re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
|
|
201
|
+
runState.updateStage(o.runDir, 'debate-revote', { status: 'complete', completedAt: now() });
|
|
202
|
+
({ debatedInput, debateFindings, debateSummary } = dbg);
|
|
203
|
+
debatedRecord = tally(debatedInput);
|
|
204
|
+
// Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
|
|
205
|
+
// list must be normalized to null here — otherwise the packet-assembly
|
|
206
|
+
// ternary below still calls buildDebateAddendum({outcomes: []}), which
|
|
207
|
+
// emits a bare "--- Debate round outcomes ---" heading with nothing
|
|
208
|
+
// under it (same defect class ee447b6 fixed on the report renderer).
|
|
209
|
+
debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
|
|
210
|
+
? dbg.addendumOutcomes : null;
|
|
211
|
+
// Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
|
|
212
|
+
// each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
|
|
213
|
+
if (dbg.degraded) { degraded.value = true; }
|
|
214
|
+
} else if (worthDebating) {
|
|
215
|
+
// Budget gone before the defense wave launched, but there WAS something to debate — the
|
|
216
|
+
// other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
|
|
217
|
+
debateSummary.outcome = 'skipped-cost-ceiling';
|
|
218
|
+
degraded.value = true;
|
|
214
219
|
}
|
|
215
|
-
|
|
216
|
-
runState.updateStage(o.runDir, 'chair',
|
|
217
|
-
{ status: chairLeg ? 'complete' : 'error', completedAt: now() });
|
|
218
|
-
// The chair chain may have promoted a fallback (or given up) — checkpoint
|
|
219
|
-
// the ACTUAL chair into run.json now so status/`--json`/the human summary
|
|
220
|
-
// never report the originally-requested chair after a promotion. Mirrors
|
|
221
|
-
// mkInput's actualChair || o.chair (a give-up with no actual chair keeps
|
|
222
|
-
// the requested chair).
|
|
223
|
-
runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
|
|
220
|
+
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
224
221
|
}
|
|
225
|
-
const chairText = chairLeg ? chairLeg.summary : null;
|
|
226
|
-
let chairConformance = 'clean';
|
|
227
222
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
model:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
|
|
223
|
+
const packet = stage2.buildChairPacket({
|
|
224
|
+
// §4.4: the chair sees Claude's de-anonymized review like any other; it casts
|
|
225
|
+
// no rankings/adjudications, so it appears ONLY as one more review block.
|
|
226
|
+
reviews: s1.reviews.map(r => ({ model: r.model, text: r.text }))
|
|
227
|
+
.concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
|
|
228
|
+
rankings: debatedInput.rankings,
|
|
229
|
+
adjudications: debatedInput.adjudications,
|
|
230
|
+
tierCounts: debatedRecord.tierCounts, date: o.date,
|
|
231
|
+
}) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
|
|
232
|
+
fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
|
|
233
|
+
|
|
234
|
+
const chairRes = await runChair(ctx, {
|
|
235
|
+
packet, degraded, statsFn, isSignalled: () => signalled,
|
|
236
|
+
});
|
|
237
|
+
if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
|
|
238
|
+
const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
|
|
245
239
|
|
|
246
240
|
// ---- Final tally (chair row included) + ledger + artifacts ----
|
|
247
241
|
const chairStats = chairLeg ? asm.buildRunStatsEntry({
|
|
248
242
|
leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
|
|
249
243
|
conformance: chairConformance,
|
|
250
244
|
}) : null;
|
|
251
|
-
|
|
245
|
+
// Built on the (possibly debated) input so the debate's amended claims, replaced
|
|
246
|
+
// adjudications and rebuttal/revote runStats rows all reach the final record.
|
|
247
|
+
const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
|
|
248
|
+
if (chairStats) { finalInput.runStats = [...(finalInput.runStats || []), chairStats]; }
|
|
252
249
|
const record = tally(finalInput);
|
|
250
|
+
if (debateFindings) { decorateRecord(record, debateFindings); }
|
|
253
251
|
if (!o.lenses) {
|
|
254
252
|
// Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
|
|
255
253
|
try { appendRunFn(record); }
|
|
256
254
|
catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
|
|
257
255
|
}
|
|
258
256
|
asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
|
|
259
|
-
runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
|
|
257
|
+
runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
|
|
260
258
|
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
|
|
261
259
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
262
260
|
|
package/src/council/verdict.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/council/verdict.js
|
|
2
2
|
'use strict';
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { parseChairVerdict } = require('./parse-stage2');
|
|
4
6
|
|
|
5
7
|
// v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
|
|
6
8
|
// nullable overallVerdict (the chair's Ship-it line; populated by the headless
|
|
@@ -29,7 +31,7 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
29
31
|
findings: record.findings.map(f => {
|
|
30
32
|
const d = byId.get(f.id) || {};
|
|
31
33
|
const tierOverride = d.tierOverride || f.tierOverride || null;
|
|
32
|
-
|
|
34
|
+
const out = {
|
|
33
35
|
id: f.id, raiser: f.raiser, severity: f.severity,
|
|
34
36
|
tier: tierOverride ? tierOverride.to : f.tier,
|
|
35
37
|
basis: f.basis, confidence: f.confidence, tierOverride,
|
|
@@ -38,6 +40,8 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
38
40
|
decision: d.decision || null,
|
|
39
41
|
applied: d.applied === true,
|
|
40
42
|
};
|
|
43
|
+
if (f.debate) { out.debate = f.debate; } // v4.1: additive debate decoration carry-through (spec §5.6)
|
|
44
|
+
return out;
|
|
41
45
|
}),
|
|
42
46
|
streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
|
|
43
47
|
runStats: record.runStats,
|
|
@@ -45,6 +49,43 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
45
49
|
};
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Recover the chair's overall verdict for a run folder.
|
|
54
|
+
*
|
|
55
|
+
* The chair's synthesis is the council's most valuable output and it is stored
|
|
56
|
+
* in exactly two places: the engine's `verdict.json` (parsed) and
|
|
57
|
+
* `chair-output.md` (prose). Neither `tally.json` nor `run.json` carries a
|
|
58
|
+
* copy — so the Stage-5 step that REPLACES `verdict.json` from `tally.json`
|
|
59
|
+
* must read the verdict back out of the run folder first, or it destroys it.
|
|
60
|
+
*
|
|
61
|
+
* Preference order, both anchored on the run dir (never on the `-o` path — the
|
|
62
|
+
* verdict belongs to the run, not to wherever the caller writes the result):
|
|
63
|
+
* 1. `<runDir>/verdict.json` `overallVerdict` — the value the engine already
|
|
64
|
+
* parsed. Guarded by `runId`: a stale or foreign verdict.json sitting in
|
|
65
|
+
* the folder must never inject another run's chair line.
|
|
66
|
+
* 2. `<runDir>/chair-output.md`, re-parsed with the engine's own
|
|
67
|
+
* `parseChairVerdict`, so there is no second parser to drift. This also
|
|
68
|
+
* recovers runs whose verdict.json was already nulled by the defect.
|
|
69
|
+
*
|
|
70
|
+
* Never invents: an absent, skipped, or unstructured chair yields null.
|
|
71
|
+
* @param {string} runDir
|
|
72
|
+
* @param {string} [runId] record.meta.runId — the run being rebuilt
|
|
73
|
+
* @returns {string|null} a canonical chair verdict phrase, or null
|
|
74
|
+
*/
|
|
75
|
+
function readOverallVerdict(runDir, runId) {
|
|
76
|
+
try {
|
|
77
|
+
const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
|
|
78
|
+
if (typeof prior.overallVerdict === 'string' && prior.overallVerdict
|
|
79
|
+
&& (!runId || prior.runId === runId)) {
|
|
80
|
+
return prior.overallVerdict;
|
|
81
|
+
}
|
|
82
|
+
} catch { /* no prior verdict.json, or unreadable — try the chair prose */ }
|
|
83
|
+
try {
|
|
84
|
+
return parseChairVerdict(fs.readFileSync(path.join(runDir, 'chair-output.md'), 'utf-8'));
|
|
85
|
+
} catch { /* no chair-output.md — the chair genuinely produced nothing */ }
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
/** Atomic write: tmp + rename (matches the repo's wave.json convention). */
|
|
49
90
|
function writeVerdictAtomic(filePath, verdict) {
|
|
50
91
|
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
@@ -52,4 +93,4 @@ function writeVerdictAtomic(filePath, verdict) {
|
|
|
52
93
|
fs.renameSync(tmp, filePath);
|
|
53
94
|
}
|
|
54
95
|
|
|
55
|
-
module.exports = { buildVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
|
|
96
|
+
module.exports = { buildVerdict, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
|