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
@@ -33,6 +33,13 @@ Operating lessons from each run fold back into `MODEL-NOTES.md` (with approval),
33
33
 
34
34
  **Transport rule — CLI not on PATH:** every command below assumes the `amicus` CLI. If `amicus` is not on PATH (typical for **plugin-only installs**), run the identical commands as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest fanout --models "m1,m2,m3" --prompt-file <path> --json`), or use the equivalent MCP tools (`amicus_fanout`, `amicus_start`, `amicus_wait`, `amicus_status`, `amicus_read`, `amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) — council briefings are always self-contained (`--no-context`), so MCP transport is equivalent.
35
35
 
36
+ **Headless contexts (v4.0):** CI and scripted environments with no Claude runtime can run the
37
+ whole mechanical pipeline (Stages 1–3 plus the deterministic Stage-5 artifacts) as one command —
38
+ `amicus council run --prompt-file <briefing.md> --models "a,b,c" --chair <model> --json` — see
39
+ [docs/council.md](../../docs/council.md#amicus-council-run). This skill's staged, human-in-the-loop
40
+ orchestration remains the interactive path: Stage 0 intake, Stage 4 decisions, and Stage 6 lessons
41
+ are human stages the engine never automates.
42
+
36
43
  ## When to use
37
44
 
38
45
  - The user provides documents, artifacts, or links **and** an analysis request **and** criteria, and wants other models to weigh in independently.
@@ -169,6 +176,7 @@ file) returns `{waveId, taskIds[]}` immediately. Preferred: call `amicus_wait` w
169
176
  one blocking call per wave; re-call it while it returns `timedOut: true`. Fallback: poll
170
177
  `amicus_status`. Either way, `amicus_read` each leg when done. The council's briefings are always
171
178
  self-contained (`--no-context`), so MCP transport is equivalent.
179
+ Council JSON returned by the MCP tools (`amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) arrives wrapped in the `<untrusted_sidecar_output>` fence since v4.0 — parse the JSON from inside the fence; CLI `--json` output remains unfenced.
172
180
 
173
181
  **Required structured output from every model.** Instruct each council model to produce:
174
182
 
@@ -14,6 +14,31 @@ const { validateTaskId, safeSessionDir } = require('./utils/validators');
14
14
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
15
15
  const { buildAbortResult } = require('./utils/result-schema');
16
16
 
17
+ /**
18
+ * Council-run abort via the sessions-dir pointer (v4.0 §8). Returns the exit
19
+ * code when the taskId was a council run, or null to fall through to the
20
+ * ordinary session/wave paths.
21
+ */
22
+ function tryCouncilAbort(project, taskId, useJson) {
23
+ const { abortCouncilRun } = require('./mcp-council-run');
24
+ const res = abortCouncilRun(project, taskId);
25
+ if (!res) { return null; }
26
+ if (res.alreadyTerminal) {
27
+ if (useJson) {
28
+ console.log(JSON.stringify(buildAbortResult({ scope: 'council-run', taskId, aborted: [] }), null, 2));
29
+ } else {
30
+ console.log(`Council run ${taskId} is not running (status: ${res.status}).`);
31
+ }
32
+ return 0;
33
+ }
34
+ if (useJson) {
35
+ console.log(JSON.stringify(buildAbortResult({ scope: 'council-run', taskId, aborted: [taskId] }), null, 2));
36
+ } else {
37
+ console.log(`Council run ${taskId} marked as aborted (${res.cascaded} running leg(s) aborted).`);
38
+ }
39
+ return 0;
40
+ }
41
+
17
42
  /**
18
43
  * Handle 'amicus abort --all --json': mark every running session aborted.
19
44
  * @returns {number} exit code (always 0 — even a no-op --all is a success)
@@ -45,6 +70,8 @@ async function handleAbortTaskJson(args, taskId) {
45
70
  const metaPath = path.join(sessionDir, 'metadata.json');
46
71
 
47
72
  if (!fs.existsSync(metaPath)) {
73
+ const council = tryCouncilAbort(project, taskId, true);
74
+ if (council !== null) { return council; }
48
75
  process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} not found` }));
49
76
  }
50
77
 
@@ -171,6 +198,8 @@ async function handleAbort(args) {
171
198
  const metaPath = path.join(sessionDir, 'metadata.json');
172
199
 
173
200
  if (!fs.existsSync(metaPath)) {
201
+ const council = tryCouncilAbort(project, taskId, false);
202
+ if (council !== null) { return council; }
174
203
  console.error(`Session ${taskId} not found`);
175
204
  process.exit(1);
176
205
  }
@@ -0,0 +1,168 @@
1
+ // src/cli-handlers-council-run.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * CLI `amicus council run` (v4.0 spec §4): flag validation, headless engine
6
+ * invocation, document emission, exit codes. Lives in its own file because
7
+ * cli-handlers-council.js is near the 300-line gate; dispatched from
8
+ * handleCouncil. Pre-flight failures go through the error envelope (exit 1)
9
+ * BEFORE any spend.
10
+ */
11
+
12
+ const path = require('path');
13
+ const { failJson, buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
14
+ const { validateTaskId } = require('./utils/validators');
15
+ const { GATEWAY_MODES } = require('./utils/model-descriptor');
16
+
17
+ const CHAIR_DEFAULT = 'deepseek';
18
+
19
+ function parseList(value) {
20
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
21
+ }
22
+
23
+ /** Resolve bench models from --models XOR --council (mirrors handleFanout). */
24
+ function resolveBench(args, useJson) {
25
+ const hasModels = typeof args.models === 'string' && args.models.trim();
26
+ const hasCouncil = args.council !== undefined && args.council !== false;
27
+ if (hasModels && hasCouncil) {
28
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
29
+ message: 'Error: pass exactly one of --models / --council, not both' }) };
30
+ }
31
+ if (!hasModels && !hasCouncil) {
32
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
33
+ message: 'Error: council run needs --models a,b,c or --council <preset> (at least 2 seats)' }) };
34
+ }
35
+ if (hasCouncil) {
36
+ if (typeof args.council !== 'string' || !args.council.trim()) {
37
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
38
+ message: 'Error: --council requires a council name (e.g. --council budget)' }) };
39
+ }
40
+ const { resolveCouncilMembers } = require('./utils/config');
41
+ const { readCache } = require('./utils/model-catalog');
42
+ const catalog = (readCache() || {}).models || [];
43
+ const expanded = resolveCouncilMembers(args.council.trim(), catalog);
44
+ if (expanded.error) {
45
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }) };
46
+ }
47
+ if (expanded.dropped && expanded.dropped.length && !useJson) {
48
+ process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
49
+ }
50
+ return { bench: expanded.models };
51
+ }
52
+ return { bench: parseList(args.models) };
53
+ }
54
+
55
+ function renderRunHuman(run) {
56
+ const lines = [
57
+ `Council run ${run.runId}: ${run.status} (exit ${run.exitCode})`,
58
+ ` bench: ${(run.bench || []).join(', ')} chair: ${run.chair}`,
59
+ ` dir: ${run.options && run.options.outDir}`,
60
+ ];
61
+ if (run.usage && run.usage.cost && typeof run.usage.cost.amount === 'number') {
62
+ lines.push(` cost: $${run.usage.cost.amount.toFixed(4)} (${run.usage.cost.source})`);
63
+ }
64
+ if (run.error) { lines.push(` error: ${run.error.code}: ${run.error.message}`); }
65
+ return lines.join('\n') + '\n';
66
+ }
67
+
68
+ /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
69
+ async function handleCouncilRun(args) {
70
+ const useJson = !!args.json;
71
+
72
+ // --prompt-file required; inline --prompt rejected (councils always have
73
+ // real briefings — same rationale as MCP fanout's briefing-via-file).
74
+ if (args.prompt !== undefined) {
75
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
76
+ message: 'Error: council run takes --prompt-file only (no inline --prompt)',
77
+ hint: 'write the briefing to a file and pass --prompt-file <path>' });
78
+ }
79
+ const { resolvePromptSource } = require('./utils/prompt-source');
80
+ const promptRes = resolvePromptSource(args);
81
+ if (promptRes.error) {
82
+ return failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error });
83
+ }
84
+
85
+ const benchRes = resolveBench(args, useJson);
86
+ if (benchRes.fail !== undefined) { return benchRes.fail; }
87
+ const bench = benchRes.bench;
88
+ if (bench.length < 2) {
89
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
90
+ message: 'Error: a council needs at least 2 seats (fanout semantics)' });
91
+ }
92
+
93
+ const chair = (typeof args.chair === 'string' && args.chair.trim())
94
+ ? args.chair.trim() : CHAIR_DEFAULT;
95
+ if (bench.includes(chair)) {
96
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
97
+ message: `Error: chair '${chair}' is a bench seat — the chair must not review`,
98
+ hint: `pick a chair outside --models (default: ${CHAIR_DEFAULT}), or remove '${chair}' from the bench` });
99
+ }
100
+ const critic = (typeof args.critic === 'string' && args.critic.trim()) ? args.critic.trim() : null;
101
+ if (critic && !bench.includes(critic)) {
102
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
103
+ message: `Error: critic '${critic}' must be one of the bench seats`,
104
+ hint: `--critic swaps one seat's brief; pass one of: ${bench.join(', ')}` });
105
+ }
106
+ const lenses = (typeof args.lenses === 'string' && args.lenses.trim()) ? parseList(args.lenses) : null;
107
+ if (critic && lenses) {
108
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
109
+ message: 'Error: --critic and --lenses are mutually exclusive in v4.0' });
110
+ }
111
+ if (lenses && lenses.length !== bench.length) {
112
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
113
+ message: `Error: --lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length})` });
114
+ }
115
+ if (args.timeout !== undefined && args.timeout <= 0) {
116
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' });
117
+ }
118
+ const mc = args['max-cost'];
119
+ if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
120
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' });
121
+ }
122
+ if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
123
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
124
+ message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` });
125
+ }
126
+ let runId;
127
+ if (args['run-id']) {
128
+ const check = validateTaskId(String(args['run-id']));
129
+ if (!check.valid) {
130
+ return failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error });
131
+ }
132
+ runId = String(args['run-id']);
133
+ } else {
134
+ runId = require('./sidecar/start').generateTaskId();
135
+ }
136
+
137
+ const project = args.cwd || process.cwd();
138
+ const runDir = args['out-dir']
139
+ ? path.resolve(project, String(args['out-dir']))
140
+ : path.resolve(project, `council-${runId}`);
141
+
142
+ const { resolveGatewayMode } = require('./utils/config');
143
+ const { runCouncil } = require('./council/run');
144
+ const { exitCode, run } = await runCouncil({
145
+ briefing: promptRes.prompt, models: bench, chair, critic, lenses,
146
+ project, runId, runDir,
147
+ timeout: args.timeout, maxCost: mc !== undefined ? mc : null,
148
+ gateway: resolveGatewayMode(args.gateway),
149
+ noValidateModel: !!args['no-validate-model'],
150
+ date: new Date().toISOString().slice(0, 10),
151
+ });
152
+
153
+ if (useJson) {
154
+ // Spec §4: exit-1 rows fail through the error envelope; 0/2 emit the manifest.
155
+ if (exitCode === 1 && run && run.error) {
156
+ process.stdout.write(JSON.stringify(buildErrorDoc({
157
+ code: run.error.code, message: run.error.message, command: 'council run',
158
+ }), null, 2) + '\n');
159
+ } else {
160
+ process.stdout.write(JSON.stringify(run, null, 2) + '\n');
161
+ }
162
+ } else {
163
+ process.stdout.write(renderRunHuman(run));
164
+ }
165
+ return exitCode;
166
+ }
167
+
168
+ module.exports = { handleCouncilRun, CHAIR_DEFAULT };
@@ -2,11 +2,11 @@
2
2
  'use strict';
3
3
  const fs = require('fs');
4
4
  const { tally } = require('./council/tally');
5
- const { deriveReliability, appendRun } = require('./council/ledger');
5
+ const { deriveReliability, appendRun, buildStatsDoc } = require('./council/ledger');
6
6
  const { sumWaveUsage, formatCost } = require('./utils/pricing');
7
7
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
8
8
  const { buildReport } = require('./council/report');
9
- const { validateFindings } = require('./council/findings');
9
+ const { validateFindings, buildValidateDoc } = require('./council/findings');
10
10
  const { buildVerdict, writeVerdictAtomic } = require('./council/verdict');
11
11
  const {
12
12
  runSave: runCouncilSave,
@@ -44,7 +44,9 @@ function runTally(inputPath, useJson, opts = {}) {
44
44
 
45
45
  function runStats(useJson) {
46
46
  const agg = deriveReliability();
47
- process.stdout.write(useJson ? JSON.stringify(agg, null, 2) + '\n' : renderStats(agg));
47
+ // v4.0 §7: --json emits the enveloped doc (breaking: was a bare array);
48
+ // human output is unchanged (renderStats still takes the rows).
49
+ process.stdout.write(useJson ? JSON.stringify(buildStatsDoc(agg), null, 2) + '\n' : renderStats(agg));
48
50
  return 0;
49
51
  }
50
52
 
@@ -117,7 +119,7 @@ function runValidate(filePath, useJson) {
117
119
  hint: 'pass a Stage-1 reviewer output file (prose + trailing ```json findings block)' });
118
120
  }
119
121
  const result = validateFindings(text);
120
- process.stdout.write(useJson ? JSON.stringify(result, null, 2) + '\n' : renderValidate(result));
122
+ process.stdout.write(useJson ? JSON.stringify(buildValidateDoc(result), null, 2) + '\n' : renderValidate(result));
121
123
  return result.ok ? 0 : 2;
122
124
  }
123
125
 
@@ -185,6 +187,7 @@ function renderVerdict(v, outPath) {
185
187
  async function handleCouncil(args) {
186
188
  const sub = args._[1];
187
189
  const useJson = !!args.json;
190
+ if (sub === 'run') { return require('./cli-handlers-council-run').handleCouncilRun(args); }
188
191
  if (sub === 'tally') { return runTally(args._[2], useJson, { append: !args['no-ledger'] }); }
189
192
  if (sub === 'stats') { return runStats(useJson); }
190
193
  if (sub === 'report') { return runReport(args, useJson); }
@@ -195,7 +198,7 @@ async function handleCouncil(args) {
195
198
  if (sub === 'show') { return runCouncilShow(args._[2], useJson); }
196
199
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
197
200
  message: `unknown council subcommand '${sub || ''}'`,
198
- hint: 'amicus council tally|stats|report|validate|verdict|save|list|show' });
201
+ hint: 'amicus council run|tally|stats|report|validate|verdict|save|list|show' });
199
202
  }
200
203
 
201
204
  module.exports = { handleCouncil };
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { validateTaskId } = require('./utils/validators');
12
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
12
13
 
13
14
  /** Render a key-value block for a single-session status payload. */
14
15
  function formatRunHuman(d) {
@@ -42,6 +43,18 @@ function formatWaveHumanStatus(d) {
42
43
  return [head, ...legLines].join('\n');
43
44
  }
44
45
 
46
+ /** Render a council-run payload: header + one line per stage. */
47
+ function formatCouncilHuman(d) {
48
+ const head = `Council run ${d.runId}: ${d.status}` +
49
+ (d.currentStage ? ` — ${d.currentStage}` : '') +
50
+ (d.legsTotal !== null && d.legsTotal !== undefined ? ` (${d.legsComplete}/${d.legsTotal} legs)` : '') +
51
+ ` (${d.elapsed})`;
52
+ const stageLines = (d.stages || []).map(s =>
53
+ ` ${String(s.name).padEnd(10)} ${String(s.status).padEnd(10)}${s.waveId ? ` wave ${s.waveId}` : ''}`);
54
+ const tail = d.reason ? [` Reason: ${d.reason}`] : [];
55
+ return [head, ...stageLines, ...tail].join('\n');
56
+ }
57
+
45
58
  /**
46
59
  * Handle 'amicus status'. Exit code 0 = status retrieved (any run state, even
47
60
  * a failed/crashed run — the QUERY succeeded); 1 = missing/invalid/unknown id.
@@ -49,28 +62,46 @@ function formatWaveHumanStatus(d) {
49
62
  * @returns {Promise<number>}
50
63
  */
51
64
  async function handleStatus(args) {
65
+ const useJson = !!args.json;
52
66
  const taskId = args.wave || args._[1];
53
67
  if (!taskId || taskId === true) {
68
+ // v4.0 §7: --json failures land on stdout as the error doc; human stderr
69
+ // is byte-identical to pre-4.0.
70
+ if (useJson) {
71
+ return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: 'task_id is required for status',
72
+ hint: 'amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)' });
73
+ }
54
74
  process.stderr.write('Error: task_id is required for status\n');
55
75
  process.stderr.write('Usage: amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)\n');
56
76
  return 1;
57
77
  }
58
78
  const check = validateTaskId(String(taskId));
59
- if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
79
+ if (!check.valid) {
80
+ if (useJson) { return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: check.error }); }
81
+ process.stderr.write(`${check.error}\n`);
82
+ return 1;
83
+ }
60
84
 
61
85
  const project = args.cwd || process.cwd();
62
86
  const { handlers } = require('./mcp-server');
63
87
  const result = await handlers.amicus_status({ taskId: String(taskId) }, project);
64
88
  const text = result.content[0].text;
65
- if (result.isError) { process.stderr.write(`${text}\n`); return 1; }
89
+ if (result.isError) {
90
+ if (useJson) { return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: text }); }
91
+ process.stderr.write(`${text}\n`);
92
+ return 1;
93
+ }
66
94
 
67
95
  let data;
68
96
  try { data = JSON.parse(text); } catch { process.stdout.write(`${text}\n`); return 0; }
69
97
  delete data.next_poll; // MCP-agent polling guidance, not CLI output
70
98
 
71
99
  if (args.json) { process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return 0; }
72
- process.stdout.write(`${data.type === 'wave' ? formatWaveHumanStatus(data) : formatRunHuman(data)}\n`);
100
+ const rendered = data.type === 'wave' ? formatWaveHumanStatus(data)
101
+ : data.type === 'council-run' ? formatCouncilHuman(data)
102
+ : formatRunHuman(data);
103
+ process.stdout.write(`${rendered}\n`);
73
104
  return 0;
74
105
  }
75
106
 
76
- module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus };
107
+ module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus, formatCouncilHuman };
package/src/cli.js CHANGED
@@ -357,6 +357,7 @@ Commands:
357
357
  continue New session building on previous
358
358
  read Output session summary/conversation
359
359
  models List/search the model catalog, refresh it, audit aliases
360
+ council run <briefing.md> (--models a,b,c | --council <name>) Headless council: reviews → cross-review → tally → chair → verdict
360
361
  council tally <input.json> [--json] Tally council findings → tiers/street-cred
361
362
  council stats [--json] Reviewer-reliability from the ledger
362
363
  council report <verdict.json> [--wave <wave.json>] [--md|--html] Disagreement+verdict report
@@ -507,6 +508,14 @@ Subcommands for 'council':
507
508
  --decisions <d.json> Optional. Stage-4 decisions array (default [])
508
509
  -o, --out <out.json> Output path (default ./verdict.json)
509
510
  --json Print the full verdict document
511
+ run --prompt-file <briefing.md> (--models a,b,c | --council <name>)
512
+ [--chair <model>] [--critic <model>] [--lenses s1,s2,...]
513
+ [--out-dir <dir>] [--json] [--max-cost <usd>] [--timeout <min>]
514
+ [--gateway auto|direct|openrouter] [--no-validate-model]
515
+ Run the full headless council engine (v4.0).
516
+ Chair default: deepseek (must NOT be a bench seat).
517
+ --critic and --lenses are mutually exclusive.
518
+ Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
510
519
  save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
511
520
  --json Machine-readable output
512
521
  list List saved councils plus the built-in benches
@@ -0,0 +1,76 @@
1
+ // src/council/anonymize.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/anonymize
6
+ * Pure label-map helpers for the headless council engine (spec §6).
7
+ * Each surviving Stage-1 review gets a stable letter label ('Review A',
8
+ * 'Review B', …) in bench order. The label↔model map lives ONLY in
9
+ * orchestrator memory and run.json — never in any judge-visible file.
10
+ */
11
+
12
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
13
+
14
+ /**
15
+ * Assign stable labels in the given model order.
16
+ * @param {string[]} models reviewed model ids (bench order)
17
+ * @returns {{entries: Array<{label: string, letter: string, model: string}>,
18
+ * labelMap: Object<string, string>, letterByModel: Object<string, string>}}
19
+ */
20
+ function assignLabels(models) {
21
+ if (!Array.isArray(models) || models.length === 0 || models.length > LETTERS.length) {
22
+ throw new Error(`assignLabels needs 1-${LETTERS.length} models`);
23
+ }
24
+ const entries = models.map((model, i) => ({
25
+ label: `Review ${LETTERS[i]}`, letter: LETTERS[i], model,
26
+ }));
27
+ const labelMap = {};
28
+ const letterByModel = {};
29
+ for (const e of entries) {
30
+ labelMap[e.label] = e.model;
31
+ letterByModel[e.model] = e.letter;
32
+ }
33
+ return { entries, labelMap, letterByModel };
34
+ }
35
+
36
+ /** Rewrite a review's local integer finding id to its run-global label id. */
37
+ function toGlobalId(letter, localId) {
38
+ return `${letter}${localId}`;
39
+ }
40
+
41
+ /**
42
+ * Rewrite a validated review's findings to run-global tally-input entries.
43
+ * @param {string} letter review letter (e.g. 'A')
44
+ * @param {string} raiser de-anonymized model id
45
+ * @param {Array<{id: number, severity: string, claim: string, location: string}>} findings
46
+ * @returns {Array<{id: string, raiser: string, severity: string, claim: string, location: string}>}
47
+ */
48
+ function toGlobalFindings(letter, raiser, findings) {
49
+ // claim + location must reach tally-input.json — Action v2 (Plan C) joins on
50
+ // them for file:line annotations; tally() reads only id/raiser/severity.
51
+ return findings.map(f => ({
52
+ id: toGlobalId(letter, f.id), raiser, severity: f.severity, claim: f.claim,
53
+ location: f.location,
54
+ }));
55
+ }
56
+
57
+ /**
58
+ * Translate a judge's anonymized ranking (labels; ties as nested arrays) into
59
+ * a tally rankings[].order array of model ids via the private label map.
60
+ * @param {Array<string|string[]>} ranking
61
+ * @param {Object<string, string>} labelMap
62
+ * @returns {{order: Array<string|string[]>, errors: string[]}}
63
+ */
64
+ function rankingToOrder(ranking, labelMap) {
65
+ const errors = [];
66
+ const mapOne = (label) => {
67
+ const model = labelMap[label];
68
+ if (!model) { errors.push(`unknown label '${label}'`); }
69
+ return model;
70
+ };
71
+ const order = (Array.isArray(ranking) ? ranking : [])
72
+ .map(slot => (Array.isArray(slot) ? slot.map(mapOne) : mapOne(slot)));
73
+ return { order, errors };
74
+ }
75
+
76
+ module.exports = { assignLabels, toGlobalId, toGlobalFindings, rankingToOrder, LETTERS };
@@ -0,0 +1,150 @@
1
+ // src/council/briefings-stage2.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/briefings-stage2
6
+ * Pure Stage-2 (judge bundle) and Stage-3 (chair packet) template builders
7
+ * for the headless council engine (spec §5). Split from ./briefings.js for
8
+ * the 300-line gate. The bundle is identical for every judge and never leaks
9
+ * seat/lens/critic information or model names (skill §5.1 / Stage-2 rule).
10
+ */
11
+
12
+ const JUDGE_NO_TOOLS_PREAMBLE =
13
+ 'Do NOT use any tools or read any files; everything is in this message; ' +
14
+ 'begin immediately with A1:';
15
+
16
+ const CHAIR_NO_TOOLS_PREAMBLE =
17
+ 'Do NOT use any tools or read any files; everything is in this message; ' +
18
+ 'begin immediately with the verdict.';
19
+
20
+ const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink'];
21
+
22
+ /** Stage-2 headless output contract (spec §5, embedded in the judge bundle). */
23
+ const JUDGE_OUTPUT_CONTRACT = [
24
+ 'End your response with a trailing fenced ```json block — no text after it — in',
25
+ 'exactly this shape:',
26
+ '',
27
+ '```json',
28
+ '{',
29
+ ' "ranking": ["Review B", "Review A", "Review C"],',
30
+ ' "adjudications": [',
31
+ ' { "id": "A1", "verdict": "agree" },',
32
+ ' { "id": "B2", "verdict": "dispute" }',
33
+ ' ]',
34
+ '}',
35
+ '```',
36
+ '',
37
+ '- "ranking": every review label below, ordered most to least accurate and insightful.',
38
+ ' Ties: use a nested array for tied labels, e.g. [["Review A", "Review B"], "Review C"].',
39
+ '- "adjudications": one entry per listed finding id; "verdict" is one of:',
40
+ ' agree | dispute | neutral. An "I missed this — it\'s valid" counts as agree.',
41
+ ].join('\n');
42
+
43
+ /**
44
+ * The single shared anonymized judge bundle.
45
+ * @param {{reviews: Array<{label: string, text: string}>,
46
+ * findings: Array<{id: string, severity: string, claim: string}>}} args
47
+ */
48
+ function buildJudgeBundle({ reviews, findings }) {
49
+ const findingLines = findings.map(f => `${f.id} [${f.severity}] ${f.claim}`).join('\n');
50
+ const reviewBlocks = reviews
51
+ .map(r => `--- ${r.label} ---\n${r.text}`)
52
+ .join('\n\n');
53
+ return [
54
+ JUDGE_NO_TOOLS_PREAMBLE,
55
+ 'You are judging the anonymized peer reviews below. Do two things:',
56
+ 'Task A — Rank: order the reviews from most to least accurate and insightful.',
57
+ 'Task B — Adjudicate: for EVERY finding id listed below, state agree, dispute, or ' +
58
+ 'neutral with your reasoning in prose.',
59
+ JUDGE_OUTPUT_CONTRACT,
60
+ '--- FINDINGS INDEX (run-global ids) ---',
61
+ findingLines,
62
+ reviewBlocks,
63
+ ].join('\n\n');
64
+ }
65
+
66
+ /** Bounded judge-repair re-prompt (solo; ≤ 2 per judge — spec §5). */
67
+ function buildJudgeRepairPrompt({ errors }) {
68
+ const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
69
+ return [
70
+ 'Do NOT use any tools or read any files; everything is in this message; begin ' +
71
+ 'immediately with the JSON block.',
72
+ 'Your previous judging response\'s trailing JSON failed validation with these errors:',
73
+ lines,
74
+ 'Re-emit ONLY the corrected JSON block as a single fenced ```json block:',
75
+ JUDGE_OUTPUT_CONTRACT,
76
+ ].join('\n\n');
77
+ }
78
+
79
+ /** Verdict-scale addendum (SEAT-BRIEFS.md § Chair verdict-scale addendum; always on headless). */
80
+ const VERDICT_SCALE_ADDENDUM = [
81
+ 'After your synthesis, add two closing sections:',
82
+ '',
83
+ '1. HARD QUESTIONS — three to five questions the material\'s author has probably not',
84
+ ' asked themselves, chosen so that an unanswerable question reveals a structural gap',
85
+ ' (not gotchas — questions whose answers should exist).',
86
+ '2. A final line, alone on the last line, containing ONLY the phrase — no rationale, no',
87
+ ' dash, no trailing text of any kind — exactly one of:',
88
+ '',
89
+ ' VERDICT: Ship it',
90
+ ' VERDICT: Fix these first',
91
+ ' VERDICT: Fundamental rethink',
92
+ '',
93
+ ' Pick one. "Ship it" = solid, nothing blocking. "Fix these first" = specific gaps',
94
+ ' must be resolved first. "Fundamental rethink" = structural problems that cannot be',
95
+ ' patched. Name the gaps or the structural problems in the synthesis ABOVE, not on the',
96
+ ' VERDICT line itself — that line carries the phrase and nothing else.',
97
+ ].join('\n');
98
+
99
+ /**
100
+ * De-anonymized chair packet (spec §5/§6: the chair sees identities).
101
+ * @param {{reviews: Array<{model: string, text: string}>,
102
+ * rankings: Array<{judge: string, order: Array<string|string[]>}>,
103
+ * adjudications: Array<{findingId: string, judge: string, verdict: string}>,
104
+ * tierCounts: object}} args
105
+ */
106
+ function buildChairPacket({ reviews, rankings, adjudications, tierCounts }) {
107
+ const reviewBlocks = reviews.map(r => `--- Review by ${r.model} ---\n${r.text}`).join('\n\n');
108
+ const rankingLines = rankings
109
+ .map(r => `${r.judge}: ${JSON.stringify(r.order)}`)
110
+ .join('\n');
111
+ const adjLines = adjudications
112
+ .map(a => `${a.findingId} — ${a.judge}: ${a.verdict}`)
113
+ .join('\n');
114
+ const tiers = JSON.stringify(tierCounts);
115
+ return [
116
+ CHAIR_NO_TOOLS_PREAMBLE,
117
+ 'You are the council chair. Write the synthesized verdict across the reviews, ' +
118
+ 'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
119
+ 'peer-validated standing (rank position and adjudication pattern), distinguish ' +
120
+ 'findings the bench broadly endorsed from contested or singleton claims, and ' +
121
+ 'arrive at an overall assessment of the material.',
122
+ `Deterministic tier counts (peers-only cascade): ${tiers}`,
123
+ '--- STAGE-1 REVIEWS (de-anonymized) ---',
124
+ reviewBlocks,
125
+ '--- PEER RANKINGS (judge: order, best first) ---',
126
+ rankingLines,
127
+ '--- PER-FINDING ADJUDICATIONS ---',
128
+ adjLines,
129
+ VERDICT_SCALE_ADDENDUM,
130
+ ].join('\n\n');
131
+ }
132
+
133
+ /** One-shot chair repair: the VERDICT line was missing (spec §5 chair contract). */
134
+ function buildChairRepairPrompt() {
135
+ return [
136
+ 'Do NOT use any tools or read any files; everything is in this message; begin ' +
137
+ 'immediately with the VERDICT line.',
138
+ 'Your synthesis was received, but the final parseable line was missing. Emit ONLY ' +
139
+ 'one line, exactly one of:',
140
+ 'VERDICT: Ship it',
141
+ 'VERDICT: Fix these first',
142
+ 'VERDICT: Fundamental rethink',
143
+ ].join('\n\n');
144
+ }
145
+
146
+ module.exports = {
147
+ JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE, CHAIR_VERDICT_VALUES,
148
+ JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM,
149
+ buildJudgeBundle, buildJudgeRepairPrompt, buildChairPacket, buildChairRepairPrompt,
150
+ };