amicus 4.9.7 → 4.9.8

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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +19 -4
  6. package/docs/council.md +140 -3
  7. package/docs/usage.md +8 -4
  8. package/package.json +1 -1
  9. package/schemas/council-verdict.schema.json +3 -1
  10. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  11. package/src/cli-council-run-tools.js +168 -0
  12. package/src/cli-handlers-council-run.js +6 -6
  13. package/src/cli.js +23 -1
  14. package/src/council/briefings-chair.js +1 -1
  15. package/src/council/briefings-task.js +11 -5
  16. package/src/council/briefings.js +25 -7
  17. package/src/council/report-lost-rows.js +89 -0
  18. package/src/council/report-md.js +3 -1
  19. package/src/council/report.js +3 -2
  20. package/src/council/run-degrade.js +22 -1
  21. package/src/council/run-finish.js +23 -1
  22. package/src/council/run-launch.js +33 -4
  23. package/src/council/run-retry-launch.js +9 -4
  24. package/src/council/run-retry.js +3 -0
  25. package/src/council/run-seat-tools-verify.js +296 -0
  26. package/src/council/run-seat-tools.js +274 -0
  27. package/src/council/run-server.js +41 -6
  28. package/src/council/run-stage1-launch.js +8 -3
  29. package/src/council/run.js +21 -21
  30. package/src/council/seat-tools.js +299 -0
  31. package/src/council/verdict-seats-reviewed.js +76 -6
  32. package/src/headless.js +136 -6
  33. package/src/mcp-council-pack-map.js +24 -0
  34. package/src/mcp-council-run.js +17 -15
  35. package/src/mcp-server.js +2 -2
  36. package/src/mcp-tools.js +15 -4
  37. package/src/opencode-client.js +26 -0
  38. package/src/pack/pack-validate.js +3 -1
  39. package/src/prompt-builder.js +2 -2
  40. package/src/sidecar/fanout.js +7 -1
  41. package/src/sidecar/heartbeat.js +46 -0
  42. package/src/sidecar/session-utils.js +7 -34
  43. package/src/utils/agent-mapping.js +1 -1
  44. package/src/utils/degrade.js +8 -0
@@ -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
@@ -0,0 +1,299 @@
1
+ // src/council/seat-tools.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/seat-tools
6
+ * Per-run tool policy for council seats (spec 2026-09-11 §4, PR 2 of 3).
7
+ *
8
+ * The study (§1) found two of the three leg-loss classes share one precondition:
9
+ * a stage-1 seat reached for a tool it did not need (gemini grep/glob over the
10
+ * global install, cohere `task {}`). Tool access is a property of the RUN, set
11
+ * by the caller according to whether the seat must go and get its material
12
+ * (§2.1): task mode defaults to `webfetch`, review to nothing, `--tools` opts
13
+ * more in, and two ids are never available to a headless seat without the
14
+ * `--agent` override. This module is pure: it decides, and builds the two agent
15
+ * configs the run's OpenCode server registers (run-server.js). Nothing here
16
+ * talks to the engine — the engine's declared ids are passed IN (`declaredIds`)
17
+ * by run.js after the server is up, so the accepted set is never hand-listed.
18
+ */
19
+
20
+ const { CHAIR_NO_TOOLS_LEAD } = require('./briefings-chair');
21
+
22
+ /**
23
+ * Ids a council seat may never opt into. Hand-listed on purpose: each names why.
24
+ * Everything else is validated against the engine's own `tool.ids()`.
25
+ */
26
+ const REFUSED_TOOL_IDS = Object.freeze({
27
+ task: 'spawns child sessions amicus cannot observe',
28
+ skill: 'is where a seat starts reading the harness instead of the brief',
29
+ question: 'asks a human, and a headless leg has none',
30
+ invalid: 'is the engine\'s error surface, not a tool',
31
+ edit: 'a council seat never modifies the tree',
32
+ write: 'a council seat never modifies the tree',
33
+ apply_patch: 'a council seat never modifies the tree',
34
+ });
35
+
36
+ /** Ids that never touch the local tree: the remote fetchers, plus the engine's own todo list. */
37
+ const NON_LOCAL_TOOL_IDS = Object.freeze(['webfetch', 'websearch', 'todowrite']);
38
+ const SEAT_READ_DENY_PATTERNS = Object.freeze(['*.env', '*.env.*', '*.envrc']); // measured 2026-09-12: patterns a granted `read` must never cover
39
+
40
+ /**
41
+ * True if `tools` contains anything outside NON_LOCAL_TOOL_IDS. Single source for the
42
+ * local/remote predicate — resolveSeatTools and seatToolsSentence both call this
43
+ * instead of each re-writing the same `.some()` (review r1 P2-R8: before this fix,
44
+ * `buildCouncilAgents` accepted a `local` flag that could contradict its own `tools`,
45
+ * silently dropping `external_directory: 'deny'`; it now denies that key
46
+ * unconditionally and takes no `local` flag). A third caller (PR 2
47
+ * Task 5 review r1, P2-R19): cli-council-run-tools.js's out-dir fence, which decides
48
+ * whether a run's directory may sit outside the project tree.
49
+ * @param {string[]} tools @returns {boolean}
50
+ */
51
+ function isLocal(tools) {
52
+ return tools.some((id) => !NON_LOCAL_TOOL_IDS.includes(id));
53
+ }
54
+
55
+ /**
56
+ * `--tools`/`--agent` are mutually exclusive (ruling P2-R28, supersedes P2-R25's
57
+ * MCP-only short-circuit): the override already runs every leg on its own agent.
58
+ * @param {string|null|undefined} agent @param {string[]|undefined} tools
59
+ * @returns {string|null} the refusal message, or null when there is no conflict
60
+ */
61
+ function agentToolsConflict(agent, tools) {
62
+ if (!agent || !Array.isArray(tools) || !tools.length) { return null; }
63
+ return `--tools cannot be combined with --agent: the override runs every leg on the engine's own ${agent} agent with that agent's own tool set; drop one of them`;
64
+ }
65
+
66
+ const ESCAPE_HATCH = '--agent Build';
67
+ const ID_SHAPE = /^[a-z][a-z0-9_]*$/;
68
+
69
+ /** @param {'task'|undefined} intent @returns {string[]} */
70
+ function defaultToolsFor(intent) {
71
+ return intent === 'task' ? ['webfetch'] : [];
72
+ }
73
+
74
+ /**
75
+ * Normalize raw values the way `--tools` does: String, trim, lowercase, drop empties.
76
+ * Shared by parseToolsFlag and resolveSeatTools so a value from EITHER door — the CLI
77
+ * flag's comma string or a direct `optIn` array (MCP arg, a config list) — is cleaned
78
+ * identically before it is ever compared against REFUSED_TOOL_IDS or ID_SHAPE.
79
+ * @param {unknown[]} raw @returns {string[]}
80
+ */
81
+ function normalizeIds(raw) {
82
+ return raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
83
+ }
84
+
85
+ /**
86
+ * The one shape-error message text both doors use, so it has a single source.
87
+ * @param {string[]} ids @returns {string|null}
88
+ */
89
+ function shapeErrorFor(ids) {
90
+ const bad = ids.filter((id) => !ID_SHAPE.test(id));
91
+ return bad.length ? `--tools: not a tool id: ${bad.join(', ')}` : null;
92
+ }
93
+
94
+ /**
95
+ * The `--tools` flag's value → ids. Shape only; refusals and the engine check
96
+ * live in resolveSeatTools so every door reaches them.
97
+ * @param {unknown} value
98
+ * @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
99
+ */
100
+ function parseToolsFlag(value) {
101
+ if (typeof value !== 'string' || !value.trim()) {
102
+ return { ok: false, message: '--tools requires a comma-separated list of tool ids (e.g. --tools webfetch,read)' };
103
+ }
104
+ const ids = [...new Set(normalizeIds(value.split(',')))];
105
+ if (!ids.length) { return { ok: false, message: '--tools requires at least one tool id' }; }
106
+ const shapeError = shapeErrorFor(ids);
107
+ if (shapeError) { return { ok: false, message: shapeError }; }
108
+ return { ok: true, ids };
109
+ }
110
+
111
+ /**
112
+ * The REFUSED_TOOL_IDS check, factored out of resolveSeatTools (review r1
113
+ * P2-R20) so it has one message and one caller list: resolveSeatTools (CLI
114
+ * defaults + opt-in) and resolveRemoteOnlyTools (the MCP door) must refuse
115
+ * `task`/`skill`/… identically — a permanently-refused id is refused for the
116
+ * SAME reason regardless of which door it arrived through, never described
117
+ * as a placement (local-vs-remote) problem the way an ordinary local tool is.
118
+ * `ids` must already be normalized (lowercase/trimmed) — every caller here
119
+ * goes through normalizeIds or parseToolsFlag first.
120
+ * @param {string[]} ids @returns {{ok: false, code: 'BAD_ARGS', message: string}|null}
121
+ */
122
+ function refusalFor(ids) {
123
+ const refused = ids.filter((id) => Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id));
124
+ if (!refused.length) { return null; }
125
+ return {
126
+ ok: false, code: 'BAD_ARGS',
127
+ message: `--tools: ${refused.map((id) => `${id} (${REFUSED_TOOL_IDS[id]})`).join('; ')} — refused for council seats; ` +
128
+ `${ESCAPE_HATCH} runs every leg on the engine's full Build agent instead`,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * The MCP door's tool policy (spec 2026-09-11 §4, ledger P2-R2): over MCP the
134
+ * run directory must stay inside the project (the fence in mcp-council-run.js),
135
+ * so a seat cannot be placed there with a LOCAL tool — refused with a message
136
+ * naming the CLI command that DOES allow it (an out-of-project --out-dir). A
137
+ * PERMANENTLY-refused id (review r1 P2-R20: task/skill/question/invalid/edit/
138
+ * write/apply_patch) is refused first, with refusalFor's reason — it is not a
139
+ * placement problem, and NON_LOCAL_TOOL_IDS.includes(id) is false for every one
140
+ * of them, so without this check the local-tools branch below caught them too
141
+ * and suggested an --out-dir command that would ALSO fail (resolveSeatTools
142
+ * refuses these ids unconditionally, everywhere, run-directory or not).
143
+ * Tools that never touch the tree (webfetch, websearch, todowrite) carry no
144
+ * placement requirement and ride through. Shape-checked the same way the CLI flag is (parseToolsFlag), so
145
+ * `mcp-council-run.js` never re-implements comma-splitting/normalizing for an
146
+ * input that happens to arrive as an array instead of a flag string. An empty
147
+ * array is treated as absent (`{ok: true, ids: []}`) rather than the
148
+ * `--tools`-with-nothing-typed shape error parseToolsFlag('') would raise —
149
+ * the MCP schema's `z.array(z.string().min(1))` allows `[]` (`.min(1)`
150
+ * constrains each string, not the array), so a caller can send it without
151
+ * ever having typed a flag at all.
152
+ * @param {string[]|string} input MCP `tools` input: an array (the declared
153
+ * schema shape) or a string (defense-in-depth for a caller that bypasses it).
154
+ * @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
155
+ */
156
+ function resolveRemoteOnlyTools(input) {
157
+ if (Array.isArray(input) && !input.length) { return { ok: true, ids: [] }; }
158
+ const parsed = parseToolsFlag(Array.isArray(input) ? input.join(',') : String(input));
159
+ if (!parsed.ok) { return { ok: false, message: parsed.message }; }
160
+ const refusal = refusalFor(parsed.ids);
161
+ if (refusal) { return { ok: false, message: refusal.message }; }
162
+ // Named mutant MCPLOCALLEAK: dropping this filter/refusal lets a local id
163
+ // (e.g. `read`) ride through as `ok: true`, reaching the spawned CLI child
164
+ // whose run dir is fenced INSIDE the project — the exact placement spec §4
165
+ // forbids. Reddens 'a local id is refused with a message naming the CLI
166
+ // (MCPLOCALLEAK target)' in tests/council/seat-tools.test.js and 'a local
167
+ // tool over MCP is refused before anything spawns — the MCP run dir must
168
+ // stay inside the project' in tests/mcp-council-run.test.js.
169
+ const local = parsed.ids.filter((id) => !NON_LOCAL_TOOL_IDS.includes(id));
170
+ if (local.length) {
171
+ return {
172
+ ok: false,
173
+ message: `tools: ${local.join(', ')} are local tools; over MCP the run directory must stay inside the project, ` +
174
+ 'and a seat with local tools must not run there. Use `amicus council run --tools ' + parsed.ids.join(',') +
175
+ ' --out-dir <dir outside the project>` from the CLI.',
176
+ };
177
+ }
178
+ return { ok: true, ids: parsed.ids };
179
+ }
180
+
181
+ /**
182
+ * Decide the seat's tool list: `defaultToolsFor(intent) ∪ optIn`, minus nothing —
183
+ * a refused, malformed, or unknown id fails the whole run BEFORE any spend.
184
+ * @param {{intent?: 'task'|undefined, optIn?: string[], declaredIds?: string[]|null}} args
185
+ * `declaredIds` null/absent = the engine has not been asked yet (shape + refusals only).
186
+ * @returns {{ok: true, tools: string[], local: boolean}|{ok: false, code: 'BAD_ARGS', message: string}}
187
+ */
188
+ function resolveSeatTools({ intent, optIn = [], declaredIds = null } = {}) {
189
+ if (!Array.isArray(optIn)) { return { ok: false, code: 'BAD_ARGS', message: `--tools must be a list of tool ids (got ${typeof optIn})` }; }
190
+ // Normalize + shape-check optIn BEFORE anything else, so a door that never goes
191
+ // through parseToolsFlag (an MCP arg array, a config list) reaches the same gates
192
+ // the --tools flag does. Skipping this is a named mutant: OPTINRAW — 'Task' would
193
+ // then dodge the refusal check below (REFUSED_TOOL_IDS keys are lowercase) and
194
+ // '../x' would dodge the shape check whenever declaredIds is null; reddens 'a
195
+ // case-variant refused id is caught without the engine list' and 'an opted-in id
196
+ // with a bad shape is refused without the engine list' in seat-tools.test.js.
197
+ const normalizedOptIn = normalizeIds(optIn);
198
+ const shapeError = shapeErrorFor(normalizedOptIn);
199
+ if (shapeError) { return { ok: false, code: 'BAD_ARGS', message: shapeError }; }
200
+ const requested = [...new Set([...defaultToolsFor(intent), ...normalizedOptIn])];
201
+ const refusal = refusalFor(requested);
202
+ if (refusal) { return refusal; }
203
+ if (Array.isArray(declaredIds)) {
204
+ const unknown = requested.filter((id) => !declaredIds.includes(id));
205
+ if (unknown.length) {
206
+ const offered = declaredIds.filter((id) => !Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id)).sort();
207
+ return {
208
+ ok: false, code: 'BAD_ARGS',
209
+ message: `--tools: the engine does not declare ${unknown.join(', ')}; it declares: ${offered.join(', ')}`,
210
+ };
211
+ }
212
+ }
213
+ const tools = requested.slice().sort();
214
+ return { ok: true, tools, local: isLocal(tools) };
215
+ }
216
+
217
+ /**
218
+ * The two agents the run's server registers (spec §4). `'*': false` is the
219
+ * engine's wildcard (measured 2026-09-12: renders a `*=deny` rule, and each
220
+ * `true` renders `<id>=allow` after it). The `webfetch` permission key below
221
+ * follows the `tools` map by construction, not by separate measurement — the
222
+ * 2026-09-12 pass measured `edit`/`bash`/`external_directory`, not `webfetch`;
223
+ * Task 7's engine probe is what asserts the map and the permission stay in
224
+ * agreement on the pinned engine.
225
+ *
226
+ * `.env` files (review r1 P2-R9, superseding the brief's "read[*.env]=ask → B53
227
+ * stall" sentence): the engine evaluates permission with `findLast` over the
228
+ * merged rule list (opencode v1.18.15, packages/opencode/src/permission/index.ts),
229
+ * and its own `read[*.env]=ask` default rule renders BEFORE the seat's tools-map
230
+ * `read=allow` — so a bare `tools.includes('read')` would let a seat granted
231
+ * `read` read `.env` outright, not stall; that is a secrets exposure, not a
232
+ * documented limitation. Measured fix: a nested `permission.read` object renders
233
+ * AFTER the tools map and wins under `findLast`. `read: { '*': 'allow', '*.env':
234
+ * 'deny', '*.env.*': 'deny' }` is measured to deny `.env`/`.env.*` and allow
235
+ * everything else; the `'*': 'allow'` entry is REQUIRED — measured without it,
236
+ * the object REPLACES rather than refines the tools-derived allow and ordinary
237
+ * reads fall through to `*=deny`. `grep` and `bash` have no such fence: opting
238
+ * either in trusts the seat with the tree's contents, `.env` included. Never
239
+ * emits `chat`.
240
+ * @param {{tools: string[]}} args
241
+ * @returns {{'council-seat': object, 'council-support': object}}
242
+ */
243
+ function buildCouncilAgents({ tools = [] } = {}) {
244
+ const allow = Object.fromEntries(tools.map((id) => [id, true]));
245
+ return {
246
+ 'council-support': {
247
+ description: 'Council support role (repair, judge, debate, chair): no tools — the material is in the briefing.',
248
+ mode: 'primary',
249
+ tools: { '*': false },
250
+ permission: { edit: 'deny', bash: 'deny', webfetch: 'deny', external_directory: 'deny' },
251
+ },
252
+ 'council-seat': {
253
+ description: `Council stage-1 seat: tools ${tools.length ? tools.join(', ') : 'none'}.`,
254
+ mode: 'primary',
255
+ tools: { '*': false, ...allow },
256
+ permission: {
257
+ edit: 'deny',
258
+ bash: tools.includes('bash') ? 'allow' : 'deny',
259
+ webfetch: tools.includes('webfetch') ? 'allow' : 'deny',
260
+ // council-seat always denies external directories; a tree cannot own
261
+ // that key — even a webfetch-only seat gets this rule (measured 2026-09-12).
262
+ external_directory: 'deny',
263
+ // The '*': 'allow' entry is load-bearing, not decorative (measured 2026-09-12):
264
+ // a nested `read` object REPLACES, rather than refines, the tools-map's
265
+ // read=allow under the engine's findLast evaluation. Dropping it is a named
266
+ // mutant: ENVALLOWDROP — every ordinary read would then fall through to the
267
+ // wildcard *=deny; reddens 'a read seat gets a nested .env-denying read
268
+ // permission' in seat-tools.test.js.
269
+ ...(tools.includes('read') ? { read: { '*': 'allow', ...Object.fromEntries(SEAT_READ_DENY_PATTERNS.map((p) => [p, 'deny'])) } } : {}),
270
+ },
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The briefing line a seat gets about its tools (spec §4 "briefing lines").
277
+ * No tools → the shared no-tools sentence (briefings-chair.js), forked only on
278
+ * its last word, exactly like the chair's. Config enforces; this informs (E1:
279
+ * told not to, gemini complied). Under `--agent` (ruling P2-R31), `tools` is ignored.
280
+ * @param {string[]} tools @param {'review'|'answer'} kind @param {{agent?: string}} [opts]
281
+ */
282
+ function seatToolsSentence(tools, kind, { agent } = {}) {
283
+ // Named mutant OVERRIDESENTENCEDROP: dropping this branch would brief an
284
+ // --agent-override seat as if a computed allowlist still applied.
285
+ if (agent) {
286
+ return `You run as the engine's ${agent} agent with its own tool set; use tools only where ` +
287
+ 'the deliverable needs them; if research is incomplete, say so in the deliverable rather ' +
288
+ 'than leave it unwritten.';
289
+ }
290
+ if (!tools || !tools.length) { return `${CHAIR_NO_TOOLS_LEAD}${kind}.`; }
291
+ const forbid = isLocal(tools) ? '' : ' — do not attempt to read files, search directories, or run commands';
292
+ return `Your tools: ${tools.join(', ')}. You have no others${forbid}; if research is incomplete, ` +
293
+ 'say so in the deliverable rather than leave it unwritten.';
294
+ }
295
+
296
+ module.exports = {
297
+ REFUSED_TOOL_IDS, REMOTE_TOOL_IDS: NON_LOCAL_TOOL_IDS, NON_LOCAL_TOOL_IDS, SEAT_READ_DENY_PATTERNS, defaultToolsFor,
298
+ parseToolsFlag, resolveSeatTools, resolveRemoteOnlyTools, buildCouncilAgents, seatToolsSentence, isLocal, agentToolsConflict,
299
+ };
@@ -14,15 +14,31 @@
14
14
  * repairs are not bench seats. `reviewed` is those whose leg completed: a
15
15
  * `timeout` is not a review any more than an `error` is.
16
16
  *
17
+ * `unverified` (#242 / spec §5, v4.9.8) is those bench seats whose findings came from a
18
+ * repair of a response with no parseable findings block — the LC-11 flag
19
+ * run-stages.js :: runStage1 sets on the row. The seat stays in `reviewed` (its leg
20
+ * completed) and is counted here too. ALWAYS written once the census is: 0 is a
21
+ * measurement, absence keeps its one meaning. Key order is reviewed / unverified / refused / of —
22
+ * the shape spec §5 names and the council-review check title prints. Not a stub count
23
+ * (study run B2: a real 19,064-byte review with a malformed trailing block carried the
24
+ * flag). A refused repair (`repairRefused`) is NOT counted under `unverified` — that seat
25
+ * tallied no findings at all — and since council #248 round 2 it has its OWN number,
26
+ * `refused` (P3-R17): a seat that contributed nothing is visible on the same gate surfaces
27
+ * (title, footer, stderr) and never reads as a full reviewer on them.
28
+ *
29
+ * `unverified ⊆ reviewed` and "bench rows only" are STRUCTURAL (council #248 round 1, B1/C2/D2):
30
+ * `isUnverifiedSeat` requires a bench role, `status: 'complete'` and the literal `true`, and
31
+ * report-lost-rows.js :: lostRowsOf uses that same function — so the census and the report agree
32
+ * on every input, engine-written or hand-assembled, and `unverified` can never exceed `reviewed`.
33
+ * On engine-written records the gate is a no-op: run-launch.js :: materializeReviews drops every
34
+ * non-complete leg before a repair can run, and seats.js :: buildSeats / run-stages.js :: roleFor
35
+ * mint only bench roles (V6/V14 show why hand-assembled records reach buildVerdict at all).
36
+ *
17
37
  * A LEAF: it requires nothing, matching its seat-loss sibling.
18
38
  */
19
39
 
20
40
  'use strict';
21
41
 
22
- /**
23
- * @param {Array<object>|undefined} runStats
24
- * @returns {{seatsReviewed?: {reviewed: number, of: number}}}
25
- */
26
42
  /**
27
43
  * Is this runStats row a BENCH seat — something that was asked to review?
28
44
  *
@@ -41,6 +57,47 @@ function isBenchRole(role) {
41
57
  || (typeof role === 'string' && role.startsWith('lens:'));
42
58
  }
43
59
 
60
+ /**
61
+ * The ONE predicate for "this row is an unverified review", shared with
62
+ * report-lost-rows.js :: lostRowsOf so the census and the report can never disagree
63
+ * (council #248 round 1, B1/C2/D2: `unverified` counted flagged rows regardless of
64
+ * status, so a hand-assembled or MCP record could publish `reviewed 0 · unverified 1`
65
+ * and CI would print `seats 0/1 (1 unverified)`). STRUCTURAL, not producer trust: a
66
+ * bench role, a COMPLETED leg, and the literal `true` tally.js emits. A flagged row that
67
+ * is not a completed bench seat is an unverified review of nothing — counted nowhere and
68
+ * rendered nowhere; a real dead leg has the sink's own dead-leg row. `unverified` can
69
+ * therefore never exceed `reviewed` (V15/V16). Named mutant: SUBSETBLIND
70
+ * (`&& r.status === 'complete'` deleted from this function).
71
+ */
72
+ // !Array.isArray: an array carrying named properties is not a row — lostRowsOf's own plain-object guard already skips it, and the census must agree.
73
+ function isUnverifiedSeat(r) {
74
+ return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role)
75
+ && r.status === 'complete' && r.findingsUnverified === true;
76
+ }
77
+
78
+ /** Its sibling for a refused repair: the same gate, and `repairRefused` a plain object. */
79
+ function isRefusedSeat(r) {
80
+ return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role) && r.status === 'complete'
81
+ && !!r.repairRefused && typeof r.repairRefused === 'object' && !Array.isArray(r.repairRefused);
82
+ }
83
+
84
+ /**
85
+ * The row's label on every human surface — the report rows and the end-of-run stderr line —
86
+ * so the two can never name one row differently (council #248 r2, A3): the seat id when the
87
+ * bench repeats an alias, else the alias (the `seat || model` rule the street-cred rows use in
88
+ * both renderers), and 'unknown' for a hand-assembled row carrying neither — silence would
89
+ * hide the flag.
90
+ */
91
+ function seatLabel(r) {
92
+ if (r && typeof r.seat === 'string' && r.seat) { return r.seat; }
93
+ if (r && typeof r.model === 'string' && r.model) { return r.model; }
94
+ return 'unknown';
95
+ }
96
+
97
+ /**
98
+ * @param {Array<object>|undefined} runStats
99
+ * @returns {{seatsReviewed?: {reviewed: number, unverified: number, refused: number, of: number}}}
100
+ */
44
101
  function seatsReviewedOf(runStats) {
45
102
  // ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
46
103
  // externally-supplied records that never touched tally() in-process — the MCP
@@ -49,12 +106,25 @@ function seatsReviewedOf(runStats) {
49
106
  // truthy non-array sails past `||` and throws on `.filter`, turning a missing
50
107
  // census into a crashed verdict build. The closed-literal comment further down
51
108
  // makes the same argument about the same caller.
52
- const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
109
+ // A row is a PLAIN OBJECT: an array wearing row properties is not a seat (council #248 r2, A5)
110
+ // the same guard the shared predicates carry, so of/reviewed/unverified/refused and the report
111
+ // rows all agree on every input.
112
+ const seats = (Array.isArray(runStats) ? runStats : [])
113
+ .filter(r => !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role));
53
114
  if (seats.length === 0) { return {}; }
54
115
  return { seatsReviewed: {
55
116
  reviewed: seats.filter(r => r.status === 'complete').length,
117
+ // The shared predicate (isUnverifiedSeat above): `=== true` matching tally.js's
118
+ // emit-when-true (V14), a completed leg (V15/V16). Named mutants: CENSUSZERO
119
+ // (`unverified: 0`) and SUBSETBLIND — tests/council/verdict.test.js.
120
+ unverified: seats.filter(isUnverifiedSeat).length,
121
+ // council #248 round 2 (A2/C2, ruling P3-R17): a refused repair — the seat tallied NO findings
122
+ // — was invisible on every gate surface while counting as a full reviewer. Its own number,
123
+ // always written, never more than `reviewed` (the same predicate gate), NOT folded into
124
+ // `unverified` (a seat with no findings has no unverified findings).
125
+ refused: seats.filter(isRefusedSeat).length,
56
126
  of: seats.length,
57
127
  } };
58
128
  }
59
129
 
60
- module.exports = { seatsReviewedOf };
130
+ module.exports = { seatsReviewedOf, isBenchRole, isUnverifiedSeat, isRefusedSeat, seatLabel };