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
|
@@ -3,31 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @module council/run-stages
|
|
6
|
-
* Stage-1 (independent reviews)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
22
|
-
const path = require('path');
|
|
23
|
-
const { validateFindings } = require('./findings');
|
|
20
|
+
const { validateFindings, countAttemptedFindings, repairCanHonorContract } = require('./findings');
|
|
24
21
|
const briefings = require('./briefings');
|
|
25
|
-
const
|
|
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
|
-
|
|
84
|
+
const deadWaves = [];
|
|
85
|
+
results.forEach((r, i) => {
|
|
87
86
|
ctx.addWave(r.wave);
|
|
88
|
-
|
|
89
|
-
if (
|
|
87
|
+
const abort = isAbortExit(r.exitCode);
|
|
88
|
+
if (abort) { aborted = r.exitCode; }
|
|
89
|
+
const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
|
|
90
|
+
legs.push(...got);
|
|
91
|
+
// ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
|
|
92
|
+
// never started; `database is locked`) contributes NOTHING to `legs`, so
|
|
93
|
+
// deadLegs cannot see it either — which is how run v441plan01 recorded
|
|
94
|
+
// stage1 'complete' with four seats missing and no trace of them. In lens
|
|
95
|
+
// mode every seat is its own wave, so a run could lose seats and still exit
|
|
96
|
+
// 0; the quorum gate only catches the non-lens seat wave. A budget refusal
|
|
97
|
+
// has its own louder channel already (run-budget.noteBudgetRefusal) and
|
|
98
|
+
// must not be double-counted here.
|
|
99
|
+
if (got.length > 0 || abort) { return; }
|
|
100
|
+
if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
|
|
101
|
+
deadWaves.push({
|
|
102
|
+
waveId: seated[i].waveId, models: seated[i].models,
|
|
103
|
+
reason: (r.wave && (r.wave.reason || r.wave.error))
|
|
104
|
+
|| (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return { aborted, legs, deadWaves };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Announce Stage-1 sub-waves that never produced a leg.
|
|
112
|
+
*
|
|
113
|
+
* POLICY (the standing "never fail closed on availability" ruling, applied the
|
|
114
|
+
* same way run-budget.js applies it to cost): the run CONTINUES with the bench
|
|
115
|
+
* that did launch. What it must never do is lose the seats SILENTLY — so every
|
|
116
|
+
* dead wave is announced on stderr, kept on run.json's stage entry (run.js) and
|
|
117
|
+
* degrades the run's exit code to 2.
|
|
118
|
+
* @param {Array<{waveId: string, models: string[], reason: string}>} deadWaves
|
|
119
|
+
* @param {(s: string) => void} [write] stderr seam
|
|
120
|
+
*/
|
|
121
|
+
function reportDeadStage1Waves(deadWaves, write = (s) => process.stderr.write(s)) {
|
|
122
|
+
for (const d of deadWaves) {
|
|
123
|
+
write(`Notice: Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs `
|
|
124
|
+
+ `— ${d.reason}. Those seats are NOT in this council. The run continues with the bench that `
|
|
125
|
+
+ 'did launch and will exit degraded (2).\n');
|
|
90
126
|
}
|
|
91
|
-
return { aborted, legs };
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
/** Role of a seat by its input alias. */
|
|
@@ -102,12 +137,21 @@ function roleFor(o, alias) {
|
|
|
102
137
|
|
|
103
138
|
/**
|
|
104
139
|
* Stage 1: independent reviews + findings validation + bounded repair.
|
|
105
|
-
* @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array
|
|
140
|
+
* @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array,
|
|
141
|
+
* deadWaves: Array, degraded: boolean}>} `degraded` covers BOTH ways a seat
|
|
142
|
+
* can go missing: a leg that ran and died (deadLegs) and a whole sub-wave that
|
|
143
|
+
* died before its legs existed (deadWaves). A pushed review carries
|
|
144
|
+
* `findingsUnverified: true` (LC-11) when its findings came from a repair whose
|
|
145
|
+
* contract could not be checked — the original block was absent or unparseable,
|
|
146
|
+
* so there was no finding count to compare against — and `repairRefused:
|
|
147
|
+
* {code, detail}` (review F1) when the contract WAS checked and broken, which is
|
|
148
|
+
* what separates a refused repair from a seat that never emitted JSON.
|
|
106
149
|
*/
|
|
107
150
|
async function runStage1(ctx) {
|
|
108
151
|
const { o } = ctx;
|
|
109
|
-
const { aborted, legs } = await launchStage1(ctx);
|
|
110
|
-
if (aborted) { return { aborted, reviews: [], deadLegs: [] }; }
|
|
152
|
+
const { aborted, legs, deadWaves } = await launchStage1(ctx);
|
|
153
|
+
if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
|
|
154
|
+
reportDeadStage1Waves(deadWaves);
|
|
111
155
|
|
|
112
156
|
const materialized = materializeReviews(o.runDir, legs);
|
|
113
157
|
const alive = new Set(materialized.map(m => m.leg));
|
|
@@ -119,110 +163,101 @@ async function runStage1(ctx) {
|
|
|
119
163
|
let conformance = 'clean';
|
|
120
164
|
let res = validateFindings(m.text);
|
|
121
165
|
let attempts = 0;
|
|
122
|
-
|
|
166
|
+
// ⚠️ LC-6: the text the repair prompt must carry. A repair solo is a FRESH
|
|
167
|
+
// session — it has no memory of the review turn — so shipping only
|
|
168
|
+
// res.errors asked the model to correct something it had never seen. Two
|
|
169
|
+
// paid models refused ("I don't have a previous review to correct") and one
|
|
170
|
+
// fabricated a finding, which reached tally.json and the chair's verdict.
|
|
171
|
+
// Tracked rather than pinned to m.text so `repairing` and `res.errors`
|
|
172
|
+
// always describe the SAME artifact: on attempt 2 the errors came from
|
|
173
|
+
// validating attempt 1's output, so attempt 1's output is what is being
|
|
174
|
+
// repaired. An empty/dead repair leg leaves it on the last real text
|
|
175
|
+
// (there is no newer artifact to name).
|
|
176
|
+
let repairing = m.text;
|
|
177
|
+
// ⚠️ LC-11: the count the repair is contractually forbidden from changing.
|
|
178
|
+
// Captured from the ORIGINAL block, because that is the generation m.text's
|
|
179
|
+
// prose actually narrates. null = absent/unparseable, so unverifiable — see
|
|
180
|
+
// the push below.
|
|
181
|
+
const attemptedCount = countAttemptedFindings(m.text);
|
|
182
|
+
// ⚠️ Review F2: never pay for a repair whose every outcome is already decided.
|
|
183
|
+
// An original declaring ZERO findings can only honor the contract by returning
|
|
184
|
+
// zero — so while the validator rejects an empty set (EMPTY_FINDINGS), a
|
|
185
|
+
// compliant repair fails validation and a non-compliant one is refused on the
|
|
186
|
+
// count below. Predicate, not a constant: Task 3 (LC-10) flips that validator
|
|
187
|
+
// rule, and this guard stops firing on its own when it does.
|
|
188
|
+
const repairable = repairCanHonorContract(attemptedCount);
|
|
189
|
+
while (!res.ok && repairable && attempts < 2 && !ctx.overBudget()) {
|
|
123
190
|
attempts += 1;
|
|
124
191
|
repairSeq += 1;
|
|
125
192
|
const waveId = `${o.runId}-p${repairSeq}`;
|
|
126
193
|
runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
127
194
|
const solo = await ctx.launchers.launchSolo({
|
|
128
|
-
model: m.modelInput,
|
|
195
|
+
model: m.modelInput,
|
|
196
|
+
prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
|
|
129
197
|
project: o.runDir, waveId, timeout: o.timeout,
|
|
130
198
|
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
131
199
|
councilRunId: o.runId, councilName: o.councilName,
|
|
132
200
|
fallback: o.fallback, catalog: o.catalog,
|
|
133
201
|
});
|
|
134
202
|
ctx.addWave(solo.wave);
|
|
135
|
-
if (isAbortExit(solo.exitCode)) {
|
|
136
|
-
|
|
203
|
+
if (isAbortExit(solo.exitCode)) {
|
|
204
|
+
return { aborted: solo.exitCode, reviews, deadLegs, deadWaves, degraded: false };
|
|
205
|
+
}
|
|
206
|
+
const repaired = (solo.leg && solo.leg.summary) || '';
|
|
207
|
+
if (repaired.trim()) { repairing = repaired; }
|
|
208
|
+
res = validateFindings(repaired);
|
|
137
209
|
if (res.ok) { conformance = 'repaired'; }
|
|
138
210
|
}
|
|
139
211
|
if (!res.ok) { conformance = 'unstructured'; }
|
|
212
|
+
// ⚠️ LC-11. `text` below is ALWAYS the seat's own prose — never the repair's
|
|
213
|
+
// output, which is a bare JSON block by design (briefings.js:19-26
|
|
214
|
+
// deliberately omits the two-part prose framing from repair prompts).
|
|
215
|
+
// Substituting it would hand the judges a narrative-free review and render a
|
|
216
|
+
// JSON dump into bundle-stage2.md as "what the judges saw".
|
|
217
|
+
//
|
|
218
|
+
// Instead the repair's CONTRACT is enforced: "the same findings, fixed — do
|
|
219
|
+
// not add or remove findings". A repair that changed the count produced a
|
|
220
|
+
// findings set this prose does not narrate, so it is refused rather than
|
|
221
|
+
// adjudicated.
|
|
222
|
+
//
|
|
223
|
+
// ⚠️ Review F4 — what this check is and is NOT. It does NOT catch costgate01:
|
|
224
|
+
// that leg emitted no fenced block at all, so attemptedCount is null, the
|
|
225
|
+
// repair is ACCEPTED and merely marked findingsUnverified. LC-12 (handing the
|
|
226
|
+
// repair prompt the artifact) is what addresses that incident. And the check
|
|
227
|
+
// deliberately over-refuses one honest case: a repair that legitimately merges
|
|
228
|
+
// a DUPLICATE_ID pair changes the count too, and is refused with it.
|
|
229
|
+
//
|
|
230
|
+
// ⚠️ Review F1: the refusal RIDES the review (repairRefused) and the runStats
|
|
231
|
+
// row, because 'unstructured' alone is indistinguishable from a seat that never
|
|
232
|
+
// emitted JSON at all — the weaker, unverifiable case would then be the only
|
|
233
|
+
// one on the record.
|
|
234
|
+
let unverified = false;
|
|
235
|
+
let repairRefused = null;
|
|
236
|
+
if (conformance === 'repaired') {
|
|
237
|
+
if (attemptedCount === null) {
|
|
238
|
+
unverified = true; // nothing to compare; say so, don't imply a check
|
|
239
|
+
} else if (res.findings.length !== attemptedCount) {
|
|
240
|
+
conformance = 'unstructured';
|
|
241
|
+
repairRefused = { code: 'REPAIR_CHANGED_FINDING_COUNT',
|
|
242
|
+
detail: `repair returned ${res.findings.length} findings, original attempted ${attemptedCount}` };
|
|
243
|
+
res = { ok: false, findings: [], errors: [repairRefused] };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
140
246
|
reviews.push({
|
|
141
247
|
model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
|
|
142
248
|
text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
|
|
249
|
+
...(unverified ? { findingsUnverified: true } : {}),
|
|
250
|
+
...(repairRefused ? { repairRefused } : {}),
|
|
143
251
|
});
|
|
144
252
|
}
|
|
145
|
-
return { aborted: null, reviews, deadLegs
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
|
|
150
|
-
* @param {object} ctx
|
|
151
|
-
* @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array,
|
|
152
|
-
* extraLabeled?: Array<{label: string, text: string}>}} args
|
|
153
|
-
* `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
|
|
154
|
-
* a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
|
|
155
|
-
* @returns {Promise<{aborted: number|null, judgeResults: Array}>}
|
|
156
|
-
*/
|
|
157
|
-
async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
|
|
158
|
-
const { o } = ctx;
|
|
159
|
-
const { rankingToOrder } = require('./anonymize');
|
|
160
|
-
fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
|
|
161
|
-
|
|
162
|
-
// Zip off `reviews` (never off `labels.entries`, which may be one longer than
|
|
163
|
-
// reviews when a file-sourced review is present) and append the extras.
|
|
164
|
-
const labeled = reviews
|
|
165
|
-
.map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
|
|
166
|
-
.concat(extraLabeled);
|
|
167
|
-
const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
|
|
168
|
-
fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
|
|
169
|
-
|
|
170
|
-
// ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
|
|
171
|
-
// review is judged but never judges (v4.1 §4.4). Do not widen with extraLabeled.
|
|
172
|
-
const judges = reviews.map(r => r.modelInput);
|
|
173
|
-
const parseCtx = {
|
|
174
|
-
labels: labels.entries.map(e => e.label),
|
|
175
|
-
findingIds: globalFindings.map(f => f.id),
|
|
176
|
-
};
|
|
177
|
-
runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
|
|
178
|
-
const { wave, exitCode } = await ctx.launchers.launchWave({
|
|
179
|
-
models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
|
|
180
|
-
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
181
|
-
noCostGate: o.noCostGate,
|
|
182
|
-
councilRunId: o.runId, councilName: o.councilName,
|
|
183
|
-
fallback: o.fallback, catalog: o.catalog,
|
|
184
|
-
});
|
|
185
|
-
ctx.addWave(wave);
|
|
186
|
-
if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
|
|
187
|
-
|
|
188
|
-
const judgeResults = [];
|
|
189
|
-
let repairSeq = 0;
|
|
190
|
-
for (const leg of (wave && wave.legs) || []) {
|
|
191
|
-
const judge = leg.modelInput || leg.model;
|
|
192
|
-
if (leg.status === 'complete' && leg.summary) {
|
|
193
|
-
fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
|
|
194
|
-
}
|
|
195
|
-
let conformance = 'clean';
|
|
196
|
-
let parsed = (leg.status === 'complete' && leg.summary)
|
|
197
|
-
? parseJudgeOutput(leg.summary, parseCtx)
|
|
198
|
-
: { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
|
|
199
|
-
let attempts = 0;
|
|
200
|
-
while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
|
|
201
|
-
attempts += 1;
|
|
202
|
-
repairSeq += 1;
|
|
203
|
-
const waveId = `${o.runId}-q${repairSeq}`;
|
|
204
|
-
runState.appendStageWave(o.runDir, 'stage2', waveId);
|
|
205
|
-
const solo = await ctx.launchers.launchSolo({
|
|
206
|
-
model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
|
|
207
|
-
project: ctx.scratchDir, waveId, timeout: o.timeout,
|
|
208
|
-
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
209
|
-
councilRunId: o.runId, councilName: o.councilName,
|
|
210
|
-
fallback: o.fallback, catalog: o.catalog,
|
|
211
|
-
});
|
|
212
|
-
ctx.addWave(solo.wave);
|
|
213
|
-
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
|
|
214
|
-
parsed = parseJudgeOutput((solo.leg && solo.leg.summary) || '', parseCtx);
|
|
215
|
-
if (parsed.ok) { conformance = 'repaired'; }
|
|
216
|
-
}
|
|
217
|
-
if (!parsed.ok) {
|
|
218
|
-
judgeResults.push({ judge, ok: false, order: null, adjudications: null,
|
|
219
|
-
conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
|
|
223
|
-
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
|
|
224
|
-
}
|
|
225
|
-
return { aborted: null, judgeResults };
|
|
253
|
+
return { aborted: null, reviews, deadLegs, deadWaves,
|
|
254
|
+
degraded: deadLegs.length > 0 || deadWaves.length > 0 };
|
|
226
255
|
}
|
|
227
256
|
|
|
228
|
-
|
|
257
|
+
// runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
|
|
258
|
+
// this module stays the single import surface for the stage loops. The cycle that
|
|
259
|
+
// once blocked that is gone: isAbortExit was hoisted into run-launch.js — the
|
|
260
|
+
// module that produces the exit codes — so the child no longer imports from its
|
|
261
|
+
// parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
|
|
262
|
+
// and run-debate.js, which have always taken it from here.
|
|
263
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor, reportDeadStage1Waves };
|
package/src/council/run-state.js
CHANGED
|
@@ -85,6 +85,28 @@ function initRun(runDir, seed) {
|
|
|
85
85
|
return writeRun(runDir, run);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Seed run.json for a NEW council run and write its sessions-dir pointer.
|
|
90
|
+
* Lifted verbatim out of run.js for the 300-line gate (v4.4.1 Task 0.5, which
|
|
91
|
+
* needed room for the per-run shared server) — same seed, same order, same
|
|
92
|
+
* pointer write.
|
|
93
|
+
* @param {object} o the council run's resolved options
|
|
94
|
+
*/
|
|
95
|
+
function initCouncilRun(o) {
|
|
96
|
+
initRun(o.runDir, {
|
|
97
|
+
schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
|
|
98
|
+
bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
|
|
99
|
+
labelMap: null,
|
|
100
|
+
// Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
|
|
101
|
+
// "no debate key" contract and fail the object-typed schema), and with a VALID
|
|
102
|
+
// outcome from the first write so a run killed mid-debate stays schema-valid.
|
|
103
|
+
...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
|
|
104
|
+
options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
|
|
105
|
+
usage: null, pid: process.pid, createdAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
writePointer(o.project, o.runId, o.runDir);
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
/** Read-merge-write checkpoint (atomic; abort-wins on status). */
|
|
89
111
|
function checkpoint(runDir, patch) {
|
|
90
112
|
const existing = readRun(runDir) || {};
|
|
@@ -155,7 +177,7 @@ function listPointers(project) {
|
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
module.exports = {
|
|
158
|
-
RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
|
|
180
|
+
RUN_FILE, readRun, initRun, initCouncilRun, checkpoint, updateStage, appendStageWave,
|
|
159
181
|
writeSpawnPid, readSpawnPid,
|
|
160
182
|
pointerPath, writePointer, readPointer, listPointers,
|
|
161
183
|
};
|
package/src/council/run.js
CHANGED
|
@@ -22,20 +22,18 @@ const path = require('path');
|
|
|
22
22
|
const { tally } = require('./tally');
|
|
23
23
|
const { assignLabels, toGlobalFindings } = require('./anonymize');
|
|
24
24
|
const briefings = require('./briefings');
|
|
25
|
-
const stage2 = require('./briefings-stage2');
|
|
26
25
|
const runState = require('./run-state');
|
|
27
26
|
const { createLaunchers } = require('./run-launch');
|
|
28
|
-
const { runStage1, runStage2 } = require('./run-stages');
|
|
27
|
+
const { runStage1, runStage2 } = require('./run-stages'); // stage 2 lives in ./run-stage2 (300-line gate), re-exported there
|
|
29
28
|
const { runChair, pickFallbackChair } = require('./run-chair');
|
|
30
29
|
const runDebateMod = require('./run-debate');
|
|
31
|
-
const { buildDebateAddendum } = require('./briefings-debate');
|
|
32
30
|
const { decorateRecord } = require('./debate');
|
|
33
31
|
const asm = require('./run-assemble');
|
|
34
|
-
const {
|
|
35
|
-
const { emitRunStarted,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
32
|
+
const { createBudget } = require('./run-budget');
|
|
33
|
+
const { emitRunStarted, emitStageStarted, emitStageTerminal } = require('../observe/events');
|
|
34
|
+
// v4.4.1 CA-6: the whole exit-code vocabulary (SIGNAL_EXIT, statusForExit and the
|
|
35
|
+
// degradation in resolveTerminalExit) lives in ./run-finalize — see its docblock.
|
|
36
|
+
const { writeRunTerminal, resolveTerminalExit, SIGNAL_EXIT } = require('./run-finalize');
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
39
|
* @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
|
|
@@ -45,40 +43,35 @@ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
|
|
|
45
43
|
* launchWave/launchSolo for leg ledger attribution. fallback/catalog (v4.3 Task 18
|
|
46
44
|
* §6.2): ctx.o carries both, but only run-stages.js's stage launches read them —
|
|
47
45
|
* the chair/debate legs never substitute via chains.
|
|
48
|
-
* @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn
|
|
46
|
+
* @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?,
|
|
47
|
+
* startOpenCodeServerFn? (v4.4.1 Task 0.5 test seam, see ./run-server)}
|
|
49
48
|
* @returns {Promise<{exitCode: number, run: object}>}
|
|
50
49
|
*/
|
|
51
50
|
async function runCouncil(options, deps = {}) {
|
|
52
51
|
const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
|
|
53
52
|
noCostGate: false, councilName: null, ...options };
|
|
54
53
|
o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
|
|
55
|
-
const launchers = deps.launchers || createLaunchers();
|
|
56
54
|
const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
|
|
57
55
|
const statsFn = deps.statsFn || require('./ledger').deriveReliability;
|
|
58
56
|
const installSignals = deps.installSignalAbortFn
|
|
59
57
|
|| require('../utils/session-abort').installSignalAbort;
|
|
60
58
|
const now = () => new Date().toISOString();
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
// v4.4: the whole budget position lives in ./run-budget — its docblock carries the "fail LOUD,
|
|
61
|
+
// not CLOSED" ruling, why reserveBudget (not merely remainingBudget) is what holds the ceiling
|
|
62
|
+
// across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
|
|
63
|
+
// why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
|
|
64
|
+
const degraded = { value: false };
|
|
65
|
+
const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
|
|
66
|
+
noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degraded });
|
|
67
|
+
// v4.4.1 Task 0.5: ONE OpenCode server for the whole run — ./run-server carries
|
|
68
|
+
// the why and the evidence that `_scratch` judge isolation survives it. Acquired
|
|
69
|
+
// below (a getter, because the launchers are built first); null = as before.
|
|
70
|
+
let sharedServer = null;
|
|
71
|
+
const launchers = deps.launchers
|
|
72
|
+
|| createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer });
|
|
69
73
|
|
|
70
|
-
runState.
|
|
71
|
-
schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
|
|
72
|
-
bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
|
|
73
|
-
labelMap: null,
|
|
74
|
-
// Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
|
|
75
|
-
// "no debate key" contract and fail the object-typed schema), and with a VALID
|
|
76
|
-
// outcome from the first write so a run killed mid-debate stays schema-valid.
|
|
77
|
-
...(o.debate ? { debate: { enabled: true, outcome: 'nothing-to-debate' } } : {}),
|
|
78
|
-
options: { timeout: o.timeout || null, maxCost: o.maxCost, gateway: o.gateway || 'auto', outDir: o.runDir },
|
|
79
|
-
usage: null, pid: process.pid, createdAt: now(),
|
|
80
|
-
});
|
|
81
|
-
runState.writePointer(o.project, o.runId, o.runDir);
|
|
74
|
+
runState.initCouncilRun(o); // run.json seed + sessions-dir pointer (run-state.js)
|
|
82
75
|
emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
|
|
83
76
|
|
|
84
77
|
let signalled = null;
|
|
@@ -89,22 +82,25 @@ async function runCouncil(options, deps = {}) {
|
|
|
89
82
|
},
|
|
90
83
|
});
|
|
91
84
|
|
|
92
|
-
const degraded = { value: false };
|
|
93
85
|
const finalize = async (exitCode, error) => {
|
|
94
86
|
uninstall();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:
|
|
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',
|
|
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.
|
|
142
|
+
if (s1.degraded) { degraded.value = true; } // bench shrank → never a "full run"
|
|
139
143
|
if (s1.reviews.length < 2) {
|
|
140
144
|
return finalize(1, {
|
|
141
145
|
code: 'COUNCIL_QUORUM',
|
|
@@ -144,6 +148,7 @@ async function runCouncil(options, deps = {}) {
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
// ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
|
|
151
|
+
noticeUnknownSpend(); // v4.4: warn EARLY on a long run, not only at finalize
|
|
147
152
|
if (overBudget()) {
|
|
148
153
|
return finalize(1, {
|
|
149
154
|
code: 'COST_EXCEEDED',
|
|
@@ -247,16 +252,10 @@ async function runCouncil(options, deps = {}) {
|
|
|
247
252
|
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
248
253
|
}
|
|
249
254
|
|
|
250
|
-
const packet =
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
|
|
255
|
-
rankings: debatedInput.rankings,
|
|
256
|
-
adjudications: debatedInput.adjudications,
|
|
257
|
-
tierCounts: debatedRecord.tierCounts, date: o.date,
|
|
258
|
-
}) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
|
|
259
|
-
fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
|
|
255
|
+
const packet = asm.buildChairPacketFile({
|
|
256
|
+
runDir: o.runDir, reviews: s1.reviews, claudeReview, date: o.date,
|
|
257
|
+
tallyInput: debatedInput, record: debatedRecord, debateOutcomes,
|
|
258
|
+
});
|
|
260
259
|
|
|
261
260
|
const chairRes = await runChair(ctx, {
|
|
262
261
|
packet, degraded, statsFn, isSignalled: () => signalled,
|
package/src/council/tally.js
CHANGED
|
@@ -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,
|