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
@@ -0,0 +1,248 @@
1
+ // src/council/run-server.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-server
6
+ * ONE OpenCode server per council run (v4.4.1 Task 0.5).
7
+ *
8
+ * A run used to start a server per wave: the Stage-1 seat wave, the critic solo,
9
+ * each findings repair, the Stage-2 judge wave, each judge repair, each debate
10
+ * wave, and the chair chain — 10+ process spawns, each one a fresh chance to
11
+ * lose OpenCode's SQLite startup race. Stage 1 launches its seat wave and its
12
+ * critic solo under one Promise.all (run-stages.js), so two of those starts are
13
+ * ~140ms apart by construction: run v441plan01 lost four of five seats in 736ms
14
+ * to `database is locked` and failed quorum.
15
+ *
16
+ * ⚠️ THE `_scratch/` BOUNDARY IS PRESERVED, and that is not an assumption.
17
+ * Stage 2 runs its judges in `<runDir>/_scratch` so a tool-capable judge cannot
18
+ * read the de-anonymized `review-<model>.md` files or the plaintext labelMap in
19
+ * the parent run dir. Nothing about that isolation lives in the server process:
20
+ * 1. `startOpenCodeServer` takes no project/cwd — `buildServerOptions`
21
+ * (opencode-client.js) passes only hostname/port/signal/config to
22
+ * `createOpencodeServer`. The server is directory-agnostic.
23
+ * 2. Scoping is PER CALL: run-launch.js sets `directory: opts.project` on
24
+ * every launch, fanout threads it to each leg, and runHeadless turns it
25
+ * into `query.directory` on create/prompt/messages/status/abort (dirArgs,
26
+ * headless.js). A judge's calls carry `_scratch`; a Stage-1 leg's carry the
27
+ * run dir. One server answers both, scoped per request.
28
+ * 3. The MCP surface is identical for both stages already: every council
29
+ * launch passes `noMcp: true` and nothing else MCP-related, and fanout's
30
+ * buildMcpConfig call receives no `projectDir`, so its result is a pure
31
+ * function of process-level state — the Stage-1 and Stage-2 servers were
32
+ * being built from the SAME config all along. `acquireRunServer` rebuilds
33
+ * it the same way, so a judge sees exactly the MCP servers it saw before.
34
+ * Sharing one server therefore changes which PROCESS answers, never which
35
+ * directory a call is scoped to or which tools a judge is handed.
36
+ */
37
+
38
+ /**
39
+ * Resolve the EXECUTABLE model ids the run's single server must register.
40
+ *
41
+ * ⚠️ Why raw inputs are not enough (v4.4.1 fix wave, finding F1). The seed lands
42
+ * in `buildProviderModels` (utils/config.js), whose `addRoute` DROPS any string
43
+ * without a `/` — and a council bench is alias-shaped (`gpt`, `glm`, `minimax`).
44
+ * So seeding `[models, critic, chair]` verbatim registered nothing at all beyond
45
+ * the alias loop's own defaults, which is exactly the pre-#61 state Task 4.6/7.3
46
+ * fixed: a leg whose router decision diverges from its alias's stored value (and
47
+ * from that value's curated OpenRouter mirror) is told to launch an id the
48
+ * server never registered.
49
+ *
50
+ * ⚠️ AND PREFIX-STRIPPING IS NOT A SUBSTITUTE. For a DIVERGENT vendor the two
51
+ * gateway-native ids are DIFFERENT STRINGS, not differently prefixed —
52
+ * OpenRouter serves `anthropic/claude-opus-4.8`, the direct API serves
53
+ * `anthropic/claude-opus-4-8` (see utils/curated-models.js `DIVERGENT_VENDORS` /
54
+ * `toGatewayRoutes`). Nothing here derives an id: every id in the seed is one
55
+ * `resolveRouteForLaunch` handed back, which is the same call, with the same
56
+ * inputs, that `fanout-validate.js` makes to decide what a per-wave server
57
+ * registers. Same function, same arguments, same answer.
58
+ *
59
+ * Three sources, matching the three reachable divergences:
60
+ * 1. every bench seat + the critic + the chair (the launches that always happen);
61
+ * 2. the v4.3 Task 18 §6.2 FALLBACK-CHAIN UNION — a substitute that never runs
62
+ * must still be an allowed model if one IS selected mid-wave. run-launch.js
63
+ * forwards `fallback`/`catalog` on every stage launch, so a per-wave server
64
+ * would have registered these; the shared server must too;
65
+ * 3. the chair `pickFallbackChair` could promote out of the reliability ledger.
66
+ * Deterministic from the ledger as it stands at run start — this run's own
67
+ * row is appended only AFTER the chair leg, so the pick cannot move under us.
68
+ *
69
+ * NEVER FAILS CLOSED: an unresolvable entry is logged and DROPPED, never an
70
+ * error. Registration is config, not spend — and a leg that cannot route will
71
+ * fail on its own, loudly, in its own wave.
72
+ *
73
+ * @param {object} o the council run's resolved options
74
+ * @param {{resolveRouteFn?: Function, statsFn?: Function}} [deps] test seams
75
+ * @returns {Promise<{models: string[], notices: string[]}>}
76
+ */
77
+ async function resolveRunServerModels(o, deps = {}) {
78
+ const { logger } = require('../utils/logger');
79
+ const resolveFn = deps.resolveRouteFn
80
+ || require('../utils/route-launch').resolveRouteForLaunch;
81
+ const gatewayMode = o.gateway || 'auto';
82
+ const validateModel = !o.noValidateModel;
83
+ const ids = new Set();
84
+ const notices = [];
85
+
86
+ const resolve = async (input, source) => {
87
+ if (!input || typeof input !== 'string') { return null; }
88
+ let route;
89
+ try {
90
+ route = await resolveFn({ model: input, gatewayMode, source, allowSelection: false, validateModel });
91
+ } catch { route = { kind: 'error' }; }
92
+ if (!route || route.kind !== 'resolved' || !route.executableId) {
93
+ logger.warn('Council model failed to route — dropped from the shared server registration',
94
+ { runId: o.runId, model: input });
95
+ return null;
96
+ }
97
+ ids.add(route.executableId);
98
+ // resolveRouteForLaunch BURNS the one-shot per-vendor migration flag when it
99
+ // builds the result, so whichever caller resolves first owns the notice.
100
+ // That is now this one — carry it out so acquireRunServer can print it
101
+ // rather than letting it evaporate (council waves run quiet, so the wave
102
+ // doc's `notices` were never shown for a council run anyway).
103
+ if (route.notice) { notices.push(route.notice); }
104
+ return route.executableId;
105
+ };
106
+
107
+ const primaries = [];
108
+ for (const input of [...(o.models || []), o.critic, o.chair].filter(Boolean)) {
109
+ const id = await resolve(input, 'cli');
110
+ if (id) { primaries.push(id); }
111
+ }
112
+
113
+ // (2) fallback-chain union — same derivation fanout-validate.js uses.
114
+ if (o.fallback && o.fallback.enabled) {
115
+ const { deriveChain } = require('../sidecar/fallback-chains');
116
+ for (const primary of primaries) {
117
+ let chain = [];
118
+ try { chain = deriveChain(primary, { config: { chains: o.fallback.chains }, catalog: o.catalog }) || []; }
119
+ catch { chain = []; }
120
+ for (const candidate of chain) { await resolve(candidate, 'fallback'); }
121
+ }
122
+ }
123
+
124
+ // (3) the chair the ledger could promote mid-run (run-chair.js).
125
+ try {
126
+ const statsFn = deps.statsFn || require('./ledger').deriveReliability;
127
+ const { pickFallbackChair } = require('./run-chair');
128
+ const promoted = pickFallbackChair(statsFn() || [], o.models || [], o.chair);
129
+ if (promoted) { await resolve(promoted, 'cli'); }
130
+ } catch { /* no ledger yet, or an unreadable one: nothing to promote */ }
131
+
132
+ return { models: [...ids], notices: [...new Set(notices)] };
133
+ }
134
+
135
+ /**
136
+ * Record the shared server's fate on run.json — guarded, always.
137
+ *
138
+ * ⚠️ BOOKKEEPING MUST NEVER SINK THE RUN IT REPORTS ON. run.js awaits
139
+ * `acquireRunServer` OUTSIDE its try, so a throw from here would escape
140
+ * runCouncil as a rejection, past its documented "never rejects for run errors"
141
+ * contract, with no result for the caller at all (the run-finalize precedent).
142
+ *
143
+ * `checkpoint` is a READ-MERGE-WRITE (run-state.js: read run.json → shallow
144
+ * `{...existing, ...patch}` → one atomic whole-file write), so writing this key
145
+ * cannot clobber `budgetRefusals[]` or anything else already on the document.
146
+ * Verified, not assumed — `tests/council/run-state.test.js` pins it.
147
+ *
148
+ * @param {object} o the council run's resolved options
149
+ * @param {object} patch a single top-level run.json key
150
+ * @param {string} what the field name, for the failure log
151
+ */
152
+ function recordServerFate(o, patch, what) {
153
+ const { logger } = require('../utils/logger');
154
+ try { require('./run-state').checkpoint(o.runDir, patch); }
155
+ catch (writeErr) {
156
+ logger.warn(`Could not record ${what} on run.json`, { runId: o.runId, error: writeErr.message });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Start the run's single OpenCode server.
162
+ *
163
+ * Never fails closed (standing project ruling): a start failure is a NOTICE, not
164
+ * an abort. Returning null simply means no server is injected, so every wave
165
+ * falls back to starting its own — exactly the pre-v4.4.1 behaviour, with the
166
+ * lock-class retry in session-utils still in front of it. That fallback is also
167
+ * the bug this task removed, so it is recorded on run.json as
168
+ * `sharedServerUnavailable` (Step 10.5); see the catch block.
169
+ *
170
+ * ⚠️ BOTH OUTCOMES ARE RECORDED, and the POSITIVE one is the point. Step 10.5
171
+ * originally made only the failure durable, which left "the shared server WAS
172
+ * used" provable solely by INFERENCE: fanout writes `goPid` into a wave's
173
+ * metadata only for a wave that owns its server (`if (!externalServer …)`), so
174
+ * an investigator had to notice a goPid on the wrong wave and reason backwards.
175
+ * Step 10.5's own text says that inference "should not be the primary
176
+ * diagnostic" — so `sharedServer {acquired, at, goPid, models}` is written on
177
+ * acquisition SUCCESS. A run record now answers the question directly, in the
178
+ * affirmative, and the two keys are mutually exclusive by construction: exactly
179
+ * one of them is present on any run that reached the acquisition.
180
+ *
181
+ * @param {object} o the council run's resolved options ({models, critic, chair, …})
182
+ * @param {{startOpenCodeServerFn?: Function, resolveRouteFn?: Function,
183
+ * statsFn?: Function}} [deps] test seams
184
+ * @returns {Promise<{serverClient: object, server: object}|null>}
185
+ */
186
+ async function acquireRunServer(o, deps = {}) {
187
+ const { logger } = require('../utils/logger');
188
+ const startFn = deps.startOpenCodeServerFn
189
+ || require('../sidecar/session-utils').startOpenCodeServer;
190
+ const { buildMcpConfig } = require('../sidecar/start');
191
+
192
+ // Same inputs run-launch.js hands every council launch (see note 3 above).
193
+ const mcpServers = buildMcpConfig({ noMcp: true });
194
+ // #61 Task 4.6/7.3 sole-input invariant, applied one scope level up.
195
+ const { models, notices } = await resolveRunServerModels(o, deps);
196
+ for (const notice of notices) { process.stderr.write(`Notice: ${notice}\n`); }
197
+
198
+ try {
199
+ const { client, server } = await startFn(mcpServers, { models });
200
+ logger.info('Council run using ONE shared OpenCode server',
201
+ { runId: o.runId, url: server.url, models: models.length });
202
+ // The POSITIVE, durable signal (see the ⚠️ above). `goPid` is the field that
203
+ // makes it a correlator rather than a boolean: no wave writes a goPid when a
204
+ // server is injected, so "run.json names pid N and no wave metadata does"
205
+ // reads as proof the shared server served the run — no inference required.
206
+ recordServerFate(o, {
207
+ sharedServer: {
208
+ acquired: true, at: new Date().toISOString(),
209
+ goPid: (server && server.goPid) || null, models: models.length,
210
+ },
211
+ }, 'sharedServer');
212
+ return { serverClient: client, server };
213
+ } catch (err) {
214
+ // ⚠️ v4.4.1 Step 10.5: a failed acquisition drops the run back to ONE SERVER
215
+ // PER WAVE — precisely the racy behaviour this task removed. Degrading is
216
+ // correct (never fail closed); degrading QUIETLY is not. Run v441plan02 did
217
+ // exactly this and lost 4 of 5 seats to `database is locked`, and the only
218
+ // signal was a single stderr `Notice:` that a `| tail` discarded. run.json
219
+ // recorded nothing, so the degrade was diagnosable only by inference — a
220
+ // `goPid` on the critic wave, which fanout writes only for a wave that owns
221
+ // its own server. That inference is not a diagnostic. So the degrade is now
222
+ // DURABLE: it lands on the run's own record, next to `budgetRefusals[]`,
223
+ // for the same reason — a silent partial is the failure mode this whole
224
+ // release exists to remove.
225
+ const degrade = { error: err.message, at: new Date().toISOString() };
226
+ recordServerFate(o, { sharedServerUnavailable: degrade }, 'sharedServerUnavailable');
227
+ logger.warn('Shared OpenCode server unavailable — falling back to one server per wave', {
228
+ runId: o.runId, error: err.message,
229
+ });
230
+ process.stderr.write(
231
+ `Notice: could not start a shared OpenCode server (${err.message}); each wave will start `
232
+ + 'its own, which is the configuration that races. Expect degraded results.\n');
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Close the run's server. Called from exactly ONE place — run.js's finalize(),
239
+ * the single path every terminal outcome (success, error, abort, signal) already
240
+ * funnels through. A second close site is how a mid-run teardown gets introduced.
241
+ * @param {{server: object}|null} shared
242
+ */
243
+ async function releaseRunServer(shared) {
244
+ if (!shared || !shared.server) { return; }
245
+ try { await shared.server.close(); } catch { /* best-effort: the run is over */ }
246
+ }
247
+
248
+ module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels };
@@ -0,0 +1,118 @@
1
+ // src/council/run-stage2.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-stage2
6
+ * Stage-2 (anonymized cross-review) loop for the headless council engine —
7
+ * shared bundle, judge wave in _scratch, parse + bounded repair. Lifted
8
+ * verbatim out of ./run-stages.js for the 300-line gate (v4.4.1 Task 2),
9
+ * mirroring the briefings.js → briefings-stage2.js split. Stage 1 and the
10
+ * shared helpers (slug, roleFor) stay in ./run-stages.js, which re-exports
11
+ * runStage2 so callers keep one import surface. This module imports NOTHING
12
+ * from its parent: isAbortExit comes from run-launch.js, where the exit codes
13
+ * are produced, which is what dissolved the old cycle (v4.4.1 review F5).
14
+ *
15
+ * Headless adaptation (vs SKILL.md): a judge still malformed after 2 repairs is
16
+ * dropped from rankings and adjudications (ok:false) and recorded conformance
17
+ * 'unstructured' (spec §5).
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const stage2 = require('./briefings-stage2');
23
+ const { parseJudgeOutput } = require('./parse-stage2');
24
+ const { sanitizeName, isAbortExit } = require('./run-launch');
25
+ const runState = require('./run-state');
26
+
27
+ /**
28
+ * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
29
+ * @param {object} ctx
30
+ * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
31
+ * extraLabeled?: Array<{label: string, text: string}>}} args
32
+ * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
33
+ * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
34
+ * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
35
+ */
36
+ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
37
+ const { o } = ctx;
38
+ const { rankingToOrder } = require('./anonymize');
39
+ fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
40
+
41
+ // Zip off `reviews` (never off `labels.entries`, which may be one longer than
42
+ // reviews when a file-sourced review is present) and append the extras.
43
+ const labeled = reviews
44
+ .map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
45
+ .concat(extraLabeled);
46
+ const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
47
+ fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
48
+
49
+ // ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
50
+ // review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
51
+ const judges = reviews.map(r => r.modelInput);
52
+ const parseCtx = {
53
+ labels: labels.entries.map(e => e.label),
54
+ findingIds: globalFindings.map(f => f.id),
55
+ };
56
+ runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
57
+ const { wave, exitCode } = await ctx.launchers.launchWave({
58
+ models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
59
+ timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
60
+ noCostGate: o.noCostGate,
61
+ councilRunId: o.runId, councilName: o.councilName,
62
+ fallback: o.fallback, catalog: o.catalog,
63
+ });
64
+ ctx.addWave(wave);
65
+ if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
66
+
67
+ const judgeResults = [];
68
+ let repairSeq = 0;
69
+ for (const leg of (wave && wave.legs) || []) {
70
+ const judge = leg.modelInput || leg.model;
71
+ if (leg.status === 'complete' && leg.summary) {
72
+ fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
73
+ }
74
+ let conformance = 'clean';
75
+ let parsed = (leg.status === 'complete' && leg.summary)
76
+ ? parseJudgeOutput(leg.summary, parseCtx)
77
+ : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
78
+ let attempts = 0;
79
+ // ⚠️ LC-12: the judging text the repair prompt must carry, tracked exactly like
80
+ // Stage-1's `repairing` so `judging` and `parsed.errors` always describe the SAME
81
+ // generation — on attempt 2 the errors came from validating attempt 1's output.
82
+ // An empty/dead repair leg leaves it on the last real text (there is no newer
83
+ // artifact to name). Stage 2 is the worse place for this omission than Stage 1:
84
+ // a judge that refuses has no `conformance` column, so the tally silently shows
85
+ // fewer votes and a finding's basis counts can flip its tier.
86
+ let judging = leg.summary || '';
87
+ while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
88
+ attempts += 1;
89
+ repairSeq += 1;
90
+ const waveId = `${o.runId}-q${repairSeq}`;
91
+ runState.appendStageWave(o.runDir, 'stage2', waveId);
92
+ const solo = await ctx.launchers.launchSolo({
93
+ model: judge,
94
+ prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors, judgement: judging }),
95
+ project: ctx.scratchDir, waveId, timeout: o.timeout,
96
+ gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
97
+ councilRunId: o.runId, councilName: o.councilName,
98
+ fallback: o.fallback, catalog: o.catalog,
99
+ });
100
+ ctx.addWave(solo.wave);
101
+ if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
102
+ const out = (solo.leg && solo.leg.summary) || '';
103
+ if (out.trim()) { judging = out; }
104
+ parsed = parseJudgeOutput(out, parseCtx);
105
+ if (parsed.ok) { conformance = 'repaired'; }
106
+ }
107
+ if (!parsed.ok) {
108
+ judgeResults.push({ judge, ok: false, order: null, adjudications: null,
109
+ conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
110
+ continue;
111
+ }
112
+ const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
113
+ judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
114
+ }
115
+ return { aborted: null, judgeResults };
116
+ }
117
+
118
+ module.exports = { runStage2 };