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,141 @@
1
+ // src/council/briefings.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/briefings
6
+ * Pure Stage-1 briefing template builders for the headless council engine
7
+ * (spec §5), adapted from skills/second-opinion/SEAT-BRIEFS.md with stricter
8
+ * headless output contracts. No IO, no model calls. Stage-2 / chair templates
9
+ * live in ./briefings-stage2.js (300-line gate split).
10
+ */
11
+
12
+ /** SEAT-BRIEFS.md standard anti-sycophancy clause — verbatim, EVERY Stage-1 briefing. */
13
+ const ANTI_SYCOPHANCY_CLAUSE =
14
+ 'Do not soften findings to be agreeable. Lead with your most severe finding. ' +
15
+ 'No praise cushions before criticism, and never perform enthusiasm you don\'t hold — ' +
16
+ 'if the artifact is mediocre, say so and show why. Do not pad: report every real ' +
17
+ 'finding and no invented ones. An empty severity category is a valid result.';
18
+
19
+ /**
20
+ * "Produce exactly two things" framing (prose review, THEN the trailing json
21
+ * block). Stage-1 builders need this. The findings-repair prompt deliberately
22
+ * does NOT use it — a repair turn wants ONLY the corrected json, never a
23
+ * fresh prose review (keeping this fragment out of the repair prompt fixes
24
+ * the prior self-contradiction: "re-emit ONLY the JSON" followed by this
25
+ * "write prose then json" framing).
26
+ */
27
+ const FINDINGS_TWO_PART_FRAMING = [
28
+ 'Produce exactly two things, in this order:',
29
+ '',
30
+ '1. A prose review — your full narrative assessment of the material.',
31
+ '2. A trailing fenced ```json block immediately after the prose — no text after it:',
32
+ ].join('\n');
33
+
34
+ /** JSON shape + field rules — shared by the full Stage-1 contract AND the repair prompt. */
35
+ const FINDINGS_JSON_SHAPE = [
36
+ '```json',
37
+ '{',
38
+ ' "overall": "one-paragraph take",',
39
+ ' "findings": [',
40
+ ' { "id": 1, "severity": "blocker",',
41
+ ' "claim": "…", "location": "…", "rationale": "…" }',
42
+ ' ]',
43
+ '}',
44
+ '```',
45
+ '',
46
+ '- "id" — sequential integer within this review, starting at 1.',
47
+ '- "severity" — one of: blocker | major | minor | nit.',
48
+ '- "claim", "location", "rationale" — non-empty strings.',
49
+ 'Emit the JSON verbatim after the prose, without preamble, so it parses cleanly.',
50
+ ].join('\n');
51
+
52
+ /** Strict headless structured-output contract (prose + trailing ```json findings block). */
53
+ const FINDINGS_CONTRACT = [FINDINGS_TWO_PART_FRAMING, FINDINGS_JSON_SHAPE].join('\n\n');
54
+
55
+ /** Adapted from SEAT-BRIEFS.md § Critic seat brief (quota deliberately absent). */
56
+ const CRITIC_BRIEF = [
57
+ 'You are this review bench\'s designated critic. Assume problems exist; your job is to',
58
+ 'find them, not to confirm the material is fine. Work through four passes and fold',
59
+ 'everything into one findings list:',
60
+ '',
61
+ '1. Adversarial pass — for every claim: what evidence supports it, or is it an',
62
+ ' assumption presented as fact? For every goal: is it measurable? For every decision:',
63
+ ' what alternatives were considered? For every scope boundary: real constraint, or',
64
+ ' avoidance of hard work?',
65
+ '2. Edge-case hunt — walk every journey, requirement, and scenario. What happens on the',
66
+ ' unexpected input, the failed integration, the malformed data? At zero, at one, at',
67
+ ' scale? Report only unhandled cases.',
68
+ '3. Consistency check — cross-reference sections against each other: do goals have',
69
+ ' metrics, and metrics targets? Do requirements trace back to stated needs? Does',
70
+ ' anything contradict a stated non-goal or constraint?',
71
+ '4. Executability test — could someone act on this material without coming back with',
72
+ ' clarifying questions? Wherever the answer is no, name the specific section and',
73
+ ' exactly what is missing.',
74
+ '',
75
+ 'Be specific: name the section, the line, the exact gap. Report every real finding and',
76
+ 'no invented ones; do not pad to look thorough. An empty pass is a valid result.',
77
+ ].join('\n');
78
+
79
+ function dateLine(date) {
80
+ return `Today's date is ${date}.`;
81
+ }
82
+
83
+ function compose(role, { briefing, date }) {
84
+ return [
85
+ role,
86
+ ANTI_SYCOPHANCY_CLAUSE,
87
+ dateLine(date),
88
+ FINDINGS_CONTRACT,
89
+ '--- MATERIAL / BRIEFING ---',
90
+ briefing,
91
+ ].join('\n\n');
92
+ }
93
+
94
+ /** Standard seat briefing (Stage-1 fanout wave). */
95
+ function buildSeatBriefing(args) {
96
+ return compose(
97
+ 'You are one reviewer on an independent multi-model review bench. Review the material ' +
98
+ 'below against the briefing\'s own criteria.',
99
+ args
100
+ );
101
+ }
102
+
103
+ /** Critic seat briefing (concurrent solo — spec §4 --critic). */
104
+ function buildCriticBriefing(args) {
105
+ return compose(CRITIC_BRIEF, args);
106
+ }
107
+
108
+ /** Expert-lens briefing (concurrent solo per seat — spec §4 --lenses). */
109
+ function buildLensBriefing({ lens, briefing, date }) {
110
+ return compose(
111
+ `Review this material strictly through the lens of a ${lens}. Raise only findings ` +
112
+ 'that perspective is qualified to raise, at the depth a top practitioner of it would ' +
113
+ 'reach. Stay in-domain: if something matters but is outside your lens, leave it to ' +
114
+ 'the other reviewers.',
115
+ { briefing, date }
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Bounded findings-repair re-prompt (solo; ≤ 2 per seat — SKILL.md Stage-1
121
+ * repair loop). References ONLY the json-shape fragment — never the
122
+ * "prose review THEN json" framing — so a headless model isn't handed license
123
+ * to write a whole new prose review on a tight repair turn.
124
+ */
125
+ function buildFindingsRepairPrompt({ errors }) {
126
+ const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
127
+ return [
128
+ 'Do NOT use any tools or read any files; everything is in this message; begin ' +
129
+ 'immediately with the JSON block.',
130
+ 'Your previous review\'s trailing findings JSON failed validation with these errors:',
131
+ lines,
132
+ 'Re-emit ONLY the corrected findings JSON block (the same findings, fixed — do not ' +
133
+ 'add or remove findings), as a single fenced ```json block:',
134
+ FINDINGS_JSON_SHAPE,
135
+ ].join('\n\n');
136
+ }
137
+
138
+ module.exports = {
139
+ ANTI_SYCOPHANCY_CLAUSE, FINDINGS_CONTRACT, FINDINGS_JSON_SHAPE, FINDINGS_TWO_PART_FRAMING,
140
+ buildSeatBriefing, buildCriticBriefing, buildLensBriefing, buildFindingsRepairPrompt,
141
+ };
@@ -45,4 +45,16 @@ function validateFindings(jsonText) {
45
45
  return { ok: errors.length === 0, findings: errors.length === 0 ? findings : [], errors };
46
46
  }
47
47
 
48
- module.exports = { validateFindings, SEVERITIES };
48
+ /**
49
+ * v4.0 §7: stamp the council v2 envelope onto a validateFindings result
50
+ * (additive — ok/findings/errors stay top-level; existing key-readers keep
51
+ * working). Used by `amicus council validate --json`.
52
+ * @param {{ok:boolean, findings:Array, errors:Array}} result
53
+ * @returns {object} enveloped validate doc
54
+ */
55
+ function buildValidateDoc(result) {
56
+ const { COUNCIL_SCHEMA_VERSION } = require('./tally');
57
+ return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-validate', ...result };
58
+ }
59
+
60
+ module.exports = { validateFindings, buildValidateDoc, SEVERITIES, lastJsonBlock };
@@ -79,4 +79,16 @@ function deriveReliability(opts = {}) {
79
79
  });
80
80
  }
81
81
 
82
- module.exports = { buildLedgerRows, appendRun, deriveReliability, LEDGER_FILE, LEDGER_SCHEMA_VERSION };
82
+ /**
83
+ * v4.0 §7: wrap the deriveReliability() rows in the council v2 envelope —
84
+ * THE one sanctioned breaking shape change (`council stats --json` used to
85
+ * emit the bare array). Human rendering keeps consuming the bare rows.
86
+ * @param {Array<object>} models deriveReliability() output
87
+ * @returns {{schemaVersion: number, type: 'council-stats', models: Array<object>}}
88
+ */
89
+ function buildStatsDoc(models) {
90
+ const { COUNCIL_SCHEMA_VERSION } = require('./tally');
91
+ return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-stats', models };
92
+ }
93
+
94
+ module.exports = { buildLedgerRows, appendRun, deriveReliability, buildStatsDoc, LEDGER_FILE, LEDGER_SCHEMA_VERSION };
@@ -0,0 +1,103 @@
1
+ // src/council/parse-stage2.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/parse-stage2
6
+ * Stage-2 output parsing for the headless council engine (spec §5): the
7
+ * judge's trailing JSON block ({ranking, adjudications}) and the chair's
8
+ * final `VERDICT:` line. Shares last-JSON-block extraction with findings.js.
9
+ * Pure — the ≤2-repair loop lives in run-stages.js; the tri-state
10
+ * (clean|repaired|unstructured) is recorded by the driver.
11
+ */
12
+
13
+ const { lastJsonBlock } = require('./findings');
14
+
15
+ const JUDGE_VERDICTS = ['agree', 'dispute', 'neutral'];
16
+ const CHAIR_VERDICTS = ['Ship it', 'Fix these first', 'Fundamental rethink'];
17
+
18
+ /**
19
+ * Parse + shape-validate one judge's output.
20
+ * @param {string} text raw judge output (prose + trailing ```json block)
21
+ * @param {{labels: string[], findingIds: string[]}} ctx known labels/ids
22
+ * @returns {{ok: boolean, ranking: Array|null, adjudications: Array|null,
23
+ * errors: Array<{code: string, detail: string}>}}
24
+ */
25
+ function parseJudgeOutput(text, { labels, findingIds }) {
26
+ const fail = (errors) => ({ ok: false, ranking: null, adjudications: null, errors });
27
+ const body = lastJsonBlock(text || '');
28
+ if (body === null) {
29
+ return fail([{ code: 'NO_FENCED_BLOCK', detail: 'no ```json block found' }]);
30
+ }
31
+ let parsed;
32
+ try { parsed = JSON.parse(body); }
33
+ catch (e) { return fail([{ code: 'NOT_PARSEABLE', detail: e.message }]); }
34
+
35
+ const errors = [];
36
+ const known = new Set(labels);
37
+ const flat = [];
38
+ if (!Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
39
+ errors.push({ code: 'BAD_RANKING', detail: 'ranking must be a non-empty array of review labels' });
40
+ } else {
41
+ for (const slot of parsed.ranking) {
42
+ for (const label of (Array.isArray(slot) ? slot : [slot])) {
43
+ if (!known.has(label)) {
44
+ errors.push({ code: 'UNKNOWN_LABEL', detail: `unknown review label '${label}'` });
45
+ }
46
+ flat.push(label);
47
+ }
48
+ }
49
+ if (new Set(flat).size !== flat.length) {
50
+ errors.push({ code: 'DUPLICATE_LABEL', detail: 'a review label appears more than once' });
51
+ }
52
+ }
53
+
54
+ const knownIds = new Set(findingIds);
55
+ if (!Array.isArray(parsed.adjudications)) {
56
+ errors.push({ code: 'BAD_ADJUDICATIONS', detail: 'adjudications must be an array' });
57
+ } else {
58
+ for (const a of parsed.adjudications) {
59
+ const id = a && a.id;
60
+ if (!knownIds.has(id)) {
61
+ errors.push({ code: 'UNKNOWN_FINDING_ID', detail: `unknown finding id '${id}'` });
62
+ }
63
+ if (!a || !JUDGE_VERDICTS.includes(a.verdict)) {
64
+ errors.push({ code: 'BAD_VERDICT', detail: `bad verdict '${a && a.verdict}' on '${id}'` });
65
+ }
66
+ }
67
+ }
68
+
69
+ if (errors.length) { return fail(errors); }
70
+ return { ok: true, ranking: parsed.ranking, adjudications: parsed.adjudications, errors: [] };
71
+ }
72
+
73
+ /** Per-phrase `^<phrase>(?![A-Za-z0-9])` matchers — prefix-anchored, case-sensitive. */
74
+ const CHAIR_VERDICT_PREFIXES = CHAIR_VERDICTS.map(
75
+ (v) => new RegExp('^' + v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9])')
76
+ );
77
+
78
+ /**
79
+ * Parse the chair's final verdict line. Last matching `VERDICT:` line wins. A
80
+ * line matches when the text after `VERDICT:` equals a canonical phrase, or
81
+ * starts with one followed by a word boundary (trailing rationale, e.g.
82
+ * `VERDICT: Fix these first — <gaps>`). Returns the canonical phrase, not the
83
+ * trailing text.
84
+ * @param {string} text
85
+ * @returns {string|null} one of CHAIR_VERDICTS, or null
86
+ */
87
+ function parseChairVerdict(text) {
88
+ let found = null;
89
+ for (const line of String(text || '').split('\n')) {
90
+ const m = line.match(/^\s*VERDICT:\s*(.+?)\s*$/);
91
+ if (!m) { continue; }
92
+ const rest = m[1];
93
+ for (let i = 0; i < CHAIR_VERDICTS.length; i++) {
94
+ if (rest === CHAIR_VERDICTS[i] || CHAIR_VERDICT_PREFIXES[i].test(rest)) {
95
+ found = CHAIR_VERDICTS[i];
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ return found;
101
+ }
102
+
103
+ module.exports = { parseJudgeOutput, parseChairVerdict, CHAIR_VERDICTS, JUDGE_VERDICTS };
@@ -0,0 +1,100 @@
1
+ // src/council/run-assemble.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-assemble
6
+ * Pure assembly + artifact emission for the headless council engine (spec §5):
7
+ * the five-keys tally input (meta pins: claudeInCouncil false, models = bench
8
+ * seats exactly — critic included, chair excluded — runType 'headless'),
9
+ * runStats rows copied verbatim from leg docs, and the run-dir artifact set
10
+ * (tally-input.json, tally.json, verdict.json with overallVerdict, report.html,
11
+ * chair-output.md). Raiser self-votes are INCLUDED in adjudications — exclusion
12
+ * is tally's job (tally.js:95); judged is tally's job (tally.js:110).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { writeFileAtomic } = require('../utils/atomic-write');
18
+ const { buildVerdict, writeVerdictAtomic } = require('./verdict');
19
+ const { buildReport } = require('./report');
20
+
21
+ const CONFORMANCE_RANK = { clean: 0, repaired: 1, unstructured: 2 };
22
+
23
+ /** Worst-wins merge of Stage-1 findings conformance and Stage-2 judge conformance. */
24
+ function worseConformance(a, b) {
25
+ return (CONFORMANCE_RANK[a] || 0) >= (CONFORMANCE_RANK[b] || 0) ? a : b;
26
+ }
27
+
28
+ /**
29
+ * One runStats row from a leg run document. Verbatim copies only — a missing
30
+ * leg doc yields durationMs/usage null (never invent a value). `model` (the
31
+ * council alias) overrides leg.model (the resolved executable id) so ledger
32
+ * rows join meta.models by exact string (ledger.js:20-24).
33
+ */
34
+ function buildRunStatsEntry({ leg, model, role, wasChair, conformance }) {
35
+ return {
36
+ model: model !== undefined ? model : (leg ? leg.model : null),
37
+ role,
38
+ wasChair: !!wasChair,
39
+ conformance: conformance || 'clean',
40
+ status: leg ? leg.status : 'error',
41
+ durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
42
+ usage: (leg && leg.usage) || null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Assemble the five-keys tally input (spec §5 / SKILL.md Stage-2 recipe).
48
+ * @param {{runId: string, date: string, bench: string[], chair: string,
49
+ * reviews: Array<{model, role, conformance, leg, globalFindings}>,
50
+ * judgeResults: Array<{judge, ok, order, adjudications}>,
51
+ * chairStats: object|null}} args
52
+ */
53
+ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats }) {
54
+ const meta = {
55
+ runId, date, runType: 'headless',
56
+ models: bench.slice(), // bench seats exactly: critic included, chair excluded
57
+ chair,
58
+ claudeInCouncil: false, // pinned for headless runs
59
+ };
60
+ const findings = reviews.flatMap(r => r.globalFindings);
61
+ const okJudges = judgeResults.filter(j => j.ok);
62
+ const adjudications = okJudges.flatMap(j =>
63
+ j.adjudications.map(a => ({ findingId: a.id, judge: j.judge, verdict: a.verdict })));
64
+ const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order }));
65
+ const runStats = reviews.map(r => buildRunStatsEntry({
66
+ leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
67
+ }));
68
+ if (chairStats) { runStats.push(chairStats); }
69
+ return { meta, findings, adjudications, rankings, runStats };
70
+ }
71
+
72
+ /** Persist the assembled input (auditability) and the tally record. */
73
+ function writeTallyFiles({ runDir, tallyInput, record }) {
74
+ writeFileAtomic(path.join(runDir, 'tally-input.json'),
75
+ JSON.stringify(tallyInput, null, 2), { mode: 0o600 });
76
+ writeFileAtomic(path.join(runDir, 'tally.json'),
77
+ JSON.stringify(record, null, 2), { mode: 0o600 });
78
+ }
79
+
80
+ /**
81
+ * Undecided verdict + deterministic report. Sets the nullable overallVerdict
82
+ * (council family v2, Plan A) on buildVerdict's output — independent of
83
+ * buildVerdict's own signature.
84
+ * @returns {object} the verdict written to disk
85
+ */
86
+ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
87
+ const verdict = buildVerdict(record, []);
88
+ verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
89
+ writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
90
+ const html = buildReport({ verdict }, { format: 'html' });
91
+ fs.writeFileSync(path.join(runDir, 'report.html'), html, { mode: 0o600 });
92
+ if (chairText) {
93
+ fs.writeFileSync(path.join(runDir, 'chair-output.md'), chairText, { mode: 0o600 });
94
+ }
95
+ return verdict;
96
+ }
97
+
98
+ module.exports = {
99
+ buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
100
+ };
@@ -0,0 +1,99 @@
1
+ // src/council/run-launch.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-launch
6
+ * Council-flavored, DI-injected launch wrappers over the fanout transport
7
+ * (spec §5). Every council call — Stage-1 wave, critic/lens solos, Stage-2
8
+ * judge wave, repair re-prompts, chair — goes through runFanout with:
9
+ * - `--agent Plan` default (fixes the skill-vs-engine 'build' default
10
+ * mismatch, fanout.js:184),
11
+ * - `--no-context` (council briefings are self-contained),
12
+ * - quiet mode (the engine owns stdout; wave docs are consumed in-process).
13
+ * Solos are SINGLE-LEG WAVES: one launch primitive gives every call the same
14
+ * leg contract, budget gate, signal abort, and usage accounting.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ /**
21
+ * @param {{fanoutFn?: Function}} [deps] test seam; default = real runFanout
22
+ * @returns {{launchWave: Function, launchSolo: Function}}
23
+ */
24
+ function createLaunchers(deps = {}) {
25
+ const fanoutFn = deps.fanoutFn || require('../sidecar/fanout').runFanout;
26
+
27
+ /**
28
+ * @param {{models: string[], prompt: string, project: string, waveId: string,
29
+ * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string}} opts
30
+ * @returns {Promise<{wave: object|null, exitCode: number}>}
31
+ */
32
+ async function launchWave(opts) {
33
+ fs.mkdirSync(opts.project, { recursive: true });
34
+ const { wave, exitCode } = await fanoutFn({
35
+ models: opts.models.join(','),
36
+ prompt: opts.prompt,
37
+ promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
38
+ waveId: opts.waveId,
39
+ project: opts.project,
40
+ agent: opts.agent || 'Plan',
41
+ timeout: opts.timeout,
42
+ summaryLength: 'verbose',
43
+ includeContext: false,
44
+ gatewayMode: opts.gateway,
45
+ noValidateModel: opts.noValidateModel,
46
+ json: false,
47
+ quiet: true,
48
+ // Spec §6 judge isolation: pin every leg's OpenCode tool-exec cwd to its
49
+ // own session dir (judges' `project` is `<runDir>/_scratch`, so this
50
+ // scopes them there) and strip inherited MCP servers, so a tool-capable
51
+ // judge can't read the de-anonymized review-*.md files or the plaintext
52
+ // labelMap in run.json sitting in the parent run dir.
53
+ directory: opts.project,
54
+ noMcp: true,
55
+ });
56
+ return { wave, exitCode };
57
+ }
58
+
59
+ /**
60
+ * One-model launch (critic/lens legs, repairs, the chair) as a 1-leg wave.
61
+ * @returns {Promise<{wave: object|null, exitCode: number, leg: object|null}>}
62
+ */
63
+ async function launchSolo(opts) {
64
+ const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
65
+ const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
66
+ return { wave, exitCode, leg };
67
+ }
68
+
69
+ return { launchWave, launchSolo };
70
+ }
71
+
72
+ /** Filesystem-safe model name for review-/judge- artifact filenames. */
73
+ function sanitizeName(model) {
74
+ return String(model).replace(/[^a-zA-Z0-9._-]/g, '-');
75
+ }
76
+
77
+ /**
78
+ * Write `review-<modelInput>.md` per surviving Stage-1 leg (skill layout).
79
+ * Dead legs and empty summaries are skipped — the caller applies the
80
+ * wave-degrade rules to what remains.
81
+ * @param {string} runDir
82
+ * @param {Array<object>} legs run documents from the wave/solo docs
83
+ * @returns {Array<{model: string, modelInput: string, file: string, text: string, leg: object}>}
84
+ */
85
+ function materializeReviews(runDir, legs) {
86
+ const out = [];
87
+ for (const leg of legs) {
88
+ if (!leg || leg.status !== 'complete') { continue; }
89
+ const text = leg.summary;
90
+ if (!text || !String(text).trim()) { continue; }
91
+ const modelInput = leg.modelInput || leg.model;
92
+ const file = path.join(runDir, `review-${sanitizeName(modelInput)}.md`);
93
+ fs.writeFileSync(file, text, { mode: 0o600 });
94
+ out.push({ model: leg.model, modelInput, file, text, leg });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ module.exports = { createLaunchers, materializeReviews, sanitizeName };
@@ -0,0 +1,187 @@
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
+
29
+ function isAbortExit(code) { return code === 130 || code === 143; }
30
+
31
+ function slug(text) {
32
+ return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
33
+ }
34
+
35
+ /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
36
+ async function launchStage1(ctx) {
37
+ const { o, launchers } = ctx;
38
+ const common = {
39
+ project: o.runDir, timeout: o.timeout, gateway: o.gateway,
40
+ noValidateModel: o.noValidateModel,
41
+ };
42
+ const launches = [];
43
+ if (o.lenses) {
44
+ o.models.forEach((m, i) => launches.push(launchers.launchSolo({
45
+ ...common, model: m, waveId: `${o.runId}-l${i + 1}`,
46
+ prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
47
+ })));
48
+ } else {
49
+ const seats = o.models.filter(m => m !== o.critic);
50
+ if (seats.length > 0) {
51
+ launches.push(launchers.launchWave({
52
+ ...common, models: seats, waveId: `${o.runId}-s1`,
53
+ prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
+ }));
55
+ }
56
+ if (o.critic) {
57
+ launches.push(launchers.launchSolo({
58
+ ...common, model: o.critic, waveId: `${o.runId}-c1`,
59
+ prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
60
+ }));
61
+ }
62
+ }
63
+ const results = await Promise.all(launches);
64
+ let aborted = null;
65
+ const legs = [];
66
+ for (const r of results) {
67
+ ctx.addWave(r.wave);
68
+ if (isAbortExit(r.exitCode)) { aborted = r.exitCode; }
69
+ if (r.wave && Array.isArray(r.wave.legs)) { legs.push(...r.wave.legs); }
70
+ }
71
+ return { aborted, legs };
72
+ }
73
+
74
+ /** Role of a seat by its input alias. */
75
+ function roleFor(o, alias) {
76
+ if (o.lenses) {
77
+ const i = o.models.indexOf(alias);
78
+ return i === -1 ? 'seat' : `lens:${slug(o.lenses[i])}`;
79
+ }
80
+ return alias === o.critic ? 'critic' : 'seat';
81
+ }
82
+
83
+ /**
84
+ * Stage 1: independent reviews + findings validation + bounded repair.
85
+ * @returns {Promise<{aborted: number|null, reviews: Array, deadLegs: Array}>}
86
+ */
87
+ async function runStage1(ctx) {
88
+ const { o } = ctx;
89
+ const { aborted, legs } = await launchStage1(ctx);
90
+ if (aborted) { return { aborted, reviews: [], deadLegs: [] }; }
91
+
92
+ const materialized = materializeReviews(o.runDir, legs);
93
+ const alive = new Set(materialized.map(m => m.leg));
94
+ const deadLegs = legs.filter(l => !alive.has(l));
95
+
96
+ const reviews = [];
97
+ let repairSeq = 0;
98
+ for (const m of materialized) {
99
+ let conformance = 'clean';
100
+ let res = validateFindings(m.text);
101
+ let attempts = 0;
102
+ while (!res.ok && attempts < 2 && !ctx.overBudget()) {
103
+ attempts += 1;
104
+ repairSeq += 1;
105
+ const solo = await ctx.launchers.launchSolo({
106
+ model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
107
+ project: o.runDir, waveId: `${o.runId}-p${repairSeq}`, timeout: o.timeout,
108
+ gateway: o.gateway, noValidateModel: o.noValidateModel,
109
+ });
110
+ ctx.addWave(solo.wave);
111
+ if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
112
+ res = validateFindings((solo.leg && solo.leg.summary) || '');
113
+ if (res.ok) { conformance = 'repaired'; }
114
+ }
115
+ if (!res.ok) { conformance = 'unstructured'; }
116
+ reviews.push({
117
+ model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
118
+ text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
119
+ });
120
+ }
121
+ return { aborted: null, reviews, deadLegs };
122
+ }
123
+
124
+ /**
125
+ * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
126
+ * @param {object} ctx
127
+ * @param {{reviews: Array, labels: {entries, labelMap}, globalFindings: Array}} args
128
+ * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
129
+ */
130
+ async function runStage2(ctx, { reviews, labels, globalFindings }) {
131
+ const { o } = ctx;
132
+ const { rankingToOrder } = require('./anonymize');
133
+ fs.mkdirSync(ctx.scratchDir, { recursive: true, mode: 0o700 });
134
+
135
+ const labeled = labels.entries.map((e, i) => ({ label: e.label, text: reviews[i].text }));
136
+ const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings });
137
+ fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
138
+
139
+ const judges = reviews.map(r => r.modelInput);
140
+ const parseCtx = {
141
+ labels: labels.entries.map(e => e.label),
142
+ findingIds: globalFindings.map(f => f.id),
143
+ };
144
+ const { wave, exitCode } = await ctx.launchers.launchWave({
145
+ models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
146
+ timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
147
+ });
148
+ ctx.addWave(wave);
149
+ if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
150
+
151
+ const judgeResults = [];
152
+ let repairSeq = 0;
153
+ for (const leg of (wave && wave.legs) || []) {
154
+ const judge = leg.modelInput || leg.model;
155
+ if (leg.status === 'complete' && leg.summary) {
156
+ fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
157
+ }
158
+ let conformance = 'clean';
159
+ let parsed = (leg.status === 'complete' && leg.summary)
160
+ ? parseJudgeOutput(leg.summary, parseCtx)
161
+ : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
162
+ let attempts = 0;
163
+ while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
164
+ attempts += 1;
165
+ repairSeq += 1;
166
+ const solo = await ctx.launchers.launchSolo({
167
+ model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
168
+ project: ctx.scratchDir, waveId: `${o.runId}-q${repairSeq}`, timeout: o.timeout,
169
+ gateway: o.gateway, noValidateModel: o.noValidateModel,
170
+ });
171
+ ctx.addWave(solo.wave);
172
+ if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
173
+ parsed = parseJudgeOutput((solo.leg && solo.leg.summary) || '', parseCtx);
174
+ if (parsed.ok) { conformance = 'repaired'; }
175
+ }
176
+ if (!parsed.ok) {
177
+ judgeResults.push({ judge, ok: false, order: null, adjudications: null,
178
+ conformance: leg.status === 'complete' ? 'unstructured' : 'clean' });
179
+ continue;
180
+ }
181
+ const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
182
+ judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance });
183
+ }
184
+ return { aborted: null, judgeResults };
185
+ }
186
+
187
+ module.exports = { runStage1, runStage2, isAbortExit, slug };