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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +169 -0
  3. package/README.md +4 -4
  4. package/commands/council.md +6 -6
  5. package/package.json +3 -2
  6. package/schemas/council-run.schema.json +15 -1
  7. package/schemas/council-tally.schema.json +10 -1
  8. package/schemas/council-verdict.schema.json +10 -1
  9. package/schemas/error.schema.json +1 -1
  10. package/scripts/postinstall.js +6 -3
  11. package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
  12. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
  13. package/skills/second-opinion/MODEL-NOTES.md +21 -0
  14. package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
  15. package/skills/second-opinion/SKILL.md +319 -333
  16. package/skills/sidecar/SKILL.md +5 -5
  17. package/src/cli-handlers-council-run.js +9 -0
  18. package/src/cli-handlers-council.js +20 -2
  19. package/src/cli.js +8 -0
  20. package/src/council/briefings-debate.js +158 -0
  21. package/src/council/briefings-stage2.js +16 -9
  22. package/src/council/debate.js +98 -0
  23. package/src/council/ledger.js +2 -1
  24. package/src/council/parse-stage2.js +83 -1
  25. package/src/council/report-html.js +28 -1
  26. package/src/council/report.js +64 -4
  27. package/src/council/run-assemble.js +91 -9
  28. package/src/council/run-chair.js +145 -0
  29. package/src/council/run-debate.js +293 -0
  30. package/src/council/run-launch.js +27 -1
  31. package/src/council/run-stages.js +19 -7
  32. package/src/council/run.js +104 -110
  33. package/src/council/verdict.js +43 -2
  34. package/src/mcp-council-run.js +7 -0
  35. package/src/mcp-server.js +28 -3
  36. package/src/mcp-tools.js +24 -4
  37. package/src/utils/curated-models.js +22 -20
  38. package/src/utils/error-doc.js +2 -0
  39. 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
- module.exports = { createLaunchers, materializeReviews, sanitizeName };
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}} args
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
- const labeled = labels.entries.map((e, i) => ({ label: e.label, text: reviews[i].text }));
149
- const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings });
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 }; }