amicus 4.9.7 → 4.10.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -0,0 +1,274 @@
1
+ // src/council/run-seat-tools.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-seat-tools
6
+ * `runCouncil`'s seat-tools wiring (spec 2026-09-11 §4, PR 2 of 3), split out of
7
+ * run.js under controller ruling P2-R14 (the 300-line size gate: run.js sat at
8
+ * exactly 300 lines with no headroom for this task's ~20 lines of logic). The
9
+ * engine-rendering tripwire's own pure pieces, plus `verificationDirectories`'
10
+ * one side effect — the best-effort `_scratch` mkdir it documents —
11
+ * (`listEngineAgents`, `verifyAgentRendering`, `verifyAgentFields`,
12
+ * `verificationDirectories`) live in the sibling module run-seat-tools-verify.js
13
+ * (council #247 round 3, same size gate; `verifyAgentFields` added round 6);
14
+ * `listEngineAgents`/`verifyAgentRendering`/`verifyAgentFields` are re-exported
15
+ * below so every existing importer keeps requiring them from here.
16
+ *
17
+ * Two pure-ish steps, called from run.js on either side of `acquireRunServer`:
18
+ * - `preflightSeatTools` (BEFORE the server): shape + refusals need no
19
+ * engine, and the run-directory placement rule for a local tool is a
20
+ * property of the tool id alone (seat-tools.js :: isLocal) — so both are
21
+ * decided, and refused, at ZERO SPEND. Its `councilAgents` output feeds
22
+ * `acquireRunServer`'s `agents` config, which is why it must run first.
23
+ * - `validateSeatToolsAgainstEngine` (AFTER the server, before any launch):
24
+ * the opt-in ids are checked against the engine's OWN declared tool list
25
+ * (run-server.js :: listEngineToolIds), so the accepted set is never
26
+ * hand-listed and never launched unvalidated.
27
+ *
28
+ * Neither function calls `finalize` — that stays run.js's job (the single exit
29
+ * every terminal outcome funnels through) — they return `{error}` and run.js
30
+ * decides what to do with it.
31
+ */
32
+
33
+ /**
34
+ * The seat-tools `intent` argument both functions derive from `o.intent`
35
+ * (`'task'` or absent — never a bare boolean or the raw `o.intent` string).
36
+ * One helper so the two derivations can never drift apart.
37
+ * @param {{intent?: string}} o @returns {'task'|undefined}
38
+ */
39
+ function seatIntentOf(o) { return o.intent === 'task' ? 'task' : undefined; }
40
+
41
+ /**
42
+ * Decide the run's seat-tools policy before the server starts. Pure except for
43
+ * the two lazy `require`s (seat-tools.js, project-root-allowlist.js), which
44
+ * carry no state of their own.
45
+ * @param {{agent?: string, intent?: string, tools?: string[], runDir: string, project: string}} o
46
+ * @returns {{error: {code: string, message: string}}|{error: null, seatTools: string[],
47
+ * seatToolsLocal: boolean, councilAgents: object|null}}
48
+ */
49
+ function preflightSeatTools(o) {
50
+ const seatTools = require('./seat-tools');
51
+ // Review r1: not-null-and-not-undefined (not just `!== undefined`), so
52
+ // `agent: null` — the CLI house style for an unset option (run.js's own `o`
53
+ // seed spreads `critic: null, lenses: null, maxCost: null, ...` the same
54
+ // way) — is treated as absent, not as an invalid override. Every OTHER
55
+ // `o.agent` check in this module already does this for free (`null` is
56
+ // falsy), so this guard was the one place stricter than the rest of the
57
+ // module. `!= null` would say the same thing in one operator, but the
58
+ // repo's eqeqeq('always') lint rule bans loose equality outright.
59
+ if (o.agent !== null && o.agent !== undefined && o.agent !== 'Plan' && o.agent !== 'Build') {
60
+ return { error: { code: 'BAD_ARGS', message: `Error: agent must be Plan or Build; got '${o.agent}'` } };
61
+ }
62
+ // Named mutant TOOLSNOTARRAY: dropping this check lets a non-array `tools`
63
+ // (a bare string, say) reach resolveSeatTools below, where
64
+ // `Array.isArray(o.tools) ? o.tools : []` silently discards it as `[]`
65
+ // instead of refusing the run. Pinned by run-tools.test.js's `tools: 'read'`
66
+ // case (BAD_ARGS naming the received type; nothing launches).
67
+ if (o.tools !== undefined && o.tools !== null && !Array.isArray(o.tools)) {
68
+ return { error: { code: 'BAD_ARGS', message: 'Error: tools must be an array of tool ids (got ' + typeof o.tools + ')' } };
69
+ }
70
+ // Ruling P2-R28 (supersedes P2-R25): --tools/--agent are refused together on
71
+ // every door. Named mutant AGENTTOOLSCONFLICT: dropping this check reddens
72
+ // the runCouncil conflict test (agent + a non-empty tools array must exit 1
73
+ // naming "cannot be combined", never reach a launch).
74
+ const conflict = seatTools.agentToolsConflict(o.agent, o.tools);
75
+ if (conflict) { return { error: { code: 'BAD_ARGS', message: `Error: ${conflict}` } }; }
76
+ // The --agent escape hatch wins: no council agents, every leg runs on the
77
+ // engine's own agent — so --tools is never even consulted under it. Pinned
78
+ // by run-tools.test.js's "--agent Build short-circuits resolveSeatTools
79
+ // even under task intent" case — review intent alone can't tell the
80
+ // short-circuit apart from an unconditional resolveSeatTools call (both
81
+ // give `tools: []`), only the task default (`['webfetch']`) can.
82
+ const seatPolicy = o.agent
83
+ ? { ok: true, tools: [], local: false }
84
+ : seatTools.resolveSeatTools({ intent: seatIntentOf(o), optIn: Array.isArray(o.tools) ? o.tools : [] });
85
+ if (!seatPolicy.ok) { return { error: { code: seatPolicy.code, message: `Error: ${seatPolicy.message}` } }; }
86
+ if (seatPolicy.local) {
87
+ // Defensive refusal: with a local tool and a falsy o.project,
88
+ // isPathInside(runDir, undefined) is false, so the placement rule below
89
+ // would PASS, and run-launch.js's directory fallback
90
+ // (`(opts.role === 'seat' && opts.directory) || opts.project`) resolves to
91
+ // the run dir itself — a direct caller (bypassing the CLI's required
92
+ // --project) could scope a seat to the very dir holding its own sibling
93
+ // sessions, exactly what the placement rule below exists to prevent.
94
+ // Named mutant NOPROJECTSCOPE: dropping this guard lets that through.
95
+ // Pinned by run-tools.test.js's "project: undefined" case.
96
+ if (!o.project) {
97
+ return { error: { code: 'BAD_ARGS', message: 'Error: a local tool needs a project directory to scope the seats to' } };
98
+ }
99
+ // Run-directory placement (spec §4): a seat that can read the project tree
100
+ // must not be able to read this run's sibling sessions, so the run dir must
101
+ // sit OUTSIDE the tree (and still under a root amicus is willing to write to).
102
+ // Named mutant DIRPLACEDROP: dropping the `isPathInside(...) ||` conjunct
103
+ // (keeping only the allowed-root half) would accept a runDir NESTED inside
104
+ // the project as long as the project itself sits under an allowed root —
105
+ // exactly the escape this rule exists to close. Pinned by run-tools.test.js's
106
+ // "run dir INSIDE the project" case (allowed root, still refused) and by its
107
+ // "outside the project" case (same allowed root, not refused once sibling).
108
+ const { isPathInside, isAllowedProjectRoot } = require('../project-root-allowlist');
109
+ // Ruling P2-R54 (C1, round 6): `isPathInside` compares canonicalized
110
+ // STRINGS only, and run-dir creation follows a symlinked ancestor — so an
111
+ // `--out-dir` that is (or sits under) a symlink/junction into the project
112
+ // passed this rule lexically while landing physically inside the tree
113
+ // (measured 2026-09-13). Named mutant SYMLINKBLIND: dropping the
114
+ // `isPhysicallyInside(...) ||` conjunct restores that escape.
115
+ const { isPhysicallyInside } = require('./run-seat-tools-verify');
116
+ if (isPathInside(o.runDir, o.project) || isPhysicallyInside(o.runDir, o.project) || !isAllowedProjectRoot(o.runDir)) {
117
+ // C5 (P2-R35): name the ids THIS run classed local (seat-tools.js's own
118
+ // NON_LOCAL_TOOL_IDS, not a hand-rolled copy) — a typo'd id (e.g.
119
+ // `webfetsh`) is classed local by the same not-explicitly-remote rule.
120
+ const localIds = seatPolicy.tools.filter((id) => !seatTools.NON_LOCAL_TOOL_IDS.includes(id));
121
+ return {
122
+ error: {
123
+ code: 'BAD_ARGS',
124
+ message: `Error: --tools with a local tool (${localIds.join(', ')}) needs --out-dir OUTSIDE the project tree `
125
+ + '(a seat that can read the tree must not be able to read the run\'s sibling sessions; a run directory '
126
+ + 'that resolves inside the project through a symlink counts as inside) and under your home, tmp or '
127
+ + 'AMICUS_PROJECT_ROOTS',
128
+ },
129
+ };
130
+ }
131
+ }
132
+ return {
133
+ error: null,
134
+ seatTools: seatPolicy.tools,
135
+ seatToolsLocal: seatPolicy.local,
136
+ councilAgents: o.agent ? null : seatTools.buildCouncilAgents({ tools: seatPolicy.tools }),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Validate the run's seat tools against the engine's own declaration, after
142
+ * the server is up and before any Stage-1 leg launches. Two independent
143
+ * checks:
144
+ *
145
+ * 1. Declared tool ids (ruling P2-R30): runs for the intent's DEFAULT too,
146
+ * not only an explicit opt-in — a defaults-only run is never launched
147
+ * against an engine that does not actually declare a tool it would use. A
148
+ * no-op whenever there is nothing to validate (`--agent`, or a review run
149
+ * with no tools). When the engine cannot be asked at all (no shared
150
+ * server), an explicit `--tools` opt-in still refuses, but a
151
+ * defaults-only run degrades quietly — nobody opted into this check.
152
+ *
153
+ * 2. The engine-rendered agents (ruling P2-R33), gated on `o.councilAgents &&
154
+ * canVerify`. Ruling P2-R38 (B1, round 3) DROPS the P2-R30 asymmetry for
155
+ * this half: once verification can run, a null agent list REFUSES
156
+ * regardless of whether `--tools` was ever typed — never launch council
157
+ * agents this run could not verify. `canVerify` is true when this run owns
158
+ * its server (production never injects `deps.launchers`; run.js only
159
+ * acquires the shared server when `!deps.launchers`) OR a test supplies
160
+ * its own lister (`deps.listEngineAgentsFn`). With injected launchers and
161
+ * no lister there is no real server to ask and the launchers ARE the
162
+ * test's own transport, so the check is skipped rather than judged against
163
+ * a null it could never have resolved. In production `deps.launchers` is
164
+ * never injected, so verification always runs and a missing list always
165
+ * refuses. When verifiable, every directory in run-seat-tools-verify.js ::
166
+ * verificationDirectories (ruling P2-R39 adds `_scratch`) is checked
167
+ * against council-seat/council-support's rendered rules
168
+ * (run-seat-tools-verify.js :: verifyAgentRendering). Named mutant
169
+ * TRIPWIREOFF: skipping this whole block leaves a widened agent undetected.
170
+ * @param {object} o intent/tools/seatTools/seatToolsLocal/councilAgents/runDir/project
171
+ * @param {{serverClient: object}|null} sharedServer
172
+ * @param {{listEngineToolIdsFn?: Function, listEngineAgentsFn?: Function,
173
+ * launchers?: object}} deps test seams; `launchers` gates `canVerify` (see above)
174
+ * @returns {Promise<{error: {code: string, message: string}|null}>}
175
+ */
176
+ async function validateSeatToolsAgainstEngine(o, sharedServer, deps = {}) {
177
+ // Named mutant DEFAULTSUNCHECKED: narrowing this to `Array.isArray(o.tools)
178
+ // && o.tools.length` (the pre-P2-R30 guard) would skip a task-intent run
179
+ // with NO explicit --tools even when the engine declares no `webfetch` at
180
+ // all. Pinned by run-tools.test.js's "no tools, engine has no webfetch" case.
181
+ if (Array.isArray(o.seatTools) && o.seatTools.length) {
182
+ const seatTools = require('./seat-tools');
183
+ const listIds = deps.listEngineToolIdsFn || require('./run-server').listEngineToolIds;
184
+ const declared = await listIds(sharedServer, o.project);
185
+ if (!declared) {
186
+ // No way to ask: an explicit opt-in is refused (as before P2-R30); a
187
+ // defaults-only run's declared-id check is skipped instead — nobody
188
+ // opted into it and the shared-server degrade is recorded elsewhere —
189
+ // but the run still falls through to the agent-rendering tripwire
190
+ // below (ruling P2-R38): a defaults-only run is refused there too once
191
+ // a null agent list comes back. Named mutant EARLYRETURN: restoring
192
+ // `return { error: null }` here lets a task-intent default run launch
193
+ // unverified when the engine lists no tool ids.
194
+ if (Array.isArray(o.tools) && o.tools.length) {
195
+ return {
196
+ error: {
197
+ code: 'BAD_ARGS',
198
+ message: 'Error: --tools could not be validated: the run\'s engine did not list its tools '
199
+ + '(no shared server, or the tool-ids endpoint failed); nothing was launched',
200
+ },
201
+ };
202
+ }
203
+ } else {
204
+ // This second resolveSeatTools call is a GATE, not a recompute:
205
+ // `o.seatTools` (preflightSeatTools's result) is already authoritative
206
+ // and unchanged by this check, so `checked.tools`/`checked.local` are
207
+ // deliberately discarded here — only `checked.ok` (declared-id
208
+ // refusals, now over the default too) is consulted.
209
+ const checked = seatTools.resolveSeatTools({ intent: seatIntentOf(o), optIn: o.tools || [], declaredIds: declared });
210
+ if (!checked.ok) { return { error: { code: checked.code, message: `Error: ${checked.message}` } }; }
211
+ }
212
+ }
213
+ // Ruling P2-R38: the run owns its server (production never injects
214
+ // `launchers`) OR a test injects its own lister — either way there is
215
+ // something real to judge a null answer against. See the docblock above.
216
+ const canVerify = !!deps.listEngineAgentsFn || !deps.launchers;
217
+ if (o.councilAgents && canVerify) {
218
+ const { listEngineAgents, verifyAgentRendering, verifyAgentFields, verificationDirectories } = require('./run-seat-tools-verify');
219
+ const agentsFn = deps.listEngineAgentsFn || listEngineAgents;
220
+ // Shared by both checks below (permission rendering and, ruling P2-R53,
221
+ // the non-permission surface) so their refusal is byte-identical either
222
+ // way — a caller cannot tell which check caught the tree from the message.
223
+ const renderMismatch = (reason, dir) => ({
224
+ error: {
225
+ code: 'BAD_ARGS',
226
+ message: 'Error: the engine rendered the council agents differently from what this run registered '
227
+ + `(${reason}, directory ${dir}) — an opencode.json or .opencode/agent file the engine `
228
+ + 'loads for that directory (the tree\'s, or your global config) defines council-seat/'
229
+ + 'council-support and alters them; remove those entries, or run with --agent Plan to use the '
230
+ + 'engine\'s own agent knowingly (v4.9.7 behaviour). Nothing was launched.',
231
+ },
232
+ });
233
+ const directories = verificationDirectories(o);
234
+ for (const dir of directories) {
235
+ const list = await agentsFn(sharedServer, dir);
236
+ if (!list) {
237
+ // Ruling P2-R38: never launch council agents this run could not
238
+ // verify — a defaults-only run now refuses exactly like an explicit
239
+ // opt-in; there is no quiet degrade left to fall back on.
240
+ return {
241
+ error: {
242
+ code: 'BAD_ARGS',
243
+ message: 'Error: the council agents could not be verified against the run\'s engine '
244
+ + `(${sharedServer ? 'the shared server answered without an agent list' : 'no shared server was available to answer the agent list'}); `
245
+ + 'nothing was launched; --agent Plan runs every leg on the engine\'s own Plan agent as v4.9.7 did '
246
+ + '(reads, searches and shell allowed; edits denied), knowingly and without the allowlist',
247
+ },
248
+ };
249
+ }
250
+ for (const [name, allowlist] of [['council-seat', o.seatTools || []], ['council-support', []]]) {
251
+ const agent = list.find((a) => a.name === name);
252
+ if (!agent) {
253
+ return { error: { code: 'BAD_ARGS', message: `Error: the engine did not register ${name}` } };
254
+ }
255
+ const verified = verifyAgentRendering(Array.isArray(agent.permission) ? agent.permission : [], allowlist);
256
+ if (!verified.ok) { return renderMismatch(verified.reason, dir); }
257
+ // Ruling P2-R53 (B1, round 6): verifyAgentRendering only ever checked
258
+ // `permission` — a tree can ALSO set a council agent's prompt, model,
259
+ // sampling, options and mode (measured 2026-09-13). Named mutant
260
+ // FIELDSBLIND (in verifyAgentFields itself): returning {ok:true}
261
+ // unconditionally there reddens this call site's own coverage too.
262
+ const fieldsVerified = verifyAgentFields(agent);
263
+ if (!fieldsVerified.ok) { return renderMismatch(fieldsVerified.reason, dir); }
264
+ }
265
+ }
266
+ }
267
+ return { error: null };
268
+ }
269
+
270
+ const { listEngineAgents, verifyAgentRendering, verifyAgentFields } = require('./run-seat-tools-verify');
271
+
272
+ module.exports = {
273
+ preflightSeatTools, validateSeatToolsAgainstEngine, listEngineAgents, verifyAgentRendering, verifyAgentFields,
274
+ };
@@ -21,10 +21,12 @@
21
21
  * (opencode-client.js) passes only hostname/port/signal/config to
22
22
  * `createOpencodeServer`. The server is directory-agnostic.
23
23
  * 2. Scoping is PER CALL: run-launch.js sets `directory: opts.project` on
24
- * every launch, fanout threads it to each leg, and runHeadless turns it
25
- * into `query.directory` on create/prompt/messages/status/abort (dirArgs,
26
- * headless.js). A judge's calls carry `_scratch`; a Stage-1 leg's carry the
27
- * run dir. One server answers both, scoped per request.
24
+ * every launch except a stage-1 seat launch (`opts.role === 'seat'`,
25
+ * P2-R11) that passes `opts.directory` never a judge/debate/chair
26
+ * leg's. fanout threads it to each leg, and runHeadless turns it into
27
+ * `query.directory` on create/prompt/messages/status/abort (dirArgs,
28
+ * headless.js). A judge's calls carry `_scratch`; a Stage-1 leg's carry
29
+ * the run dir. One server answers both, scoped per request.
28
30
  * 3. The MCP surface is identical for both stages already: every council
29
31
  * launch passes `noMcp: true` and nothing else MCP-related, and fanout's
30
32
  * buildMcpConfig call receives no `projectDir`, so its result is a pure
@@ -216,7 +218,12 @@ async function acquireRunServer(o, deps = {}) {
216
218
  for (const notice of notices) { process.stderr.write(`Notice: ${notice}\n`); }
217
219
 
218
220
  try {
219
- const { client, server } = await startFn(mcpServers, { models });
221
+ const { client, server } = await startFn(mcpServers, {
222
+ models,
223
+ // Spec 2026-09-11 §4: the run's two council agents (run.js computes them
224
+ // from the intent and --tools before this call).
225
+ ...(o.councilAgents ? { agents: o.councilAgents } : {}),
226
+ });
220
227
  logger.info('Council run using ONE shared OpenCode server',
221
228
  { runId: o.runId, url: server.url, models: models.length });
222
229
  // The POSITIVE, durable signal (see the ⚠️ above). `goPid` is the field that
@@ -227,6 +234,7 @@ async function acquireRunServer(o, deps = {}) {
227
234
  sharedServer: {
228
235
  acquired: true, at: new Date().toISOString(),
229
236
  goPid: (server && server.goPid) || null, models: models.length,
237
+ agents: Object.keys(o.councilAgents || {}),
230
238
  },
231
239
  }, 'sharedServer');
232
240
  return { serverClient: client, server };
@@ -262,4 +270,31 @@ async function releaseRunServer(shared) {
262
270
  try { await shared.server.close(); } catch { /* best-effort: the run is over */ }
263
271
  }
264
272
 
265
- module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels, recordServerFate };
273
+ /**
274
+ * The tool ids the run's engine declares (spec 2026-09-11 §4): what `--tools`
275
+ * is validated against, read from the engine itself so the accepted set is
276
+ * never hand-listed. Best-effort and never throws: null means "could not ask"
277
+ * (no shared server, an engine without the endpoint, a transport error), and
278
+ * run.js refuses `--tools` on null rather than launching unvalidated.
279
+ * Measured 2026-09-12 on the pinned SDK 1.18.15 with a keyless server start
280
+ * (`GET /experimental/tool/ids`); the keyless probe suite
281
+ * `tests/council-agents-engine.integration.test.js`, added later in this PR,
282
+ * pins it in CI (P2-R12: this citation named that file before it existed).
283
+ * @param {{serverClient: object}|null} shared
284
+ * @param {string} directory the project directory the query is scoped to
285
+ * @returns {Promise<string[]|null>}
286
+ */
287
+ async function listEngineToolIds(shared, directory) {
288
+ const client = shared && shared.serverClient;
289
+ if (!client || !client.tool || typeof client.tool.ids !== 'function') { return null; }
290
+ try {
291
+ const res = await client.tool.ids({ query: { directory } });
292
+ return (res && Array.isArray(res.data)) ? res.data.slice() : null;
293
+ } catch (err) {
294
+ const { logger } = require('../utils/logger');
295
+ logger.debug('Engine tool list unavailable', { error: err.message });
296
+ return null;
297
+ }
298
+ }
299
+
300
+ module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels, recordServerFate, listEngineToolIds };
@@ -33,6 +33,11 @@ async function launchStage1(ctx) {
33
33
  // the chair (run-chair.js) and debate legs (run-debate.js) never receive
34
34
  // this, so they never substitute via chains.
35
35
  fallback: o.fallback, catalog: o.catalog,
36
+ // Spec 2026-09-11 §4: stage-1 legs are SEATS (council-seat); with a local
37
+ // tool opted in they are scoped to the project tree while their metadata
38
+ // stays in the run dir (`project: o.runDir` above).
39
+ role: 'seat',
40
+ ...(o.seatToolsLocal ? { directory: o.project } : {}),
36
41
  };
37
42
  const launches = [];
38
43
  const seated = []; // parallel to `launches`: what each one was SUPPOSED to seat
@@ -47,7 +52,7 @@ async function launchStage1(ctx) {
47
52
  seated.push({ waveId, models: [m], roster: seats.slice(i, i + 1) });
48
53
  launches.push(launchers.launchSolo({
49
54
  ...common, model: m, waveId, seats: seated[seated.length - 1].roster,
50
- prompt: briefings.stage1LensBriefing(o.intent, { lens: o.lenses[i], briefing: o.briefing, date: o.date }),
55
+ prompt: briefings.stage1LensBriefing(o.intent, { lens: o.lenses[i], briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent }),
51
56
  }));
52
57
  });
53
58
  } else {
@@ -61,7 +66,7 @@ async function launchStage1(ctx) {
61
66
  roster: seats.filter(s => s.alias !== o.critic) });
62
67
  launches.push(launchers.launchWave({
63
68
  ...common, models: seats1, waveId: `${o.runId}-s1`, seats: seated[seated.length - 1].roster,
64
- prompt: briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date }),
69
+ prompt: briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent }),
65
70
  }));
66
71
  }
67
72
  if (o.critic) {
@@ -70,7 +75,7 @@ async function launchStage1(ctx) {
70
75
  roster: seats.filter(s => s.alias === o.critic).slice(0, 1) });
71
76
  launches.push(launchers.launchSolo({
72
77
  ...common, model: o.critic, waveId: `${o.runId}-c1`, seats: seated[seated.length - 1].roster,
73
- prompt: briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date }),
78
+ prompt: briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent }),
74
79
  }));
75
80
  }
76
81
  }
@@ -38,13 +38,13 @@ const { finishRun } = require('./run-finish');
38
38
  /**
39
39
  * @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
40
40
  * runDir, timeout?, maxCost?, gateway?, noValidateModel?, date, debate?, noCostGate?,
41
- * councilName?, fallback?, catalog?} councilName (v4.3 Task 3) = preset name when
42
- * launched via `--council <preset>`, else null — threaded via ctx.o into every
43
- * launchWave/launchSolo for leg ledger attribution. fallback/catalog (v4.3 Task 18
44
- * §6.2): ctx.o carries both, but only run-stages.js's stage launches read them —
45
- * the chair/debate legs never substitute via chains.
41
+ * councilName?, fallback?, catalog?, tools?: string[], agent?: 'Plan'|'Build'} councilName
42
+ * (v4.3 Task 3) = preset name when launched via `--council <preset>`, else null —
43
+ * threaded via ctx.o into every launchWave/launchSolo for leg ledger attribution.
44
+ * fallback/catalog (v4.3 Task 18 §6.2): ctx.o carries both, but only run-stages.js's
45
+ * stage launches read them — the chair/debate legs never substitute via chains.
46
46
  * @param {object} [deps] {launchers?, appendRunFn?, statsFn?, installSignalAbortFn?,
47
- * startOpenCodeServerFn? (v4.4.1 Task 0.5 test seam, see ./run-server)}
47
+ * startOpenCodeServerFn?, listEngineToolIdsFn? (v4.4.1 Task 0.5 / spec 2026-09-11 §4 test seams, see ./run-server)}
48
48
  * @returns {Promise<{exitCode: number, run: object}>}
49
49
  */
50
50
  async function runCouncil(options, deps = {}) {
@@ -70,22 +70,13 @@ async function runCouncil(options, deps = {}) {
70
70
  // below (a getter, because the launchers are built first); null = as before.
71
71
  let sharedServer = null;
72
72
  const launchers = deps.launchers
73
- || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer });
73
+ || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer,
74
+ councilAgents: () => o.councilAgents || null, agentOverride: () => o.agent }); // spec 2026-09-11 §4: getters, decided below.
74
75
 
75
76
  runState.initCouncilRun(o); // run.json seed + sessions-dir pointer (run-state.js)
76
77
 
77
- // dropped-members (spec §5, Plan 4): a seat the user's preset requested that
78
- // never resolved is a lost seat — announced like every other loss. Fires
79
- // once per member, before any launch (zero spend), for BOTH transports.
80
- for (const dm of o.droppedMembers || []) {
81
- degrade.note({
82
- channel: 'dropped-members',
83
- what: `seat ${dm.member} was not seated`,
84
- why: dm.reason,
85
- effect: 'the bench is smaller than the preset requested; the run will exit degraded (2)',
86
- data: { member: dm.member, reason: dm.reason },
87
- });
88
- }
78
+ // dropped-members announcement lives in ./run-degrade (300-line gate, P2-R14).
79
+ require('./run-degrade').noteDroppedMembers(degrade, o.droppedMembers);
89
80
 
90
81
  emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
91
82
 
@@ -113,8 +104,15 @@ async function runCouncil(options, deps = {}) {
113
104
  return { exitCode: code, run };
114
105
  };
115
106
 
107
+ // Spec 2026-09-11 §4 (PR 2): seat tools decided + refused pre-spend, checked below.
108
+ const st = require('./run-seat-tools').preflightSeatTools(o);
109
+ if (st.error) { return finalize(1, st.error); }
110
+ Object.assign(o, { seatTools: st.seatTools, seatToolsLocal: st.seatToolsLocal, councilAgents: st.councilAgents });
111
+
116
112
  // Injected launchers bring their own transport. Never throws — degrades to null.
117
113
  if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer({ ...o, degrade }, deps); }
114
+ const ev = await require('./run-seat-tools').validateSeatToolsAgainstEngine(o, sharedServer, deps);
115
+ if (ev.error) { return finalize(1, ev.error); }
118
116
 
119
117
  const ctx = { o, launchers, addWave, overBudget, degrade, scratchDir: path.join(o.runDir, '_scratch') };
120
118
 
@@ -142,11 +140,13 @@ async function runCouncil(options, deps = {}) {
142
140
  o.seats = seatPre.seats;
143
141
  o.criticSeat = seatPre.criticSeat;
144
142
  runState.checkpoint(o.runDir, { seats: o.seats, criticSeat: o.criticSeat,
145
- ...(o.intent === 'task' ? { intent: 'task' } : {}) }); // v4.9 W5.3: emit-when-'task', never 'review'
143
+ ...(o.intent === 'task' ? { intent: 'task' } : {}), // v4.9 W5.3: emit-when-'task', never 'review'
144
+ // spec §4: seatTools emit-when-non-empty, agentOverride emit-when-set.
145
+ ...(o.seatTools && o.seatTools.length ? { seatTools: o.seatTools } : {}), ...(o.agent ? { agentOverride: o.agent } : {}) });
146
146
 
147
147
  // Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
148
148
  fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
149
- briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date }), { mode: 0o600 });
149
+ briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent }), { mode: 0o600 });
150
150
 
151
151
  // ---- Stage 1: independent reviews ----
152
152
  // Lens mode launches one solo per seat instead of a `-s1` seat wave, so it