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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/architecture-map.md +41 -6
- package/docs/configuration.md +14 -8
- package/docs/council.md +140 -3
- package/docs/usage.md +29 -6
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- 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-handlers.js +8 -1
- package/src/cli.js +34 -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/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/setup.js +20 -18
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/degrade.js +8 -0
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- 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
|
|
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
|