amicus 4.3.0 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -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 };
|