amicus 4.5.4 → 4.6.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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +1 -1
  4. package/commands/council.md +1 -1
  5. package/docs/DISTRIBUTION.md +38 -11
  6. package/docs/usage.md +1 -1
  7. package/package.json +3 -2
  8. package/schemas/council-run.schema.json +20 -0
  9. package/schemas/council-verdict.schema.json +20 -0
  10. package/schemas/doctor.schema.json +23 -1
  11. package/src/cli-council-run-render.js +51 -0
  12. package/src/cli-handlers-council-run.js +45 -44
  13. package/src/cli-handlers-council.js +9 -3
  14. package/src/cli-handlers-doctor.js +16 -37
  15. package/src/cli-handlers-watch.js +1 -1
  16. package/src/cli.js +1 -1
  17. package/src/council/ledger.js +5 -1
  18. package/src/council/report-html.js +16 -1
  19. package/src/council/report.js +25 -1
  20. package/src/council/run-assemble.js +25 -7
  21. package/src/council/run-budget.js +14 -8
  22. package/src/council/run-chair.js +21 -4
  23. package/src/council/run-debate-stage.js +115 -0
  24. package/src/council/run-degrade.js +44 -0
  25. package/src/council/run-finalize.js +18 -3
  26. package/src/council/run-server.js +24 -7
  27. package/src/council/run-stage2.js +10 -2
  28. package/src/council/run-stages.js +23 -21
  29. package/src/council/run.js +39 -67
  30. package/src/council/verdict.js +74 -8
  31. package/src/mcp-council-bench.js +45 -0
  32. package/src/mcp-council-run.js +11 -28
  33. package/src/mcp-server.js +5 -1
  34. package/src/mcp-tools.js +8 -0
  35. package/src/utils/degrade.js +68 -0
  36. package/src/utils/doctor-degrade.js +51 -0
  37. package/src/utils/doctor-electron-mcp-check.js +64 -5
  38. package/src/utils/doctor-engine-check.js +14 -3
  39. package/src/utils/doctor-mcp-checks.js +10 -3
  40. package/src/utils/known-flags.js +2 -1
  41. package/src/utils/remediation-hints.js +5 -3
  42. package/src/utils/result-schema.js +6 -2
  43. package/src/utils/session-index-tmp-sweep.js +2 -1
  44. package/src/workspace/run-scan.js +5 -1
@@ -11,6 +11,7 @@ const { formatCost } = require('../utils/pricing');
11
11
  const { formatDuration } = require('../utils/format-duration');
12
12
  const { TIER_ORDER, SYMBOL } = require('./report');
13
13
  const { tokenCss } = require('../design/tokens');
14
+ const { formatDegrade } = require('../utils/degrade');
14
15
 
15
16
  const TIER_VAR = {
16
17
  Disputed: 'var(--tier-disputed)',
@@ -52,9 +53,23 @@ function renderHtml(m) {
52
53
  const costRows = m.cost.rows.map(r =>
53
54
  `<tr><td>${esc(r.model)}</td><td>${esc(r.status)}</td><td>${dur(r.durationMs)}</td>` +
54
55
  `<td>${esc(formatCost(r.cost))}</td></tr>`).join('');
56
+ // v4.6 Plan 2: 'What was lost' rows — m.degrades is absent on hand-built
57
+ // models (same tolerance as m.debate below); ONE voice (Plan 1's
58
+ // formatDegrade) rendered into the row, not a second HTML dialect.
59
+ const lostRows = (m.degrades || []).map(d =>
60
+ `<tr><td>${esc(d.channel)}</td><td>${esc(formatDegrade(d).trimEnd())}</td></tr>`).join('');
55
61
  const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
56
62
  h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).map(esc).join(' · ');
57
63
 
64
+ // Heading-over-nothing, same guard idiom as debateSection below: absent or
65
+ // empty degrades ⇒ no section at all, so a clean verdict's HTML stays
66
+ // byte-identical to before this section existed. Losses are headline news,
67
+ // so the section sits directly after the Verdict-summary table (report.js's
68
+ // renderMd mirrors this placement immediately after the tier loop).
69
+ const lostSection = lostRows
70
+ ? `<h2>What was lost</h2><table><tr><th>Channel</th><th>Notice</th></tr>${lostRows}</table>`
71
+ : '';
72
+
58
73
  // m.debate is absent on hand-built models (tests/council/report.test.js calls
59
74
  // renderHtml directly with no debate key) — the guard must tolerate that, and
60
75
  // absent/empty ⇒ no section at all so a no-debate report stays byte-identical
@@ -113,7 +128,7 @@ td.c { text-align: center; }
113
128
  <h1>Council Report — ${esc(h.runType)} (${esc(h.runId)})</h1>
114
129
  <p class="meta">${meta}</p>
115
130
  <h2>Verdict summary</h2>
116
- <table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>
131
+ <table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>${lostSection}
117
132
  <h2>Adjudication matrix</h2>
118
133
  <table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
119
134
  <p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>
@@ -11,6 +11,7 @@
11
11
 
12
12
  const { formatCost, sumWaveUsage } = require('../utils/pricing');
13
13
  const { formatDuration } = require('../utils/format-duration');
14
+ const { formatDegrade } = require('../utils/degrade');
14
15
 
15
16
  const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
16
17
  const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
@@ -65,8 +66,14 @@ function toModel(verdict, wave) {
65
66
  .map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
66
67
  };
67
68
  const runStats = verdict.runStats || [];
69
+ // Cost-row role tag (Plan 2 final review F1): #83 gave judges their own
70
+ // runStats row, so a bench model can now appear twice (seat + judge),
71
+ // indistinguishable by `model` alone. Tag ONLY judge rows — old verdicts have
72
+ // no judge rows at all, so chair/critic/lens/seat rows stay byte-identical to
73
+ // their historical rendering.
68
74
  const costRows = runStats.map(r => ({
69
- model: r.model, status: r.status, durationMs: r.durationMs,
75
+ model: r.role === 'judge' ? `${r.model} (judge)` : r.model,
76
+ status: r.status, durationMs: r.durationMs,
70
77
  cost: r.usage && r.usage.cost ? r.usage.cost : null,
71
78
  }));
72
79
  const total = (wave && wave.usage && wave.usage.cost) ? wave.usage.cost : sumWaveUsage(runStats).cost;
@@ -78,6 +85,13 @@ function toModel(verdict, wave) {
78
85
  tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
79
86
  judges, findings, debate,
80
87
  streetCred: verdict.streetCred || [],
88
+ // v4.6 Plan 2: additive and OPTIONAL on the verdict (verdict.js only sets
89
+ // it when the run actually degraded), so a clean verdict's model — and
90
+ // therefore its rendered report — is byte-for-byte unchanged.
91
+ // Plan 2 final review F2: LOSSES ONLY — a heal is announced on stderr/run.json but
92
+ // is not a loss (spec D4, §8), so it must never render under "What was
93
+ // lost". deriveSeatLoss (verdict.js) applies the same kind !== 'heal' filter.
94
+ degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal'),
81
95
  cost: { rows: costRows, total },
82
96
  };
83
97
  }
@@ -97,6 +111,16 @@ function renderMd(m) {
97
111
  out.push('| Tier | Count |\n|---|---|');
98
112
  for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
99
113
 
114
+ // Heading-over-nothing: emitted ONLY when the run actually degraded, so a
115
+ // clean verdict's report stays byte-identical to before this section
116
+ // existed. Losses are headline news, so they sit directly under the
117
+ // summary, before the reader reaches the adjudication detail.
118
+ if (m.degrades.length) {
119
+ out.push('\n## What was lost\n');
120
+ // ONE voice (Plan 1's formatDegrade) — the report must not grow a dialect.
121
+ for (const d of m.degrades) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
122
+ }
123
+
100
124
  out.push('\n## Adjudication matrix\n');
101
125
  out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
102
126
  out.push(`|---|---|---|${m.judges.map(() => '---').join('|')}|---|---|`);
@@ -19,7 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { writeFileAtomic } = require('../utils/atomic-write');
22
- const { buildVerdict, summarizeSeatLoss, writeVerdictAtomic } = require('./verdict');
22
+ const { buildVerdict, summarizeSeatLoss, deriveSeatLoss, writeVerdictAtomic } = require('./verdict');
23
23
  const { buildReport } = require('./report');
24
24
  const { validateFindings } = require('./findings');
25
25
  const { toGlobalFindings } = require('./anonymize');
@@ -160,6 +160,16 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
160
160
  findings.push(...claudeReview.globalFindings);
161
161
  runStats.push(claudeRunStatsRow());
162
162
  }
163
+ // #83 (v4.6 Plan 2): Stage-2 judge legs are ~38% of a run's cost and had no
164
+ // runStats row at all — per-leg cost was unattributable from the artifact.
165
+ // One row per judge, attributing the judge's ORIGINAL Stage-2 wave leg (never
166
+ // a repair solo's — run-stage2.js mirrors Stage-1's convention there); a judge
167
+ // whose wave leg died still gets an honest error row.
168
+ for (const j of (judgeResults || [])) {
169
+ runStats.push(buildRunStatsEntry({
170
+ leg: j.leg, model: j.judge, role: 'judge', conformance: j.conformance,
171
+ }));
172
+ }
163
173
  if (chairStats) { runStats.push(chairStats); }
164
174
  return { meta, findings, adjudications, rankings, runStats };
165
175
  }
@@ -176,14 +186,22 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
176
186
  * Undecided verdict + deterministic report. Sets the nullable overallVerdict
177
187
  * (council family v2, Plan A) on buildVerdict's output — independent of
178
188
  * buildVerdict's own signature.
189
+ * @param {{runDir: string, record: object, overallVerdict?: (string|null),
190
+ * chairText?: string, critic?: string, deadWaves?: Array<object>,
191
+ * degrades?: Array<object>}} o `degrades` (v4.6 Plan 2), when present, is
192
+ * both carried onto the verdict and used to DERIVE `seatLoss` (deriveSeatLoss)
193
+ * in preference to summarizing it from `deadWaves` (summarizeSeatLoss).
179
194
  * @returns {object} the verdict written to disk
180
195
  */
181
- function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves }) {
182
- // v4.5.2 computed here rather than in run.js so verdict assembly stays in
183
- // one place; see summarizeSeatLoss in ./verdict for why a lost critic has to
184
- // reach the verdict at all.
185
- const seatLoss = summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
186
- const verdict = buildVerdict(record, [], { seatLoss });
196
+ function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves, degrades }) {
197
+ // v4.6 Plan 2 (spec D3): when the sink's records are available they are the
198
+ // single source of truth seatLoss derives from them so it can never
199
+ // disagree with degrades[]. deadWaves remains the fallback for direct
200
+ // callers that predate the sink (their tests pass unedited).
201
+ const seatLoss = degrades
202
+ ? deriveSeatLoss({ runId: record.meta.runId, critic, degrades })
203
+ : summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
204
+ const verdict = buildVerdict(record, [], { seatLoss, degrades });
187
205
  verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
188
206
  writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
189
207
  const html = buildReport({ verdict }, { format: 'html' });
@@ -38,15 +38,17 @@ const { sumWaveUsage } = require('../utils/pricing');
38
38
  * @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
39
39
  * @param {string} [opts.runDir] run directory; when given, budget refusals are
40
40
  * checkpointed into run.json so the record outlives the stderr notice
41
- * @param {{value: boolean}} [opts.degraded] the driver's degrade flag; a budget
42
- * refusal sets it so the run can never exit 0 with a silently shrunken bench
41
+ * @param {{note: Function}} opts.degrade the council degrade sink; a budget
42
+ * refusal announces through it so the run can never exit 0 with a silently
43
+ * shrunken bench. Required, not optional — noteBudgetRefusal dereferences it
44
+ * unconditionally.
43
45
  * @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
44
46
  * @returns {{spendState:Function, spent:Function, overBudget:Function,
45
47
  * remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
46
48
  * addWave:Function, reserveBudget:Function, releaseBudget:Function,
47
49
  * noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
48
50
  */
49
- function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
51
+ function createBudget({ allLegs, maxCost, runDir, degrade, write }) {
50
52
  const legs = allLegs || [];
51
53
  const emit = write || ((s) => process.stderr.write(s));
52
54
  const hasCeiling = maxCost !== null && maxCost !== undefined;
@@ -146,11 +148,15 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
146
148
  const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
147
149
  reason: 'max-cost', at: new Date().toISOString() };
148
150
  refusals.push(rec);
149
- if (degraded) { degraded.value = true; }
150
- emit(`Notice: the $${maxCost} --max-cost ceiling refused wave ${rec.waveId} `
151
- + `(${rec.models.join(', ') || 'no models'}) — those seats DID NOT LAUNCH and are missing from `
152
- + 'this council. The run continues with the bench that did launch and will exit degraded (2). '
153
- + 'Raise --max-cost, or pass --no-cost-gate, to seat them.\n');
151
+ const message = info && info.message;
152
+ degrade.note({
153
+ channel: 'budget-refusal',
154
+ what: `wave ${rec.waveId} (${rec.models.join(', ') || 'no models'}) those seats DID NOT `
155
+ + 'LAUNCH and are missing from this council',
156
+ why: `the $${maxCost} --max-cost ceiling refused it${message ? `: ${message}` : ''}`,
157
+ effect: 'The run continues with the bench that did launch and will exit degraded (2)',
158
+ remedy: 'Raise --max-cost, or pass --no-cost-gate, to seat them',
159
+ });
154
160
  if (runDir) {
155
161
  // Never let bookkeeping sink a run that is otherwise fine.
156
162
  try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
@@ -46,13 +46,13 @@ function pickFallbackChair(statsRows, bench, failedChair) {
46
46
  * Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
47
47
  * single VERDICT-line repair re-prompt.
48
48
  * @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
49
- * @param {{packet: string, degraded: {value: boolean}, statsFn: Function,
49
+ * @param {{packet: string, degrade: {note: Function}, statsFn: Function,
50
50
  * isSignalled: function(): (number|null)}} args
51
51
  * @returns {Promise<{aborted: number|null, chairLeg: object|null,
52
52
  * actualChair: string|null, chairText: string|null,
53
53
  * chairConformance: string, overallVerdict: string|null}>}
54
54
  */
55
- async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
55
+ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
56
56
  const { o, launchers, addWave, overBudget } = ctx;
57
57
  const now = () => new Date().toISOString();
58
58
  const bail = (code) => ({
@@ -81,11 +81,19 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
81
81
 
82
82
  let chairLeg = null;
83
83
  let actualChair = null;
84
+ let skippedForCost = false;
84
85
  if (overBudget()) {
85
86
  // Ceiling hit after the tally is computable: skip the chair, write the
86
87
  // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
87
88
  // Never abort in-flight legs for cost — this only stops NEW launches.
88
- degraded.value = true;
89
+ skippedForCost = true;
90
+ degrade.note({
91
+ channel: 'chair-skipped-cost-ceiling',
92
+ what: 'the chair did not run',
93
+ why: 'the --max-cost ceiling was reached before the chair could launch',
94
+ effect: 'the verdict is written with no chair synthesis and overallVerdict null; will exit degraded (2)',
95
+ remedy: 'raise --max-cost, or re-run the chair alone against the existing tally',
96
+ });
89
97
  runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
90
98
  emitStageTerminal(o.runDir, o.runId, 'chair', 'skipped', null, o.follow);
91
99
  } else {
@@ -146,7 +154,16 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
146
154
  // A completed chair whose verdict never parsed is 'unstructured' even when
147
155
  // the repair was skipped (e.g. the chair leg itself tripped --max-cost).
148
156
  if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
149
- if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
157
+ if (!skippedForCost && (!chairLeg || !overallVerdict)) {
158
+ degrade.note({
159
+ channel: 'chair-failed',
160
+ what: 'the council has no chair synthesis',
161
+ why: chairLeg
162
+ ? 'the chair ran but its output carried no parseable VERDICT: line'
163
+ : 'no chair leg completed, including after the fallback chain',
164
+ effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
165
+ });
166
+ }
150
167
 
151
168
  return {
152
169
  aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
@@ -0,0 +1,115 @@
1
+ // src/council/run-debate-stage.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-debate-stage
6
+ * Debate orchestration, extracted from run.js (v4.6 Plan 1 Task 1) so that file
7
+ * could come off the 300-line size-gate cliff. Logic moved verbatim; the only
8
+ * change is the explicit parameter object replacing closure access — PLUS one
9
+ * necessary deviation the plan's block-boundary audit missed: the original
10
+ * line here was `return finalize(dbg.aborted);` (spec §5.7, a signal arriving
11
+ * mid-debate). `finalize` is a run.js-local closure bound to session/server
12
+ * teardown state (uninstall, the shared OpenCode server) that cannot be
13
+ * reconstructed in a separate module without duplicating that single-close-site
14
+ * guarantee. Mirroring the SAME convention run.js already uses for Stage 1/2
15
+ * (`runStage1`/`runStage2` return an `aborted` field; run.js itself decides to
16
+ * call `finalize`), this module returns `{ ...the five bindings, aborted:
17
+ * dbg.aborted }` on that one path instead, and run.js's call site now carries
18
+ * a `if (debateAborted) { return finalize(debateAborted); }` guard, exactly
19
+ * paralleling its existing `if (signalled || s1.aborted) { ... }` /
20
+ * `if (signalled || s2.aborted) { ... }` lines. Verified against
21
+ * tests/council/run-debate.test.js's "abort mid-debate (defense wave
22
+ * signalled) → finalize aborted, NO tally-final, NO ledger" end-to-end case,
23
+ * which exercises exactly this path through runCouncil and continues to pass
24
+ * unedited.
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const runState = require('./run-state');
29
+ const runDebateMod = require('./run-debate');
30
+ const { tally } = require('./tally');
31
+ const { emitStageStarted, emitStageTerminal } = require('../observe/events');
32
+
33
+ const now = () => new Date().toISOString();
34
+
35
+ async function runDebateStage(ctx, { provisional, provisionalInput, overBudget }) {
36
+ const { o } = ctx;
37
+ let debatedInput = provisionalInput, debatedRecord = provisional;
38
+ let debateOutcomes = null, debateFindings = null;
39
+ let debateSummary = o.debate ? { enabled: true, outcome: 'nothing-to-debate',
40
+ contested: 0, disputed: 0, defended: 0, amended: 0, withdrawn: 0, noResponse: 0,
41
+ revoteJudges: 0, revoteApplied: 0, verdictChanges: 0 } : null;
42
+ if (o.debate) {
43
+ // spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
44
+ // checkpoint — no ledger append, written before any debate leg launches.
45
+ fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
46
+ runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
47
+ emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
48
+ emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
49
+ const worthDebating = !runDebateMod.nothingToDebate(provisional);
50
+ if (worthDebating && !overBudget()) {
51
+ runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
52
+ emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
53
+ const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
54
+ // A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
55
+ // the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
56
+ if (dbg.aborted) {
57
+ runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
58
+ contested: dbg.contested, disputed: dbg.disputed } });
59
+ // See module docblock: `finalize` lives in run.js, not here. Return the
60
+ // signal (mirroring runStage1/runStage2's `aborted` field) instead of
61
+ // calling it directly — run.js's call site finalizes on our behalf.
62
+ return { debatedInput, debatedRecord, debateOutcomes, debateFindings, debateSummary, aborted: dbg.aborted };
63
+ }
64
+ runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
65
+ emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
66
+ // run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
67
+ // knows whether the wave launched. Never advertise a `-rv` id here: a skipped
68
+ // re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
69
+ // Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
70
+ // defended/amended or the cost ceiling skipped it — 'complete' would report
71
+ // work that never happened.
72
+ runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
73
+ ? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
74
+ // debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
75
+ // v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
76
+ emitStageTerminal(o.runDir, o.runId, 'debate-revote',
77
+ dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
78
+ ({ debatedInput, debateFindings, debateSummary } = dbg);
79
+ debatedRecord = tally(debatedInput);
80
+ // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
81
+ // list must be normalized to null here — otherwise the packet-assembly
82
+ // ternary below still calls buildDebateAddendum({outcomes: []}), which
83
+ // emits a bare "--- Debate round outcomes ---" heading with nothing
84
+ // under it (same defect class ee447b6 fixed on the report renderer).
85
+ debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
86
+ ? dbg.addendumOutcomes : null;
87
+ // Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
88
+ // each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
89
+ if (dbg.degraded) {
90
+ ctx.degrade.note({
91
+ channel: 'debate-degraded',
92
+ what: 'the debate round did not complete cleanly',
93
+ why: 'one or more defense or re-vote legs died or returned unstructured output',
94
+ effect: 'affected findings keep their provisional tier; will exit degraded (2)',
95
+ });
96
+ }
97
+ } else if (worthDebating) {
98
+ // Budget gone before the defense wave launched, but there WAS something to debate — the
99
+ // other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
100
+ debateSummary.outcome = 'skipped-cost-ceiling';
101
+ ctx.degrade.note({
102
+ channel: 'debate-degraded',
103
+ what: 'the debate round did not run',
104
+ why: 'the --max-cost ceiling was reached before the defense wave could launch',
105
+ effect: 'contested findings were not debated and keep their provisional tier; will exit degraded (2)',
106
+ remedy: 'raise --max-cost to let the debate round run',
107
+ });
108
+ }
109
+ runState.checkpoint(o.runDir, { debate: debateSummary });
110
+ }
111
+
112
+ return { debatedInput, debatedRecord, debateOutcomes, debateFindings, debateSummary };
113
+ }
114
+
115
+ module.exports = { runDebateStage };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module council/run-degrade
5
+ * THE CHOKE POINT. The only place in the council runtime permitted to set
6
+ * `degraded.value`. Announcing is a side effect of degrading, which is what
7
+ * makes "every degrade is announced" true by construction rather than by
8
+ * discipline — see tests/council/degrade-invariant.test.js, which fails if
9
+ * anyone writes `degraded.value = true` anywhere else.
10
+ */
11
+ const { makeDegrade, formatDegrade } = require('../utils/degrade');
12
+
13
+ function createDegradeSink({ runDir, degraded, write }) {
14
+ const emit = write || ((s) => process.stderr.write(s));
15
+ const records = [];
16
+
17
+ // One level, no re-entry. Without this rule a disk-full condition becomes an
18
+ // unbounded loop of degrades about failing to record degrades (spec §7).
19
+ const safeEmit = (s) => { try { emit(s); } catch { /* EPIPE etc — never mask the run */ } };
20
+
21
+ function note(input) {
22
+ let record;
23
+ try {
24
+ record = makeDegrade(input);
25
+ } catch (err) {
26
+ record = makeDegrade({
27
+ channel: 'internal',
28
+ what: `a degrade on channel '${input && input.channel}' could not be recorded`,
29
+ why: (err && err.message) || 'unknown error',
30
+ effect: 'the run still degrades; the original detail is lost',
31
+ });
32
+ }
33
+ records.push(record);
34
+ safeEmit(formatDegrade(record));
35
+ try {
36
+ require('./run-state').checkpoint(runDir, { degrades: records.slice() });
37
+ } catch { /* precedent: run-budget.js:156 — a degrade that cannot be persisted is still announced */ }
38
+ if (record.kind === 'degrade' && degraded) { degraded.value = true; }
39
+ }
40
+
41
+ return { note, all: () => records.slice() };
42
+ }
43
+
44
+ module.exports = { createDegradeSink };
@@ -59,12 +59,27 @@ function statusForExit(code) {
59
59
  * a run that published a total it knows is a floor has not earned that.
60
60
  *
61
61
  * @param {{signalled: number|null, exitCode: number, degraded?: {value: boolean},
62
- * inexactUnderCeiling?: () => boolean}} args
62
+ * degrade?: {note: Function}, inexactUnderCeiling?: () => boolean}} args
63
63
  * @returns {number}
64
64
  */
65
- function resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling }) {
65
+ function resolveTerminalExit({ signalled, exitCode, degraded, degrade, inexactUnderCeiling }) {
66
66
  if (signalled) { return signalled; }
67
- if (degraded && inexactUnderCeiling && inexactUnderCeiling()) { degraded.value = true; }
67
+ // Final-review F1: the inexactness itself is still announced on EVERY path by
68
+ // noticeUnknownSpend() at writeRunTerminal, below — that is unconditional and
69
+ // untouched. What is gated here is only the CLAIM this record's effect text
70
+ // makes, "the run exits degraded (2)", which is true on exactly the codes that
71
+ // end up 2 (a clean 0 about to flip, or an already-degraded 2). On exit 1
72
+ // (quorum/internal error) or an abort/signal code the run does NOT exit 2, so
73
+ // noting that sentence there would be false.
74
+ if (degrade && inexactUnderCeiling && inexactUnderCeiling() && (exitCode === 0 || exitCode === 2)) {
75
+ // Late channel (spec §6 rule 1): fires AFTER verdict assembly, so this record is run.json-only by design — it can never appear on verdict.degrades[].
76
+ degrade.note({
77
+ channel: 'inexact-under-ceiling',
78
+ what: 'the run total is a lower bound, not an exact figure',
79
+ why: 'one or more legs reported no usage, so their cost is unknown',
80
+ effect: '--max-cost bounded only KNOWN spend; the run exits degraded (2)',
81
+ });
82
+ }
68
83
  return (exitCode === 0 && degraded && degraded.value) ? 2 : exitCode;
69
84
  }
70
85
 
@@ -145,7 +145,12 @@ async function resolveRunServerModels(o, deps = {}) {
145
145
  * cannot clobber `budgetRefusals[]` or anything else already on the document.
146
146
  * Verified, not assumed — `tests/council/run-state.test.js` pins it.
147
147
  *
148
- * @param {object} o the council run's resolved options
148
+ * @param {object} o the council run's resolved options (carries `degrade`, the
149
+ * council sink, for the `sharedServerUnavailable` announcement below). `o.degrade`
150
+ * is expected to be set on that path — run.js threads it — but the call below is
151
+ * still guarded rather than assumed: a throw from this module would escape
152
+ * runCouncil past its "never rejects for run errors" contract (see this file's
153
+ * own docblock), which is worse than a missed note.
149
154
  * @param {object} patch a single top-level run.json key
150
155
  * @param {string} what the field name, for the failure log
151
156
  */
@@ -155,6 +160,21 @@ function recordServerFate(o, patch, what) {
155
160
  catch (writeErr) {
156
161
  logger.warn(`Could not record ${what} on run.json`, { runId: o.runId, error: writeErr.message });
157
162
  }
163
+ if (what === 'sharedServerUnavailable') {
164
+ const unavailable = patch.sharedServerUnavailable;
165
+ const reason = typeof unavailable === 'string' ? unavailable
166
+ : (unavailable && unavailable.error) || 'unknown error';
167
+ if (o.degrade) {
168
+ o.degrade.note({
169
+ channel: 'shared-server-unavailable',
170
+ kind: 'degrade',
171
+ what: 'could not start a shared OpenCode server',
172
+ why: reason,
173
+ effect: 'each wave will start its own, which is the configuration that races; the run '
174
+ + 'will exit degraded (2)',
175
+ });
176
+ }
177
+ }
158
178
  }
159
179
 
160
180
  /**
@@ -222,14 +242,11 @@ async function acquireRunServer(o, deps = {}) {
222
242
  // DURABLE: it lands on the run's own record, next to `budgetRefusals[]`,
223
243
  // for the same reason — a silent partial is the failure mode this whole
224
244
  // release exists to remove.
225
- const degrade = { error: err.message, at: new Date().toISOString() };
226
- recordServerFate(o, { sharedServerUnavailable: degrade }, 'sharedServerUnavailable');
245
+ const serverFailure = { error: err.message, at: new Date().toISOString() };
246
+ recordServerFate(o, { sharedServerUnavailable: serverFailure }, 'sharedServerUnavailable');
227
247
  logger.warn('Shared OpenCode server unavailable — falling back to one server per wave', {
228
248
  runId: o.runId, error: err.message,
229
249
  });
230
- process.stderr.write(
231
- `Notice: could not start a shared OpenCode server (${err.message}); each wave will start `
232
- + 'its own, which is the configuration that races. Expect degraded results.\n');
233
250
  return null;
234
251
  }
235
252
  }
@@ -245,4 +262,4 @@ async function releaseRunServer(shared) {
245
262
  try { await shared.server.close(); } catch { /* best-effort: the run is over */ }
246
263
  }
247
264
 
248
- module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels };
265
+ module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels, recordServerFate };
@@ -106,11 +106,19 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
106
106
  }
107
107
  if (!parsed.ok) {
108
108
  judgeResults.push({ judge, ok: false, order: null, adjudications: null,
109
- conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
109
+ conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
110
+ // #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
111
+ // Stage-1's convention (reviews carry the original wave leg even when a
112
+ // repair ran — repairs are separately recorded via appendStageWave).
113
+ // A repair solo's leg is NOT preferred here: attributing it instead
114
+ // would leave every non-repaired (the common case) judge with a false
115
+ // `status: 'error'` row — worse than the missing row #83 complained about.
116
+ leg: leg || null });
110
117
  continue;
111
118
  }
112
119
  const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
113
- judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
120
+ judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
121
+ leg: leg || null });
114
122
  }
115
123
  return { aborted: null, judgeResults };
116
124
  }
@@ -107,25 +107,6 @@ async function launchStage1(ctx) {
107
107
  return { aborted, legs, deadWaves };
108
108
  }
109
109
 
110
- /**
111
- * Announce Stage-1 sub-waves that never produced a leg.
112
- *
113
- * POLICY (the standing "never fail closed on availability" ruling, applied the
114
- * same way run-budget.js applies it to cost): the run CONTINUES with the bench
115
- * that did launch. What it must never do is lose the seats SILENTLY — so every
116
- * dead wave is announced on stderr, kept on run.json's stage entry (run.js) and
117
- * degrades the run's exit code to 2.
118
- * @param {Array<{waveId: string, models: string[], reason: string}>} deadWaves
119
- * @param {(s: string) => void} [write] stderr seam
120
- */
121
- function reportDeadStage1Waves(deadWaves, write = (s) => process.stderr.write(s)) {
122
- for (const d of deadWaves) {
123
- write(`Notice: Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs `
124
- + `— ${d.reason}. Those seats are NOT in this council. The run continues with the bench that `
125
- + 'did launch and will exit degraded (2).\n');
126
- }
127
- }
128
-
129
110
  /** Role of a seat by its input alias. */
130
111
  function roleFor(o, alias) {
131
112
  if (o.lenses) {
@@ -151,12 +132,33 @@ async function runStage1(ctx) {
151
132
  const { o } = ctx;
152
133
  const { aborted, legs, deadWaves } = await launchStage1(ctx);
153
134
  if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
154
- reportDeadStage1Waves(deadWaves);
135
+
136
+ for (const d of deadWaves) {
137
+ ctx.degrade.note({
138
+ channel: 'dead-wave',
139
+ what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
140
+ why: d.reason,
141
+ effect: 'Those seats are NOT in this council. The run continues with the bench that did '
142
+ + 'launch and will exit degraded (2)',
143
+ data: { waveId: d.waveId, models: d.models, reason: d.reason },
144
+ });
145
+ }
155
146
 
156
147
  const materialized = materializeReviews(o.runDir, legs);
157
148
  const alive = new Set(materialized.map(m => m.leg));
158
149
  const deadLegs = legs.filter(l => !alive.has(l));
159
150
 
151
+ for (const leg of deadLegs) {
152
+ ctx.degrade.note({
153
+ channel: 'dead-leg',
154
+ what: `seat ${leg.modelInput || leg.model} did not review`,
155
+ why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
156
+ effect: `${materialized.length} of ${legs.length} seats reviewed; `
157
+ + 'the run continues with the bench that did and will exit degraded (2)',
158
+ data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
159
+ });
160
+ }
161
+
160
162
  const reviews = [];
161
163
  let repairSeq = 0;
162
164
  for (const m of materialized) {
@@ -263,4 +265,4 @@ async function runStage1(ctx) {
263
265
  // module that produces the exit codes — so the child no longer imports from its
264
266
  // parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
265
267
  // and run-debate.js, which have always taken it from here.
266
- module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor, reportDeadStage1Waves };
268
+ module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };