amicus 4.4.0 → 4.5.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
@@ -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 } = 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));
@@ -130,6 +174,21 @@ async function runStage1(ctx) {
130
174
  // repaired. An empty/dead repair leg leaves it on the last real text
131
175
  // (there is no newer artifact to name).
132
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 (RESOLVED — deleted v4.5, owner-ruled 2026-07-27): a
183
+ // repairCanHonorContract predicate used to sit here so a review declaring
184
+ // ZERO findings never paid for a repair while EMPTY_FINDINGS rejected empty
185
+ // sets — every outcome of that wave was predetermined. LC-10 (v4.4.1) made a
186
+ // well-formed empty set VALID, which flipped the predicate constant-true by
187
+ // its own design, so it was removed rather than left as a dead guard someone
188
+ // deletes silently later. ⚠️ If you ever make validateFindings reject empty
189
+ // sets again, you are re-arming the F2 deadlock: restore a repairability
190
+ // check here first. tests/council/run-stages.test.js "never pays for a
191
+ // repair" pins the observable behavior.
133
192
  while (!res.ok && attempts < 2 && !ctx.overBudget()) {
134
193
  attempts += 1;
135
194
  repairSeq += 1;
@@ -144,99 +203,64 @@ async function runStage1(ctx) {
144
203
  fallback: o.fallback, catalog: o.catalog,
145
204
  });
146
205
  ctx.addWave(solo.wave);
147
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
206
+ if (isAbortExit(solo.exitCode)) {
207
+ return { aborted: solo.exitCode, reviews, deadLegs, deadWaves, degraded: false };
208
+ }
148
209
  const repaired = (solo.leg && solo.leg.summary) || '';
149
210
  if (repaired.trim()) { repairing = repaired; }
150
211
  res = validateFindings(repaired);
151
212
  if (res.ok) { conformance = 'repaired'; }
152
213
  }
153
214
  if (!res.ok) { conformance = 'unstructured'; }
215
+ // ⚠️ LC-11. `text` below is ALWAYS the seat's own prose — never the repair's
216
+ // output, which is a bare JSON block by design (briefings.js:19-26
217
+ // deliberately omits the two-part prose framing from repair prompts).
218
+ // Substituting it would hand the judges a narrative-free review and render a
219
+ // JSON dump into bundle-stage2.md as "what the judges saw".
220
+ //
221
+ // Instead the repair's CONTRACT is enforced: "the same findings, fixed — do
222
+ // not add or remove findings". A repair that changed the count produced a
223
+ // findings set this prose does not narrate, so it is refused rather than
224
+ // adjudicated.
225
+ //
226
+ // ⚠️ Review F4 — what this check is and is NOT. It does NOT catch costgate01:
227
+ // that leg emitted no fenced block at all, so attemptedCount is null, the
228
+ // repair is ACCEPTED and merely marked findingsUnverified. LC-12 (handing the
229
+ // repair prompt the artifact) is what addresses that incident. And the check
230
+ // deliberately over-refuses one honest case: a repair that legitimately merges
231
+ // a DUPLICATE_ID pair changes the count too, and is refused with it.
232
+ //
233
+ // ⚠️ Review F1: the refusal RIDES the review (repairRefused) and the runStats
234
+ // row, because 'unstructured' alone is indistinguishable from a seat that never
235
+ // emitted JSON at all — the weaker, unverifiable case would then be the only
236
+ // one on the record.
237
+ let unverified = false;
238
+ let repairRefused = null;
239
+ if (conformance === 'repaired') {
240
+ if (attemptedCount === null) {
241
+ unverified = true; // nothing to compare; say so, don't imply a check
242
+ } else if (res.findings.length !== attemptedCount) {
243
+ conformance = 'unstructured';
244
+ repairRefused = { code: 'REPAIR_CHANGED_FINDING_COUNT',
245
+ detail: `repair returned ${res.findings.length} findings, original attempted ${attemptedCount}` };
246
+ res = { ok: false, findings: [], errors: [repairRefused] };
247
+ }
248
+ }
154
249
  reviews.push({
155
250
  model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
156
251
  text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
252
+ ...(unverified ? { findingsUnverified: true } : {}),
253
+ ...(repairRefused ? { repairRefused } : {}),
157
254
  });
158
255
  }
159
- return { aborted: null, reviews, deadLegs };
160
- }
161
-
162
- /**
163
- * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
164
- * @param {object} ctx
165
- * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
166
- * extraLabeled?: Array<{label: string, text: string}>}} args
167
- * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
168
- * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
169
- * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
170
- */
171
- async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
172
- const { o } = ctx;
173
- const { rankingToOrder } = require('./anonymize');
174
- fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
175
-
176
- // Zip off `reviews` (never off `labels.entries`, which may be one longer than
177
- // reviews when a file-sourced review is present) and append the extras.
178
- const labeled = reviews
179
- .map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
180
- .concat(extraLabeled);
181
- const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
182
- fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
183
-
184
- // ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
185
- // review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
186
- const judges = reviews.map(r => r.modelInput);
187
- const parseCtx = {
188
- labels: labels.entries.map(e => e.label),
189
- findingIds: globalFindings.map(f => f.id),
190
- };
191
- runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
192
- const { wave, exitCode } = await ctx.launchers.launchWave({
193
- models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
194
- timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
195
- noCostGate: o.noCostGate,
196
- councilRunId: o.runId, councilName: o.councilName,
197
- fallback: o.fallback, catalog: o.catalog,
198
- });
199
- ctx.addWave(wave);
200
- if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
201
-
202
- const judgeResults = [];
203
- let repairSeq = 0;
204
- for (const leg of (wave && wave.legs) || []) {
205
- const judge = leg.modelInput || leg.model;
206
- if (leg.status === 'complete' && leg.summary) {
207
- fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
208
- }
209
- let conformance = 'clean';
210
- let parsed = (leg.status === 'complete' && leg.summary)
211
- ? parseJudgeOutput(leg.summary, parseCtx)
212
- : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
213
- let attempts = 0;
214
- while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
215
- attempts += 1;
216
- repairSeq += 1;
217
- const waveId = `${o.runId}-q${repairSeq}`;
218
- runState.appendStageWave(o.runDir, 'stage2', waveId);
219
- const solo = await ctx.launchers.launchSolo({
220
- model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
221
- project: ctx.scratchDir, waveId, timeout: o.timeout,
222
- gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
223
- councilRunId: o.runId, councilName: o.councilName,
224
- fallback: o.fallback, catalog: o.catalog,
225
- });
226
- ctx.addWave(solo.wave);
227
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
228
- parsed = parseJudgeOutput((solo.leg && solo.leg.summary) || '', parseCtx);
229
- if (parsed.ok) { conformance = 'repaired'; }
230
- }
231
- if (!parsed.ok) {
232
- judgeResults.push({ judge, ok: false, order: null, adjudications: null,
233
- conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
234
- continue;
235
- }
236
- const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
237
- judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
238
- }
239
- return { aborted: null, judgeResults };
256
+ return { aborted: null, reviews, deadLegs, deadWaves,
257
+ degraded: deadLegs.length > 0 || deadWaves.length > 0 };
240
258
  }
241
259
 
242
- module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
260
+ // runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
261
+ // this module stays the single import surface for the stage loops. The cycle that
262
+ // once blocked that is gone: isAbortExit was hoisted into run-launch.js — the
263
+ // module that produces the exit codes — so the child no longer imports from its
264
+ // parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
265
+ // and run-debate.js, which have always taken it from here.
266
+ module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor, reportDeadStage1Waves };
@@ -85,6 +85,45 @@ 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
+ // F9 (v4.5): additive-only — absent (not null) without --template; the MCP
105
+ // seed (mcp-council-run.js, initRun directly) never sets this in v4.5.
106
+ ...(o.template ? { template: o.template } : {}),
107
+ // v4.5 Task 12 (B7/F5): additive-only — absent (not null) without --pack.
108
+ // Preserved across a later MCP-child initRun whose own seed omits it —
109
+ // mergeRun's plain shallow merge never drops a key patch doesn't mention.
110
+ ...(o.pack ? { pack: o.pack } : {}),
111
+ // v4.5 Wave 2 (post-HOLD chip, task-23-report.md Anomaly 1): additive-only
112
+ // — absent (never an empty array) when nothing was dropped. Handler-computed
113
+ // (cli-handlers-council-run.js's resolveBench, via resolveCouncilMembers) so
114
+ // a scripted/--json/MCP caller has a signal a bench member vanished without
115
+ // diffing `bench` against the preset's nominal member list. Same
116
+ // preserved-across-a-later-MCP-child-seed precedent as `pack` above — the
117
+ // MCP handler pre-seeds this directly (mcp-council-run.js) before spawning
118
+ // the CLI child, whose own seed (bench already expanded to --models) never
119
+ // recomputes it and so never mentions the key.
120
+ ...(o.droppedMembers && o.droppedMembers.length ? { droppedMembers: o.droppedMembers } : {}),
121
+ options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
122
+ usage: null, pid: process.pid, createdAt: new Date().toISOString(),
123
+ });
124
+ writePointer(o.project, o.runId, o.runDir);
125
+ }
126
+
88
127
  /** Read-merge-write checkpoint (atomic; abort-wins on status). */
89
128
  function checkpoint(runDir, patch) {
90
129
  const existing = readRun(runDir) || {};
@@ -155,7 +194,7 @@ function listPointers(project) {
155
194
  }
156
195
 
157
196
  module.exports = {
158
- RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
197
+ RUN_FILE, readRun, initRun, initCouncilRun, checkpoint, updateStage, appendStageWave,
159
198
  writeSpawnPid, readSpawnPid,
160
199
  pointerPath, writePointer, readPointer, listPointers,
161
200
  };
@@ -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
32
  const { createBudget } = require('./run-budget');
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 };
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,12 +43,13 @@ 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
- noCostGate: false, councilName: null, ...options };
52
+ noCostGate: false, councilName: null, template: null, pack: null, ...options };
54
53
  o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
55
54
  const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
56
55
  const statsFn = deps.statsFn || require('./ledger').deriveReliability;
@@ -64,21 +63,15 @@ async function runCouncil(options, deps = {}) {
64
63
  // why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
65
64
  const degraded = { value: false };
66
65
  const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
67
- noteBudgetRefusal } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degraded });
68
- const launchers = deps.launchers || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal });
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;
@@ -91,20 +84,23 @@ async function runCouncil(options, deps = {}) {
91
84
 
92
85
  const finalize = async (exitCode, error) => {
93
86
  uninstall();
94
- const code = signalled || exitCode;
95
- const status = (code === 130 || code === 143) ? 'aborted'
96
- : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
97
- noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
98
- const run = runState.checkpoint(o.runDir, {
99
- status, exitCode: code, error: error || null,
100
- usage: usageBlock(),
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',
@@ -248,16 +252,10 @@ async function runCouncil(options, deps = {}) {
248
252
  runState.checkpoint(o.runDir, { debate: debateSummary });
249
253
  }
250
254
 
251
- const packet = stage2.buildChairPacket({
252
- // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
253
- // no rankings/adjudications, so it appears ONLY as one more review block.
254
- reviews: s1.reviews.map(r => ({ model: r.model, text: r.text }))
255
- .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
256
- rankings: debatedInput.rankings,
257
- adjudications: debatedInput.adjudications,
258
- tierCounts: debatedRecord.tierCounts, date: o.date,
259
- }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
260
- 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
+ });
261
259
 
262
260
  const chairRes = await runChair(ctx, {
263
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,