amicus 4.9.7 → 4.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +19 -4
  6. package/docs/council.md +140 -3
  7. package/docs/usage.md +8 -4
  8. package/package.json +1 -1
  9. package/schemas/council-verdict.schema.json +3 -1
  10. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  11. package/src/cli-council-run-tools.js +168 -0
  12. package/src/cli-handlers-council-run.js +6 -6
  13. package/src/cli.js +23 -1
  14. package/src/council/briefings-chair.js +1 -1
  15. package/src/council/briefings-task.js +11 -5
  16. package/src/council/briefings.js +25 -7
  17. package/src/council/report-lost-rows.js +89 -0
  18. package/src/council/report-md.js +3 -1
  19. package/src/council/report.js +3 -2
  20. package/src/council/run-degrade.js +22 -1
  21. package/src/council/run-finish.js +23 -1
  22. package/src/council/run-launch.js +33 -4
  23. package/src/council/run-retry-launch.js +9 -4
  24. package/src/council/run-retry.js +3 -0
  25. package/src/council/run-seat-tools-verify.js +296 -0
  26. package/src/council/run-seat-tools.js +274 -0
  27. package/src/council/run-server.js +41 -6
  28. package/src/council/run-stage1-launch.js +8 -3
  29. package/src/council/run.js +21 -21
  30. package/src/council/seat-tools.js +299 -0
  31. package/src/council/verdict-seats-reviewed.js +76 -6
  32. package/src/headless.js +136 -6
  33. package/src/mcp-council-pack-map.js +24 -0
  34. package/src/mcp-council-run.js +17 -15
  35. package/src/mcp-server.js +2 -2
  36. package/src/mcp-tools.js +15 -4
  37. package/src/opencode-client.js +26 -0
  38. package/src/pack/pack-validate.js +3 -1
  39. package/src/prompt-builder.js +2 -2
  40. package/src/sidecar/fanout.js +7 -1
  41. package/src/sidecar/heartbeat.js +46 -0
  42. package/src/sidecar/session-utils.js +7 -34
  43. package/src/utils/agent-mapping.js +1 -1
  44. package/src/utils/degrade.js +8 -0
@@ -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: MCP has fenced this since v4.5 (mcp-council-run.js:137-141); the CLI
218
- // never did, so `--out-dir ../../x` wrote outside the project and exited 0.
219
- const { isPathInside } = require('./project-root-allowlist');
220
- if (!isPathInside(runDir, project)) {
221
- return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --out-dir must stay inside the project: '${args['out-dir']}' resolves outside ${project}` });
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 Read-only analysis and planning
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
- /** Task expert-lens briefing (concurrent solo per seat — --lenses). */
122
- function buildTaskLensBriefing({ lens, briefing, date }) {
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
 
@@ -98,15 +98,24 @@ function dateLine(date) {
98
98
  }
99
99
 
100
100
  /**
101
- * The generalized Stage-1 skeleton (v4.9 W6): role / clause / date / contract /
102
- * separator / briefing. Both intents compose through here, which is what makes
103
- * the `--- MATERIAL / BRIEFING ---` separator a PRODUCTION contract,
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
- /** Expert-lens briefing (concurrent solo per seat — spec §4 --lenses). */
138
- function buildLensBriefing({ lens, briefing, date }) {
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 };
@@ -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 run actually degraded, so a
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.
@@ -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
- module.exports = { createDegradeSink };
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: opts.agent || 'Plan',
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
- directory: opts.project,
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