amicus 4.3.0 → 4.4.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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -3,31 +3,25 @@
3
3
 
4
4
  /**
5
5
  * @module council/run-stages
6
- * Stage-1 (independent reviews) and Stage-2 (anonymized cross-review) loops
7
- * for the headless council engine launch, materialize, validate, bounded
8
- * repair. Split from run.js for the 300-line gate. All model calls go through
9
- * ctx.launchers (DI); the whole-run cost ceiling is consulted via
10
- * ctx.overBudget() before every paid repair launch (spec §4).
6
+ * Stage-1 (independent reviews) loop for the headless council engine — launch,
7
+ * materialize, validate, bounded repair. Split from run.js for the 300-line
8
+ * gate; Stage 2 lives in ./run-stage2.js for the same reason (v4.4.1 Task 2) and
9
+ * is RE-EXPORTED from here, so this module is the single import surface for both
10
+ * stage loops. All model calls go through ctx.launchers (DI); the whole-run cost
11
+ * ceiling is consulted via ctx.overBudget() before every paid repair launch
12
+ * (spec §4).
11
13
  *
12
- * Headless adaptations (vs SKILL.md):
13
- * - A review still malformed after 2 repair re-prompts is KEPT with
14
- * conformance 'unstructured' and zero findings entries (the skill's Claude
15
- * hand-parse fallback has no headless equivalent; the review still gets
16
- * ranked in Stage 2).
17
- * - A judge still malformed after 2 repairs is dropped from rankings and
18
- * adjudications (ok:false) and recorded conformance 'unstructured' (spec §5).
14
+ * Headless adaptation (vs SKILL.md): a review still malformed after 2 repair
15
+ * re-prompts is KEPT with conformance 'unstructured' and zero findings entries
16
+ * (the skill's Claude hand-parse fallback has no headless equivalent; the
17
+ * review still gets ranked in Stage 2).
19
18
  */
20
19
 
21
- const fs = require('fs');
22
- const path = require('path');
23
- const { validateFindings } = require('./findings');
20
+ const { validateFindings, countAttemptedFindings, repairCanHonorContract } = require('./findings');
24
21
  const briefings = require('./briefings');
25
- const stage2 = require('./briefings-stage2');
26
- const { parseJudgeOutput } = require('./parse-stage2');
27
- const { materializeReviews, sanitizeName } = require('./run-launch');
22
+ const { materializeReviews, isAbortExit } = require('./run-launch');
28
23
  const runState = require('./run-state');
29
-
30
- function isAbortExit(code) { return code === 130 || code === 143; }
24
+ const { runStage2 } = require('./run-stage2');
31
25
 
32
26
  function slug(text) {
33
27
  return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
@@ -50,6 +44,7 @@ async function launchStage1(ctx) {
50
44
  fallback: o.fallback, catalog: o.catalog,
51
45
  };
52
46
  const launches = [];
47
+ const seated = []; // parallel to `launches`: what each one was SUPPOSED to seat
53
48
  // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
54
49
  // stages[].waveIds, so an id written after the launch leaves that leg
55
50
  // reachable only by the pid kill (no per-leg abort marker).
@@ -58,6 +53,7 @@ async function launchStage1(ctx) {
58
53
  o.models.forEach((m, i) => {
59
54
  const waveId = `${o.runId}-l${i + 1}`;
60
55
  record(waveId);
56
+ seated.push({ waveId, models: [m] });
61
57
  launches.push(launchers.launchSolo({
62
58
  ...common, model: m, waveId,
63
59
  prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
@@ -67,6 +63,7 @@ async function launchStage1(ctx) {
67
63
  const seats = o.models.filter(m => m !== o.critic);
68
64
  if (seats.length > 0) {
69
65
  record(`${o.runId}-s1`);
66
+ seated.push({ waveId: `${o.runId}-s1`, models: seats.slice() });
70
67
  launches.push(launchers.launchWave({
71
68
  ...common, models: seats, waveId: `${o.runId}-s1`,
72
69
  prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
@@ -74,6 +71,7 @@ async function launchStage1(ctx) {
74
71
  }
75
72
  if (o.critic) {
76
73
  record(`${o.runId}-c1`);
74
+ seated.push({ waveId: `${o.runId}-c1`, models: [o.critic] });
77
75
  launches.push(launchers.launchSolo({
78
76
  ...common, model: o.critic, waveId: `${o.runId}-c1`,
79
77
  prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
@@ -83,12 +81,49 @@ async function launchStage1(ctx) {
83
81
  const results = await Promise.all(launches);
84
82
  let aborted = null;
85
83
  const legs = [];
86
- for (const r of results) {
84
+ const deadWaves = [];
85
+ results.forEach((r, i) => {
87
86
  ctx.addWave(r.wave);
88
- if (isAbortExit(r.exitCode)) { aborted = r.exitCode; }
89
- if (r.wave && Array.isArray(r.wave.legs)) { legs.push(...r.wave.legs); }
87
+ const abort = isAbortExit(r.exitCode);
88
+ if (abort) { aborted = r.exitCode; }
89
+ const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
90
+ legs.push(...got);
91
+ // ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
92
+ // never started; `database is locked`) contributes NOTHING to `legs`, so
93
+ // deadLegs cannot see it either — which is how run v441plan01 recorded
94
+ // stage1 'complete' with four seats missing and no trace of them. In lens
95
+ // mode every seat is its own wave, so a run could lose seats and still exit
96
+ // 0; the quorum gate only catches the non-lens seat wave. A budget refusal
97
+ // has its own louder channel already (run-budget.noteBudgetRefusal) and
98
+ // must not be double-counted here.
99
+ if (got.length > 0 || abort) { return; }
100
+ if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
101
+ deadWaves.push({
102
+ waveId: seated[i].waveId, models: seated[i].models,
103
+ reason: (r.wave && (r.wave.reason || r.wave.error))
104
+ || (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
105
+ });
106
+ });
107
+ return { aborted, legs, deadWaves };
108
+ }
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');
90
126
  }
91
- return { aborted, legs };
92
127
  }
93
128
 
94
129
  /** Role of a seat by its input alias. */
@@ -102,12 +137,21 @@ function roleFor(o, alias) {
102
137
 
103
138
  /**
104
139
  * Stage 1: independent reviews + findings validation + bounded repair.
105
- * @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array}>}
140
+ * @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array,
141
+ * deadWaves: Array, degraded: boolean}>} `degraded` covers BOTH ways a seat
142
+ * can go missing: a leg that ran and died (deadLegs) and a whole sub-wave that
143
+ * died before its legs existed (deadWaves). A pushed review carries
144
+ * `findingsUnverified: true` (LC-11) when its findings came from a repair whose
145
+ * contract could not be checked — the original block was absent or unparseable,
146
+ * so there was no finding count to compare against — and `repairRefused:
147
+ * {code, detail}` (review F1) when the contract WAS checked and broken, which is
148
+ * what separates a refused repair from a seat that never emitted JSON.
106
149
  */
107
150
  async function runStage1(ctx) {
108
151
  const { o } = ctx;
109
- const { aborted, legs } = await launchStage1(ctx);
110
- if (aborted) { return { aborted, reviews: [], deadLegs: [] }; }
152
+ const { aborted, legs, deadWaves } = await launchStage1(ctx);
153
+ if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
154
+ reportDeadStage1Waves(deadWaves);
111
155
 
112
156
  const materialized = materializeReviews(o.runDir, legs);
113
157
  const alive = new Set(materialized.map(m => m.leg));
@@ -119,110 +163,101 @@ async function runStage1(ctx) {
119
163
  let conformance = 'clean';
120
164
  let res = validateFindings(m.text);
121
165
  let attempts = 0;
122
- while (!res.ok && attempts < 2 && !ctx.overBudget()) {
166
+ // ⚠️ LC-6: the text the repair prompt must carry. A repair solo is a FRESH
167
+ // session — it has no memory of the review turn — so shipping only
168
+ // res.errors asked the model to correct something it had never seen. Two
169
+ // paid models refused ("I don't have a previous review to correct") and one
170
+ // fabricated a finding, which reached tally.json and the chair's verdict.
171
+ // Tracked rather than pinned to m.text so `repairing` and `res.errors`
172
+ // always describe the SAME artifact: on attempt 2 the errors came from
173
+ // validating attempt 1's output, so attempt 1's output is what is being
174
+ // repaired. An empty/dead repair leg leaves it on the last real text
175
+ // (there is no newer artifact to name).
176
+ let repairing = m.text;
177
+ // ⚠️ LC-11: the count the repair is contractually forbidden from changing.
178
+ // Captured from the ORIGINAL block, because that is the generation m.text's
179
+ // prose actually narrates. null = absent/unparseable, so unverifiable — see
180
+ // the push below.
181
+ const attemptedCount = countAttemptedFindings(m.text);
182
+ // ⚠️ Review F2: never pay for a repair whose every outcome is already decided.
183
+ // An original declaring ZERO findings can only honor the contract by returning
184
+ // zero — so while the validator rejects an empty set (EMPTY_FINDINGS), a
185
+ // compliant repair fails validation and a non-compliant one is refused on the
186
+ // count below. Predicate, not a constant: Task 3 (LC-10) flips that validator
187
+ // rule, and this guard stops firing on its own when it does.
188
+ const repairable = repairCanHonorContract(attemptedCount);
189
+ while (!res.ok && repairable && attempts < 2 && !ctx.overBudget()) {
123
190
  attempts += 1;
124
191
  repairSeq += 1;
125
192
  const waveId = `${o.runId}-p${repairSeq}`;
126
193
  runState.appendStageWave(o.runDir, 'stage1', waveId);
127
194
  const solo = await ctx.launchers.launchSolo({
128
- model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
195
+ model: m.modelInput,
196
+ prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
129
197
  project: o.runDir, waveId, timeout: o.timeout,
130
198
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
131
199
  councilRunId: o.runId, councilName: o.councilName,
132
200
  fallback: o.fallback, catalog: o.catalog,
133
201
  });
134
202
  ctx.addWave(solo.wave);
135
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
136
- res = validateFindings((solo.leg && solo.leg.summary) || '');
203
+ if (isAbortExit(solo.exitCode)) {
204
+ return { aborted: solo.exitCode, reviews, deadLegs, deadWaves, degraded: false };
205
+ }
206
+ const repaired = (solo.leg && solo.leg.summary) || '';
207
+ if (repaired.trim()) { repairing = repaired; }
208
+ res = validateFindings(repaired);
137
209
  if (res.ok) { conformance = 'repaired'; }
138
210
  }
139
211
  if (!res.ok) { conformance = 'unstructured'; }
212
+ // ⚠️ LC-11. `text` below is ALWAYS the seat's own prose — never the repair's
213
+ // output, which is a bare JSON block by design (briefings.js:19-26
214
+ // deliberately omits the two-part prose framing from repair prompts).
215
+ // Substituting it would hand the judges a narrative-free review and render a
216
+ // JSON dump into bundle-stage2.md as "what the judges saw".
217
+ //
218
+ // Instead the repair's CONTRACT is enforced: "the same findings, fixed — do
219
+ // not add or remove findings". A repair that changed the count produced a
220
+ // findings set this prose does not narrate, so it is refused rather than
221
+ // adjudicated.
222
+ //
223
+ // ⚠️ Review F4 — what this check is and is NOT. It does NOT catch costgate01:
224
+ // that leg emitted no fenced block at all, so attemptedCount is null, the
225
+ // repair is ACCEPTED and merely marked findingsUnverified. LC-12 (handing the
226
+ // repair prompt the artifact) is what addresses that incident. And the check
227
+ // deliberately over-refuses one honest case: a repair that legitimately merges
228
+ // a DUPLICATE_ID pair changes the count too, and is refused with it.
229
+ //
230
+ // ⚠️ Review F1: the refusal RIDES the review (repairRefused) and the runStats
231
+ // row, because 'unstructured' alone is indistinguishable from a seat that never
232
+ // emitted JSON at all — the weaker, unverifiable case would then be the only
233
+ // one on the record.
234
+ let unverified = false;
235
+ let repairRefused = null;
236
+ if (conformance === 'repaired') {
237
+ if (attemptedCount === null) {
238
+ unverified = true; // nothing to compare; say so, don't imply a check
239
+ } else if (res.findings.length !== attemptedCount) {
240
+ conformance = 'unstructured';
241
+ repairRefused = { code: 'REPAIR_CHANGED_FINDING_COUNT',
242
+ detail: `repair returned ${res.findings.length} findings, original attempted ${attemptedCount}` };
243
+ res = { ok: false, findings: [], errors: [repairRefused] };
244
+ }
245
+ }
140
246
  reviews.push({
141
247
  model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
142
248
  text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
249
+ ...(unverified ? { findingsUnverified: true } : {}),
250
+ ...(repairRefused ? { repairRefused } : {}),
143
251
  });
144
252
  }
145
- return { aborted: null, reviews, deadLegs };
146
- }
147
-
148
- /**
149
- * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
150
- * @param {object} ctx
151
- * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
152
- * extraLabeled?: Array<{label: string, text: string}>}} args
153
- * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
154
- * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
155
- * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
156
- */
157
- async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
158
- const { o } = ctx;
159
- const { rankingToOrder } = require('./anonymize');
160
- fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
161
-
162
- // Zip off `reviews` (never off `labels.entries`, which may be one longer than
163
- // reviews when a file-sourced review is present) and append the extras.
164
- const labeled = reviews
165
- .map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
166
- .concat(extraLabeled);
167
- const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
168
- fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
169
-
170
- // ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
171
- // review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
172
- const judges = reviews.map(r => r.modelInput);
173
- const parseCtx = {
174
- labels: labels.entries.map(e => e.label),
175
- findingIds: globalFindings.map(f => f.id),
176
- };
177
- runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
178
- const { wave, exitCode } = await ctx.launchers.launchWave({
179
- models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
180
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
181
- noCostGate: o.noCostGate,
182
- councilRunId: o.runId, councilName: o.councilName,
183
- fallback: o.fallback, catalog: o.catalog,
184
- });
185
- ctx.addWave(wave);
186
- if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
187
-
188
- const judgeResults = [];
189
- let repairSeq = 0;
190
- for (const leg of (wave && wave.legs) || []) {
191
- const judge = leg.modelInput || leg.model;
192
- if (leg.status === 'complete' && leg.summary) {
193
- fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
194
- }
195
- let conformance = 'clean';
196
- let parsed = (leg.status === 'complete' && leg.summary)
197
- ? parseJudgeOutput(leg.summary, parseCtx)
198
- : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
199
- let attempts = 0;
200
- while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
201
- attempts += 1;
202
- repairSeq += 1;
203
- const waveId = `${o.runId}-q${repairSeq}`;
204
- runState.appendStageWave(o.runDir, 'stage2', waveId);
205
- const solo = await ctx.launchers.launchSolo({
206
- model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
207
- project: ctx.scratchDir, waveId, timeout: o.timeout,
208
- gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
209
- councilRunId: o.runId, councilName: o.councilName,
210
- fallback: o.fallback, catalog: o.catalog,
211
- });
212
- ctx.addWave(solo.wave);
213
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
214
- parsed = parseJudgeOutput((solo.leg && solo.leg.summary) || '', parseCtx);
215
- if (parsed.ok) { conformance = 'repaired'; }
216
- }
217
- if (!parsed.ok) {
218
- judgeResults.push({ judge, ok: false, order: null, adjudications: null,
219
- conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
220
- continue;
221
- }
222
- const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
223
- judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
224
- }
225
- return { aborted: null, judgeResults };
253
+ return { aborted: null, reviews, deadLegs, deadWaves,
254
+ degraded: deadLegs.length > 0 || deadWaves.length > 0 };
226
255
  }
227
256
 
228
- module.exports = { runStage1, runStage2, isAbortExit, slug };
257
+ // runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
258
+ // this module stays the single import surface for the stage loops. The cycle that
259
+ // once blocked that is gone: isAbortExit was hoisted into run-launch.js — the
260
+ // module that produces the exit codes — so the child no longer imports from its
261
+ // parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
262
+ // and run-debate.js, which have always taken it from here.
263
+ module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor, reportDeadStage1Waves };
@@ -85,6 +85,28 @@ function initRun(runDir, seed) {
85
85
  return writeRun(runDir, run);
86
86
  }
87
87
 
88
+ /**
89
+ * Seed run.json for a NEW council run and write its sessions-dir pointer.
90
+ * Lifted verbatim out of run.js for the 300-line gate (v4.4.1 Task 0.5, which
91
+ * needed room for the per-run shared server) — same seed, same order, same
92
+ * pointer write.
93
+ * @param {object} o the council run's resolved options
94
+ */
95
+ function initCouncilRun(o) {
96
+ initRun(o.runDir, {
97
+ schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
98
+ bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
99
+ labelMap: null,
100
+ // Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
101
+ // "no debate key" contract and fail the object-typed schema), and with a VALID
102
+ // outcome from the first write so a run killed mid-debate stays schema-valid.
103
+ ...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
104
+ options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
105
+ usage: null, pid: process.pid, createdAt: new Date().toISOString(),
106
+ });
107
+ writePointer(o.project, o.runId, o.runDir);
108
+ }
109
+
88
110
  /** Read-merge-write checkpoint (atomic; abort-wins on status). */
89
111
  function checkpoint(runDir, patch) {
90
112
  const existing = readRun(runDir) || {};
@@ -155,7 +177,7 @@ function listPointers(project) {
155
177
  }
156
178
 
157
179
  module.exports = {
158
- RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
180
+ RUN_FILE, readRun, initRun, initCouncilRun, checkpoint, updateStage, appendStageWave,
159
181
  writeSpawnPid, readSpawnPid,
160
182
  pointerPath, writePointer, readPointer, listPointers,
161
183
  };
@@ -22,20 +22,18 @@ const path = require('path');
22
22
  const { tally } = require('./tally');
23
23
  const { assignLabels, toGlobalFindings } = require('./anonymize');
24
24
  const briefings = require('./briefings');
25
- const stage2 = require('./briefings-stage2');
26
25
  const runState = require('./run-state');
27
26
  const { createLaunchers } = require('./run-launch');
28
- const { runStage1, runStage2 } = require('./run-stages');
27
+ const { runStage1, runStage2 } = require('./run-stages'); // stage 2 lives in ./run-stage2 (300-line gate), re-exported there
29
28
  const { runChair, pickFallbackChair } = require('./run-chair');
30
29
  const runDebateMod = require('./run-debate');
31
- const { buildDebateAddendum } = require('./briefings-debate');
32
30
  const { decorateRecord } = require('./debate');
33
31
  const asm = require('./run-assemble');
34
- const { sumWaveUsage } = require('../utils/pricing');
35
- const { emitRunStarted, emitRunTerminal, emitStageStarted, emitStageTerminal } = require('../observe/events');
36
- const { fireCouncilOnComplete } = require('../observe/on-complete');
37
-
38
- const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
32
+ const { createBudget } = require('./run-budget');
33
+ const { emitRunStarted, emitStageStarted, emitStageTerminal } = require('../observe/events');
34
+ // v4.4.1 CA-6: the whole exit-code vocabulary (SIGNAL_EXIT, statusForExit and the
35
+ // degradation in resolveTerminalExit) lives in ./run-finalize — see its docblock.
36
+ const { writeRunTerminal, resolveTerminalExit, SIGNAL_EXIT } = require('./run-finalize');
39
37
 
40
38
  /**
41
39
  * @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
@@ -45,40 +43,35 @@ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
45
43
  * launchWave/launchSolo for leg ledger attribution. fallback/catalog (v4.3 Task 18
46
44
  * §6.2): ctx.o carries both, but only run-stages.js's stage launches read them —
47
45
  * the chair/debate legs never substitute via chains.
48
- * @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?}
46
+ * @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?,
47
+ * startOpenCodeServerFn? (v4.4.1 Task 0.5 test seam, see ./run-server)}
49
48
  * @returns {Promise<{exitCode: number, run: object}>}
50
49
  */
51
50
  async function runCouncil(options, deps = {}) {
52
51
  const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
53
52
  noCostGate: false, councilName: null, ...options };
54
53
  o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
55
- const launchers = deps.launchers || createLaunchers();
56
54
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
57
55
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
58
56
  const installSignals = deps.installSignalAbortFn
59
57
  || require('../utils/session-abort').installSignalAbort;
60
58
  const now = () => new Date().toISOString();
61
59
 
62
- const allLegs = [];
63
- const addWave = (wave) => { if (wave && Array.isArray(wave.legs)) { allLegs.push(...wave.legs); } };
64
- const spent = () => {
65
- const c = sumWaveUsage(allLegs).cost;
66
- return typeof c.amount === 'number' ? c.amount : 0;
67
- };
68
- const overBudget = () => o.maxCost !== null && o.maxCost !== undefined && spent() >= o.maxCost;
60
+ // v4.4: the whole budget position lives in ./run-budget — its docblock carries the "fail LOUD,
61
+ // not CLOSED" ruling, why reserveBudget (not merely remainingBudget) is what holds the ceiling
62
+ // across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
63
+ // why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
64
+ const degraded = { value: false };
65
+ const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
66
+ noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degraded });
67
+ // v4.4.1 Task 0.5: ONE OpenCode server for the whole run — ./run-server carries
68
+ // the why and the evidence that `_scratch` judge isolation survives it. Acquired
69
+ // below (a getter, because the launchers are built first); null = as before.
70
+ let sharedServer = null;
71
+ const launchers = deps.launchers
72
+ || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer });
69
73
 
70
- runState.initRun(o.runDir, {
71
- schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
72
- bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
73
- labelMap: null,
74
- // Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
75
- // "no debate key" contract and fail the object-typed schema), and with a VALID
76
- // outcome from the first write so a run killed mid-debate stays schema-valid.
77
- ...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
78
- options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
79
- usage: null, pid: process.pid, createdAt: now(),
80
- });
81
- runState.writePointer(o.project, o.runId, o.runDir);
74
+ runState.initCouncilRun(o); // run.json seed + sessions-dir pointer (run-state.js)
82
75
  emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
83
76
 
84
77
  let signalled = null;
@@ -89,22 +82,25 @@ async function runCouncil(options, deps = {}) {
89
82
  },
90
83
  });
91
84
 
92
- const degraded = { value: false };
93
85
  const finalize = async (exitCode, error) => {
94
86
  uninstall();
95
- const code = signalled || exitCode;
96
- const status = (code === 130 || code === 143) ? 'aborted'
97
- : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
98
- const run = runState.checkpoint(o.runDir, {
99
- status, exitCode: code, error: error || null,
100
- usage: { cost: sumWaveUsage(allLegs).cost },
101
- completedAt: now(),
102
- });
103
- emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
104
- await fireCouncilOnComplete(o.onComplete, run, { runId: o.runId, runDir: o.runDir, exitCode: code, project: o.project }, o.onCompleteDeps);
87
+ // ONE close site: finalize is the single path every terminal outcome takes.
88
+ // ⚠️ …and it CLAIMS the handle before releasing it, so no re-entry (present
89
+ // or future) can close the same server twice. A double close is worse than a
90
+ // duplicate start: it tears the server out from under anything still in
91
+ // flight. Everything downstream of here is guarded in ./run-finalize —
92
+ // bookkeeping must never sink a run that already finished.
93
+ const claimed = sharedServer;
94
+ sharedServer = null;
95
+ await require('./run-server').releaseRunServer(claimed);
96
+ const code = resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling });
97
+ const run = await writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock });
105
98
  return { exitCode: code, run };
106
99
  };
107
100
 
101
+ // Injected launchers bring their own transport. Never throws — degrades to null.
102
+ if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer(o, deps); }
103
+
108
104
  const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
109
105
 
110
106
  try {
@@ -129,13 +125,21 @@ async function runCouncil(options, deps = {}) {
129
125
  });
130
126
  emitStageStarted(o.runDir, o.runId, 'stage1', o.lenses ? null : `${o.runId}-s1`, o.follow);
131
127
  const s1 = await runStage1(ctx);
128
+ // Step 10's other half: a Stage 1 that lost seats is NOT 'complete'. It was
129
+ // checkpointed 'complete' unconditionally, so run v441plan01 recorded a clean
130
+ // stage with four dead seats — and in lens mode (one solo per seat) the run
131
+ // could exit 0 outright, since only the non-lens seat wave meets the quorum
132
+ // gate. Never aborts: it reports and degrades (standing ruling).
133
+ const s1Status = s1.degraded ? 'partial' : 'complete';
134
+ const deadWaves = s1.deadWaves || [];
132
135
  runState.updateStage(o.runDir, 'stage1', {
133
- status: 'complete', completedAt: now(),
136
+ status: s1Status, completedAt: now(),
134
137
  taskIds: s1.reviews.map(r => (r.leg && r.leg.taskId)).filter(Boolean),
138
+ ...(deadWaves.length ? { deadWaves } : {}),
135
139
  });
136
- emitStageTerminal(o.runDir, o.runId, 'stage1', 'complete', o.lenses ? null : `${o.runId}-s1`, o.follow);
140
+ emitStageTerminal(o.runDir, o.runId, 'stage1', s1Status, o.lenses ? null : `${o.runId}-s1`, o.follow);
137
141
  if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
138
- if (s1.deadLegs.length > 0) { degraded.value = true; } // bench shrank → never a "full run"
142
+ if (s1.degraded) { degraded.value = true; } // bench shrank → never a "full run"
139
143
  if (s1.reviews.length < 2) {
140
144
  return finalize(1, {
141
145
  code: 'COUNCIL_QUORUM',
@@ -144,6 +148,7 @@ async function runCouncil(options, deps = {}) {
144
148
  }
145
149
 
146
150
  // ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
151
+ noticeUnknownSpend(); // v4.4: warn EARLY on a long run, not only at finalize
147
152
  if (overBudget()) {
148
153
  return finalize(1, {
149
154
  code: 'COST_EXCEEDED',
@@ -247,16 +252,10 @@ async function runCouncil(options, deps = {}) {
247
252
  runState.checkpoint(o.runDir, { debate: debateSummary });
248
253
  }
249
254
 
250
- const packet = stage2.buildChairPacket({
251
- // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
252
- // no rankings/adjudications, so it appears ONLY as one more review block.
253
- reviews: s1.reviews.map(r => ({ model: r.model, text: r.text }))
254
- .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
255
- rankings: debatedInput.rankings,
256
- adjudications: debatedInput.adjudications,
257
- tierCounts: debatedRecord.tierCounts, date: o.date,
258
- }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
259
- fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
255
+ const packet = asm.buildChairPacketFile({
256
+ runDir: o.runDir, reviews: s1.reviews, claudeReview, date: o.date,
257
+ tallyInput: debatedInput, record: debatedRecord, debateOutcomes,
258
+ });
260
259
 
261
260
  const chairRes = await runChair(ctx, {
262
261
  packet, degraded, statsFn, isSignalled: () => signalled,
@@ -114,6 +114,16 @@ function tally(input) {
114
114
  findings: outFindings,
115
115
  runStats: (runStats || []).map(r => ({
116
116
  model: r.model, role: r.role, wasChair: !!r.wasChair, conformance: r.conformance || 'clean',
117
+ // ⚠️ Review F3: this allowlist already carries `conformance`, which makes
118
+ // tally.json (and verdict.json, which copies runStats verbatim) THE per-run
119
+ // artifact showing a seat's conformance — so the two facts that qualify it
120
+ // travel with it: LC-11's `findingsUnverified` (contract uncheckable) and
121
+ // F1's `repairRefused` (contract checked and broken). Additive, emitted only
122
+ // when set, and the runStats schema declares no additionalProperties, so a
123
+ // run without either is byte-for-byte unchanged. The append-only LEDGER is
124
+ // deliberately NOT extended — that is a schema-versioned product decision.
125
+ ...(r.findingsUnverified ? { findingsUnverified: true } : {}),
126
+ ...(r.repairRefused ? { repairRefused: r.repairRefused } : {}),
117
127
  status: r.status || 'unknown',
118
128
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
119
129
  usage: r.usage || null,