amicus 4.0.1 → 4.1.1
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 +169 -0
- package/README.md +4 -4
- package/commands/council.md +6 -6
- package/package.json +3 -2
- 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/skills/sidecar/SKILL.md +5 -5
- 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 +64 -4
- package/src/council/run-assemble.js +91 -9
- package/src/council/run-chair.js +145 -0
- package/src/council/run-debate.js +293 -0
- package/src/council/run-launch.js +27 -1
- package/src/council/run-stages.js +19 -7
- package/src/council/run.js +104 -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 +24 -4
- package/src/utils/curated-models.js +22 -20
- package/src/utils/error-doc.js +2 -0
- package/src/utils/model-fetcher.js +6 -0
|
@@ -0,0 +1,293 @@
|
|
|
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
|
+
// run.js needs to know whether the wave actually launched so it can
|
|
227
|
+
// checkpoint debate-revote 'skipped' (not a false 'complete') when nothing
|
|
228
|
+
// was defended/amended, or the cost ceiling skipped it (spec §5.7).
|
|
229
|
+
const revoteLaunched = wouldRevote && !costCeiling;
|
|
230
|
+
if (revoteLaunched) {
|
|
231
|
+
const rv = await runRevoteWave(ctx, judges, defendedOrAmended);
|
|
232
|
+
if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
|
|
233
|
+
revoteByJudge = rv.byJudge;
|
|
234
|
+
revoteLegs = rv.legs;
|
|
235
|
+
// revote-<model>.md per surviving judge leg, mirroring rebuttal-<model>.md
|
|
236
|
+
// (spec §5.1 'raw outputs revote-<model>.md').
|
|
237
|
+
materializeDebate(ctx.o.runDir, revoteLegs, 'revote');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- Pure reassembly ----
|
|
241
|
+
const { input: debatedInput, debateFindings } = applyDebate({
|
|
242
|
+
tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
|
|
243
|
+
debatedInput.runStats = [...(debatedInput.runStats || []),
|
|
244
|
+
...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs })];
|
|
245
|
+
|
|
246
|
+
// verdictChanges: findings whose tier moved from provisional to debated.
|
|
247
|
+
const provTierById = new Map(provisionalRecord.findings.map(f => [f.id, f.tier]));
|
|
248
|
+
const debatedRec = tally(debatedInput);
|
|
249
|
+
let verdictChanges = 0;
|
|
250
|
+
for (const f of debatedRec.findings) { if (provTierById.get(f.id) !== f.tier) { verdictChanges += 1; } }
|
|
251
|
+
|
|
252
|
+
// ---- Artifacts + summary ----
|
|
253
|
+
const revotesJson = [];
|
|
254
|
+
for (const [judge, perId] of Object.entries(revoteByJudge)) {
|
|
255
|
+
for (const [id, rv] of Object.entries(perId)) { revotesJson.push({ judge, id, verdict: rv.verdict, reason: rv.reason || null, applied: true }); }
|
|
256
|
+
}
|
|
257
|
+
fs.writeFileSync(path.join(ctx.o.runDir, 'debate.json'),
|
|
258
|
+
JSON.stringify({ findings: debateFindings, revotes: revotesJson }, null, 2), { mode: 0o600 });
|
|
259
|
+
|
|
260
|
+
const counts = { defended: 0, amended: 0, withdrawn: 0, noResponse: 0 };
|
|
261
|
+
const COUNT_KEY = { defend: 'defended', amend: 'amended', withdraw: 'withdrawn' };
|
|
262
|
+
for (const df of debateFindings) { counts[COUNT_KEY[df.action] || 'noResponse'] += 1; }
|
|
263
|
+
const debateSummary = {
|
|
264
|
+
enabled: true, outcome: costCeiling ? 'skipped-cost-ceiling' : 'ran',
|
|
265
|
+
contested, disputed, ...counts,
|
|
266
|
+
revoteJudges: revoteLegs.length, revoteApplied: revotesJson.length, verdictChanges,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ---- Degradation (spec §5.7) → run.js maps this to exit code 2 ----
|
|
270
|
+
// A dead/unstructured-after-repair defense solo, a partial or fully-dead re-vote wave, or a
|
|
271
|
+
// cost-ceiling skip of a warranted re-vote each degrade the run. (Abort short-circuits above;
|
|
272
|
+
// nothing-to-debate and a clean run are NOT degradations.)
|
|
273
|
+
const bad = (l) => l.status !== 'complete' || l.conformance === 'unstructured';
|
|
274
|
+
const degraded = defenseResults.some(d => bad(d.leg)) || revoteLegs.some(bad) || costCeiling;
|
|
275
|
+
|
|
276
|
+
// Chair-addendum outcomes (spec §5.3c). `action` is the PAST_TENSE form
|
|
277
|
+
// buildDebateAddendum renders verbatim — only the four valid values ever reach it.
|
|
278
|
+
const priorById = new Map(provisionalRecord.findings.map(
|
|
279
|
+
f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.judge, a.verdict]))]));
|
|
280
|
+
const addendumOutcomes = debateFindings.map(df => ({
|
|
281
|
+
id: df.id, originalClaim: (tallyInput.findings.find(f => f.id === df.id) || {}).claim,
|
|
282
|
+
action: PAST_TENSE[df.action] || PAST_TENSE['no-response'],
|
|
283
|
+
amendedClaim: df.action === 'amend' ? df.claim : null,
|
|
284
|
+
priorVerdicts: priorById.get(df.id) || {},
|
|
285
|
+
revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.judge, r.verdict])),
|
|
286
|
+
}));
|
|
287
|
+
|
|
288
|
+
return { debatedInput, debateFindings, debateSummary, addendumOutcomes,
|
|
289
|
+
defenseLegs: defenseResults.map(d => d.leg), revoteLegs, verdictChanges,
|
|
290
|
+
degraded, aborted: null, revoteLaunched };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
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 };
|
|
@@ -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 }; }
|