amicus 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +78 -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 +37 -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 +187 -0
- package/src/council/run-state.js +122 -0
- package/src/council/run.js +269 -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-run.js +267 -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/electron-lock.js +4 -1
- 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,122 @@
|
|
|
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
|
+
|
|
24
|
+
function runPath(runDir) { return path.join(runDir, RUN_FILE); }
|
|
25
|
+
|
|
26
|
+
/** @returns {object|null} parsed run.json, or null when missing/corrupt */
|
|
27
|
+
function readRun(runDir) {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(runPath(runDir), 'utf-8')); }
|
|
29
|
+
catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Abort-wins merge: once status is 'aborted', it cannot change to anything else. */
|
|
33
|
+
function mergeRun(existing, patch) {
|
|
34
|
+
const merged = { ...existing, ...patch };
|
|
35
|
+
// If the prior run was aborted, preserve that status regardless of patch content
|
|
36
|
+
// (prevents falsy status values, null, '', or omitted status from overwriting)
|
|
37
|
+
if (existing.status === 'aborted' || patch.status === 'aborted') {
|
|
38
|
+
merged.status = 'aborted';
|
|
39
|
+
// An aborted run is terminal: never let a later (racing) finalize report a
|
|
40
|
+
// clean/degraded exitCode. Prefer a recorded abort code, then a patch abort
|
|
41
|
+
// code, else default SIGTERM's 143.
|
|
42
|
+
const prior = [130, 143].includes(existing.exitCode) ? existing.exitCode : null;
|
|
43
|
+
const fromPatch = [130, 143].includes(patch.exitCode) ? patch.exitCode : null;
|
|
44
|
+
merged.exitCode = prior || fromPatch || 143;
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeRun(runDir, run) {
|
|
50
|
+
writeFileAtomic(runPath(runDir), JSON.stringify(run, null, 2), { mode: 0o600 });
|
|
51
|
+
return run;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create (or merge into) run.json; preserves an existing createdAt. */
|
|
55
|
+
function initRun(runDir, seed) {
|
|
56
|
+
fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const existing = readRun(runDir) || {};
|
|
58
|
+
const run = mergeRun(existing, {
|
|
59
|
+
...seed,
|
|
60
|
+
createdAt: existing.createdAt || seed.createdAt,
|
|
61
|
+
});
|
|
62
|
+
return writeRun(runDir, run);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read-merge-write checkpoint (atomic; abort-wins on status). */
|
|
66
|
+
function checkpoint(runDir, patch) {
|
|
67
|
+
const existing = readRun(runDir) || {};
|
|
68
|
+
return writeRun(runDir, mergeRun(existing, patch));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Upsert one stages[] entry by name; other stages and order preserved. */
|
|
72
|
+
function updateStage(runDir, name, patch) {
|
|
73
|
+
const existing = readRun(runDir) || {};
|
|
74
|
+
const stages = Array.isArray(existing.stages) ? existing.stages.slice() : [];
|
|
75
|
+
const i = stages.findIndex(s => s && s.name === name);
|
|
76
|
+
if (i === -1) { stages.push({ name, ...patch }); }
|
|
77
|
+
else { stages[i] = { ...stages[i], ...patch }; }
|
|
78
|
+
return checkpoint(runDir, { stages });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripPrefix(runId) { return String(runId).replace(/^council-/, ''); }
|
|
82
|
+
|
|
83
|
+
/** `<project>/.claude/amicus_sessions/council-<runId>.json` */
|
|
84
|
+
function pointerPath(project, runId) {
|
|
85
|
+
return path.join(project, '.claude', SESSIONS_DIR, `council-${stripPrefix(runId)}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writePointer(project, runId, runDir) {
|
|
89
|
+
const p = pointerPath(project, runId);
|
|
90
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
91
|
+
writeFileAtomic(p, JSON.stringify({ runId: stripPrefix(runId), runDir }, null, 2), { mode: 0o600 });
|
|
92
|
+
return p;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @returns {{runId: string, runDir: string}|null} */
|
|
96
|
+
function readPointer(project, runId) {
|
|
97
|
+
try {
|
|
98
|
+
const ptr = JSON.parse(fs.readFileSync(pointerPath(project, runId), 'utf-8'));
|
|
99
|
+
return (ptr && ptr.runId && ptr.runDir) ? ptr : null;
|
|
100
|
+
} catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** All council pointers in the project sessions dir. */
|
|
104
|
+
function listPointers(project) {
|
|
105
|
+
const root = path.join(project, '.claude', SESSIONS_DIR);
|
|
106
|
+
let names = [];
|
|
107
|
+
try { names = fs.readdirSync(root); } catch { return []; }
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const n of names) {
|
|
110
|
+
if (!/^council-[a-zA-Z0-9_-]{1,64}\.json$/.test(n)) { continue; }
|
|
111
|
+
try {
|
|
112
|
+
const ptr = JSON.parse(fs.readFileSync(path.join(root, n), 'utf-8'));
|
|
113
|
+
if (ptr && ptr.runId && ptr.runDir) { out.push(ptr); }
|
|
114
|
+
} catch { /* skip corrupt pointer */ }
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
RUN_FILE, readRun, initRun, checkpoint, updateStage,
|
|
121
|
+
pointerPath, writePointer, readPointer, listPointers,
|
|
122
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
runState.updateStage(o.runDir, 'stage1',
|
|
114
|
+
{ status: 'running', startedAt: now(), waveId: `${o.runId}-s1`, project: o.runDir });
|
|
115
|
+
const s1 = await runStage1(ctx);
|
|
116
|
+
runState.updateStage(o.runDir, 'stage1', {
|
|
117
|
+
status: 'complete', completedAt: now(),
|
|
118
|
+
taskIds: s1.reviews.map(r => (r.leg && r.leg.taskId)).filter(Boolean),
|
|
119
|
+
});
|
|
120
|
+
if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
|
|
121
|
+
if (s1.deadLegs.length > 0) { degraded.value = true; } // bench shrank → never a "full run"
|
|
122
|
+
if (s1.reviews.length < 2) {
|
|
123
|
+
return finalize(1, {
|
|
124
|
+
code: 'COUNCIL_QUORUM',
|
|
125
|
+
message: `Only ${s1.reviews.length} Stage-1 review(s) survived; a council needs at least 2`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
|
|
130
|
+
if (overBudget()) {
|
|
131
|
+
return finalize(1, {
|
|
132
|
+
code: 'COST_EXCEEDED',
|
|
133
|
+
message: `Cost ceiling $${o.maxCost} reached before cross-review; no tally exists`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- Stage 2: anonymized cross-review ----
|
|
138
|
+
const labels = assignLabels(s1.reviews.map(r => r.model));
|
|
139
|
+
runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
|
|
140
|
+
// Attach each review's run-global findings (buildTallyInput reads
|
|
141
|
+
// r.globalFindings per review, not a bare parallel array).
|
|
142
|
+
s1.reviews.forEach((r, i) => {
|
|
143
|
+
r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
|
|
144
|
+
});
|
|
145
|
+
const globalFindings = s1.reviews.flatMap(r => r.globalFindings);
|
|
146
|
+
runState.updateStage(o.runDir, 'stage2',
|
|
147
|
+
{ status: 'running', startedAt: now(), waveId: `${o.runId}-s2`, project: ctx.scratchDir });
|
|
148
|
+
const s2 = await runStage2(ctx, { reviews: s1.reviews, labels, globalFindings });
|
|
149
|
+
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
150
|
+
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
151
|
+
if (s2.judgeResults.filter(j => j.ok).length < 2) { degraded.value = true; } // thin cross-review
|
|
152
|
+
|
|
153
|
+
// Merge Stage-2 judging conformance into each seat's row (worst wins).
|
|
154
|
+
const byJudge = new Map(s2.judgeResults.map(j => [j.judge, j]));
|
|
155
|
+
for (const r of s1.reviews) {
|
|
156
|
+
const j = byJudge.get(r.model);
|
|
157
|
+
if (j) { r.conformance = asm.worseConformance(r.conformance, j.conformance); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Chair synthesis (provisional tally feeds the packet) ----
|
|
161
|
+
const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
|
|
162
|
+
runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
|
|
163
|
+
reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats,
|
|
164
|
+
});
|
|
165
|
+
const provisionalInput = mkInput(null, o.chair);
|
|
166
|
+
const provisional = tally(provisionalInput);
|
|
167
|
+
|
|
168
|
+
const packet = stage2.buildChairPacket({
|
|
169
|
+
reviews: s1.reviews.map(r => ({ model: r.model, text: r.text })),
|
|
170
|
+
rankings: provisionalInput.rankings,
|
|
171
|
+
adjudications: provisionalInput.adjudications,
|
|
172
|
+
tierCounts: provisional.tierCounts,
|
|
173
|
+
});
|
|
174
|
+
fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
|
|
175
|
+
const attemptChair = async (model, waveId) => {
|
|
176
|
+
const solo = await launchers.launchSolo({
|
|
177
|
+
model, prompt: packet, project: o.runDir, waveId,
|
|
178
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
179
|
+
});
|
|
180
|
+
addWave(solo.wave);
|
|
181
|
+
const ok = solo.leg && solo.leg.status === 'complete'
|
|
182
|
+
&& solo.leg.summary && solo.leg.summary.trim();
|
|
183
|
+
return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
let chairLeg = null;
|
|
187
|
+
let actualChair = null;
|
|
188
|
+
if (overBudget()) {
|
|
189
|
+
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
190
|
+
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
191
|
+
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
192
|
+
degraded.value = true;
|
|
193
|
+
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
194
|
+
} else {
|
|
195
|
+
runState.updateStage(o.runDir, 'chair', { status: 'running', startedAt: now(), project: o.runDir });
|
|
196
|
+
// Fallback chain (spec §4): retry same chair once → promote best
|
|
197
|
+
// non-bench model from the ledger → give up (no Claude fallback headless).
|
|
198
|
+
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
199
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
200
|
+
if (!attempt.leg && !overBudget()) {
|
|
201
|
+
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
202
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
203
|
+
}
|
|
204
|
+
if (attempt.leg) { actualChair = o.chair; }
|
|
205
|
+
else if (!overBudget()) {
|
|
206
|
+
let statsRows = [];
|
|
207
|
+
try { statsRows = statsFn(); } catch { /* no ledger yet */ }
|
|
208
|
+
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
209
|
+
if (fallback) {
|
|
210
|
+
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
211
|
+
if (isAbortExit(attempt.exitCode) || signalled) { return finalize(attempt.exitCode || signalled); }
|
|
212
|
+
if (attempt.leg) { actualChair = fallback; }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
chairLeg = attempt.leg;
|
|
216
|
+
runState.updateStage(o.runDir, 'chair',
|
|
217
|
+
{ status: chairLeg ? 'complete' : 'error', completedAt: now() });
|
|
218
|
+
// The chair chain may have promoted a fallback (or given up) — checkpoint
|
|
219
|
+
// the ACTUAL chair into run.json now so status/`--json`/the human summary
|
|
220
|
+
// never report the originally-requested chair after a promotion. Mirrors
|
|
221
|
+
// mkInput's actualChair || o.chair (a give-up with no actual chair keeps
|
|
222
|
+
// the requested chair).
|
|
223
|
+
runState.checkpoint(o.runDir, { chair: actualChair || o.chair });
|
|
224
|
+
}
|
|
225
|
+
const chairText = chairLeg ? chairLeg.summary : null;
|
|
226
|
+
let chairConformance = 'clean';
|
|
227
|
+
|
|
228
|
+
// ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
|
|
229
|
+
let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
|
|
230
|
+
if (chairText && !overallVerdict && !overBudget()) {
|
|
231
|
+
const repair = await launchers.launchSolo({
|
|
232
|
+
model: actualChair, prompt: stage2.buildChairRepairPrompt(),
|
|
233
|
+
project: o.runDir, waveId: `${o.runId}-ch4`,
|
|
234
|
+
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
235
|
+
});
|
|
236
|
+
addWave(repair.wave);
|
|
237
|
+
if (isAbortExit(repair.exitCode) || signalled) { return finalize(repair.exitCode || signalled); }
|
|
238
|
+
overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
|
|
239
|
+
chairConformance = overallVerdict ? 'repaired' : 'unstructured';
|
|
240
|
+
}
|
|
241
|
+
// A completed chair whose verdict never parsed is 'unstructured' even when
|
|
242
|
+
// the repair was skipped (e.g. the chair leg itself tripped --max-cost).
|
|
243
|
+
if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
|
|
244
|
+
if (!chairLeg || !overallVerdict) { degraded.value = true; } // spec table: exit 2 rows
|
|
245
|
+
|
|
246
|
+
// ---- Final tally (chair row included) + ledger + artifacts ----
|
|
247
|
+
const chairStats = chairLeg ? asm.buildRunStatsEntry({
|
|
248
|
+
leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
|
|
249
|
+
conformance: chairConformance,
|
|
250
|
+
}) : null;
|
|
251
|
+
const finalInput = mkInput(chairStats, actualChair || o.chair);
|
|
252
|
+
const record = tally(finalInput);
|
|
253
|
+
if (!o.lenses) {
|
|
254
|
+
// Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
|
|
255
|
+
try { appendRunFn(record); }
|
|
256
|
+
catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
|
|
257
|
+
}
|
|
258
|
+
asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
|
|
259
|
+
runState.updateStage(o.runDir, 'tally', { status: 'complete', completedAt: now() });
|
|
260
|
+
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
|
|
261
|
+
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
262
|
+
|
|
263
|
+
return finalize(degraded.value ? 2 : 0);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return finalize(1, { code: 'INTERNAL', message: err.message });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
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),
|
package/src/council/verdict.js
CHANGED
|
@@ -2,23 +2,30 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
|
|
6
|
+
// nullable overallVerdict (the chair's Ship-it line; populated by the headless
|
|
7
|
+
// engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
|
|
8
|
+
const VERDICT_SCHEMA_VERSION = 2;
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
9
12
|
* @param {object} record tally() output
|
|
10
13
|
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
14
|
+
* @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
|
|
15
|
+
* parsed chair `VERDICT:` line; omitted/undefined → null.
|
|
11
16
|
*/
|
|
12
|
-
function buildVerdict(record, decisions = []) {
|
|
17
|
+
function buildVerdict(record, decisions = [], opts = {}) {
|
|
13
18
|
const byId = new Map(decisions.map(d => [d.id, d]));
|
|
14
19
|
return {
|
|
15
20
|
schemaVersion: VERDICT_SCHEMA_VERSION,
|
|
21
|
+
type: 'council-verdict',
|
|
16
22
|
runId: record.meta.runId,
|
|
17
23
|
runType: record.meta.runType,
|
|
18
24
|
date: record.meta.date,
|
|
19
25
|
chair: record.meta.chair,
|
|
20
26
|
council: record.meta.models,
|
|
21
27
|
claudeInCouncil: record.meta.claudeInCouncil,
|
|
28
|
+
overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
|
|
22
29
|
findings: record.findings.map(f => {
|
|
23
30
|
const d = byId.get(f.id) || {};
|
|
24
31
|
const tierOverride = d.tierOverride || f.tierOverride || null;
|
package/src/headless.js
CHANGED
|
@@ -22,7 +22,8 @@ const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require(
|
|
|
22
22
|
*
|
|
23
23
|
* #BL-7 residual (15b.3): the bare `[SIDECAR_FOLD]` string is now a LEGACY
|
|
24
24
|
* literal only, kept exported for external consumers with no nonce context
|
|
25
|
-
* (
|
|
25
|
+
* (the no-nonce fallback paths were retired in v4.0 §9 — the constant is
|
|
26
|
+
* export-only back-compat now).
|
|
26
27
|
* NOTE for anyone `.toContain('[SIDECAR_FOLD]')`-checking real run output:
|
|
27
28
|
* that substring check does NOT match the real nonced marker — a nonced
|
|
28
29
|
* marker is `[SIDECAR_FOLD:<nonce>]`, which lacks the literal closing
|
|
@@ -716,46 +717,42 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
716
717
|
* Extract summary from output (everything before the trailing fold marker)
|
|
717
718
|
* Spec Reference: §6.2 - Return summary (everything before the fold marker)
|
|
718
719
|
*
|
|
720
|
+
* v4.0 §9 (BL-7 done-done): `nonce` is REQUIRED for any non-empty output.
|
|
721
|
+
* The pre-15b.3 no-nonce fallback (matching the legacy bare `[SIDECAR_FOLD]`
|
|
722
|
+
* marker) is retired — no code path, internal or external, may complete on a
|
|
723
|
+
* bare marker. Callers with no nonce have no valid marker to split on and
|
|
724
|
+
* must not call this.
|
|
725
|
+
*
|
|
719
726
|
* @param {string} output - Raw output from OpenCode
|
|
720
|
-
* @param {string}
|
|
721
|
-
* back to matching the LEGACY bare `[SIDECAR_FOLD]` marker — this keeps
|
|
722
|
-
* extractSummary usable as a standalone string utility (e.g. re-processing
|
|
723
|
-
* output captured before the nonce scheme, or a caller that genuinely has
|
|
724
|
-
* no nonce context) without ever accepting a WRONG nonce as a match.
|
|
727
|
+
* @param {string} nonce - This run's fold nonce (required for non-empty output)
|
|
725
728
|
* @returns {string} Extracted summary
|
|
729
|
+
* @throws {TypeError} when output is non-empty and nonce is missing/empty
|
|
726
730
|
*/
|
|
727
731
|
function extractSummary(output, nonce) {
|
|
728
732
|
if (!output) {
|
|
729
733
|
return '';
|
|
730
734
|
}
|
|
735
|
+
if (!nonce) {
|
|
736
|
+
throw new TypeError('extractSummary requires a per-run nonce (15b.3/v4.0 §9)');
|
|
737
|
+
}
|
|
731
738
|
|
|
732
739
|
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
733
740
|
// A marker echoed mid-output (describing code, reproducing these
|
|
734
741
|
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
735
742
|
// content. Only the true trailing marker is stripped.
|
|
736
|
-
const idx =
|
|
743
|
+
const idx = findTrailingFoldMarker(output, nonce);
|
|
737
744
|
if (idx !== -1) {
|
|
738
745
|
return output.slice(0, idx).trim();
|
|
739
746
|
}
|
|
740
747
|
return output.trim();
|
|
741
748
|
}
|
|
742
749
|
|
|
743
|
-
/**
|
|
744
|
-
* Legacy bare-marker trailing match (`[SIDECAR_FOLD]`, no nonce) — the
|
|
745
|
-
* pre-15b.3 behavior, kept only for extractSummary's no-nonce fallback path.
|
|
746
|
-
* NEVER used by runHeadless's own detection (that always carries a nonce —
|
|
747
|
-
* see findTrailingFoldMarker), so no live completion path can be forced by a
|
|
748
|
-
* bare marker.
|
|
749
|
-
* @param {string} output
|
|
750
|
-
* @returns {number}
|
|
751
|
-
*/
|
|
752
|
-
function findLegacyBareTrailingMarker(output) {
|
|
753
|
-
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
754
|
-
return m ? m.index : -1;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
750
|
/**
|
|
758
751
|
* Format a structured fold output with metadata
|
|
752
|
+
* v4.0 §9: `nonce` is REQUIRED — the pre-15b.3 bare-`[SIDECAR_FOLD]` writer
|
|
753
|
+
* fallback is retired; the bare literal is never written by any path. The
|
|
754
|
+
* FOLD_MARKER/COMPLETE_MARKER constants remain exported for external
|
|
755
|
+
* consumers' greps/back-compat only (docs/SHIMS.md).
|
|
759
756
|
* @param {Object} options - Fold output options
|
|
760
757
|
* @param {string} options.model - Model identifier
|
|
761
758
|
* @param {string} options.sessionId - Session identifier
|
|
@@ -763,14 +760,16 @@ function findLegacyBareTrailingMarker(output) {
|
|
|
763
760
|
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
764
761
|
* @param {string} [options.mode='headless'] - Execution mode
|
|
765
762
|
* @param {string} options.summary - Summary text
|
|
766
|
-
* @param {string}
|
|
767
|
-
* falls back to the legacy bare `[SIDECAR_FOLD]` marker for back-compat with
|
|
768
|
-
* external callers of this exported utility that predate the nonce scheme.
|
|
763
|
+
* @param {string} options.nonce - This run's fold nonce (required)
|
|
769
764
|
* @returns {string} Formatted fold output
|
|
765
|
+
* @throws {TypeError} when nonce is missing/empty
|
|
770
766
|
*/
|
|
771
767
|
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary, nonce }) {
|
|
768
|
+
if (!nonce) {
|
|
769
|
+
throw new TypeError('formatFoldOutput requires a per-run nonce (15b.3/v4.0 §9)');
|
|
770
|
+
}
|
|
772
771
|
return [
|
|
773
|
-
|
|
772
|
+
buildFoldMarker(nonce),
|
|
774
773
|
`Model: ${model}`,
|
|
775
774
|
`Session: ${sessionId}`,
|
|
776
775
|
`Client: ${client || 'code-local'}`,
|