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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +19 -4
- package/docs/council.md +140 -3
- package/docs/usage.md +8 -4
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli.js +23 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/session-utils.js +7 -34
- package/src/utils/agent-mapping.js +1 -1
- 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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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, {
|
|
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
|
-
|
|
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
|
}
|
package/src/council/run.js
CHANGED
|
@@ -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
|
|
42
|
-
* launched via `--council <preset>`, else null —
|
|
43
|
-
* launchWave/launchSolo for leg ledger attribution.
|
|
44
|
-
* §6.2): ctx.o carries both, but only run-stages.js's
|
|
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
|
|
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
|
|
78
|
-
|
|
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' } : {})
|
|
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
|
-
|
|
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 };
|