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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +15 -3
  4. package/electron/main.js +4 -1
  5. package/package.json +3 -1
  6. package/schemas/abort.schema.json +17 -0
  7. package/schemas/alias-audit.schema.json +17 -0
  8. package/schemas/council-run.schema.json +37 -0
  9. package/schemas/council-stats.schema.json +28 -0
  10. package/schemas/council-tally.schema.json +70 -0
  11. package/schemas/council-validate.schema.json +22 -0
  12. package/schemas/council-verdict.schema.json +47 -0
  13. package/schemas/doctor.schema.json +29 -0
  14. package/schemas/error.schema.json +23 -0
  15. package/schemas/model-catalog.schema.json +19 -0
  16. package/schemas/run.schema.json +26 -0
  17. package/schemas/spend.schema.json +16 -0
  18. package/schemas/wave.schema.json +33 -0
  19. package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
  20. package/skills/second-opinion/SKILL.md +8 -0
  21. package/src/cli-handlers-abort.js +29 -0
  22. package/src/cli-handlers-council-run.js +168 -0
  23. package/src/cli-handlers-council.js +8 -5
  24. package/src/cli-handlers-status.js +35 -4
  25. package/src/cli.js +9 -0
  26. package/src/council/anonymize.js +76 -0
  27. package/src/council/briefings-stage2.js +150 -0
  28. package/src/council/briefings.js +141 -0
  29. package/src/council/findings.js +13 -1
  30. package/src/council/ledger.js +13 -1
  31. package/src/council/parse-stage2.js +103 -0
  32. package/src/council/run-assemble.js +100 -0
  33. package/src/council/run-launch.js +99 -0
  34. package/src/council/run-stages.js +187 -0
  35. package/src/council/run-state.js +122 -0
  36. package/src/council/run.js +269 -0
  37. package/src/council/tally.js +3 -1
  38. package/src/council/verdict.js +9 -2
  39. package/src/headless.js +24 -25
  40. package/src/mcp-council-run.js +267 -0
  41. package/src/mcp-server.js +87 -28
  42. package/src/mcp-tools.js +50 -0
  43. package/src/prompt-builder.js +36 -19
  44. package/src/sidecar/electron-lock.js +4 -1
  45. package/src/sidecar/fanout-leg.js +2 -2
  46. package/src/sidecar/fanout.js +1 -1
  47. package/src/sidecar/resume.js +7 -2
  48. package/src/utils/abort-result.js +1 -1
  49. package/src/utils/error-doc.js +2 -0
  50. package/src/utils/fold-marker.js +21 -0
  51. package/src/utils/route-error.js +26 -0
  52. package/src/utils/start-helpers.js +19 -10
  53. 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 };
@@ -66,7 +66,8 @@ function computeStreetCred(rankings, models) {
66
66
  });
67
67
  }
68
68
 
69
- const COUNCIL_SCHEMA_VERSION = 1;
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),
@@ -2,23 +2,30 @@
2
2
  'use strict';
3
3
  const fs = require('fs');
4
4
 
5
- const VERDICT_SCHEMA_VERSION = 1;
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
- * (see extractSummary/formatFoldOutput's no-nonce fallback paths below).
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} [nonce] - This run's fold nonce (15b.3). When omitted, falls
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 = nonce ? findTrailingFoldMarker(output, nonce) : findLegacyBareTrailingMarker(output);
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} [options.nonce] - This run's fold nonce (15b.3). When omitted,
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
- nonce ? buildFoldMarker(nonce) : FOLD_MARKER,
772
+ buildFoldMarker(nonce),
774
773
  `Model: ${model}`,
775
774
  `Session: ${sessionId}`,
776
775
  `Client: ${client || 'code-local'}`,