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
|
@@ -36,9 +36,11 @@ function slug(text) {
|
|
|
36
36
|
/** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
|
|
37
37
|
async function launchStage1(ctx) {
|
|
38
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.
|
|
39
41
|
const common = {
|
|
40
42
|
project: o.runDir, timeout: o.timeout, gateway: o.gateway,
|
|
41
|
-
noValidateModel: o.noValidateModel,
|
|
43
|
+
noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
42
44
|
};
|
|
43
45
|
const launches = [];
|
|
44
46
|
// Record every sub-wave BEFORE it launches: `amicus abort` cascades over
|
|
@@ -118,7 +120,7 @@ async function runStage1(ctx) {
|
|
|
118
120
|
const solo = await ctx.launchers.launchSolo({
|
|
119
121
|
model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
|
|
120
122
|
project: o.runDir, waveId, timeout: o.timeout,
|
|
121
|
-
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
123
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
122
124
|
});
|
|
123
125
|
ctx.addWave(solo.wave);
|
|
124
126
|
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
|
|
@@ -137,18 +139,27 @@ async function runStage1(ctx) {
|
|
|
137
139
|
/**
|
|
138
140
|
* Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
|
|
139
141
|
* @param {object} ctx
|
|
140
|
-
* @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.
|
|
141
146
|
* @returns {Promise<{aborted: number|null, judgeResults: Array}>}
|
|
142
147
|
*/
|
|
143
|
-
async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
148
|
+
async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
|
|
144
149
|
const { o } = ctx;
|
|
145
150
|
const { rankingToOrder } = require('./anonymize');
|
|
146
151
|
fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
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 });
|
|
150
159
|
fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
|
|
151
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.
|
|
152
163
|
const judges = reviews.map(r => r.modelInput);
|
|
153
164
|
const parseCtx = {
|
|
154
165
|
labels: labels.entries.map(e => e.label),
|
|
@@ -158,6 +169,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
|
158
169
|
const { wave, exitCode } = await ctx.launchers.launchWave({
|
|
159
170
|
models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
|
|
160
171
|
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
172
|
+
noCostGate: o.noCostGate,
|
|
161
173
|
});
|
|
162
174
|
ctx.addWave(wave);
|
|
163
175
|
if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
|
|
@@ -182,7 +194,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
|
182
194
|
const solo = await ctx.launchers.launchSolo({
|
|
183
195
|
model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
|
|
184
196
|
project: ctx.scratchDir, waveId, timeout: o.timeout,
|
|
185
|
-
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
197
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
186
198
|
});
|
|
187
199
|
ctx.addWave(solo.wave);
|
|
188
200
|
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
|
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,6 +97,12 @@ 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 });
|
|
@@ -141,17 +139,20 @@ async function runCouncil(options, deps = {}) {
|
|
|
141
139
|
}
|
|
142
140
|
|
|
143
141
|
// ---- Stage 2: anonymized cross-review ----
|
|
144
|
-
|
|
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'] : []));
|
|
145
144
|
runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
|
|
146
145
|
// Attach each review's run-global findings (buildTallyInput reads
|
|
147
146
|
// r.globalFindings per review, not a bare parallel array).
|
|
148
147
|
s1.reviews.forEach((r, i) => {
|
|
149
148
|
r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
|
|
150
149
|
});
|
|
151
|
-
const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
|
|
150
|
+
const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
|
|
151
|
+
.concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
|
|
152
152
|
runState.updateStage(o.runDir, 'stage2',
|
|
153
153
|
{ status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
|
|
154
|
-
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 }] : [] });
|
|
155
156
|
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
156
157
|
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
157
158
|
if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
|
|
@@ -166,105 +167,94 @@ async function runCouncil(options, deps = {}) {
|
|
|
166
167
|
// ---- Chair synthesis (provisional tally feeds the packet) ----
|
|
167
168
|
const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
|
|
168
169
|
runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
|
|
169
|
-
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
|
|
170
|
+
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
|
|
170
171
|
});
|
|
171
172
|
const provisionalInput = mkInput(null, o.chair);
|
|
172
173
|
const provisional = tally(provisionalInput);
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (overBudget()) {
|
|
196
|
-
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
197
|
-
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
198
|
-
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
199
|
-
degraded.value = true;
|
|
200
|
-
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
201
|
-
} else {
|
|
202
|
-
runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
|
|
203
|
-
// Fallback chain (spec §4): retry same chair once → promote best
|
|
204
|
-
// non-bench model from the ledger → give up (no Claude fallback headless).
|
|
205
|
-
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
206
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
207
|
-
if (!attempt.leg && !overBudget()) {
|
|
208
|
-
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
209
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
210
|
-
}
|
|
211
|
-
if (attempt.leg) { actualChair = o.chair; }
|
|
212
|
-
else if (!overBudget()) {
|
|
213
|
-
let statsRows = [];
|
|
214
|
-
try { statsRows = statsFn(); } catch { /* no ledger yet */ }
|
|
215
|
-
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
216
|
-
if (fallback) {
|
|
217
|
-
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
218
|
-
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
219
|
-
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);
|
|
220
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;
|
|
221
219
|
}
|
|
222
|
-
|
|
223
|
-
runState.updateStage(o.runDir, 'chair',
|
|
224
|
-
{ status: chairLeg ? 'complete' : 'error', completedAt: now() });
|
|
225
|
-
// The chair chain may have promoted a fallback (or given up) — checkpoint
|
|
226
|
-
// the ACTUAL chair into run.json now so status/`--json`/the human summary
|
|
227
|
-
// never report the originally-requested chair after a promotion. Mirrors
|
|
228
|
-
// mkInput's actualChair || o.chair (a give-up with no actual chair keeps
|
|
229
|
-
// the requested chair).
|
|
230
|
-
runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
|
|
220
|
+
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
231
221
|
}
|
|
232
|
-
const chairText = chairLeg ? chairLeg.summary : null;
|
|
233
|
-
let chairConformance = 'clean';
|
|
234
222
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
|
|
252
|
-
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;
|
|
253
239
|
|
|
254
240
|
// ---- Final tally (chair row included) + ledger + artifacts ----
|
|
255
241
|
const chairStats = chairLeg ? asm.buildRunStatsEntry({
|
|
256
242
|
leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
|
|
257
243
|
conformance: chairConformance,
|
|
258
244
|
}) : null;
|
|
259
|
-
|
|
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]; }
|
|
260
249
|
const record = tally(finalInput);
|
|
250
|
+
if (debateFindings) { decorateRecord(record, debateFindings); }
|
|
261
251
|
if (!o.lenses) {
|
|
262
252
|
// Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
|
|
263
253
|
try { appendRunFn(record); }
|
|
264
254
|
catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
|
|
265
255
|
}
|
|
266
256
|
asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
|
|
267
|
-
runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
|
|
257
|
+
runState.updateStage(o.runDir, o.debate ? 'tally-final' : 'tally', { status: 'complete', completedAt: now() });
|
|
268
258
|
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
|
|
269
259
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
270
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 };
|
package/src/mcp-council-run.js
CHANGED
|
@@ -123,6 +123,13 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
123
123
|
if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
|
|
124
124
|
if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
|
|
125
125
|
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
126
|
+
// v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
|
|
127
|
+
// reason outDir is — an MCP client may send a relative path, and the child's cwd
|
|
128
|
+
// is the run dir. Validation of the file itself stays in the spawned engine's
|
|
129
|
+
// pre-flight (run-assemble.preflightClaudeReview), so every entry point shares it.
|
|
130
|
+
if (input.debate) { args.push('--debate'); }
|
|
131
|
+
if (input.claudeReviewFile) { args.push('--claude-review', path.resolve(project, String(input.claudeReviewFile))); }
|
|
132
|
+
if (input.noCostGate) { args.push('--no-cost-gate'); }
|
|
126
133
|
|
|
127
134
|
let child;
|
|
128
135
|
try { child = helpers.spawnFn(args, runDir); } catch (err) {
|
package/src/mcp-server.js
CHANGED
|
@@ -13,7 +13,7 @@ const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
|
|
|
13
13
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
14
14
|
const { durationBetween } = require('./utils/result-schema');
|
|
15
15
|
const { canonicalProjectPath } = require('./utils/project-path');
|
|
16
|
-
const { isAllowedProjectRoot } = require('./project-root-allowlist');
|
|
16
|
+
const { isAllowedProjectRoot, isPathInside } = require('./project-root-allowlist');
|
|
17
17
|
const { recordSession } = require('./utils/session-index');
|
|
18
18
|
const { fileURLToPath } = require('url');
|
|
19
19
|
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
@@ -1141,10 +1141,35 @@ const handlers = {
|
|
|
1141
1141
|
} catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
|
|
1142
1142
|
},
|
|
1143
1143
|
|
|
1144
|
-
async amicus_verdict(input) {
|
|
1144
|
+
async amicus_verdict(input, project) {
|
|
1145
1145
|
try {
|
|
1146
1146
|
const { buildVerdict } = require('./council/verdict');
|
|
1147
|
-
|
|
1147
|
+
// The chair's synthesis lives only in the engine's verdict.json /
|
|
1148
|
+
// chair-output.md, and this tool's output replaces verdict.json — so it
|
|
1149
|
+
// must be carried through or it is destroyed. Unlike the CLI there is no
|
|
1150
|
+
// run-folder path to anchor on (`record` arrives inline), so it is an
|
|
1151
|
+
// explicit input; omitted → null, never fabricated.
|
|
1152
|
+
const verdict = buildVerdict(input.record, input.decisions || [],
|
|
1153
|
+
{ overallVerdict: input.overallVerdict });
|
|
1154
|
+
if (!input.render) {
|
|
1155
|
+
return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
|
|
1156
|
+
}
|
|
1157
|
+
// v4.1 §4.5c: return the markdown rendering (so Cowork can assemble report.md
|
|
1158
|
+
// without Bash) and, when an outDir is given, refresh report.html on disk.
|
|
1159
|
+
const { buildReport } = require('./council/report');
|
|
1160
|
+
const md = buildReport({ verdict }, { format: 'md' });
|
|
1161
|
+
if (input.outDir) {
|
|
1162
|
+
// Containment parity with amicus_council_run (mcp-council-run.js): an
|
|
1163
|
+
// MCP-supplied outDir must not write outside the project directory.
|
|
1164
|
+
const cwd = project || getProjectDir(input.project);
|
|
1165
|
+
const outDir = path.resolve(cwd, String(input.outDir));
|
|
1166
|
+
if (!isPathInside(outDir, cwd)) {
|
|
1167
|
+
return textResult(`outDir must resolve to a path inside the project directory (${cwd}).`, true);
|
|
1168
|
+
}
|
|
1169
|
+
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
1170
|
+
fs.writeFileSync(path.join(outDir, 'report.html'), buildReport({ verdict }, { format: 'html' }), { mode: 0o600 });
|
|
1171
|
+
}
|
|
1172
|
+
return textResult(fenceSidecarOutput(md));
|
|
1148
1173
|
} catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
|
|
1149
1174
|
},
|
|
1150
1175
|
|
package/src/mcp-tools.js
CHANGED
|
@@ -406,11 +406,12 @@ function getTools() {
|
|
|
406
406
|
},
|
|
407
407
|
{
|
|
408
408
|
name: 'amicus_verdict',
|
|
409
|
-
annotations: { readOnlyHint:
|
|
409
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
410
410
|
description:
|
|
411
411
|
"Merge a tally record with Claude's Stage-4 decisions into the verdict " +
|
|
412
412
|
'object (final tiers after overrides, decisions, applied flags). Pure + ' +
|
|
413
|
-
'synchronous; returns the verdict
|
|
413
|
+
'synchronous; returns the verdict. Writes nothing unless render:true AND ' +
|
|
414
|
+
'outDir are given — then it also refreshes <outDir>/report.html.',
|
|
414
415
|
inputSchema: {
|
|
415
416
|
record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
|
|
416
417
|
decisions: z.array(z.object({
|
|
@@ -418,6 +419,13 @@ function getTools() {
|
|
|
418
419
|
duplicateOf: z.string().nullable().optional(),
|
|
419
420
|
tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
|
|
420
421
|
})).optional().describe('Stage-4 per-finding decisions (default []).'),
|
|
422
|
+
overallVerdict: z.string().nullable().optional().describe(
|
|
423
|
+
"The chair's VERDICT line, read from the engine-written <runDir>/verdict.json " +
|
|
424
|
+
'(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
|
|
425
|
+
'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
|
|
426
|
+
'chair was skipped; never author one yourself.'),
|
|
427
|
+
render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
|
|
428
|
+
outDir: z.string().optional().describe('Dir to write report.html into when render:true — resolved against the project dir and rejected if it escapes it. Omit to write nothing.'),
|
|
421
429
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
422
430
|
},
|
|
423
431
|
},
|
|
@@ -466,6 +474,18 @@ function getTools() {
|
|
|
466
474
|
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
467
475
|
'Routing preference: auto (default), direct, or openrouter.'
|
|
468
476
|
),
|
|
477
|
+
debate: z.boolean().optional().describe(
|
|
478
|
+
'Add a Stage-2.5 rebuttal round: raisers defend Contested/Disputed findings and ' +
|
|
479
|
+
'disputing judges re-vote before the chair synthesizes.'
|
|
480
|
+
),
|
|
481
|
+
claudeReviewFile: z.string().optional().describe(
|
|
482
|
+
"Path to Claude's own review file (prose + findings JSON) to include as a judged " +
|
|
483
|
+
'entry. Claude is reviewed and ranked like a seat, but never judges or chairs.'
|
|
484
|
+
),
|
|
485
|
+
noCostGate: z.boolean().optional().describe(
|
|
486
|
+
'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
|
|
487
|
+
'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
|
|
488
|
+
),
|
|
469
489
|
project: z.string().optional().describe(
|
|
470
490
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
471
491
|
),
|
package/src/utils/error-doc.js
CHANGED
|
@@ -22,6 +22,8 @@ const ERROR_CODES = Object.freeze({
|
|
|
22
22
|
INTERNAL: 'INTERNAL', // unexpected pre-flight throw
|
|
23
23
|
COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
|
|
24
24
|
COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
|
|
25
|
+
// council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
|
|
26
|
+
COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
/**
|