amicus 4.4.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 (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +3 -1
  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/main.js +4 -1
  21. package/electron/setup-ui-aliases.js +6 -6
  22. package/electron/workspace-ui/live-model.js +12 -1
  23. package/electron/workspace-ui/md-lite.js +52 -8
  24. package/electron/workspace-ui/workspace-matrix.js +46 -9
  25. package/electron/workspace-ui/workspace-panels.js +14 -3
  26. package/electron/workspace-ui/workspace-render.js +7 -1
  27. package/electron/workspace-ui/workspace-verbs.js +48 -2
  28. package/package.json +8 -3
  29. package/schemas/council-run.schema.json +20 -0
  30. package/schemas/progress.schema.json +12 -0
  31. package/schemas/spend.schema.json +52 -4
  32. package/src/cli-handlers-spend.js +20 -2
  33. package/src/cli-handlers-watch.js +11 -0
  34. package/src/cli.js +4 -2
  35. package/src/council/briefings-debate.js +27 -7
  36. package/src/council/briefings-stage2.js +155 -25
  37. package/src/council/briefings.js +24 -1
  38. package/src/council/findings.js +236 -9
  39. package/src/council/parse-stage2.js +10 -2
  40. package/src/council/report.js +19 -8
  41. package/src/council/run-assemble.js +42 -1
  42. package/src/council/run-budget.js +64 -11
  43. package/src/council/run-chair.js +4 -1
  44. package/src/council/run-debate.js +4 -2
  45. package/src/council/run-finalize.js +102 -0
  46. package/src/council/run-launch.js +29 -1
  47. package/src/council/run-server.js +248 -0
  48. package/src/council/run-stage2.js +118 -0
  49. package/src/council/run-stages.js +132 -111
  50. package/src/council/run-state.js +23 -1
  51. package/src/council/run.js +44 -46
  52. package/src/council/tally.js +10 -0
  53. package/src/headless.js +175 -6
  54. package/src/observe/council-legs.js +60 -3
  55. package/src/observe/live-doc.js +18 -1
  56. package/src/observe/watch-render.js +4 -1
  57. package/src/sidecar/child-sessions.js +1 -2
  58. package/src/sidecar/fanout-leg-fallback.js +69 -21
  59. package/src/sidecar/fanout-leg.js +6 -0
  60. package/src/sidecar/fanout-signals.js +61 -0
  61. package/src/sidecar/fanout-wave-io.js +75 -0
  62. package/src/sidecar/fanout.js +61 -70
  63. package/src/sidecar/progress-fields.js +26 -4
  64. package/src/sidecar/progress.js +8 -1
  65. package/src/sidecar/session-utils.js +23 -14
  66. package/src/spend-query.js +17 -5
  67. package/src/utils/lifecycle.js +37 -1
  68. package/src/utils/path-fence.js +39 -1
  69. package/src/utils/pricing.js +26 -10
  70. package/src/utils/server-setup.js +79 -1
  71. package/src/utils/spend-ledger.js +24 -3
  72. package/src/workspace/artifact-guard.js +22 -1
  73. package/src/workspace/fold-format.js +33 -4
  74. package/src/workspace/live-normalize.js +28 -15
  75. package/src/workspace/run-detail.js +7 -1
@@ -35,7 +35,15 @@ function parseJudgeOutput(text, { labels, findingIds }) {
35
35
  const errors = [];
36
36
  const known = new Set(labels);
37
37
  const flat = [];
38
- if (!Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
38
+ // ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS it returns null and
39
+ // throws nothing — so a body of literal `null` sailed past the catch above and
40
+ // `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
41
+ // (:129) and parseRevote (:167) below already carried this `!parsed` guard; the judge
42
+ // path and findings.js's validateFindings did not, which made it an asymmetry among
43
+ // five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
44
+ // a `null` body reports exactly what a keyless `{}` body already reported —
45
+ // BAD_RANKING + BAD_ADJUDICATIONS — and no new error code enters a repair prompt.
46
+ if (!parsed || !Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
39
47
  errors.push({ code: 'BAD_RANKING', detail: 'ranking must be a non-empty array of review labels' });
40
48
  } else {
41
49
  for (const slot of parsed.ranking) {
@@ -52,7 +60,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
52
60
  }
53
61
 
54
62
  const knownIds = new Set(findingIds);
55
- if (!Array.isArray(parsed.adjudications)) {
63
+ if (!parsed || !Array.isArray(parsed.adjudications)) { // see the `!parsed` note above
56
64
  errors.push({ code: 'BAD_ADJUDICATIONS', detail: 'adjudications must be an array' });
57
65
  } else {
58
66
  for (const a of parsed.adjudications) {
@@ -114,15 +114,26 @@ function renderMd(m) {
114
114
  for (const s of m.streetCred) { out.push(`| ${s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
115
115
 
116
116
  out.push('\n## Findings by tier\n');
117
- for (const t of TIER_ORDER) {
118
- const group = m.findings.filter(f => f.tier === t);
119
- if (!group.length) { continue; }
120
- out.push(`### ${t}`);
121
- for (const f of group) {
122
- const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
123
- out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
117
+ // LC-10 fast-follow (review minor M3): m.findings can legitimately be EMPTY
118
+ // (every seat honestly reported nothing) TIER_ORDER's four groups are then
119
+ // all empty too, and the loop below emits nothing, leaving this heading with
120
+ // no content beneath it before '## Cost'. Same heading-over-nothing class
121
+ // Task 3 closed in the Stage-2 prompts (buildJudgeBundle/buildChairPacket),
122
+ // human-facing here rather than model-facing. State the clean bench instead
123
+ // of leaving the heading to dangle.
124
+ if (!m.findings.length) {
125
+ out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
126
+ } else {
127
+ for (const t of TIER_ORDER) {
128
+ const group = m.findings.filter(f => f.tier === t);
129
+ if (!group.length) { continue; }
130
+ out.push(`### ${t}`);
131
+ for (const f of group) {
132
+ const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
133
+ out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
134
+ }
135
+ out.push('');
124
136
  }
125
- out.push('');
126
137
  }
127
138
 
128
139
  // Defensive: never emit the heading unless at least one grouping has
@@ -40,13 +40,26 @@ function worseConformance(a, b) {
40
40
  * leg doc yields durationMs/usage null (never invent a value). `model` (the
41
41
  * council alias) overrides leg.model (the resolved executable id) so ledger
42
42
  * rows join meta.models by exact string (ledger.js:20-24).
43
+ *
44
+ * ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
45
+ * class of fact as `conformance` and ride the same row. They are the two halves
46
+ * of the repair contract's outcome: `findingsUnverified` marks a 'repaired' seat
47
+ * whose contract could NOT be checked (the original block was absent or
48
+ * unparseable, so there was no finding count to compare), and `repairRefused`
49
+ * ({code, detail}) marks the stronger case — the contract WAS checked and broken,
50
+ * which is otherwise indistinguishable from a seat that never emitted JSON at
51
+ * all. Both are additive and present only when set, so a run without either is
52
+ * byte-for-byte unchanged.
43
53
  */
44
- function buildRunStatsEntry({ leg, model, role, wasChair, conformance }) {
54
+ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
55
+ repairRefused }) {
45
56
  return {
46
57
  model: model !== undefined ? model : (leg ? leg.model : null),
47
58
  role,
48
59
  wasChair: !!wasChair,
49
60
  conformance: conformance || 'clean',
61
+ ...(findingsUnverified ? { findingsUnverified: true } : {}),
62
+ ...(repairRefused ? { repairRefused } : {}),
50
63
  status: leg ? leg.status : 'error',
51
64
  durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
52
65
  usage: (leg && leg.usage) || null,
@@ -139,6 +152,7 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
139
152
  const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order }));
140
153
  const runStats = reviews.map(r => buildRunStatsEntry({
141
154
  leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
155
+ findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused,
142
156
  }));
143
157
  if (claudeReview) {
144
158
  meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
@@ -176,7 +190,34 @@ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
176
190
  return verdict;
177
191
  }
178
192
 
193
+ /**
194
+ * Build the chair packet and persist it as `chair-packet.md`. Lifted verbatim
195
+ * out of run.js for the 300-line gate (v4.4.1 Task 0.5) — same composition,
196
+ * same debate addendum, same file write.
197
+ * @param {{runDir: string, reviews: Array, claudeReview: object|null,
198
+ * tallyInput: object, record: object, debateOutcomes: Array|null, date: string}} args
199
+ * `tallyInput`/`record` are the DEBATED ones when --debate ran, the
200
+ * provisional pair otherwise (run.js keeps that sequencing).
201
+ * @returns {string} the packet text (run.js hands it straight to runChair)
202
+ */
203
+ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, record, debateOutcomes, date }) {
204
+ const { buildChairPacket } = require('./briefings-stage2');
205
+ const { buildDebateAddendum } = require('./briefings-debate');
206
+ const packet = buildChairPacket({
207
+ // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
208
+ // no rankings/adjudications, so it appears ONLY as one more review block.
209
+ reviews: reviews.map(r => ({ model: r.model, text: r.text }))
210
+ .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
211
+ rankings: tallyInput.rankings,
212
+ adjudications: tallyInput.adjudications,
213
+ tierCounts: record.tierCounts, date,
214
+ }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
215
+ fs.writeFileSync(path.join(runDir, 'chair-packet.md'), packet, { mode: 0o600 });
216
+ return packet;
217
+ }
218
+
179
219
  module.exports = {
180
220
  buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
221
+ buildChairPacketFile,
181
222
  preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
182
223
  };
@@ -24,6 +24,10 @@
24
24
  * UNDER-REPORTING, not "continuing in the presence of uncertainty" — and
25
25
  * nothing here converts uncertainty into a fabricated number in either
26
26
  * direction (no rounding unknown up to a guess, no rounding it down to zero).
27
+ *
28
+ * v4.4.1 CA-6 completes that posture at the EXIT CODE (see inexactUnderCeiling
29
+ * below): a ceiling never blocks, but a run under a ceiling no longer exits 0
30
+ * while publishing a total it knows is only a floor.
27
31
  */
28
32
 
29
33
  const { sumWaveUsage } = require('../utils/pricing');
@@ -40,7 +44,7 @@ const { sumWaveUsage } = require('../utils/pricing');
40
44
  * @returns {{spendState:Function, spent:Function, overBudget:Function,
41
45
  * remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
42
46
  * addWave:Function, reserveBudget:Function, releaseBudget:Function,
43
- * noteBudgetRefusal:Function, budgetRefusals:Function}}
47
+ * noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
44
48
  */
45
49
  function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
46
50
  const legs = allLegs || [];
@@ -155,10 +159,20 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
155
159
  };
156
160
  const budgetRefusals = () => refusals.slice();
157
161
 
158
- let noticed = false;
162
+ // ⚠️ v4.4.1 CA-3: a plain `noticed` boolean announced the FIRST unknown leg and
163
+ // silently swallowed every one created afterwards (Stage 2, repairs, debate,
164
+ // chair) — run.json kept the correct final count, so the data was right and
165
+ // only the announcement was wrong, which is exactly the failure mode the
166
+ // fail-loud posture exists to prevent. Track the count that was last announced
167
+ // instead, so a GROWING total re-announces while an unchanged one stays quiet:
168
+ // the notice keeps its "once per new fact" character without going silent on
169
+ // the later stages. (The alternative — deferring every notice to finalize() —
170
+ // was rejected: the notice exists to inform a decision still in flight.)
171
+ let noticedAt = -1;
159
172
  /**
160
- * One prominent, un-missable notice per run when the total is incomplete — for
161
- * EITHER reason, which are different statements and are worded differently:
173
+ * One prominent, un-missable notice each time the incomplete total GROWS (see
174
+ * `noticedAt` above; re-calling it with nothing new is silent) — for EITHER
175
+ * reason, which are different statements and are worded differently:
162
176
  * - `unknownLegs` — the leg reported no usage at all.
163
177
  * - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
164
178
  * subagent whose CHILD session is billed separately and whose spend the
@@ -174,8 +188,9 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
174
188
  */
175
189
  const noticeUnknownSpend = () => {
176
190
  const s = spendState();
177
- if ((s.unknownLegs === 0 && s.subtreeUnknownLegs === 0) || noticed) { return; }
178
- noticed = true;
191
+ const n = s.unknownLegs + s.subtreeUnknownLegs;
192
+ if (n === 0 || n === noticedAt) { return; }
193
+ noticedAt = n;
179
194
  const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
180
195
  const parts = [];
181
196
  if (s.unknownLegs > 0) {
@@ -185,10 +200,27 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
185
200
  parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
186
201
  + 'is billed separately and could NOT be determined');
187
202
  }
188
- emit(`Notice: ${parts.join('; and ')} and is NOT included in the $${s.known.toFixed(4)} `
189
- + `total${ceiling}. Real spend is HIGHER than reported this total is at least, not exactly, `
190
- + 'what was spent. See run.json usage (unknownLegs / subtreeUnknownLegs), or '
191
- + '`amicus spend --json` (sourceMix.unknown).\n');
203
+ // v4.4.1 A3: the counts are CUMULATIVE, and re-announcing a grown total
204
+ // ("1 council leg(s)…" then "4 council leg(s)…") reads as two separate
205
+ // findings that a reader can reasonably add up to five. "so far this run"
206
+ // says once, for both clauses, that each number is a running total.
207
+ emit(`Notice: so far this run, ${parts.join('; and ')} and is NOT included in the `
208
+ + `$${s.known.toFixed(4)} total${ceiling}. Real spend is HIGHER than reported — this total `
209
+ + 'is at least, not exactly, what was spent. See run.json usage (unknownLegs / '
210
+ + 'subtreeUnknownLegs), or `amicus spend --json` (sourceMix.unknown).'
211
+ // v4.4.1 CA-6: with a ceiling set, an inexact total is not a clean run — say
212
+ // so where the uncertainty is announced, so exit 2 is never a surprise.
213
+ //
214
+ // ⚠️ Review F2: hedged to "is on track to" rather than "will". This notice
215
+ // fires from noticeUnknownSpend(), which run.js calls immediately BEFORE
216
+ // the overBudget() check — so a run whose KNOWN spend also crosses the
217
+ // ceiling right here prints this sentence and then exits 1
218
+ // (COST_EXCEEDED), not 2. A signalled run exits 130/143. "Will" would be a
219
+ // guarantee this code cannot make; "is on track to" states the tendency
220
+ // that holds in the common case without promising an outcome decided
221
+ // later, downstream of this call.
222
+ + (hasCeiling ? ' Because a ceiling is set and this total is inexact, the run is on track '
223
+ + 'to exit degraded (2); the ceiling itself still never halts a run.' : '') + '\n');
192
224
  };
193
225
 
194
226
  /**
@@ -217,8 +249,29 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
217
249
  };
218
250
  };
219
251
 
252
+ /**
253
+ * v4.4.1 CA-6 (OWNER RULING, 2026-07-26). Is a `--max-cost` ceiling in force
254
+ * over a total we already know to be incomplete?
255
+ *
256
+ * `--max-cost` bounds KNOWN spend: an unknown leg contributes nothing to it and
257
+ * never halts a run — see `overBudget` above, which this deliberately does NOT
258
+ * touch, and which must keep ignoring unknown legs. That policy is right and it
259
+ * stays. What was wrong was the REPORT. A run could exit 0 — read by every
260
+ * script and every human as "clean, and inside your ceiling" — while knowingly
261
+ * publishing a floor: `council-wsgate02` really spent $0.9859 against a $0.75
262
+ * ceiling (131%) while amicus believed $0.3720, and exited 0.
263
+ *
264
+ * So the exit code degrades to 2, through the SAME `degraded` channel
265
+ * `noteBudgetRefusal` already uses for a shrunken bench: the run finished, its
266
+ * answer is good, and the cost figure underneath it is not the whole bill.
267
+ * With NO ceiling there is nothing to be inexact against and the exit code is
268
+ * untouched — an unpriced leg on an unbounded run is a fact, not a degradation.
269
+ * @returns {boolean}
270
+ */
271
+ const inexactUnderCeiling = () => hasCeiling && !usageBlock().costExact;
272
+
220
273
  return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
221
- addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals };
274
+ addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals, inexactUnderCeiling };
222
275
  }
223
276
 
224
277
  module.exports = { createBudget };
@@ -129,7 +129,10 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
129
129
  if (chairText && !overallVerdict && !overBudget()) {
130
130
  runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
131
131
  const repair = await launchers.launchSolo({
132
- model: actualChair, prompt: stage2.buildChairRepairPrompt(),
132
+ // ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
133
+ // VERDICT line is missing — so a fresh repair session that cannot see the
134
+ // synthesis is picking a verdict on an artifact it has never read.
135
+ model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
133
136
  project: o.runDir, waveId: `${o.runId}-ch4`,
134
137
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
135
138
  noCostGate: o.noCostGate,
@@ -95,7 +95,8 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
95
95
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
96
96
  const res2 = await ctx.launchers.launchSolo({
97
97
  ...legOpts(ctx, repairId), model: raiser,
98
- prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors }),
98
+ // ⚠️ LC-12: a repair solo is a fresh session — the defense that failed rides along.
99
+ prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors, defense: leg.summary }),
99
100
  });
100
101
  ctx.addWave(res2.wave);
101
102
  if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
@@ -144,7 +145,8 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
144
145
  const repairId = `${waveId}-${judge}r`;
145
146
  runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
146
147
  const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
147
- prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors }) });
148
+ // ⚠️ LC-12: ditto the re-vote output being repaired rides with its errors.
149
+ prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
148
150
  ctx.addWave(r2.wave);
149
151
  if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
150
152
  const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
@@ -0,0 +1,102 @@
1
+ // src/council/run-finalize.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-finalize
6
+ * The TERMINAL half of run.js's finalize(): the whole exit-code vocabulary
7
+ * (signal → code, code → status, and the degradation that resolves the run's
8
+ * FINAL code), the last-chance unknown-spend notice, run.json's terminal
9
+ * checkpoint, the run-terminal event and the on-complete hook. Extracted from
10
+ * run.js for the 300-line size gate (v4.4.1 fix wave) — run.js keeps the half
11
+ * that must stay in the closure (uninstalling signals and releasing the run's
12
+ * shared server).
13
+ *
14
+ * ⚠️ BOOKKEEPING MUST NEVER SINK A RUN THAT ALREADY FINISHED. run.js documents
15
+ * "Never rejects for run errors: always resolves {exitCode, run}", but every
16
+ * `return finalize(…)` in it is a bare `return` of a promise — which, by async
17
+ * semantics, does NOT route through the enclosing catch. So a throw from the
18
+ * terminal checkpoint (an unwritable run dir, a full disk) escaped runCouncil as
19
+ * a REJECTION, past its own contract, and past run.js's `catch`. Worse, the
20
+ * throw landed between the server release and the caller, so the caller had no
21
+ * result to act on. Everything here is therefore guarded: a failure is announced
22
+ * on stderr and the run still resolves with a document whose terminal fields are
23
+ * authoritative.
24
+ */
25
+
26
+ const { emitRunTerminal } = require('../observe/events');
27
+ const { fireCouncilOnComplete } = require('../observe/on-complete');
28
+ const runState = require('./run-state');
29
+
30
+ /** Abort signal → the run's exit code. Lives here with the rest of the exit-code
31
+ * vocabulary; re-exported from ./run (its long-standing public home). */
32
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
33
+
34
+ /**
35
+ * run.json status for a council exit code (spec §4 degradation table).
36
+ * @param {number} code @returns {string}
37
+ */
38
+ function statusForExit(code) {
39
+ return (code === 130 || code === 143) ? 'aborted'
40
+ : code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
41
+ }
42
+
43
+ /**
44
+ * The run's FINAL exit code, and the ONE place a would-be-clean run degrades.
45
+ *
46
+ * Precedence, highest first:
47
+ * 1. a signal — an aborted run reports how it was killed, nothing else;
48
+ * 2. a non-zero code the driver already decided (1 = error, 2 = degraded) —
49
+ * never re-labelled, because a failure is not a degradation;
50
+ * 3. `degraded.value` — the flag a shrunken bench, a thin cross-review, a dead
51
+ * debate leg and (v4.4.1 CA-6) an inexact total under a `--max-cost`
52
+ * ceiling all already set. 0 becomes 2.
53
+ *
54
+ * ⚠️ CA-6 is wired here rather than at the ceiling itself ON PURPOSE. The
55
+ * standing owner ruling — "I don't want hitting a ceiling to stop us from
56
+ * solving real problems" — is absolute: `overBudget()` still trips on KNOWN
57
+ * spend only and a ceiling still never blocks a run. What changes is only what
58
+ * the run CLAIMS on the way out. Exit 0 means "clean, and inside your ceiling";
59
+ * a run that published a total it knows is a floor has not earned that.
60
+ *
61
+ * @param {{signalled: number|null, exitCode: number, degraded?: {value: boolean},
62
+ * inexactUnderCeiling?: () => boolean}} args
63
+ * @returns {number}
64
+ */
65
+ function resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling }) {
66
+ if (signalled) { return signalled; }
67
+ if (degraded && inexactUnderCeiling && inexactUnderCeiling()) { degraded.value = true; }
68
+ return (exitCode === 0 && degraded && degraded.value) ? 2 : exitCode;
69
+ }
70
+
71
+ /**
72
+ * Write the run's terminal record and fire its terminal observers.
73
+ *
74
+ * @param {{o: object, code: number, error?: object|null,
75
+ * noticeUnknownSpend: Function, usageBlock: Function,
76
+ * deps?: {fireOnCompleteFn?: Function, write?: Function}}} args
77
+ * @returns {Promise<object>} the run document — always usable, even when the
78
+ * write failed (the on-disk doc merged under the authoritative terminal fields).
79
+ */
80
+ async function writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock, deps = {} }) {
81
+ const status = statusForExit(code);
82
+ const terminal = { status, exitCode: code, error: error || null };
83
+ try {
84
+ noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
85
+ const run = runState.checkpoint(o.runDir, {
86
+ ...terminal, usage: usageBlock(), completedAt: new Date().toISOString(),
87
+ });
88
+ emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
89
+ await (deps.fireOnCompleteFn || fireCouncilOnComplete)(o.onComplete, run,
90
+ { runId: o.runId, runDir: o.runDir, exitCode: code, project: o.project }, o.onCompleteDeps);
91
+ return run;
92
+ } catch (err) {
93
+ const write = deps.write || ((s) => process.stderr.write(s));
94
+ write(`Notice: council run bookkeeping failed at finalize: ${err.message}. The run itself `
95
+ + `finished with exit ${code} (${status}); run.json may be incomplete.\n`);
96
+ let onDisk = {};
97
+ try { onDisk = runState.readRun(o.runDir) || {}; } catch { /* unreadable too */ }
98
+ return { runId: o.runId, ...onDisk, ...terminal };
99
+ }
100
+ }
101
+
102
+ module.exports = { statusForExit, resolveTerminalExit, writeRunTerminal, SIGNAL_EXIT };
@@ -17,6 +17,20 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
 
20
+ /**
21
+ * Did a launch exit because a SIGNAL killed it (130 = SIGINT, 143 = SIGTERM)
22
+ * rather than because the work failed? Every stage loop short-circuits on this
23
+ * instead of treating the wave as a normal failure.
24
+ *
25
+ * It lives HERE, with the module that produces those exit codes, because every
26
+ * stage loop needs it — including run-stage2.js. Defining it in run-stages.js and
27
+ * importing it back from its own child made the two mutually circular, which is
28
+ * why runStage2 could not be re-exported from run-stages.js (v4.4.1 review F5).
29
+ * @param {number} code
30
+ * @returns {boolean}
31
+ */
32
+ function isAbortExit(code) { return code === 130 || code === 143; }
33
+
20
34
  /**
21
35
  * @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
22
36
  * reserveBudget?: (waveId: string, estimate: number) => boolean,
@@ -32,6 +46,11 @@ const path = require('path');
32
46
  * synchronously, with the estimate it just computed; see run-budget.js.
33
47
  * onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
34
48
  * never launched can never vanish silently.
49
+ * sharedServer (v4.4.1 Task 0.5): a GETTER returning the run's single
50
+ * {serverClient, server} pair, or null. A getter (not a value) because run.js
51
+ * builds the launchers before it acquires the server — see ./run-server for
52
+ * why one server per run, and why `_scratch` isolation survives it. Returning
53
+ * null leaves the transport call byte-identical: the wave owns its own server.
35
54
  * @returns {{launchWave: Function, launchSolo: Function}}
36
55
  */
37
56
  function createLaunchers(deps = {}) {
@@ -39,6 +58,7 @@ function createLaunchers(deps = {}) {
39
58
  const remainingBudget = deps.remainingBudget || null;
40
59
  const reserveBudget = deps.reserveBudget || null;
41
60
  const onBudgetRefusal = deps.onBudgetRefusal || null;
61
+ const sharedServer = deps.sharedServer || null;
42
62
 
43
63
  /**
44
64
  * @param {{models: string[], prompt: string, project: string, waveId: string,
@@ -61,8 +81,14 @@ function createLaunchers(deps = {}) {
61
81
  // provider or no ceiling, so the transport call is byte-identical for
62
82
  // non-council callers and for `--max-cost`-less runs.
63
83
  const remaining = remainingBudget ? remainingBudget() : null;
84
+ // v4.4.1 Task 0.5: every launch in the run rides the SAME OpenCode server.
85
+ // NOT `client` — that key is fanout's client TYPE string; the SDK client is
86
+ // `serverClient` (see the seam comment in fanout.js). Absent → the wave
87
+ // starts and closes its own server, exactly as before.
88
+ const shared = sharedServer ? sharedServer() : null;
64
89
  const { wave, exitCode, errorDoc } = await fanoutFn({
65
90
  ...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
91
+ ...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
66
92
  // v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
67
93
  // transport resolved routing; a concurrently launching sibling can claim
68
94
  // part of that allowance in the meantime. This is the CLAIM that settles
@@ -173,4 +199,6 @@ function materializeDebate(runDir, legs, prefix) {
173
199
  return out;
174
200
  }
175
201
 
176
- module.exports = { createLaunchers, materializeReviews, materializeDebate, sanitizeName };
202
+ module.exports = {
203
+ createLaunchers, materializeReviews, materializeDebate, sanitizeName, isAbortExit,
204
+ };