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
@@ -2,23 +2,30 @@
2
2
  'use strict';
3
3
  const fs = require('fs');
4
4
 
5
- const VERDICT_SCHEMA_VERSION = 1;
5
+ // v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
6
+ // nullable overallVerdict (the chair's Ship-it line; populated by the headless
7
+ // engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
8
+ const VERDICT_SCHEMA_VERSION = 2;
6
9
 
7
10
  /**
8
11
  * Merge a tally record with Claude's Stage-4 decisions into the verdict record.
9
12
  * @param {object} record tally() output
10
13
  * @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
14
+ * @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
15
+ * parsed chair `VERDICT:` line; omitted/undefined → null.
11
16
  */
12
- function buildVerdict(record, decisions = []) {
17
+ function buildVerdict(record, decisions = [], opts = {}) {
13
18
  const byId = new Map(decisions.map(d => [d.id, d]));
14
19
  return {
15
20
  schemaVersion: VERDICT_SCHEMA_VERSION,
21
+ type: 'council-verdict',
16
22
  runId: record.meta.runId,
17
23
  runType: record.meta.runType,
18
24
  date: record.meta.date,
19
25
  chair: record.meta.chair,
20
26
  council: record.meta.models,
21
27
  claudeInCouncil: record.meta.claudeInCouncil,
28
+ overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
22
29
  findings: record.findings.map(f => {
23
30
  const d = byId.get(f.id) || {};
24
31
  const tierOverride = d.tierOverride || f.tierOverride || null;
package/src/headless.js CHANGED
@@ -22,7 +22,8 @@ const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require(
22
22
  *
23
23
  * #BL-7 residual (15b.3): the bare `[SIDECAR_FOLD]` string is now a LEGACY
24
24
  * literal only, kept exported for external consumers with no nonce context
25
- * (see extractSummary/formatFoldOutput's no-nonce fallback paths below).
25
+ * (the no-nonce fallback paths were retired in v4.0 §9 — the constant is
26
+ * export-only back-compat now).
26
27
  * NOTE for anyone `.toContain('[SIDECAR_FOLD]')`-checking real run output:
27
28
  * that substring check does NOT match the real nonced marker — a nonced
28
29
  * marker is `[SIDECAR_FOLD:<nonce>]`, which lacks the literal closing
@@ -716,46 +717,42 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
716
717
  * Extract summary from output (everything before the trailing fold marker)
717
718
  * Spec Reference: §6.2 - Return summary (everything before the fold marker)
718
719
  *
720
+ * v4.0 §9 (BL-7 done-done): `nonce` is REQUIRED for any non-empty output.
721
+ * The pre-15b.3 no-nonce fallback (matching the legacy bare `[SIDECAR_FOLD]`
722
+ * marker) is retired — no code path, internal or external, may complete on a
723
+ * bare marker. Callers with no nonce have no valid marker to split on and
724
+ * must not call this.
725
+ *
719
726
  * @param {string} output - Raw output from OpenCode
720
- * @param {string} [nonce] - This run's fold nonce (15b.3). When omitted, falls
721
- * back to matching the LEGACY bare `[SIDECAR_FOLD]` marker — this keeps
722
- * extractSummary usable as a standalone string utility (e.g. re-processing
723
- * output captured before the nonce scheme, or a caller that genuinely has
724
- * no nonce context) without ever accepting a WRONG nonce as a match.
727
+ * @param {string} nonce - This run's fold nonce (required for non-empty output)
725
728
  * @returns {string} Extracted summary
729
+ * @throws {TypeError} when output is non-empty and nonce is missing/empty
726
730
  */
727
731
  function extractSummary(output, nonce) {
728
732
  if (!output) {
729
733
  return '';
730
734
  }
735
+ if (!nonce) {
736
+ throw new TypeError('extractSummary requires a per-run nonce (15b.3/v4.0 §9)');
737
+ }
731
738
 
732
739
  // Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
733
740
  // A marker echoed mid-output (describing code, reproducing these
734
741
  // instructions, or from scraped content) is NOT a delimiter — keep it as
735
742
  // content. Only the true trailing marker is stripped.
736
- const idx = nonce ? findTrailingFoldMarker(output, nonce) : findLegacyBareTrailingMarker(output);
743
+ const idx = findTrailingFoldMarker(output, nonce);
737
744
  if (idx !== -1) {
738
745
  return output.slice(0, idx).trim();
739
746
  }
740
747
  return output.trim();
741
748
  }
742
749
 
743
- /**
744
- * Legacy bare-marker trailing match (`[SIDECAR_FOLD]`, no nonce) — the
745
- * pre-15b.3 behavior, kept only for extractSummary's no-nonce fallback path.
746
- * NEVER used by runHeadless's own detection (that always carries a nonce —
747
- * see findTrailingFoldMarker), so no live completion path can be forced by a
748
- * bare marker.
749
- * @param {string} output
750
- * @returns {number}
751
- */
752
- function findLegacyBareTrailingMarker(output) {
753
- const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
754
- return m ? m.index : -1;
755
- }
756
-
757
750
  /**
758
751
  * Format a structured fold output with metadata
752
+ * v4.0 §9: `nonce` is REQUIRED — the pre-15b.3 bare-`[SIDECAR_FOLD]` writer
753
+ * fallback is retired; the bare literal is never written by any path. The
754
+ * FOLD_MARKER/COMPLETE_MARKER constants remain exported for external
755
+ * consumers' greps/back-compat only (docs/SHIMS.md).
759
756
  * @param {Object} options - Fold output options
760
757
  * @param {string} options.model - Model identifier
761
758
  * @param {string} options.sessionId - Session identifier
@@ -763,14 +760,16 @@ function findLegacyBareTrailingMarker(output) {
763
760
  * @param {string} [options.cwd] - Working directory (defaults to process.cwd())
764
761
  * @param {string} [options.mode='headless'] - Execution mode
765
762
  * @param {string} options.summary - Summary text
766
- * @param {string} [options.nonce] - This run's fold nonce (15b.3). When omitted,
767
- * falls back to the legacy bare `[SIDECAR_FOLD]` marker for back-compat with
768
- * external callers of this exported utility that predate the nonce scheme.
763
+ * @param {string} options.nonce - This run's fold nonce (required)
769
764
  * @returns {string} Formatted fold output
765
+ * @throws {TypeError} when nonce is missing/empty
770
766
  */
771
767
  function formatFoldOutput({ model, sessionId, client, cwd, mode, summary, nonce }) {
768
+ if (!nonce) {
769
+ throw new TypeError('formatFoldOutput requires a per-run nonce (15b.3/v4.0 §9)');
770
+ }
772
771
  return [
773
- nonce ? buildFoldMarker(nonce) : FOLD_MARKER,
772
+ buildFoldMarker(nonce),
774
773
  `Model: ${model}`,
775
774
  `Session: ${sessionId}`,
776
775
  `Client: ${client || 'code-local'}`,
@@ -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
+ };
@@ -0,0 +1,161 @@
1
+ // src/mcp-council-run.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module mcp-council-run
6
+ * MCP surface for headless council runs (spec §8): the amicus_council_run
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
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const runState = require('./council/run-state');
17
+ const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
+ const { isPathInside } = require('./project-root-allowlist');
19
+
20
+ function textResult(text, isError) {
21
+ const result = { content: [{ type: 'text', text }] };
22
+ if (isError) { result.isError = true; }
23
+ return result;
24
+ }
25
+
26
+ /** Resolve the bench: models XOR council preset (amicus_fanout parity). */
27
+ function resolveBenchInput(input) {
28
+ const inputModels = Array.isArray(input.models) ? input.models : [];
29
+ const hasModels = inputModels.length > 0;
30
+ const hasCouncil = typeof input.council === 'string' && input.council.trim();
31
+ if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
32
+ if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
33
+ if (hasCouncil) {
34
+ const { resolveCouncilMembers } = require('./utils/config');
35
+ const { readCache } = require('./utils/model-catalog');
36
+ const catalog = (readCache() || {}).models || [];
37
+ const expanded = resolveCouncilMembers(input.council.trim(), catalog);
38
+ if (expanded.error) { return { error: expanded.error }; }
39
+ return { bench: expanded.models };
40
+ }
41
+ return { bench: inputModels };
42
+ }
43
+
44
+ /**
45
+ * amicus_council_run: validate → prep run dir → spawn CLI child → return
46
+ * {runId, runDir} immediately (fenced).
47
+ * @param {object} input tool input
48
+ * @param {string} project resolved project dir
49
+ * @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
50
+ */
51
+ async function handleCouncilRunTool(input, project, helpers) {
52
+ const CHAIR_DEFAULT = 'deepseek';
53
+ if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
54
+ return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
55
+ }
56
+ let briefing;
57
+ try { briefing = fs.readFileSync(input.briefingFile, 'utf-8'); }
58
+ catch (e) { return textResult(`Cannot read briefingFile ${input.briefingFile}: ${e.message}`, true); }
59
+ if (briefing.charCodeAt(0) === 0xFEFF) { briefing = briefing.slice(1); }
60
+ if (!briefing.trim()) { return textResult(`briefingFile ${input.briefingFile} is empty.`, true); }
61
+
62
+ const benchRes = resolveBenchInput(input);
63
+ if (benchRes.error) { return textResult(benchRes.error, true); }
64
+ const bench = benchRes.bench;
65
+ if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
66
+ const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
67
+ if (bench.includes(chair)) {
68
+ return textResult(`Chair '${chair}' is a bench seat — pick a chair outside the bench (default: ${CHAIR_DEFAULT}).`, true);
69
+ }
70
+ const critic = (typeof input.critic === 'string' && input.critic.trim()) ? input.critic.trim() : null;
71
+ if (critic && !bench.includes(critic)) {
72
+ return textResult(`Critic '${critic}' must be one of the bench seats (${bench.join(', ')}).`, true);
73
+ }
74
+ const lenses = Array.isArray(input.lenses) && input.lenses.length ? input.lenses : null;
75
+ if (critic && lenses) { return textResult('critic and lenses are mutually exclusive in v4.0.', true); }
76
+ if (lenses && lenses.length !== bench.length) {
77
+ return textResult(`lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length}).`, true);
78
+ }
79
+ if (input.timeoutMinutes !== undefined &&
80
+ (typeof input.timeoutMinutes !== 'number' || !Number.isFinite(input.timeoutMinutes) || input.timeoutMinutes <= 0)) {
81
+ return textResult('timeoutMinutes must be a positive number.', true);
82
+ }
83
+ if (input.maxCost !== undefined &&
84
+ (typeof input.maxCost !== 'number' || !Number.isFinite(input.maxCost) || input.maxCost <= 0)) {
85
+ return textResult('maxCost must be a positive number.', true);
86
+ }
87
+
88
+ const { generateTaskId } = require('./sidecar/start');
89
+ const runId = generateTaskId();
90
+ const runDir = input.outDir
91
+ ? path.resolve(project, String(input.outDir))
92
+ : path.join(project, `council-${runId}`);
93
+ if (!isPathInside(runDir, project)) {
94
+ return textResult(`outDir must resolve to a path inside the project directory (${project}).`, true);
95
+ }
96
+ const briefingPath = path.join(runDir, 'briefing.md');
97
+ try {
98
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
99
+ fs.writeFileSync(briefingPath, briefing, { mode: 0o600 });
100
+ runState.initRun(runDir, {
101
+ schemaVersion: 2, type: 'council-run', runId, status: 'running', stages: [],
102
+ bench, chair, critic, lenses, labelMap: null,
103
+ options: {
104
+ timeout: input.timeoutMinutes || null,
105
+ maxCost: (typeof input.maxCost === 'number') ? input.maxCost : null,
106
+ gateway: input.gateway || 'auto', outDir: runDir,
107
+ },
108
+ usage: null, createdAt: new Date().toISOString(),
109
+ });
110
+ runState.writePointer(project, runId, runDir);
111
+ } catch (err) {
112
+ return textResult(`Failed to prepare council run: ${err.message}`, true);
113
+ }
114
+
115
+ const args = [
116
+ 'council', 'run', '--prompt-file', briefingPath, '--run-id', runId,
117
+ '--out-dir', runDir, '--json', '--cwd', project,
118
+ '--models', bench.join(','), '--chair', chair,
119
+ '--client', helpers.clientName,
120
+ ];
121
+ if (critic) { args.push('--critic', critic); }
122
+ if (lenses) { args.push('--lenses', lenses.join(',')); }
123
+ if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
124
+ if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
125
+ if (input.gateway) { args.push('--gateway', input.gateway); }
126
+
127
+ let child;
128
+ try { child = helpers.spawnFn(args, runDir); } catch (err) {
129
+ try {
130
+ runState.checkpoint(runDir, { status: 'error', error: { code: 'INTERNAL', message: err.message }, completedAt: new Date().toISOString() });
131
+ } catch { /* best-effort */ }
132
+ return textResult(`Failed to start council run: ${err.message}`, true);
133
+ }
134
+ // Record the child's pid NOW: the engine writes its own pid at startup, but a
135
+ // child that dies before that leaves a pid-less status:'running' run.json that
136
+ // crash detection skips and abort cannot signal. Written to its own file, not
137
+ // patched into run.json — the child owns run.json and a cross-process
138
+ // read-merge-write has no lock (see run-state.writeSpawnPid).
139
+ try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
140
+ catch { /* best-effort */ }
141
+
142
+ const body = JSON.stringify({
143
+ schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
144
+ message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
145
+ 'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
146
+ 'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
147
+ });
148
+ // Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
149
+ return textResult(fenceSidecarOutput(body));
150
+ }
151
+
152
+ // The council-awareness helpers live in their own module; re-exported here so
153
+ // mcp-server and cli-handlers-abort keep requiring one council MCP entry point.
154
+ const awareness = require('./mcp-council-awareness');
155
+
156
+ module.exports = {
157
+ handleCouncilRunTool,
158
+ buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
159
+ listCouncilRuns: awareness.listCouncilRuns,
160
+ abortCouncilRun: awareness.abortCouncilRun,
161
+ };