amicus 3.2.3 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +106 -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 +38 -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 +203 -0
  35. package/src/council/run-state.js +161 -0
  36. package/src/council/run.js +277 -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-awareness.js +187 -0
  41. package/src/mcp-council-run.js +161 -0
  42. package/src/mcp-server.js +87 -28
  43. package/src/mcp-tools.js +50 -0
  44. package/src/prompt-builder.js +36 -19
  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,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/wave.schema.json",
4
+ "title": "amicus wave result document",
5
+ "description": "Fan-out wave result (`fanout --json`, wave.json). counts named buckets may not sum to total (see result-schema.js COUNTS REMAINDER RULE).",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "type", "waveId", "status", "counts", "legs"],
8
+ "properties": {
9
+ "schemaVersion": { "const": 2 },
10
+ "type": { "const": "wave" },
11
+ "waveId": { "type": "string" },
12
+ "status": { "type": "string" },
13
+ "error": { "type": ["string", "null"] },
14
+ "counts": {
15
+ "type": "object",
16
+ "required": ["total", "complete", "error", "timeout", "aborted"],
17
+ "properties": {
18
+ "total": { "type": "number" },
19
+ "complete": { "type": "number" },
20
+ "error": { "type": "number" },
21
+ "timeout": { "type": "number" },
22
+ "aborted": { "type": "number" }
23
+ }
24
+ },
25
+ "legs": { "type": "array", "items": { "type": "object" } },
26
+ "prompt": { "type": ["object", "null"] },
27
+ "createdAt": { "type": ["string", "null"] },
28
+ "completedAt": { "type": ["string", "null"] },
29
+ "durationMs": { "type": ["number", "null"] },
30
+ "usage": { "type": "object" },
31
+ "notices": { "type": "array", "items": { "type": "string" } }
32
+ }
33
+ }
@@ -178,13 +178,15 @@ Append to the chair packet (`_tmp-chair-packet.md`) when the element is toggled
178
178
  > 1. **HARD QUESTIONS** — three to five questions the artifact's author has probably not
179
179
  > asked themselves, chosen so that an unanswerable question reveals a structural gap in
180
180
  > the artifact (not gotchas — questions whose answers should exist).
181
- > 2. A final line, alone on the last line, in exactly this format:
181
+ > 2. A final line, alone on the last line, containing ONLY the phrase — no rationale, no
182
+ > dash, no trailing text of any kind — exactly one of:
182
183
  >
183
184
  > `VERDICT: Ship it` | `VERDICT: Fix these first` | `VERDICT: Fundamental rethink`
184
185
  >
185
186
  > Pick one. "Ship it" = solid, nothing blocking. "Fix these first" = specific gaps must
186
- > be resolved before the artifact is useful name them. "Fundamental rethink" =
187
- > structural problems that cannot be patched say what is wrong at the foundation.
187
+ > be resolved first. "Fundamental rethink" = structural problems that cannot be patched.
188
+ > Name the gaps or the structural problems in the synthesis ABOVE, not on the VERDICT
189
+ > line itself — that line carries the phrase and nothing else.
188
190
 
189
191
  **Orchestration note:** surface the chair's `VERDICT:` line verbatim at the top of
190
192
  `report.md` and in the inline chat presentation of the results.
@@ -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 };