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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cli-council-run-tools
|
|
3
|
+
* `--tools`/`--agent` validation and the v4.7 out-dir fence for `council run`.
|
|
4
|
+
*
|
|
5
|
+
* Spec 2026-09-11 §4. Split out of cli-handlers-council-run.js (P2-R15, PR 2
|
|
6
|
+
* Task 5): that file sits at the 300-line pre-commit size gate and the
|
|
7
|
+
* combined block does not fit inline.
|
|
8
|
+
*
|
|
9
|
+
* Shape only — refusals and the engine's declared-tool check are NOT here,
|
|
10
|
+
* they live in `runCouncil` (Task 4) so MCP, the workflow and any direct
|
|
11
|
+
* `require('./council/run')` caller share them. The out-dir fence IS a
|
|
12
|
+
* CLI-only concern: MCP has fenced the out-dir since v4.5
|
|
13
|
+
* (mcp-council-run.js:137-141's own `isPathInside(runDir, project)`) — this
|
|
14
|
+
* module is what gives the CLI door the same fence, which is why it lives
|
|
15
|
+
* beside the flags it depends on rather than in the engine.
|
|
16
|
+
*
|
|
17
|
+
* The fence is the v4.7 PR6 rule (`--out-dir` must stay inside the project)
|
|
18
|
+
* RELAXED for a run whose seats carry a LOCAL tool (read, grep, glob, bash):
|
|
19
|
+
* spec §4 requires such a run's directory sit OUTSIDE the project tree, and
|
|
20
|
+
* `runCouncil` enforces that placement (outside AND under an allowed root)
|
|
21
|
+
* itself — this module only has to stop blocking it. `--agent` combined with
|
|
22
|
+
* `--tools` is refused outright (ruling P2-R28), before this fence is ever
|
|
23
|
+
* consulted — the escape hatch (no council agents, no allowlist) has no
|
|
24
|
+
* tools-based agent for the relaxation to apply to.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const { ERROR_CODES } = require('./utils/error-doc');
|
|
30
|
+
const { isPathInside } = require('./project-root-allowlist');
|
|
31
|
+
const { parseToolsFlag, isLocal, agentToolsConflict } = require('./council/seat-tools');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{args: object, explicitKeys: Set<string>, runDir: string, project: string}} ctx
|
|
35
|
+
* @returns {{error: ({code: string, message: string, hint?: string}|null),
|
|
36
|
+
* toolIds?: string[], agentOverride?: ('Plan'|'Build'), notices?: string[]}}
|
|
37
|
+
*/
|
|
38
|
+
function checkCouncilRunTools({ args, explicitKeys, runDir, project }) {
|
|
39
|
+
// Spec 2026-09-11 §4: shape only (refusals + the engine check live in
|
|
40
|
+
// runCouncil so every door — CLI, MCP, workflow — shares them).
|
|
41
|
+
let toolIds;
|
|
42
|
+
// council #247 round 5 (P2-R51, B3): `tools: null` is the CLI house style
|
|
43
|
+
// for an unset option, exactly as `agent: null` below, and runCouncil's
|
|
44
|
+
// own preflight (preflightSeatTools) already treats a null `o.tools` as
|
|
45
|
+
// absent — so this door must too.
|
|
46
|
+
if (args.tools !== null && (explicitKeys.has('tools') || args.tools !== undefined)) {
|
|
47
|
+
// B6 (P2-R35): a repeated `--tools a --tools b` (or a caller that builds
|
|
48
|
+
// args directly, MCP-input style) can arrive as an array — join it before
|
|
49
|
+
// parseToolsFlag, which only ever spoke the comma-string shape.
|
|
50
|
+
const raw = Array.isArray(args.tools) ? args.tools.join(',') : args.tools;
|
|
51
|
+
const parsed = parseToolsFlag(raw);
|
|
52
|
+
if (!parsed.ok) { return { error: { code: ERROR_CODES.BAD_ARGS, message: `Error: ${parsed.message}` } }; }
|
|
53
|
+
toolIds = parsed.ids;
|
|
54
|
+
}
|
|
55
|
+
let agentOverride;
|
|
56
|
+
// council #247 D5: `agent: null` is the CLI house style for an unset
|
|
57
|
+
// option, never an invalid override and never `--agent`'s own skip branch.
|
|
58
|
+
if (args.agent !== null && (explicitKeys.has('agent') || args.agent !== undefined)) {
|
|
59
|
+
const a = typeof args.agent === 'string' ? args.agent.toLowerCase() : '';
|
|
60
|
+
if (a !== 'plan' && a !== 'build') {
|
|
61
|
+
// council #247 round 6 (P2-R55, C8a): a non-string --agent (schema-bypass,
|
|
62
|
+
// MCP-input style — agentToolsConflict already accepts any typeof for its
|
|
63
|
+
// first argument) used to render as the bare, unreadable `[object Object]`;
|
|
64
|
+
// JSON.stringify it instead. A genuine string still renders bare.
|
|
65
|
+
const got = typeof args.agent === 'string' ? args.agent : JSON.stringify(args.agent);
|
|
66
|
+
return {
|
|
67
|
+
error: {
|
|
68
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
69
|
+
message: `Error: --agent must be Plan or Build; got '${got}'`,
|
|
70
|
+
hint: 'Chat is not supported headless; omit --agent to run seats on the council agents',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
agentOverride = a === 'plan' ? 'Plan' : 'Build';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Ruling P2-R28 (supersedes P2-R25): --tools/--agent are refused together,
|
|
78
|
+
// before either is consulted further, on every door.
|
|
79
|
+
const conflict = agentToolsConflict(agentOverride, toolIds);
|
|
80
|
+
if (conflict) { return { error: { code: ERROR_CODES.BAD_ARGS, message: `Error: ${conflict}` } }; }
|
|
81
|
+
|
|
82
|
+
// Spec 2026-09-11 §4: a run whose seats carry a LOCAL tool must put its run dir
|
|
83
|
+
// OUTSIDE the project tree (runCouncil refuses inside, and requires an allowed
|
|
84
|
+
// root); every other run keeps the v4.7 fence exactly as it was.
|
|
85
|
+
//
|
|
86
|
+
// Named mutant: FENCEALWAYS — dropping the `!wantsLocalTool &&` conjunct (so
|
|
87
|
+
// the condition is just `!isPathInside(...)`) restores the unconditional v4.7
|
|
88
|
+
// fence; reddens "a local tool lets --out-dir sit outside the project" in
|
|
89
|
+
// tests/cli-council-run-flags.test.js (a --tools read run with an
|
|
90
|
+
// out-of-project --out-dir would then fail BAD_ARGS instead of reaching
|
|
91
|
+
// runCouncil). `agentOverride` no longer needs its own conjunct here: the
|
|
92
|
+
// conflict check above already refuses any run where both are set.
|
|
93
|
+
//
|
|
94
|
+
// isLocal (not a hand-rolled `.some()`) — review r1 P2-R19: it is
|
|
95
|
+
// council/seat-tools.js's single source for the local/remote predicate
|
|
96
|
+
// (its own JSDoc names three other callers); re-deriving it here was a
|
|
97
|
+
// fourth, silently-driftable copy of the same test.
|
|
98
|
+
//
|
|
99
|
+
// council #247 round 5 (P2-R51, D4): an `--agent` run carries no toolIds of
|
|
100
|
+
// its own (the conflict check above refuses combining `--agent` with
|
|
101
|
+
// `--tools`), so `wantsLocalTool` is false and this same fence applies to
|
|
102
|
+
// it too — an outside run directory would put the `--agent` leg's working
|
|
103
|
+
// directory outside the tree it must read, and the engine's own
|
|
104
|
+
// `external_directory` default is `ask`, a prompt a headless leg can never
|
|
105
|
+
// answer.
|
|
106
|
+
const wantsLocalTool = Array.isArray(toolIds) && isLocal(toolIds);
|
|
107
|
+
if (!wantsLocalTool && !isPathInside(runDir, project)) {
|
|
108
|
+
return {
|
|
109
|
+
error: {
|
|
110
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
111
|
+
message: `Error: --out-dir must stay inside the project: '${args['out-dir']}' resolves outside ${project}`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const notices = [];
|
|
117
|
+
// council #247 round 6 (P2-R55, C2): `read` denies only .env/.env.*/.envrc
|
|
118
|
+
// at the engine — every OTHER file in the tree, including other secrets
|
|
119
|
+
// (.npmrc, key files, credentials), is readable. The CLI names the exact
|
|
120
|
+
// fence so a caller does not assume `read` is a general secrets guard.
|
|
121
|
+
if (Array.isArray(toolIds) && toolIds.includes('read')) {
|
|
122
|
+
notices.push('Notice: --tools read opens every file in the project tree except the names .env, ' +
|
|
123
|
+
'.env.* and .envrc (case-sensitive on Linux); keep any other secret (.npmrc, key files, ' +
|
|
124
|
+
'credentials) out of a tree you point a read seat at.');
|
|
125
|
+
}
|
|
126
|
+
// A1/D2: `bash` sits outside every fence (run directory, home, network) —
|
|
127
|
+
// the CLI names that in a Notice whenever a caller opts it in.
|
|
128
|
+
if (Array.isArray(toolIds) && toolIds.includes('bash')) {
|
|
129
|
+
notices.push('Notice: --tools bash gives every stage-1 seat a shell as you: no fence applies — it can ' +
|
|
130
|
+
'reach the run directory outside the tree (this run\'s own records included: the label map ' +
|
|
131
|
+
'that anonymizes the bench and every review already on disk, so bench anonymity and ' +
|
|
132
|
+
'independence do not hold under bash), your home directory and the network, and the webfetch ' +
|
|
133
|
+
'deny does not bind a shell.');
|
|
134
|
+
}
|
|
135
|
+
// council #247 round 5 (P2-R50, B2/D5/C2): `grep`/`glob` search the whole
|
|
136
|
+
// project tree with no per-file fence either (grep returns `.env`
|
|
137
|
+
// contents, glob lists `.env` names — the read fence only ever binds
|
|
138
|
+
// `read`), so the CLI names that in a Notice too, whenever either is
|
|
139
|
+
// opted in (both together name both ids, `grep/glob`).
|
|
140
|
+
const grepGlobIds = ['grep', 'glob'].filter((id) => Array.isArray(toolIds) && toolIds.includes(id));
|
|
141
|
+
if (grepGlobIds.length) {
|
|
142
|
+
notices.push(`Notice: --tools ${grepGlobIds.join('/')} search the whole project tree with no per-file fence — ` +
|
|
143
|
+
'grep returns .env contents and glob lists .env names; the read fence does not bind them. Opt them in ' +
|
|
144
|
+
'only on a tree without secrets.');
|
|
145
|
+
}
|
|
146
|
+
// Ruling P2-R41b (A4, round 3): --agent Build is the escape hatch running
|
|
147
|
+
// every leg on the engine's own agent, full tool set included — unlike the
|
|
148
|
+
// council-seat allowlist, Build can edit files and run commands, so the CLI
|
|
149
|
+
// names that in a Notice too.
|
|
150
|
+
if (agentOverride === 'Build') {
|
|
151
|
+
notices.push('Notice: --agent Build runs every leg on the engine\'s Build agent, which can edit files ' +
|
|
152
|
+
'and run commands, with the run directory (inside the project) as its ' +
|
|
153
|
+
'working directory.');
|
|
154
|
+
}
|
|
155
|
+
// council #247 round 5 (P2-R50, D3): --agent Plan runs every leg — judges
|
|
156
|
+
// and the chair included, not only stage-1 — on the engine's own Plan
|
|
157
|
+
// agent, which can read, search and run shell commands (only edits are
|
|
158
|
+
// denied); the CLI names that in a Notice too.
|
|
159
|
+
if (agentOverride === 'Plan') {
|
|
160
|
+
notices.push('Notice: --agent Plan runs every leg (judges and the chair included) on the engine\'s Plan ' +
|
|
161
|
+
'agent, which can read, search and run commands (edits denied) — v4.9.7\'s behaviour — with the run ' +
|
|
162
|
+
'directory (inside the project) as its working directory.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { error: null, toolIds, agentOverride, ...(notices.length ? { notices } : {}) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { checkCouncilRunTools };
|
|
@@ -214,12 +214,11 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
214
214
|
const runDir = args['out-dir']
|
|
215
215
|
? path.resolve(project, String(args['out-dir']))
|
|
216
216
|
: path.resolve(project, `council-${runId}`);
|
|
217
|
-
// v4.7 PR6:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
217
|
+
// Spec 2026-09-11 §4 + the v4.7 PR6 fence: --tools/--agent shape and --out-dir placement — ./cli-council-run-tools.
|
|
218
|
+
const tf = require('./cli-council-run-tools').checkCouncilRunTools({ args, explicitKeys, runDir, project });
|
|
219
|
+
if (tf.error) { return failJson(useJson, tf.error); }
|
|
220
|
+
// A1/D2: e.g. the --tools bash Notice, printed either way (JSON still uses stderr for it).
|
|
221
|
+
for (const n of tf.notices || []) { process.stderr.write(n + '\n'); }
|
|
223
222
|
|
|
224
223
|
const { resolveGatewayMode, loadConfig } = require('./utils/config');
|
|
225
224
|
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
@@ -250,6 +249,7 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
250
249
|
tag: args.tag, // v4.7 F8: undefined when no --tag; Task 3 stores it on the run.json seed.
|
|
251
250
|
// v4.9 W5.2: o.intent is 'task' or ABSENT, never 'review' (validated above).
|
|
252
251
|
...(args.intent === 'task' ? { intent: 'task' } : {}),
|
|
252
|
+
...(tf.toolIds ? { tools: tf.toolIds } : {}), ...(tf.agentOverride ? { agent: tf.agentOverride } : {}),
|
|
253
253
|
droppedMembers: benchRes.droppedMembers, // v4.5 Wave 2: [] when nothing dropped; additive on the run.json seed (run-state.js).
|
|
254
254
|
// v4.1 §4.5b/§4.5d. `--claude-review` is resolved here but VALIDATED by the
|
|
255
255
|
// engine's preflightClaudeReview (run-assemble.js): the reserved-seat and
|
package/src/cli.js
CHANGED
|
@@ -588,6 +588,7 @@ Subcommands for 'council':
|
|
|
588
588
|
[--fallback] [--no-fallback] [--on-complete <cmd>]
|
|
589
589
|
[--template <name|path>] [--artifact <file>] [--var <k=v>]
|
|
590
590
|
[--pack <name|path>] [--tag <t>] [--intent review|task]
|
|
591
|
+
[--tools <a,b,c>] [--agent Plan|Build]
|
|
591
592
|
Run the full headless council engine (v4.0).
|
|
592
593
|
Chair default: deepseek (must NOT be a bench seat).
|
|
593
594
|
--critic and --lenses are mutually exclusive.
|
|
@@ -619,6 +620,27 @@ Subcommands for 'council':
|
|
|
619
620
|
explicit flags always override the pack's values.
|
|
620
621
|
--intent task marks a task-mode run (v4.9);
|
|
621
622
|
review is the default and is never stored.
|
|
623
|
+
--tools <a,b,c> opts stage-1 seats into tools by the
|
|
624
|
+
engine's own ids (task mode defaults to webfetch,
|
|
625
|
+
review to none); task, skill, edit, write,
|
|
626
|
+
apply_patch, question and invalid are refused.
|
|
627
|
+
A local tool (read, grep, glob, bash) needs --out-dir OUTSIDE
|
|
628
|
+
the project tree. read is denied exactly the
|
|
629
|
+
names .env, .env.* and .envrc at the engine
|
|
630
|
+
(case-sensitive on Linux; no other spelling is
|
|
631
|
+
fenced); grep, glob and bash have no per-file
|
|
632
|
+
fence — opt them in only on a tree without
|
|
633
|
+
secrets. bash is outside every fence, this
|
|
634
|
+
run's own records included (bench anonymity
|
|
635
|
+
does not hold under it) — the CLI warns when
|
|
636
|
+
you opt any of them in. --tools and --agent
|
|
637
|
+
cannot be combined.
|
|
638
|
+
--agent Plan|Build runs every leg on the
|
|
639
|
+
engine's own agent instead (the escape hatch; no
|
|
640
|
+
council agents, no allowlist). Plan's legs,
|
|
641
|
+
judges and chair included, can read, search and
|
|
642
|
+
run commands (edits denied); Build's can also
|
|
643
|
+
edit. The CLI warns for either.
|
|
622
644
|
Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
|
|
623
645
|
save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
|
|
624
646
|
--json Machine-readable output
|
|
@@ -718,7 +740,7 @@ const USAGE_TRAILER = `
|
|
|
718
740
|
OpenCode Agent Types:
|
|
719
741
|
Chat Reads auto, writes/bash ask permission (interactive default)
|
|
720
742
|
Build Full tool access (headless default)
|
|
721
|
-
Plan
|
|
743
|
+
Plan Analysis without edits (reads, searches, shell allowed)
|
|
722
744
|
|
|
723
745
|
NOTE: --agent chat is interactive-only (incompatible with --no-ui).
|
|
724
746
|
Headless mode defaults to build agent.
|
|
@@ -289,7 +289,7 @@ function chairRepairPromptFor(intent, args) {
|
|
|
289
289
|
|
|
290
290
|
module.exports = {
|
|
291
291
|
dateLine,
|
|
292
|
-
CHAIR_NO_TOOLS_PREAMBLE, chairRepairPromptFor,
|
|
292
|
+
CHAIR_NO_TOOLS_LEAD, CHAIR_NO_TOOLS_PREAMBLE, chairRepairPromptFor,
|
|
293
293
|
CHAIR_VERDICT_VALUES,
|
|
294
294
|
VERDICT_SCALE_ADDENDUM,
|
|
295
295
|
CHAIR_TASK,
|
|
@@ -110,22 +110,28 @@ const TASK_CRITIC_BRIEF = [
|
|
|
110
110
|
|
|
111
111
|
/** Task seat briefing (Stage-1 fanout wave). */
|
|
112
112
|
function buildTaskSeatBriefing(args) {
|
|
113
|
-
return composeWith(TASK_SEAT_ROLE, TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, args);
|
|
113
|
+
return composeWith(TASK_SEAT_ROLE, TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, args, 'answer');
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/** Task critic briefing (concurrent solo — --critic, ruling V13). */
|
|
117
117
|
function buildTaskCriticBriefing(args) {
|
|
118
|
-
return composeWith(TASK_CRITIC_BRIEF, TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, args);
|
|
118
|
+
return composeWith(TASK_CRITIC_BRIEF, TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, args, 'answer');
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Task expert-lens briefing (concurrent solo per seat — --lenses).
|
|
123
|
+
* Named mutant LENSTOOLSDROP (see briefings.js :: buildLensBriefing for the full account):
|
|
124
|
+
* dropping `tools` from the destructure or the rebuilt object below reddens the task-intent
|
|
125
|
+
* case in 'lens and critic briefings carry a real tools line too, not just the seat
|
|
126
|
+
* (LENSTOOLSDROP guard)', tests/council/briefings-tools.test.js.
|
|
127
|
+
*/
|
|
128
|
+
function buildTaskLensBriefing({ lens, briefing, date, tools, agent }) {
|
|
123
129
|
return composeWith(
|
|
124
130
|
`Do the work the briefing asks for strictly through the lens of a ${lens}. ` +
|
|
125
131
|
'Produce only what that perspective is qualified to produce, at the depth a top ' +
|
|
126
132
|
'practitioner of it would reach. Stay in-domain: if something matters but is outside ' +
|
|
127
133
|
'your lens, leave it to the other analysts.',
|
|
128
|
-
TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, { briefing, date }
|
|
134
|
+
TASK_ANTI_SYCOPHANCY_CLAUSE, TASK_FINDINGS_CONTRACT, { briefing, date, tools, agent }, 'answer'
|
|
129
135
|
);
|
|
130
136
|
}
|
|
131
137
|
|
package/src/council/briefings.js
CHANGED
|
@@ -98,15 +98,24 @@ function dateLine(date) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* The generalized Stage-1 skeleton (v4.9 W6
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* The generalized Stage-1 skeleton (v4.9 W6; tools sentence added spec
|
|
102
|
+
* 2026-09-11 §4): role / tools sentence / clause / date / contract /
|
|
103
|
+
* separator / briefing. Both intents compose through here, which is what
|
|
104
|
+
* makes the `--- MATERIAL / BRIEFING ---` separator — a PRODUCTION contract,
|
|
104
105
|
* src/sidecar/list-search.js:14 splits briefing-stage1.md on it — a single
|
|
105
106
|
* spelling in both modes. briefings-task.js top-requires this.
|
|
107
|
+
* @param {string} role @param {string} clause @param {string} contract
|
|
108
|
+
* @param {{briefing: string, date?: string, tools?: string[], agent?: string}} args
|
|
109
|
+
* @param {'review'|'answer'} [kind] the no-tools sentence's last word (spec §4:
|
|
110
|
+
* the seat sentence forks exactly where the chair's does)
|
|
106
111
|
*/
|
|
107
|
-
function composeWith(role, clause, contract, { briefing, date }) {
|
|
112
|
+
function composeWith(role, clause, contract, { briefing, date, tools, agent }, kind = 'review') {
|
|
113
|
+
// lazy, defensively — not cycle-avoidance: seat-tools.js requires only ./briefings-chair,
|
|
114
|
+
// which itself requires only ./seats, so no require cycle with this module exists today.
|
|
115
|
+
const { seatToolsSentence } = require('./seat-tools');
|
|
108
116
|
return [
|
|
109
117
|
role,
|
|
118
|
+
seatToolsSentence(tools || [], kind, { agent }),
|
|
110
119
|
clause,
|
|
111
120
|
dateLine(date),
|
|
112
121
|
contract,
|
|
@@ -134,14 +143,23 @@ function buildCriticBriefing(args) {
|
|
|
134
143
|
return compose(CRITIC_BRIEF, args);
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Expert-lens briefing (concurrent solo per seat — spec §4 --lenses).
|
|
148
|
+
* `tools` must survive both the destructure below and the rebuilt object handed to
|
|
149
|
+
* `compose` — this builder (unlike buildSeatBriefing/buildCriticBriefing, which just
|
|
150
|
+
* forward the `args` they're given) reconstructs a NARROWER object, so dropping `tools`
|
|
151
|
+
* from either spot is silent. Named mutant LENSTOOLSDROP: drop `tools` here; reddens the
|
|
152
|
+
* review-intent case in 'lens and critic briefings carry a real tools line too, not just
|
|
153
|
+
* the seat (LENSTOOLSDROP guard)', tests/council/briefings-tools.test.js. The task-intent
|
|
154
|
+
* twin of this same mutant lives in briefings-task.js :: buildTaskLensBriefing.
|
|
155
|
+
*/
|
|
156
|
+
function buildLensBriefing({ lens, briefing, date, tools, agent }) {
|
|
139
157
|
return compose(
|
|
140
158
|
`Review this material strictly through the lens of a ${lens}. Raise only findings ` +
|
|
141
159
|
'that perspective is qualified to raise, at the depth a top practitioner of it would ' +
|
|
142
160
|
'reach. Stay in-domain: if something matters but is outside your lens, leave it to ' +
|
|
143
161
|
'the other reviewers.',
|
|
144
|
-
{ briefing, date }
|
|
162
|
+
{ briefing, date, tools, agent }
|
|
145
163
|
);
|
|
146
164
|
}
|
|
147
165
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/council/report-lost-rows.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/report-lost-rows
|
|
6
|
+
* "What was lost" rows the tally already knows (#242, spec §5): one per runStats seat whose
|
|
7
|
+
* findings came from a repair nothing could verify (`findingsUnverified`), one per refused
|
|
8
|
+
* repair (`repairRefused`). Derived at RENDER time from `verdict.runStats` — never written into
|
|
9
|
+
* run.json or verdict.json's `degrades[]`, never handed to the degrade sink — so the run's exit
|
|
10
|
+
* code, its `degraded` state and every on-disk artifact are unchanged, and re-rendering an
|
|
11
|
+
* older verdict.json shows the rows too.
|
|
12
|
+
*
|
|
13
|
+
* A LEAF over utils/degrade: the rows are `makeDegrade` records so both renderers print them
|
|
14
|
+
* through `formatDegrade`, the report's one voice, and their channels sit in DEGRADE_CHANNELS
|
|
15
|
+
* (the degrade-contract drift pin reads this file's `channel:` literals).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ The wording never says "stub". The flag means exactly what run-stages.js :: runStage1 says:
|
|
18
|
+
* the ORIGINAL response carried no parseable findings block, so nothing could check the repair —
|
|
19
|
+
* true of a vacuous repair and of a good review whose trailing JSON was malformed alike (study
|
|
20
|
+
* run B2: a 19,064-byte review carried it). Both facts are tested with `=== true` / a plain
|
|
21
|
+
* object, matching what tally.js emits; a hand-assembled truthy string is not a flag.
|
|
22
|
+
*
|
|
23
|
+
* `data.seat` is the row's label — verdict-seats-reviewed.js :: seatLabel, shared with the end-of-run
|
|
24
|
+
* stderr line so no row is ever named two ways (council #248 r2, A3).
|
|
25
|
+
*
|
|
26
|
+
* Role and status ARE consulted, through the census's own predicates (verdict-seats-reviewed.js ::
|
|
27
|
+
* isUnverifiedSeat / isRefusedSeat — council #248 round 1, B1/C2/D2): a row renders here exactly
|
|
28
|
+
* when the census counts it, so the report and `seatsReviewed` can never disagree, and the row's
|
|
29
|
+
* "still counts as reviewed" is true of every row it is ever written for. A flagged row that is
|
|
30
|
+
* not a completed bench seat renders nothing — no review happened; a real dead leg has the sink's
|
|
31
|
+
* own dead-leg row. On engine-written records the gate is a no-op (run-launch.js ::
|
|
32
|
+
* materializeReviews drops non-complete legs before any repair runs; run-stages.js :: roleFor and
|
|
33
|
+
* seats.js :: buildSeats mint only bench roles).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { makeDegrade } = require('../utils/degrade');
|
|
37
|
+
// The census's own predicates — one function, two readers, so the report and
|
|
38
|
+
// `seatsReviewed` cannot disagree (a leaf that requires nothing; no cycle).
|
|
39
|
+
// … and the one seat label every human surface uses (P3-R18).
|
|
40
|
+
const { isUnverifiedSeat, isRefusedSeat, seatLabel } = require('./verdict-seats-reviewed');
|
|
41
|
+
|
|
42
|
+
function unverifiedRow(r) {
|
|
43
|
+
const seat = seatLabel(r);
|
|
44
|
+
return makeDegrade({
|
|
45
|
+
channel: 'unverified-repair',
|
|
46
|
+
what: `seat ${seat}'s findings came from a repair of a response with no findings block`,
|
|
47
|
+
why: 'nothing verified them',
|
|
48
|
+
effect: 'the tiers they were given rest on the repair alone; the seat still counts as reviewed',
|
|
49
|
+
data: { seat },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The engine's only refusal code today is REPAIR_CHANGED_FINDING_COUNT (run-stages.js); a
|
|
54
|
+
// hand-assembled row without one gets "code not recorded", never an invented code (council #248
|
|
55
|
+
// r2, C6).
|
|
56
|
+
function refusedRow(r) {
|
|
57
|
+
const seat = seatLabel(r);
|
|
58
|
+
const { code: rawCode, detail: rawDetail } = r.repairRefused;
|
|
59
|
+
const code = (typeof rawCode === 'string' && rawCode.trim()) ? rawCode.trim() : null;
|
|
60
|
+
const detail = (typeof rawDetail === 'string' && rawDetail.trim()) ? rawDetail.trim() : 'the repair broke its contract';
|
|
61
|
+
return makeDegrade({
|
|
62
|
+
channel: 'repair-refused',
|
|
63
|
+
what: `seat ${seat}'s repair was refused (${code || 'code not recorded'})`,
|
|
64
|
+
why: detail,
|
|
65
|
+
effect: 'the seat contributed no findings; its review text still reached the judges and it counts as reviewed',
|
|
66
|
+
data: { seat, code },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {*} runStats `verdict.runStats` — any shape: the report's entry points are schema-free
|
|
74
|
+
* JSON.parse (see report.js :: isSeatSpace), so THIS LEAF yields no rows rather than a throw on a
|
|
75
|
+
* non-array. That is the leaf's contract only — the cost table (report-cost.js) still throws on a
|
|
76
|
+
* non-array runStats, pre-existing and untouched here.
|
|
77
|
+
* @returns {Array<object>} frozen makeDegrade records, in runStats order; `[]` when none.
|
|
78
|
+
*/
|
|
79
|
+
function lostRowsOf(runStats) {
|
|
80
|
+
const rows = [];
|
|
81
|
+
for (const r of (Array.isArray(runStats) ? runStats : [])) {
|
|
82
|
+
if (!isPlainObject(r)) { continue; }
|
|
83
|
+
if (isUnverifiedSeat(r)) { rows.push(unverifiedRow(r)); }
|
|
84
|
+
if (isRefusedSeat(r)) { rows.push(refusedRow(r)); }
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { lostRowsOf };
|
package/src/council/report-md.js
CHANGED
|
@@ -47,7 +47,9 @@ function renderMd(m) {
|
|
|
47
47
|
// (tests/council/report-intent.test.js).
|
|
48
48
|
if (m.intent === 'task') { out.push('\n_Tiers report peer concurrence, never verification._'); }
|
|
49
49
|
|
|
50
|
-
// Heading-over-nothing: emitted ONLY when the
|
|
50
|
+
// Heading-over-nothing: emitted ONLY when the model carries losses — the
|
|
51
|
+
// sink's records plus (v4.9.8, #242) the runStats-derived
|
|
52
|
+
// unverified/refused-repair rows report.js :: toModel appends — so a
|
|
51
53
|
// clean verdict's report stays byte-identical to before this section
|
|
52
54
|
// existed. Losses are headline news, so they sit directly under the
|
|
53
55
|
// summary, before the reader reaches the adjudication detail.
|
package/src/council/report.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// v4.9 W8 T-A: the cost table's model lives in ./report-cost (extraction, this
|
|
19
19
|
// file's headroom). Eager, not lazy: that module back-requires nothing here.
|
|
20
20
|
const { buildCostModel } = require('./report-cost');
|
|
21
|
+
const { lostRowsOf } = require('./report-lost-rows'); // #242: a leaf; back-requires nothing here
|
|
21
22
|
|
|
22
23
|
const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
|
|
23
24
|
// __proto__: null — an inherited/unknown vote key (e.g. "toString") must fold as unrecognized, never resolve off Object.prototype.
|
|
@@ -263,7 +264,7 @@ function toModel(verdict, wave) {
|
|
|
263
264
|
// Plan 2 final review F2 + v4.9 W8 T-A: LOSSES ONLY. A heal is announced on stderr/run.json
|
|
264
265
|
// but is not a loss (spec D4, §8), and neither is v4.9's kind:'info' — `ledger-skipped` says
|
|
265
266
|
// a task run wrote no reliability rows, which is speech, not damage. Info records ride
|
|
266
|
-
// `notes`, which both renderers list APART from "What was lost".
|
|
267
|
+
// `notes`, which both renderers list APART from "What was lost". v4.9.8 (#242): the runStats-derived unverified/refused-repair rows (./report-lost-rows) are appended AFTER the sink's records.
|
|
267
268
|
// ⚠️ NOT the positive `kind === 'degrade'`, and the difference is measured: a record with NO
|
|
268
269
|
// kind key — hand-written, or parsed off a verdict older than kinds, which
|
|
269
270
|
// `utils/degrade.js :: formatDegrade` still deliberately serves as 'Notice' — is a loss at
|
|
@@ -276,7 +277,7 @@ function toModel(verdict, wave) {
|
|
|
276
277
|
// 'degrade'`, citing THIS lesson by name. Their kind LISTS still differ from this one's, and
|
|
277
278
|
// deliberately (over there the question is narrower: which announcements imply a LOST SEAT).
|
|
278
279
|
// Align the treatment of an ABSENT kind; never the lists.
|
|
279
|
-
degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal' && d.kind !== 'info'),
|
|
280
|
+
degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal' && d.kind !== 'info').concat(lostRowsOf(verdict.runStats)),
|
|
280
281
|
notes: (verdict.degrades || []).filter(d => d.kind === 'info'),
|
|
281
282
|
cost: buildCostModel(verdict.runStats || [], wave),
|
|
282
283
|
};
|
|
@@ -41,4 +41,25 @@ function createDegradeSink({ runDir, degraded, write }) {
|
|
|
41
41
|
return { note, all: () => records.slice() };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Announce every dropped preset member (spec §5, Plan 4): a seat the user's
|
|
46
|
+
* preset requested that never resolved is a lost seat — announced like every
|
|
47
|
+
* other loss. Fires once per member, before any launch (zero spend), for BOTH
|
|
48
|
+
* transports. Moved verbatim out of run.js for the 300-line gate (P2-R14: PR 2
|
|
49
|
+
* of the council-leg-completion work needed the headroom this freed).
|
|
50
|
+
* @param {{note: Function}} degrade the run's degrade sink
|
|
51
|
+
* @param {Array<{member: string, reason: string}>} [droppedMembers]
|
|
52
|
+
*/
|
|
53
|
+
function noteDroppedMembers(degrade, droppedMembers) {
|
|
54
|
+
for (const dm of droppedMembers || []) {
|
|
55
|
+
degrade.note({
|
|
56
|
+
channel: 'dropped-members',
|
|
57
|
+
what: `seat ${dm.member} was not seated`,
|
|
58
|
+
why: dm.reason,
|
|
59
|
+
effect: 'the bench is smaller than the preset requested; the run will exit degraded (2)',
|
|
60
|
+
data: { member: dm.member, reason: dm.reason },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { createDegradeSink, noteDroppedMembers };
|
|
@@ -11,6 +11,7 @@ const { decorateRecord } = require('./debate');
|
|
|
11
11
|
const runState = require('./run-state');
|
|
12
12
|
const asm = require('./run-assemble');
|
|
13
13
|
const { emitStageStarted, emitStageTerminal } = require('../observe/events');
|
|
14
|
+
const { isUnverifiedSeat, isRefusedSeat, seatLabel } = require('./verdict-seats-reviewed');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Build the final tally, gate the ledger append, write tally+verdict
|
|
@@ -62,8 +63,29 @@ function finishRun({ o, chairRes, debatedInput, debateFindings, appendRunFn, deg
|
|
|
62
63
|
emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
|
|
63
64
|
// Verdict assembly is the degrade cut-off: anything noted after this line
|
|
64
65
|
// reaches stderr + run.json but not verdict.json (spec §6 rule 1).
|
|
65
|
-
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
66
|
+
const verdict = asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
66
67
|
critic: o.critic, deadWaves, degrades: degrade.all() });
|
|
68
|
+
// council #248 round 1 (C1/D1): the census is in verdict.json and on the CI title, but a LOCAL
|
|
69
|
+
// run said nothing — the product principle's silent-degrade shape. One stderr line, read off the
|
|
70
|
+
// census the verdict already carries: no new computation, no artifact, no exit-code change, not a
|
|
71
|
+
// sink record (so `degraded` never flips). Emitted only when a count is non-zero, so every other
|
|
72
|
+
// run's stderr is byte-identical; names the seats so the reader need not open the report.
|
|
73
|
+
// report.html is written by writeVerdictFiles in the call just above, so the pointer is never
|
|
74
|
+
// dangling (council #248 r2, C3).
|
|
75
|
+
const census = verdict && verdict.seatsReviewed;
|
|
76
|
+
if (census && (census.unverified > 0 || census.refused > 0)) {
|
|
77
|
+
const rows = Array.isArray(verdict.runStats) ? verdict.runStats : [];
|
|
78
|
+
const parts = [];
|
|
79
|
+
if (census.unverified > 0) {
|
|
80
|
+
parts.push(`${census.unverified} of ${census.of} seats' findings came from a repair nothing could verify (${rows.filter(isUnverifiedSeat).map(seatLabel).join(', ')})`);
|
|
81
|
+
}
|
|
82
|
+
// council #248 round 2 (A2/C2, P3-R17): a refused repair — no findings tallied at all — is named
|
|
83
|
+
// on the same line, so a seat that contributed nothing is never silent locally either.
|
|
84
|
+
if (census.refused > 0) {
|
|
85
|
+
parts.push(`${census.refused} of ${census.of} seats' repairs were refused and contributed no findings (${rows.filter(isRefusedSeat).map(seatLabel).join(', ')})`);
|
|
86
|
+
}
|
|
87
|
+
process.stderr.write(`Notice: ${parts.join('; ')} — see "What was lost" in report.html\n`);
|
|
88
|
+
}
|
|
67
89
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
68
90
|
emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
|
|
69
91
|
emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
|
|
@@ -63,12 +63,16 @@ function createLaunchers(deps = {}) {
|
|
|
63
63
|
const reserveBudget = deps.reserveBudget || null;
|
|
64
64
|
const onBudgetRefusal = deps.onBudgetRefusal || null;
|
|
65
65
|
const sharedServer = deps.sharedServer || null;
|
|
66
|
+
// Spec 2026-09-11 §4: getters, like sharedServer — run.js builds the launchers
|
|
67
|
+
// before it has decided the seat policy.
|
|
68
|
+
const councilAgents = deps.councilAgents || (() => null);
|
|
69
|
+
const agentOverride = deps.agentOverride || (() => undefined);
|
|
66
70
|
|
|
67
71
|
/**
|
|
68
72
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
69
73
|
* timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
|
|
70
74
|
* councilRunId?: string, councilName?: string, tag?: string, seats?: Array<object>,
|
|
71
|
-
* fallback?: object, catalog?: Array, noOutputBackstopMs?: number}} opts
|
|
75
|
+
* fallback?: object, catalog?: Array, noOutputBackstopMs?: number, role?: 'seat', directory?: string}} opts
|
|
72
76
|
* councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
|
|
73
77
|
* ids forwarded verbatim into the runFanout call so it can stamp them onto
|
|
74
78
|
* every leg. tag (v4.7 F8 D16) rides the same forward — every call site
|
|
@@ -83,6 +87,13 @@ function createLaunchers(deps = {}) {
|
|
|
83
87
|
* noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
|
|
84
88
|
* Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
|
|
85
89
|
* to escalate the window on a Stage-1 retry.
|
|
90
|
+
* role (spec 2026-09-11 §4, P2-R11) is the LAUNCH role, not the per-leg
|
|
91
|
+
* value run-stages.js's `roleFor()` returns — every stage-1 leg (seat,
|
|
92
|
+
* critic, and lens alike) launches with the literal `role: 'seat'` to get
|
|
93
|
+
* the tool-capable agent; a caller must pass that literal, never a leg's
|
|
94
|
+
* own `seat.role`. It also gates `directory` (see the comment above that
|
|
95
|
+
* option below): only a `role: 'seat'` launch may point tool-exec cwd
|
|
96
|
+
* anywhere but `opts.project`.
|
|
86
97
|
* @returns {Promise<{wave: object|null, exitCode: number}>}
|
|
87
98
|
*/
|
|
88
99
|
async function launchWave(opts) {
|
|
@@ -100,6 +111,13 @@ function createLaunchers(deps = {}) {
|
|
|
100
111
|
// `serverClient` (see the seam comment in fanout.js). Absent → the wave
|
|
101
112
|
// starts and closes its own server, exactly as before.
|
|
102
113
|
const shared = sharedServer ? sharedServer() : null;
|
|
114
|
+
const agents = councilAgents();
|
|
115
|
+
// Spec 2026-09-11 §4: stage-1 seats and their retries run as council-seat,
|
|
116
|
+
// every other role as council-support; an explicit agent (the --agent
|
|
117
|
+
// escape hatch) wins. Without council agents (non-council DI, older
|
|
118
|
+
// callers) the pre-§4 default 'Plan' stands.
|
|
119
|
+
const agent = opts.agent || agentOverride()
|
|
120
|
+
|| (agents ? (opts.role === 'seat' ? 'council-seat' : 'council-support') : 'Plan');
|
|
103
121
|
const { wave, exitCode, errorDoc } = await fanoutFn({
|
|
104
122
|
...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
|
|
105
123
|
...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
|
|
@@ -112,12 +130,13 @@ function createLaunchers(deps = {}) {
|
|
|
112
130
|
// onto every leg and its spend-ledger row (v4.3 --retry-failed machinery).
|
|
113
131
|
// Spread-guarded so a normal launch's transport call stays byte-identical.
|
|
114
132
|
...(opts.retryOfWaveId ? { retryOfWaveId: opts.retryOfWaveId } : {}),
|
|
133
|
+
...(agents ? { serverAgents: agents } : {}),
|
|
115
134
|
models: opts.models.join(','),
|
|
116
135
|
prompt: opts.prompt,
|
|
117
136
|
promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
|
|
118
137
|
waveId: opts.waveId,
|
|
119
138
|
project: opts.project,
|
|
120
|
-
agent
|
|
139
|
+
agent,
|
|
121
140
|
timeout: opts.timeout,
|
|
122
141
|
summaryLength: 'verbose',
|
|
123
142
|
includeContext: false,
|
|
@@ -160,8 +179,18 @@ function createLaunchers(deps = {}) {
|
|
|
160
179
|
// own session dir (judges' `project` is `<runDir>/_scratch`, so this
|
|
161
180
|
// scopes them there) and strip inherited MCP servers, so a tool-capable
|
|
162
181
|
// judge can't read the de-anonymized review-*.md files or the plaintext
|
|
163
|
-
// labelMap in run.json sitting in the parent run dir.
|
|
164
|
-
|
|
182
|
+
// labelMap in run.json sitting in the parent run dir. Every launch is
|
|
183
|
+
// scoped to `opts.project` — the ONLY exception is a stage-1 seat launch
|
|
184
|
+
// (`opts.role === 'seat'`) that also passes `opts.directory`: a later
|
|
185
|
+
// task uses that to point a local-tools seat at the real project tree
|
|
186
|
+
// while the run's own metadata stays in `opts.project` (P2-R11 review).
|
|
187
|
+
// Judge, debate, and chair legs never set `role: 'seat'`, so `_scratch`
|
|
188
|
+
// isolation cannot be escaped through this option.
|
|
189
|
+
// Named mutant DIRGATEDROP: dropping the `opts.role === 'seat' &&`
|
|
190
|
+
// conjunct below lets ANY caller redirect tool-exec cwd via
|
|
191
|
+
// `opts.directory` — reddens "a non-seat launch ignores opts.directory"
|
|
192
|
+
// (tests/council/run-launch.test.js).
|
|
193
|
+
directory: (opts.role === 'seat' && opts.directory) || opts.project,
|
|
165
194
|
noMcp: true,
|
|
166
195
|
});
|
|
167
196
|
// A ceiling refusal returns `wave: null`, which the council driver's
|