amicus 3.2.3 → 4.0.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 +106 -0
- package/README.md +15 -3
- package/electron/main.js +4 -1
- package/package.json +3 -1
- package/schemas/abort.schema.json +17 -0
- package/schemas/alias-audit.schema.json +17 -0
- package/schemas/council-run.schema.json +38 -0
- package/schemas/council-stats.schema.json +28 -0
- package/schemas/council-tally.schema.json +70 -0
- package/schemas/council-validate.schema.json +22 -0
- package/schemas/council-verdict.schema.json +47 -0
- package/schemas/doctor.schema.json +29 -0
- package/schemas/error.schema.json +23 -0
- package/schemas/model-catalog.schema.json +19 -0
- package/schemas/run.schema.json +26 -0
- package/schemas/spend.schema.json +16 -0
- package/schemas/wave.schema.json +33 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
- package/skills/second-opinion/SKILL.md +8 -0
- package/src/cli-handlers-abort.js +29 -0
- package/src/cli-handlers-council-run.js +168 -0
- package/src/cli-handlers-council.js +8 -5
- package/src/cli-handlers-status.js +35 -4
- package/src/cli.js +9 -0
- package/src/council/anonymize.js +76 -0
- package/src/council/briefings-stage2.js +150 -0
- package/src/council/briefings.js +141 -0
- package/src/council/findings.js +13 -1
- package/src/council/ledger.js +13 -1
- package/src/council/parse-stage2.js +103 -0
- package/src/council/run-assemble.js +100 -0
- package/src/council/run-launch.js +99 -0
- package/src/council/run-stages.js +203 -0
- package/src/council/run-state.js +161 -0
- package/src/council/run.js +277 -0
- package/src/council/tally.js +3 -1
- package/src/council/verdict.js +9 -2
- package/src/headless.js +24 -25
- package/src/mcp-council-awareness.js +187 -0
- package/src/mcp-council-run.js +161 -0
- package/src/mcp-server.js +87 -28
- package/src/mcp-tools.js +50 -0
- package/src/prompt-builder.js +36 -19
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/resume.js +7 -2
- package/src/utils/abort-result.js +1 -1
- package/src/utils/error-doc.js +2 -0
- package/src/utils/fold-marker.js +21 -0
- package/src/utils/route-error.js +26 -0
- package/src/utils/start-helpers.js +19 -10
- package/src/utils/untrusted-fence.js +8 -7
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// src/council/run-stages.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-stages
|
|
6
|
+
* Stage-1 (independent reviews) and Stage-2 (anonymized cross-review) loops
|
|
7
|
+
* for the headless council engine — launch, materialize, validate, bounded
|
|
8
|
+
* repair. Split from run.js for the 300-line gate. All model calls go through
|
|
9
|
+
* ctx.launchers (DI); the whole-run cost ceiling is consulted via
|
|
10
|
+
* ctx.overBudget() before every paid repair launch (spec §4).
|
|
11
|
+
*
|
|
12
|
+
* Headless adaptations (vs SKILL.md):
|
|
13
|
+
* - A review still malformed after 2 repair re-prompts is KEPT with
|
|
14
|
+
* conformance 'unstructured' and zero findings entries (the skill's Claude
|
|
15
|
+
* hand-parse fallback has no headless equivalent; the review still gets
|
|
16
|
+
* ranked in Stage 2).
|
|
17
|
+
* - A judge still malformed after 2 repairs is dropped from rankings and
|
|
18
|
+
* adjudications (ok:false) and recorded conformance 'unstructured' (spec §5).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { validateFindings } = require('./findings');
|
|
24
|
+
const briefings = require('./briefings');
|
|
25
|
+
const stage2 = require('./briefings-stage2');
|
|
26
|
+
const { parseJudgeOutput } = require('./parse-stage2');
|
|
27
|
+
const { materializeReviews, sanitizeName } = require('./run-launch');
|
|
28
|
+
const runState = require('./run-state');
|
|
29
|
+
|
|
30
|
+
function isAbortExit(code) { return code === 130 || code === 143; }
|
|
31
|
+
|
|
32
|
+
function slug(text) {
|
|
33
|
+
return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
|
|
37
|
+
async function launchStage1(ctx) {
|
|
38
|
+
const { o, launchers } = ctx;
|
|
39
|
+
const common = {
|
|
40
|
+
project: o.runDir, timeout: o.timeout, gateway: o.gateway,
|
|
41
|
+
noValidateModel: o.noValidateModel,
|
|
42
|
+
};
|
|
43
|
+
const launches = [];
|
|
44
|
+
// Record every sub-wave BEFORE it launches: `amicus abort` cascades over
|
|
45
|
+
// stages[].waveIds, so an id written after the launch leaves that leg
|
|
46
|
+
// reachable only by the pid kill (no per-leg abort marker).
|
|
47
|
+
const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
48
|
+
if (o.lenses) {
|
|
49
|
+
o.models.forEach((m, i) => {
|
|
50
|
+
const waveId = `${o.runId}-l${i + 1}`;
|
|
51
|
+
record(waveId);
|
|
52
|
+
launches.push(launchers.launchSolo({
|
|
53
|
+
...common, model: m, waveId,
|
|
54
|
+
prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
const seats = o.models.filter(m => m !== o.critic);
|
|
59
|
+
if (seats.length > 0) {
|
|
60
|
+
record(`${o.runId}-s1`);
|
|
61
|
+
launches.push(launchers.launchWave({
|
|
62
|
+
...common, models: seats, waveId: `${o.runId}-s1`,
|
|
63
|
+
prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
if (o.critic) {
|
|
67
|
+
record(`${o.runId}-c1`);
|
|
68
|
+
launches.push(launchers.launchSolo({
|
|
69
|
+
...common, model: o.critic, waveId: `${o.runId}-c1`,
|
|
70
|
+
prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const results = await Promise.all(launches);
|
|
75
|
+
let aborted = null;
|
|
76
|
+
const legs = [];
|
|
77
|
+
for (const r of results) {
|
|
78
|
+
ctx.addWave(r.wave);
|
|
79
|
+
if (isAbortExit(r.exitCode)) { aborted = r.exitCode; }
|
|
80
|
+
if (r.wave && Array.isArray(r.wave.legs)) { legs.push(...r.wave.legs); }
|
|
81
|
+
}
|
|
82
|
+
return { aborted, legs };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Role of a seat by its input alias. */
|
|
86
|
+
function roleFor(o, alias) {
|
|
87
|
+
if (o.lenses) {
|
|
88
|
+
const i = o.models.indexOf(alias);
|
|
89
|
+
return i === -1 ? 'seat' : `lens:${slug(o.lenses[i])}`;
|
|
90
|
+
}
|
|
91
|
+
return alias === o.critic ? 'critic' : 'seat';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stage 1: independent reviews + findings validation + bounded repair.
|
|
96
|
+
* @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array}>}
|
|
97
|
+
*/
|
|
98
|
+
async function runStage1(ctx) {
|
|
99
|
+
const { o } = ctx;
|
|
100
|
+
const { aborted, legs } = await launchStage1(ctx);
|
|
101
|
+
if (aborted) { return { aborted, reviews: [], deadLegs: [] }; }
|
|
102
|
+
|
|
103
|
+
const materialized = materializeReviews(o.runDir, legs);
|
|
104
|
+
const alive = new Set(materialized.map(m => m.leg));
|
|
105
|
+
const deadLegs = legs.filter(l => !alive.has(l));
|
|
106
|
+
|
|
107
|
+
const reviews = [];
|
|
108
|
+
let repairSeq = 0;
|
|
109
|
+
for (const m of materialized) {
|
|
110
|
+
let conformance = 'clean';
|
|
111
|
+
let res = validateFindings(m.text);
|
|
112
|
+
let attempts = 0;
|
|
113
|
+
while (!res.ok && attempts < 2 && !ctx.overBudget()) {
|
|
114
|
+
attempts += 1;
|
|
115
|
+
repairSeq += 1;
|
|
116
|
+
const waveId = `${o.runId}-p${repairSeq}`;
|
|
117
|
+
runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
118
|
+
const solo = await ctx.launchers.launchSolo({
|
|
119
|
+
model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
|
|
120
|
+
project: o.runDir, waveId, timeout: o.timeout,
|
|
121
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
122
|
+
});
|
|
123
|
+
ctx.addWave(solo.wave);
|
|
124
|
+
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
|
|
125
|
+
res = validateFindings((solo.leg && solo.leg.summary) || '');
|
|
126
|
+
if (res.ok) { conformance = 'repaired'; }
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok) { conformance = 'unstructured'; }
|
|
129
|
+
reviews.push({
|
|
130
|
+
model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
|
|
131
|
+
text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { aborted: null, reviews, deadLegs };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
|
|
139
|
+
* @param {object} ctx
|
|
140
|
+
* @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array}} args
|
|
141
|
+
* @returns {Promise<{aborted: number|null, judgeResults: Array}>}
|
|
142
|
+
*/
|
|
143
|
+
async function runStage2(ctx, { reviews, labels, globalFindings }) {
|
|
144
|
+
const { o } = ctx;
|
|
145
|
+
const { rankingToOrder } = require('./anonymize');
|
|
146
|
+
fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
|
|
147
|
+
|
|
148
|
+
const labeled = labels.entries.map((e, i) => ({ label: e.label, text: reviews[i].text }));
|
|
149
|
+
const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings });
|
|
150
|
+
fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
|
|
151
|
+
|
|
152
|
+
const judges = reviews.map(r => r.modelInput);
|
|
153
|
+
const parseCtx = {
|
|
154
|
+
labels: labels.entries.map(e => e.label),
|
|
155
|
+
findingIds: globalFindings.map(f => f.id),
|
|
156
|
+
};
|
|
157
|
+
runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
|
|
158
|
+
const { wave, exitCode } = await ctx.launchers.launchWave({
|
|
159
|
+
models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
|
|
160
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
161
|
+
});
|
|
162
|
+
ctx.addWave(wave);
|
|
163
|
+
if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
|
|
164
|
+
|
|
165
|
+
const judgeResults = [];
|
|
166
|
+
let repairSeq = 0;
|
|
167
|
+
for (const leg of (wave && wave.legs) || []) {
|
|
168
|
+
const judge = leg.modelInput || leg.model;
|
|
169
|
+
if (leg.status === 'complete' && leg.summary) {
|
|
170
|
+
fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
|
|
171
|
+
}
|
|
172
|
+
let conformance = 'clean';
|
|
173
|
+
let parsed = (leg.status === 'complete' && leg.summary)
|
|
174
|
+
? parseJudgeOutput(leg.summary, parseCtx)
|
|
175
|
+
: { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
|
|
176
|
+
let attempts = 0;
|
|
177
|
+
while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
|
|
178
|
+
attempts += 1;
|
|
179
|
+
repairSeq += 1;
|
|
180
|
+
const waveId = `${o.runId}-q${repairSeq}`;
|
|
181
|
+
runState.appendStageWave(o.runDir, 'stage2', waveId);
|
|
182
|
+
const solo = await ctx.launchers.launchSolo({
|
|
183
|
+
model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
|
|
184
|
+
project: ctx.scratchDir, waveId, timeout: o.timeout,
|
|
185
|
+
gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
186
|
+
});
|
|
187
|
+
ctx.addWave(solo.wave);
|
|
188
|
+
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
|
|
189
|
+
parsed = parseJudgeOutput((solo.leg && solo.leg.summary) || '', parseCtx);
|
|
190
|
+
if (parsed.ok) { conformance = 'repaired'; }
|
|
191
|
+
}
|
|
192
|
+
if (!parsed.ok) {
|
|
193
|
+
judgeResults.push({ judge, ok: false, order: null, adjudications: null,
|
|
194
|
+
conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
|
|
198
|
+
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
|
|
199
|
+
}
|
|
200
|
+
return { aborted: null, judgeResults };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/council/run-state.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-state
|
|
6
|
+
* Durable state for one headless council run (spec §4/§5): atomic run.json
|
|
7
|
+
* read/write with checkpoint semantics, plus the sessions-dir pointer file
|
|
8
|
+
* (`council-<runId>.json` → {runId, runDir}) that lets status/wait/list/abort
|
|
9
|
+
* resolve council runIds without knowing --out-dir.
|
|
10
|
+
*
|
|
11
|
+
* Abort-wins: once run.json's status is 'aborted', no later checkpoint can
|
|
12
|
+
* demote it (same precedence rule as fanout's writeWaveMetadata — an external
|
|
13
|
+
* `amicus abort` must never lose a write race against the engine's own
|
|
14
|
+
* finalize).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
20
|
+
const { SESSIONS_DIR } = require('../session-manager');
|
|
21
|
+
|
|
22
|
+
const RUN_FILE = 'run.json';
|
|
23
|
+
const SPAWN_PID_FILE = 'spawn.pid';
|
|
24
|
+
|
|
25
|
+
function runPath(runDir) { return path.join(runDir, RUN_FILE); }
|
|
26
|
+
function spawnPidPath(runDir) { return path.join(runDir, SPAWN_PID_FILE); }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record the engine child's pid in its own file rather than patching run.json.
|
|
30
|
+
* The spawning process (the MCP handler) and the engine child both write
|
|
31
|
+
* run.json, and `checkpoint` is a read-merge-write with no cross-process lock —
|
|
32
|
+
* so a pid patch from the parent can clobber, or be clobbered by, whatever the
|
|
33
|
+
* child wrote in the same window. A standalone single-write file has no read
|
|
34
|
+
* side, so there is no race to lose. Readers fall back to it whenever run.json
|
|
35
|
+
* carries no pid (see readSpawnPid).
|
|
36
|
+
*/
|
|
37
|
+
function writeSpawnPid(runDir, pid) {
|
|
38
|
+
writeFileAtomic(spawnPidPath(runDir), String(pid), { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {number|null} the recorded spawn pid, or null when absent/corrupt */
|
|
42
|
+
function readSpawnPid(runDir) {
|
|
43
|
+
try {
|
|
44
|
+
const pid = Number.parseInt(fs.readFileSync(spawnPidPath(runDir), 'utf-8').trim(), 10);
|
|
45
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {object|null} parsed run.json, or null when missing/corrupt */
|
|
50
|
+
function readRun(runDir) {
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(runPath(runDir), 'utf-8')); }
|
|
52
|
+
catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Abort-wins merge: once status is 'aborted', it cannot change to anything else. */
|
|
56
|
+
function mergeRun(existing, patch) {
|
|
57
|
+
const merged = { ...existing, ...patch };
|
|
58
|
+
// If the prior run was aborted, preserve that status regardless of patch content
|
|
59
|
+
// (prevents falsy status values, null, '', or omitted status from overwriting)
|
|
60
|
+
if (existing.status === 'aborted' || patch.status === 'aborted') {
|
|
61
|
+
merged.status = 'aborted';
|
|
62
|
+
// An aborted run is terminal: never let a later (racing) finalize report a
|
|
63
|
+
// clean/degraded exitCode. Prefer a recorded abort code, then a patch abort
|
|
64
|
+
// code, else default SIGTERM's 143.
|
|
65
|
+
const prior = [130, 143].includes(existing.exitCode) ? existing.exitCode : null;
|
|
66
|
+
const fromPatch = [130, 143].includes(patch.exitCode) ? patch.exitCode : null;
|
|
67
|
+
merged.exitCode = prior || fromPatch || 143;
|
|
68
|
+
}
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeRun(runDir, run) {
|
|
73
|
+
writeFileAtomic(runPath(runDir), JSON.stringify(run, null, 2), { mode: 0o600 });
|
|
74
|
+
return run;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create (or merge into) run.json; preserves an existing createdAt. */
|
|
78
|
+
function initRun(runDir, seed) {
|
|
79
|
+
fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
const existing = readRun(runDir) || {};
|
|
81
|
+
const run = mergeRun(existing, {
|
|
82
|
+
...seed,
|
|
83
|
+
createdAt: existing.createdAt || seed.createdAt,
|
|
84
|
+
});
|
|
85
|
+
return writeRun(runDir, run);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read-merge-write checkpoint (atomic; abort-wins on status). */
|
|
89
|
+
function checkpoint(runDir, patch) {
|
|
90
|
+
const existing = readRun(runDir) || {};
|
|
91
|
+
return writeRun(runDir, mergeRun(existing, patch));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Upsert one stages[] entry by name; other stages and order preserved. */
|
|
95
|
+
function updateStage(runDir, name, patch) {
|
|
96
|
+
const existing = readRun(runDir) || {};
|
|
97
|
+
const stages = Array.isArray(existing.stages) ? existing.stages.slice() : [];
|
|
98
|
+
const i = stages.findIndex(s => s && s.name === name);
|
|
99
|
+
if (i === -1) { stages.push({ name, ...patch }); }
|
|
100
|
+
else { stages[i] = { ...stages[i], ...patch }; }
|
|
101
|
+
return checkpoint(runDir, { stages });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Append a sub-wave id to one stage's `waveIds` (dedup, launch order preserved).
|
|
106
|
+
* A stage can have several sub-waves in flight at once (lens solos, a critic
|
|
107
|
+
* solo alongside the seat wave) or in sequence (the chair's ch1..ch4 chain), so
|
|
108
|
+
* the single `waveId` field cannot describe them. `amicus abort` cascades over
|
|
109
|
+
* this list to mark every in-flight leg instead of relying on the pid kill.
|
|
110
|
+
*/
|
|
111
|
+
function appendStageWave(runDir, name, waveId) {
|
|
112
|
+
const existing = readRun(runDir) || {};
|
|
113
|
+
const stage = (existing.stages || []).find(s => s && s.name === name) || {};
|
|
114
|
+
const waveIds = Array.isArray(stage.waveIds) ? stage.waveIds : [];
|
|
115
|
+
if (waveIds.includes(waveId)) { return existing; }
|
|
116
|
+
return updateStage(runDir, name, { waveIds: [...waveIds, waveId] });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stripPrefix(runId) { return String(runId).replace(/^council-/, ''); }
|
|
120
|
+
|
|
121
|
+
/** `<project>/.claude/amicus_sessions/council-<runId>.json` */
|
|
122
|
+
function pointerPath(project, runId) {
|
|
123
|
+
return path.join(project, '.claude', SESSIONS_DIR, `council-${stripPrefix(runId)}.json`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writePointer(project, runId, runDir) {
|
|
127
|
+
const p = pointerPath(project, runId);
|
|
128
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
129
|
+
writeFileAtomic(p, JSON.stringify({ runId: stripPrefix(runId), runDir }, null, 2), { mode: 0o600 });
|
|
130
|
+
return p;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @returns {{runId: string, runDir: string}|null} */
|
|
134
|
+
function readPointer(project, runId) {
|
|
135
|
+
try {
|
|
136
|
+
const ptr = JSON.parse(fs.readFileSync(pointerPath(project, runId), 'utf-8'));
|
|
137
|
+
return (ptr && ptr.runId && ptr.runDir) ? ptr : null;
|
|
138
|
+
} catch { return null; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** All council pointers in the project sessions dir. */
|
|
142
|
+
function listPointers(project) {
|
|
143
|
+
const root = path.join(project, '.claude', SESSIONS_DIR);
|
|
144
|
+
let names = [];
|
|
145
|
+
try { names = fs.readdirSync(root); } catch { return []; }
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const n of names) {
|
|
148
|
+
if (!/^council-[a-zA-Z0-9_-]{1,64}\.json$/.test(n)) { continue; }
|
|
149
|
+
try {
|
|
150
|
+
const ptr = JSON.parse(fs.readFileSync(path.join(root, n), 'utf-8'));
|
|
151
|
+
if (ptr && ptr.runId && ptr.runDir) { out.push(ptr); }
|
|
152
|
+
} catch { /* skip corrupt pointer */ }
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
|
|
159
|
+
writeSpawnPid, readSpawnPid,
|
|
160
|
+
pointerPath, writePointer, readPointer, listPointers,
|
|
161
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/council/run.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run
|
|
6
|
+
* Headless council driver (spec §5): stage state machine over the DI launch
|
|
7
|
+
* wrappers — Stage-1 reviews → anonymized Stage-2 cross-review → tally →
|
|
8
|
+
* chair synthesis → verdict/report — checkpointing run.json after every
|
|
9
|
+
* stage (run-state) and consuming the existing pure primitives unchanged
|
|
10
|
+
* (tally, buildVerdict via run-assemble, report renderers, ledger).
|
|
11
|
+
*
|
|
12
|
+
* Tally sequencing: an in-memory provisional tally feeds the chair packet;
|
|
13
|
+
* the on-disk tally-input.json/tally.json are FINAL (chair runStats row
|
|
14
|
+
* included, actual chair in meta) and only the final record is ledgered —
|
|
15
|
+
* the skill's debate-mode provisional/final precedent.
|
|
16
|
+
*
|
|
17
|
+
* Never rejects for run errors: always resolves {exitCode, run}.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { tally } = require('./tally');
|
|
23
|
+
const { assignLabels, toGlobalFindings } = require('./anonymize');
|
|
24
|
+
const briefings = require('./briefings');
|
|
25
|
+
const stage2 = require('./briefings-stage2');
|
|
26
|
+
const { parseChairVerdict } = require('./parse-stage2');
|
|
27
|
+
const runState = require('./run-state');
|
|
28
|
+
const { createLaunchers } = require('./run-launch');
|
|
29
|
+
const { runStage1, runStage2, isAbortExit } = require('./run-stages');
|
|
30
|
+
const asm = require('./run-assemble');
|
|
31
|
+
const { sumWaveUsage } = require('../utils/pricing');
|
|
32
|
+
|
|
33
|
+
const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Chair fallback promotion (spec §4): the highest peers-only street-cred
|
|
37
|
+
* model from `council stats` that is not a bench seat and not the failed
|
|
38
|
+
* chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
|
|
39
|
+
* (deriveReliability's avgStreetCredPeersOnly; lower is better).
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
function pickFallbackChair(statsRows, bench, failedChair) {
|
|
43
|
+
const benchSet = new Set(bench);
|
|
44
|
+
const candidates = (statsRows || [])
|
|
45
|
+
.filter(r => !benchSet.has(r.model) && r.model !== failedChair
|
|
46
|
+
&& typeof r.avgStreetCredPeersOnly === 'number')
|
|
47
|
+
.sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
|
|
48
|
+
return candidates.length ? candidates[0].model : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} options {briefing, models, chair, critic?, lenses?, project,
|
|
53
|
+
* runId, runDir, timeout?, maxCost?, gateway?, noValidateModel?, date}
|
|
54
|
+
* @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?}
|
|
55
|
+
* @returns {Promise<{exitCode: number, run: object}>}
|
|
56
|
+
*/
|
|
57
|
+
async function runCouncil(options, deps = {}) {
|
|
58
|
+
const o = { critic: null, lenses: null, maxCost: null, ...options };
|
|
59
|
+
const launchers = deps.launchers || createLaunchers();
|
|
60
|
+
const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
|
|
61
|
+
const statsFn = deps.statsFn || require('./ledger').deriveReliability;
|
|
62
|
+
const installSignals = deps.installSignalAbortFn
|
|
63
|
+
|| require('../utils/session-abort').installSignalAbort;
|
|
64
|
+
const now = () => new Date().toISOString();
|
|
65
|
+
|
|
66
|
+
const allLegs = [];
|
|
67
|
+
const addWave = (wave) => { if (wave && Array.isArray(wave.legs)) { allLegs.push(...wave.legs); } };
|
|
68
|
+
const spent = () => {
|
|
69
|
+
const c = sumWaveUsage(allLegs).cost;
|
|
70
|
+
return typeof c.amount === 'number' ? c.amount : 0;
|
|
71
|
+
};
|
|
72
|
+
const overBudget = () => o.maxCost !== null && o.maxCost !== undefined && spent() >= o.maxCost;
|
|
73
|
+
|
|
74
|
+
runState.initRun(o.runDir, {
|
|
75
|
+
schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
|
|
76
|
+
bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
|
|
77
|
+
labelMap: null,
|
|
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);
|
|
82
|
+
|
|
83
|
+
let signalled = null;
|
|
84
|
+
const uninstall = installSignals({
|
|
85
|
+
onAbort: (signal) => {
|
|
86
|
+
signalled = SIGNAL_EXIT[signal] || 143;
|
|
87
|
+
runState.checkpoint(o.runDir, { status: 'aborted', exitCode: signalled, completedAt: now() });
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const degraded = { value: false };
|
|
92
|
+
const finalize = (exitCode, error) => {
|
|
93
|
+
uninstall();
|
|
94
|
+
const code = signalled || exitCode;
|
|
95
|
+
const status = (code === 130 || code === 143) ? 'aborted'
|
|
96
|
+
: code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
|
|
97
|
+
const run = runState.checkpoint(o.runDir, {
|
|
98
|
+
status, exitCode: code, error: error || null,
|
|
99
|
+
usage: { cost: sumWaveUsage(allLegs).cost },
|
|
100
|
+
completedAt: now(),
|
|
101
|
+
});
|
|
102
|
+
return { exitCode: code, run };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
|
|
109
|
+
fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
|
|
110
|
+
briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
|
|
111
|
+
|
|
112
|
+
// ---- Stage 1: independent reviews ----
|
|
113
|
+
// Lens mode launches one solo per seat instead of a `-s1` seat wave, so it
|
|
114
|
+
// has no primary wave to name — run-stages records each real sub-wave into
|
|
115
|
+
// waveIds at launch. Advertising a `-s1` that never exists made both the
|
|
116
|
+
// abort cascade and the status leg rollup chase a phantom.
|
|
117
|
+
runState.updateStage(o.runDir, 'stage1', {
|
|
118
|
+
status: 'running', startedAt: now(), project: o.runDir,
|
|
119
|
+
...(o.lenses ? {} : { waveId: `${o.runId}-s1` }),
|
|
120
|
+
});
|
|
121
|
+
const s1 = await runStage1(ctx);
|
|
122
|
+
runState.updateStage(o.runDir, 'stage1', {
|
|
123
|
+
status: 'complete', completedAt: now(),
|
|
124
|
+
taskIds: s1.reviews.map(r => (r.leg && r.leg.taskId)).filter(Boolean),
|
|
125
|
+
});
|
|
126
|
+
if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
|
|
127
|
+
if (s1.deadLegs.length > 0) { degraded.value = true; } // bench shrank → never a "full run"
|
|
128
|
+
if (s1.reviews.length < 2) {
|
|
129
|
+
return finalize(1, {
|
|
130
|
+
code: 'COUNCIL_QUORUM',
|
|
131
|
+
message: `Only ${s1.reviews.length} Stage-1 review(s) survived; a council needs at least 2`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
|
|
136
|
+
if (overBudget()) {
|
|
137
|
+
return finalize(1, {
|
|
138
|
+
code: 'COST_EXCEEDED',
|
|
139
|
+
message: `Cost ceiling $${o.maxCost} reached before cross-review; no tally exists`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Stage 2: anonymized cross-review ----
|
|
144
|
+
const labels = assignLabels(s1.reviews.map(r => r.model));
|
|
145
|
+
runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
|
|
146
|
+
// Attach each review's run-global findings (buildTallyInput reads
|
|
147
|
+
// r.globalFindings per review, not a bare parallel array).
|
|
148
|
+
s1.reviews.forEach((r, i) => {
|
|
149
|
+
r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
|
|
150
|
+
});
|
|
151
|
+
const globalFindings = s1.reviews.flatMap(r => r.globalFindings);
|
|
152
|
+
runState.updateStage(o.runDir, 'stage2',
|
|
153
|
+
{ status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
|
|
154
|
+
const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings });
|
|
155
|
+
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
156
|
+
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
157
|
+
if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
|
|
158
|
+
|
|
159
|
+
// Merge Stage-2 judging conformance into each seat's row (worst wins).
|
|
160
|
+
const byJudge = new Map(s2.judgeResults.map(j => [j.judge, j]));
|
|
161
|
+
for (const r of s1.reviews) {
|
|
162
|
+
const j = byJudge.get(r.model);
|
|
163
|
+
if (j) { r.conformance = asm.worseConformance(r.conformance, j.conformance); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- Chair synthesis (provisional tally feeds the packet) ----
|
|
167
|
+
const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
|
|
168
|
+
runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
|
|
169
|
+
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
|
|
170
|
+
});
|
|
171
|
+
const provisionalInput = mkInput(null, o.chair);
|
|
172
|
+
const provisional = tally(provisionalInput);
|
|
173
|
+
|
|
174
|
+
const packet = stage2.buildChairPacket({
|
|
175
|
+
reviews: s1.reviews.map(r => ({ model: r.model, text: r.text })),
|
|
176
|
+
rankings: provisionalInput.rankings,
|
|
177
|
+
adjudications: provisionalInput.adjudications,
|
|
178
|
+
tierCounts: provisional.tierCounts,
|
|
179
|
+
});
|
|
180
|
+
fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
|
|
181
|
+
const attemptChair = async (model, waveId) => {
|
|
182
|
+
runState.appendStageWave(o.runDir, 'chair', waveId);
|
|
183
|
+
const solo = await launchers.launchSolo({
|
|
184
|
+
model, prompt: packet, project: o.runDir, waveId,
|
|
185
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
186
|
+
});
|
|
187
|
+
addWave(solo.wave);
|
|
188
|
+
const ok = solo.leg && solo.leg.status === 'complete'
|
|
189
|
+
&& solo.leg.summary && solo.leg.summary.trim();
|
|
190
|
+
return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let chairLeg = null;
|
|
194
|
+
let actualChair = null;
|
|
195
|
+
if (overBudget()) {
|
|
196
|
+
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
197
|
+
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
198
|
+
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
199
|
+
degraded.value = true;
|
|
200
|
+
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
201
|
+
} else {
|
|
202
|
+
runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
|
|
203
|
+
// Fallback chain (spec §4): retry same chair once → promote best
|
|
204
|
+
// non-bench model from the ledger → give up (no Claude fallback headless).
|
|
205
|
+
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
206
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
207
|
+
if (!attempt.leg && !overBudget()) {
|
|
208
|
+
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
209
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
210
|
+
}
|
|
211
|
+
if (attempt.leg) { actualChair = o.chair; }
|
|
212
|
+
else if (!overBudget()) {
|
|
213
|
+
let statsRows = [];
|
|
214
|
+
try { statsRows = statsFn(); } catch { /* no ledger yet */ }
|
|
215
|
+
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
216
|
+
if (fallback) {
|
|
217
|
+
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
218
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
219
|
+
if (attempt.leg) { actualChair = fallback; }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
chairLeg = attempt.leg;
|
|
223
|
+
runState.updateStage(o.runDir, 'chair',
|
|
224
|
+
{ status: chairLeg ? 'complete' : 'error', completedAt: now() });
|
|
225
|
+
// The chair chain may have promoted a fallback (or given up) — checkpoint
|
|
226
|
+
// the ACTUAL chair into run.json now so status/`--json`/the human summary
|
|
227
|
+
// never report the originally-requested chair after a promotion. Mirrors
|
|
228
|
+
// mkInput's actualChair || o.chair (a give-up with no actual chair keeps
|
|
229
|
+
// the requested chair).
|
|
230
|
+
runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
|
|
231
|
+
}
|
|
232
|
+
const chairText = chairLeg ? chairLeg.summary : null;
|
|
233
|
+
let chairConformance = 'clean';
|
|
234
|
+
|
|
235
|
+
// ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
|
|
236
|
+
let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
|
|
237
|
+
if (chairText && !overallVerdict && !overBudget()) {
|
|
238
|
+
runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
|
|
239
|
+
const repair = await launchers.launchSolo({
|
|
240
|
+
model: actualChair, prompt: stage2.buildChairRepairPrompt(),
|
|
241
|
+
project: o.runDir, waveId: `${o.runId}-ch4`,
|
|
242
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
243
|
+
});
|
|
244
|
+
addWave(repair.wave);
|
|
245
|
+
if (isAbortExit(repair.exitCode) || signalled) { return finalize(repair.exitCode || signalled); }
|
|
246
|
+
overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
|
|
247
|
+
chairConformance = overallVerdict ? 'repaired' : 'unstructured';
|
|
248
|
+
}
|
|
249
|
+
// A completed chair whose verdict never parsed is 'unstructured' even when
|
|
250
|
+
// the repair was skipped (e.g. the chair leg itself tripped --max-cost).
|
|
251
|
+
if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
|
|
252
|
+
if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
|
|
253
|
+
|
|
254
|
+
// ---- Final tally (chair row included) + ledger + artifacts ----
|
|
255
|
+
const chairStats = chairLeg ? asm.buildRunStatsEntry({
|
|
256
|
+
leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
|
|
257
|
+
conformance: chairConformance,
|
|
258
|
+
}) : null;
|
|
259
|
+
const finalInput = mkInput(chairStats, actualChair || o.chair);
|
|
260
|
+
const record = tally(finalInput);
|
|
261
|
+
if (!o.lenses) {
|
|
262
|
+
// Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
|
|
263
|
+
try { appendRunFn(record); }
|
|
264
|
+
catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
|
|
265
|
+
}
|
|
266
|
+
asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
|
|
267
|
+
runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
|
|
268
|
+
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
|
|
269
|
+
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
270
|
+
|
|
271
|
+
return finalize(degraded.value ? 2 : 0);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return finalize(1, { code: 'INTERNAL', message: err.message });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { runCouncil, pickFallbackChair, SIGNAL_EXIT };
|
package/src/council/tally.js
CHANGED
|
@@ -66,7 +66,8 @@ function computeStreetCred(rankings, models) {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// v4.0 §7: council family v2 — every council doc carries {schemaVersion, type}.
|
|
70
|
+
const COUNCIL_SCHEMA_VERSION = 2;
|
|
70
71
|
const VERDICTS = { agree: 'a', dispute: 'd', neutral: 'n' };
|
|
71
72
|
|
|
72
73
|
function countTiers(findings) {
|
|
@@ -106,6 +107,7 @@ function tally(input) {
|
|
|
106
107
|
});
|
|
107
108
|
return {
|
|
108
109
|
schemaVersion: COUNCIL_SCHEMA_VERSION,
|
|
110
|
+
type: 'council-tally',
|
|
109
111
|
meta,
|
|
110
112
|
judged: Array.isArray(rankings) && rankings.length >= 2,
|
|
111
113
|
streetCred: computeStreetCred(rankings || [], meta.models),
|