amicus 4.0.0 → 4.1.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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +3 -3
  4. package/commands/council.md +6 -6
  5. package/package.json +1 -1
  6. package/schemas/council-run.schema.json +16 -1
  7. package/schemas/council-tally.schema.json +10 -1
  8. package/schemas/council-verdict.schema.json +10 -1
  9. package/schemas/error.schema.json +1 -1
  10. package/scripts/postinstall.js +6 -3
  11. package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
  12. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
  13. package/skills/second-opinion/MODEL-NOTES.md +21 -0
  14. package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
  15. package/skills/second-opinion/SKILL.md +319 -333
  16. package/src/cli-handlers-council-run.js +9 -0
  17. package/src/cli-handlers-council.js +20 -2
  18. package/src/cli.js +8 -0
  19. package/src/council/briefings-debate.js +158 -0
  20. package/src/council/briefings-stage2.js +16 -9
  21. package/src/council/debate.js +98 -0
  22. package/src/council/ledger.js +2 -1
  23. package/src/council/parse-stage2.js +83 -1
  24. package/src/council/report-html.js +28 -1
  25. package/src/council/report.js +50 -2
  26. package/src/council/run-assemble.js +91 -9
  27. package/src/council/run-chair.js +145 -0
  28. package/src/council/run-debate.js +289 -0
  29. package/src/council/run-launch.js +27 -1
  30. package/src/council/run-stages.js +41 -13
  31. package/src/council/run-state.js +40 -1
  32. package/src/council/run.js +108 -110
  33. package/src/council/verdict.js +43 -2
  34. package/src/mcp-council-awareness.js +187 -0
  35. package/src/mcp-council-run.js +29 -128
  36. package/src/mcp-server.js +28 -3
  37. package/src/mcp-tools.js +22 -2
  38. package/src/utils/error-doc.js +2 -0
@@ -0,0 +1,187 @@
1
+ // src/mcp-council-awareness.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module mcp-council-awareness
6
+ * The council-awareness helpers behind amicus_status / amicus_list /
7
+ * amicus_abort: they resolve a council runId through the sessions-dir pointer
8
+ * file and read the run directory directly, so the generic session handlers do
9
+ * not need to know anything about council run layout.
10
+ *
11
+ * Split out of mcp-council-run.js, which owns the amicus_council_run handler;
12
+ * that file re-exports these so existing require paths keep working.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const runState = require('./council/run-state');
18
+ const { RUNNING_VERSION } = require('./utils/version-info');
19
+
20
+ /**
21
+ * Every wave a stage launched: the primary `waveId` plus the recorded
22
+ * `waveIds` sub-waves (chair ch1..ch4, lens solos, critic solo, repairs).
23
+ */
24
+ function subWaveIds(stage) {
25
+ return [...new Set(
26
+ [stage.waveId, ...(Array.isArray(stage.waveIds) ? stage.waveIds : [])].filter(Boolean))];
27
+ }
28
+
29
+ /**
30
+ * The pid to probe for liveness. run.json's own pid is authoritative once the
31
+ * engine has checkpointed it; before that, the spawning process's record is all
32
+ * there is (see run-state.writeSpawnPid).
33
+ */
34
+ function enginePid(run, runDir) {
35
+ return run.pid || runState.readSpawnPid(runDir);
36
+ }
37
+
38
+ /** @returns {{total: number, complete: number}|null} null when not on disk yet */
39
+ function countWaveLegs(project, waveId) {
40
+ const { getSessionDir } = require('./session-manager');
41
+ const { TERMINAL_STATUSES } = require('./utils/result-schema');
42
+ let legs;
43
+ try {
44
+ legs = JSON.parse(fs.readFileSync(
45
+ path.join(getSessionDir(project, waveId), 'metadata.json'), 'utf-8')).legs;
46
+ } catch { return null; }
47
+ // A hand-edited or half-written metadata.json can carry a non-array `legs`;
48
+ // treat anything that is not an array as no legs rather than throwing out of
49
+ // a status read.
50
+ if (!Array.isArray(legs)) { return { total: 0, complete: 0 }; }
51
+ const complete = legs.filter((id) => {
52
+ try {
53
+ const m = JSON.parse(fs.readFileSync(
54
+ path.join(getSessionDir(project, id), 'metadata.json'), 'utf-8'));
55
+ return TERMINAL_STATUSES.includes(m.status);
56
+ } catch { return false; }
57
+ }).length;
58
+ return { total: legs.length, complete };
59
+ }
60
+
61
+ function elapsedOf(run) {
62
+ const end = run.completedAt || new Date().toISOString();
63
+ const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
64
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
65
+ }
66
+
67
+ /** Status payload for a council runId, or null when the id is not a council run. */
68
+ function buildCouncilStatusPayload(project, taskId) {
69
+ const ptr = runState.readPointer(project, taskId);
70
+ if (!ptr) { return null; }
71
+ const run = runState.readRun(ptr.runDir);
72
+ if (!run) { return null; }
73
+
74
+ // Crash detection: a running run.json whose engine pid is gone is 'error'.
75
+ const pid = run.status === 'running' ? enginePid(run, ptr.runDir) : null;
76
+ if (pid) {
77
+ try { process.kill(pid, 0); } catch (err) {
78
+ if (err.code !== 'EPERM') {
79
+ runState.checkpoint(ptr.runDir, {
80
+ status: 'error', completedAt: new Date().toISOString(),
81
+ error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
82
+ });
83
+ run.status = 'error';
84
+ run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
85
+ }
86
+ }
87
+ }
88
+
89
+ const stages = (run.stages || []).map(s => ({
90
+ name: s.name, status: s.status, waveId: s.waveId || null,
91
+ }));
92
+ const active = (run.stages || []).find(s => s.status === 'running') || null;
93
+ let legsTotal = null; let legsComplete = null;
94
+ // Sum across every sub-wave the active stage launched: a lens stage1 has no
95
+ // seat wave at all, and a critic solo runs beside one. Stays null until at
96
+ // least one sub-wave record exists on disk.
97
+ for (const waveId of active && active.project ? subWaveIds(active) : []) {
98
+ const c = countWaveLegs(active.project, waveId);
99
+ if (!c) { continue; }
100
+ legsTotal = (legsTotal || 0) + c.total;
101
+ legsComplete = (legsComplete || 0) + c.complete;
102
+ }
103
+ const payload = {
104
+ taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
105
+ status: run.status, currentStage: active ? active.name : null, stages,
106
+ legsTotal, legsComplete, elapsed: elapsedOf(run),
107
+ exitCode: run.exitCode !== undefined ? run.exitCode : null,
108
+ version: RUNNING_VERSION,
109
+ };
110
+ if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
111
+ return payload;
112
+ }
113
+
114
+ /** amicus_list entries for every council pointer in the project. */
115
+ function listCouncilRuns(project) {
116
+ const { sanitizePreview } = require('./sidecar/progress-fields');
117
+ const out = [];
118
+ for (const ptr of runState.listPointers(project)) {
119
+ const run = runState.readRun(ptr.runDir);
120
+ if (!run) { continue; }
121
+ let briefing = '';
122
+ try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
123
+ catch { /* optional */ }
124
+ const active = (run.stages || []).find(s => s.status === 'running');
125
+ out.push({
126
+ id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
127
+ model: null, agent: 'Plan', createdAt: run.createdAt,
128
+ briefing: sanitizePreview(briefing, 80),
129
+ stage: active ? active.name : null,
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** Mark one sub-wave and its legs aborted. @returns {number} legs newly marked */
136
+ function cascadeWave(project, waveId) {
137
+ const { markAborted } = require('./utils/session-abort');
138
+ const { getSessionDir } = require('./session-manager');
139
+ const waveDir = getSessionDir(project, waveId);
140
+ let meta = {};
141
+ try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
142
+ catch { /* wave record may not exist yet */ }
143
+ let n = 0;
144
+ // Non-array `legs` (hand-edited or half-written) would throw out of the
145
+ // for..of and lose the wave-level mark below.
146
+ for (const legId of Array.isArray(meta.legs) ? meta.legs : []) {
147
+ try { if (markAborted(getSessionDir(project, legId), 'council abort')) { n++; } }
148
+ catch { /* skip leg */ }
149
+ }
150
+ // Guarded so a failure here cannot discard the legs already marked: the
151
+ // caller only learns the count through the return value.
152
+ try { markAborted(waveDir, 'council abort'); } catch { /* best-effort */ }
153
+ return n;
154
+ }
155
+
156
+ /**
157
+ * Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
158
+ * and cascade to every in-flight sub-wave + its legs so they settle.
159
+ * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
160
+ */
161
+ function abortCouncilRun(project, taskId) {
162
+ const ptr = runState.readPointer(project, taskId);
163
+ if (!ptr) { return null; }
164
+ const run = runState.readRun(ptr.runDir);
165
+ if (!run) { return null; }
166
+ if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
167
+
168
+ let cascaded = 0;
169
+ for (const s of run.stages || []) {
170
+ if (s.status !== 'running' || !s.project) { continue; }
171
+ for (const waveId of subWaveIds(s)) {
172
+ try { cascaded += cascadeWave(s.project, waveId); } catch { /* skip sub-wave */ }
173
+ }
174
+ }
175
+ runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
176
+ const pid = enginePid(run, ptr.runDir);
177
+ if (pid) {
178
+ try { require('./utils/abort-coordinator').waitThenKill(pid).catch(() => {}); }
179
+ catch { /* best-effort */ }
180
+ }
181
+ return { aborted: true, cascaded };
182
+ }
183
+
184
+ module.exports = {
185
+ subWaveIds, countWaveLegs, elapsedOf, enginePid,
186
+ buildCouncilStatusPayload, listCouncilRuns, cascadeWave, abortCouncilRun,
187
+ };
@@ -4,18 +4,17 @@
4
4
  /**
5
5
  * @module mcp-council-run
6
6
  * MCP surface for headless council runs (spec §8): the amicus_council_run
7
- * handler (15th tool, born-fenced) plus the council-awareness helpers that
8
- * amicus_status / amicus_list / amicus_abort call through the sessions-dir
9
- * pointer file. Lives outside mcp-server.js (grandfathered-oversized); the
10
- * spawn helper is INJECTED by mcp-server at call time to avoid a require
11
- * cycle.
7
+ * handler (15th tool, born-fenced). Lives outside mcp-server.js
8
+ * (grandfathered-oversized); the spawn helper is INJECTED by mcp-server at call
9
+ * time to avoid a require cycle. The council-awareness helpers that
10
+ * amicus_status / amicus_list / amicus_abort call now live in
11
+ * mcp-council-awareness.js and are re-exported from here.
12
12
  */
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const runState = require('./council/run-state');
17
17
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
- const { RUNNING_VERSION } = require('./utils/version-info');
19
18
  const { isPathInside } = require('./project-root-allowlist');
20
19
 
21
20
  function textResult(text, isError) {
@@ -124,13 +123,28 @@ async function handleCouncilRunTool(input, project, helpers) {
124
123
  if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
125
124
  if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
126
125
  if (input.gateway) { args.push('--gateway', input.gateway); }
127
-
128
- try { helpers.spawnFn(args, runDir); } catch (err) {
126
+ // v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
127
+ // reason outDir is — an MCP client may send a relative path, and the child's cwd
128
+ // is the run dir. Validation of the file itself stays in the spawned engine's
129
+ // pre-flight (run-assemble.preflightClaudeReview), so every entry point shares it.
130
+ if (input.debate) { args.push('--debate'); }
131
+ if (input.claudeReviewFile) { args.push('--claude-review', path.resolve(project, String(input.claudeReviewFile))); }
132
+ if (input.noCostGate) { args.push('--no-cost-gate'); }
133
+
134
+ let child;
135
+ try { child = helpers.spawnFn(args, runDir); } catch (err) {
129
136
  try {
130
137
  runState.checkpoint(runDir, { status: 'error', error: { code: 'INTERNAL', message: err.message }, completedAt: new Date().toISOString() });
131
138
  } catch { /* best-effort */ }
132
139
  return textResult(`Failed to start council run: ${err.message}`, true);
133
140
  }
141
+ // Record the child's pid NOW: the engine writes its own pid at startup, but a
142
+ // child that dies before that leaves a pid-less status:'running' run.json that
143
+ // crash detection skips and abort cannot signal. Written to its own file, not
144
+ // patched into run.json — the child owns run.json and a cross-process
145
+ // read-merge-write has no lock (see run-state.writeSpawnPid).
146
+ try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
147
+ catch { /* best-effort */ }
134
148
 
135
149
  const body = JSON.stringify({
136
150
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
@@ -142,126 +156,13 @@ async function handleCouncilRunTool(input, project, helpers) {
142
156
  return textResult(fenceSidecarOutput(body));
143
157
  }
144
158
 
145
- /** ---- council-awareness helpers (consumed by mcp-server status/list/abort) ---- */
146
-
147
- function elapsedOf(run) {
148
- const end = run.completedAt || new Date().toISOString();
149
- const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
150
- return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
151
- }
152
-
153
- /** Status payload for a council runId, or null when the id is not a council run. */
154
- function buildCouncilStatusPayload(project, taskId) {
155
- const ptr = runState.readPointer(project, taskId);
156
- if (!ptr) { return null; }
157
- const run = runState.readRun(ptr.runDir);
158
- if (!run) { return null; }
159
-
160
- // Crash detection: a running run.json whose engine pid is gone is 'error'.
161
- if (run.status === 'running' && run.pid) {
162
- try { process.kill(run.pid, 0); } catch (err) {
163
- if (err.code !== 'EPERM') {
164
- runState.checkpoint(ptr.runDir, {
165
- status: 'error', completedAt: new Date().toISOString(),
166
- error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
167
- });
168
- run.status = 'error';
169
- run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
170
- }
171
- }
172
- }
173
-
174
- const stages = (run.stages || []).map(s => ({
175
- name: s.name, status: s.status, waveId: s.waveId || null,
176
- }));
177
- const active = (run.stages || []).find(s => s.status === 'running') || null;
178
- let legsTotal = null; let legsComplete = null;
179
- if (active && active.waveId && active.project) {
180
- try {
181
- const { getSessionDir } = require('./session-manager');
182
- const { TERMINAL_STATUSES } = require('./utils/result-schema');
183
- const meta = JSON.parse(fs.readFileSync(
184
- path.join(getSessionDir(active.project, active.waveId), 'metadata.json'), 'utf-8'));
185
- const legs = meta.legs || [];
186
- legsTotal = legs.length;
187
- legsComplete = legs.filter((id) => {
188
- try {
189
- const m = JSON.parse(fs.readFileSync(
190
- path.join(getSessionDir(active.project, id), 'metadata.json'), 'utf-8'));
191
- return TERMINAL_STATUSES.includes(m.status);
192
- } catch { return false; }
193
- }).length;
194
- } catch { /* stage wave not on disk yet */ }
195
- }
196
- const payload = {
197
- taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
198
- status: run.status, currentStage: active ? active.name : null, stages,
199
- legsTotal, legsComplete, elapsed: elapsedOf(run),
200
- exitCode: run.exitCode !== undefined ? run.exitCode : null,
201
- version: RUNNING_VERSION,
202
- };
203
- if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
204
- return payload;
205
- }
206
-
207
- /** amicus_list entries for every council pointer in the project. */
208
- function listCouncilRuns(project) {
209
- const { sanitizePreview } = require('./sidecar/progress-fields');
210
- const out = [];
211
- for (const ptr of runState.listPointers(project)) {
212
- const run = runState.readRun(ptr.runDir);
213
- if (!run) { continue; }
214
- let briefing = '';
215
- try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
216
- catch { /* optional */ }
217
- const active = (run.stages || []).find(s => s.status === 'running');
218
- out.push({
219
- id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
220
- model: null, agent: 'Plan', createdAt: run.createdAt,
221
- briefing: sanitizePreview(briefing, 80),
222
- stage: active ? active.name : null,
223
- });
224
- }
225
- return out;
226
- }
227
-
228
- /**
229
- * Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
230
- * and cascade to the active stage's wave + legs so in-flight legs settle.
231
- * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
232
- */
233
- function abortCouncilRun(project, taskId) {
234
- const ptr = runState.readPointer(project, taskId);
235
- if (!ptr) { return null; }
236
- const run = runState.readRun(ptr.runDir);
237
- if (!run) { return null; }
238
- if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
239
-
240
- const { markAborted } = require('./utils/session-abort');
241
- const { getSessionDir } = require('./session-manager');
242
- let cascaded = 0;
243
- for (const s of run.stages || []) {
244
- if (s.status !== 'running' || !s.waveId || !s.project) { continue; }
245
- try {
246
- const waveDir = getSessionDir(s.project, s.waveId);
247
- let meta = {};
248
- try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
249
- catch { /* wave record may not exist yet */ }
250
- for (const legId of meta.legs || []) {
251
- try { if (markAborted(getSessionDir(s.project, legId), 'council abort')) { cascaded++; } }
252
- catch { /* skip leg */ }
253
- }
254
- markAborted(waveDir, 'council abort');
255
- } catch { /* skip stage */ }
256
- }
257
- runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
258
- if (run.pid) {
259
- try { require('./utils/abort-coordinator').waitThenKill(run.pid).catch(() => {}); }
260
- catch { /* best-effort */ }
261
- }
262
- return { aborted: true, cascaded };
263
- }
159
+ // The council-awareness helpers live in their own module; re-exported here so
160
+ // mcp-server and cli-handlers-abort keep requiring one council MCP entry point.
161
+ const awareness = require('./mcp-council-awareness');
264
162
 
265
163
  module.exports = {
266
- handleCouncilRunTool, buildCouncilStatusPayload, listCouncilRuns, abortCouncilRun,
164
+ handleCouncilRunTool,
165
+ buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
166
+ listCouncilRuns: awareness.listCouncilRuns,
167
+ abortCouncilRun: awareness.abortCouncilRun,
267
168
  };
package/src/mcp-server.js CHANGED
@@ -13,7 +13,7 @@ const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
13
13
  const { SharedServerManager } = require('./utils/shared-server');
14
14
  const { durationBetween } = require('./utils/result-schema');
15
15
  const { canonicalProjectPath } = require('./utils/project-path');
16
- const { isAllowedProjectRoot } = require('./project-root-allowlist');
16
+ const { isAllowedProjectRoot, isPathInside } = require('./project-root-allowlist');
17
17
  const { recordSession } = require('./utils/session-index');
18
18
  const { fileURLToPath } = require('url');
19
19
  const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
@@ -1141,10 +1141,35 @@ const handlers = {
1141
1141
  } catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
1142
1142
  },
1143
1143
 
1144
- async amicus_verdict(input) {
1144
+ async amicus_verdict(input, project) {
1145
1145
  try {
1146
1146
  const { buildVerdict } = require('./council/verdict');
1147
- return textResult(fenceSidecarOutput(JSON.stringify(buildVerdict(input.record, input.decisions || []))));
1147
+ // The chair's synthesis lives only in the engine's verdict.json /
1148
+ // chair-output.md, and this tool's output replaces verdict.json — so it
1149
+ // must be carried through or it is destroyed. Unlike the CLI there is no
1150
+ // run-folder path to anchor on (`record` arrives inline), so it is an
1151
+ // explicit input; omitted → null, never fabricated.
1152
+ const verdict = buildVerdict(input.record, input.decisions || [],
1153
+ { overallVerdict: input.overallVerdict });
1154
+ if (!input.render) {
1155
+ return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
1156
+ }
1157
+ // v4.1 §4.5c: return the markdown rendering (so Cowork can assemble report.md
1158
+ // without Bash) and, when an outDir is given, refresh report.html on disk.
1159
+ const { buildReport } = require('./council/report');
1160
+ const md = buildReport({ verdict }, { format: 'md' });
1161
+ if (input.outDir) {
1162
+ // Containment parity with amicus_council_run (mcp-council-run.js): an
1163
+ // MCP-supplied outDir must not write outside the project directory.
1164
+ const cwd = project || getProjectDir(input.project);
1165
+ const outDir = path.resolve(cwd, String(input.outDir));
1166
+ if (!isPathInside(outDir, cwd)) {
1167
+ return textResult(`outDir must resolve to a path inside the project directory (${cwd}).`, true);
1168
+ }
1169
+ fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
1170
+ fs.writeFileSync(path.join(outDir, 'report.html'), buildReport({ verdict }, { format: 'html' }), { mode: 0o600 });
1171
+ }
1172
+ return textResult(fenceSidecarOutput(md));
1148
1173
  } catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
1149
1174
  },
1150
1175
 
package/src/mcp-tools.js CHANGED
@@ -406,11 +406,12 @@ function getTools() {
406
406
  },
407
407
  {
408
408
  name: 'amicus_verdict',
409
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
409
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
410
410
  description:
411
411
  "Merge a tally record with Claude's Stage-4 decisions into the verdict " +
412
412
  'object (final tiers after overrides, decisions, applied flags). Pure + ' +
413
- 'synchronous; returns the verdict does NOT write it to disk.',
413
+ 'synchronous; returns the verdict. Writes nothing unless render:true AND ' +
414
+ 'outDir are given — then it also refreshes <outDir>/report.html.',
414
415
  inputSchema: {
415
416
  record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
416
417
  decisions: z.array(z.object({
@@ -418,6 +419,13 @@ function getTools() {
418
419
  duplicateOf: z.string().nullable().optional(),
419
420
  tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
420
421
  })).optional().describe('Stage-4 per-finding decisions (default []).'),
422
+ overallVerdict: z.string().nullable().optional().describe(
423
+ "The chair's VERDICT line, read from the engine-written <runDir>/verdict.json " +
424
+ '(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
425
+ 'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
426
+ 'chair was skipped; never author one yourself.'),
427
+ render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
428
+ outDir: z.string().optional().describe('Dir to write report.html into when render:true — resolved against the project dir and rejected if it escapes it. Omit to write nothing.'),
421
429
  project: z.string().optional().describe('Optional project directory path.'),
422
430
  },
423
431
  },
@@ -466,6 +474,18 @@ function getTools() {
466
474
  gateway: z.enum(GATEWAY_MODES).optional().describe(
467
475
  'Routing preference: auto (default), direct, or openrouter.'
468
476
  ),
477
+ debate: z.boolean().optional().describe(
478
+ 'Add a Stage-2.5 rebuttal round: raisers defend Contested/Disputed findings and ' +
479
+ 'disputing judges re-vote before the chair synthesizes.'
480
+ ),
481
+ claudeReviewFile: z.string().optional().describe(
482
+ "Path to Claude's own review file (prose + findings JSON) to include as a judged " +
483
+ 'entry. Claude is reviewed and ranked like a seat, but never judges or chairs.'
484
+ ),
485
+ noCostGate: z.boolean().optional().describe(
486
+ 'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
487
+ 'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
488
+ ),
469
489
  project: z.string().optional().describe(
470
490
  'Optional project directory path. Auto-detected from working directory if omitted.'
471
491
  ),
@@ -22,6 +22,8 @@ const ERROR_CODES = Object.freeze({
22
22
  INTERNAL: 'INTERNAL', // unexpected pre-flight throw
23
23
  COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
24
24
  COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
25
+ // council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
26
+ COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
25
27
  });
26
28
 
27
29
  /**