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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -0,0 +1,299 @@
1
+ // src/council/seat-tools.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/seat-tools
6
+ * Per-run tool policy for council seats (spec 2026-09-11 §4, PR 2 of 3).
7
+ *
8
+ * The study (§1) found two of the three leg-loss classes share one precondition:
9
+ * a stage-1 seat reached for a tool it did not need (gemini grep/glob over the
10
+ * global install, cohere `task {}`). Tool access is a property of the RUN, set
11
+ * by the caller according to whether the seat must go and get its material
12
+ * (§2.1): task mode defaults to `webfetch`, review to nothing, `--tools` opts
13
+ * more in, and two ids are never available to a headless seat without the
14
+ * `--agent` override. This module is pure: it decides, and builds the two agent
15
+ * configs the run's OpenCode server registers (run-server.js). Nothing here
16
+ * talks to the engine — the engine's declared ids are passed IN (`declaredIds`)
17
+ * by run.js after the server is up, so the accepted set is never hand-listed.
18
+ */
19
+
20
+ const { CHAIR_NO_TOOLS_LEAD } = require('./briefings-chair');
21
+
22
+ /**
23
+ * Ids a council seat may never opt into. Hand-listed on purpose: each names why.
24
+ * Everything else is validated against the engine's own `tool.ids()`.
25
+ */
26
+ const REFUSED_TOOL_IDS = Object.freeze({
27
+ task: 'spawns child sessions amicus cannot observe',
28
+ skill: 'is where a seat starts reading the harness instead of the brief',
29
+ question: 'asks a human, and a headless leg has none',
30
+ invalid: 'is the engine\'s error surface, not a tool',
31
+ edit: 'a council seat never modifies the tree',
32
+ write: 'a council seat never modifies the tree',
33
+ apply_patch: 'a council seat never modifies the tree',
34
+ });
35
+
36
+ /** Ids that never touch the local tree: the remote fetchers, plus the engine's own todo list. */
37
+ const NON_LOCAL_TOOL_IDS = Object.freeze(['webfetch', 'websearch', 'todowrite']);
38
+ const SEAT_READ_DENY_PATTERNS = Object.freeze(['*.env', '*.env.*', '*.envrc']); // measured 2026-09-12: patterns a granted `read` must never cover
39
+
40
+ /**
41
+ * True if `tools` contains anything outside NON_LOCAL_TOOL_IDS. Single source for the
42
+ * local/remote predicate — resolveSeatTools and seatToolsSentence both call this
43
+ * instead of each re-writing the same `.some()` (review r1 P2-R8: before this fix,
44
+ * `buildCouncilAgents` accepted a `local` flag that could contradict its own `tools`,
45
+ * silently dropping `external_directory: 'deny'`; it now denies that key
46
+ * unconditionally and takes no `local` flag). A third caller (PR 2
47
+ * Task 5 review r1, P2-R19): cli-council-run-tools.js's out-dir fence, which decides
48
+ * whether a run's directory may sit outside the project tree.
49
+ * @param {string[]} tools @returns {boolean}
50
+ */
51
+ function isLocal(tools) {
52
+ return tools.some((id) => !NON_LOCAL_TOOL_IDS.includes(id));
53
+ }
54
+
55
+ /**
56
+ * `--tools`/`--agent` are mutually exclusive (ruling P2-R28, supersedes P2-R25's
57
+ * MCP-only short-circuit): the override already runs every leg on its own agent.
58
+ * @param {string|null|undefined} agent @param {string[]|undefined} tools
59
+ * @returns {string|null} the refusal message, or null when there is no conflict
60
+ */
61
+ function agentToolsConflict(agent, tools) {
62
+ if (!agent || !Array.isArray(tools) || !tools.length) { return null; }
63
+ return `--tools cannot be combined with --agent: the override runs every leg on the engine's own ${agent} agent with that agent's own tool set; drop one of them`;
64
+ }
65
+
66
+ const ESCAPE_HATCH = '--agent Build';
67
+ const ID_SHAPE = /^[a-z][a-z0-9_]*$/;
68
+
69
+ /** @param {'task'|undefined} intent @returns {string[]} */
70
+ function defaultToolsFor(intent) {
71
+ return intent === 'task' ? ['webfetch'] : [];
72
+ }
73
+
74
+ /**
75
+ * Normalize raw values the way `--tools` does: String, trim, lowercase, drop empties.
76
+ * Shared by parseToolsFlag and resolveSeatTools so a value from EITHER door — the CLI
77
+ * flag's comma string or a direct `optIn` array (MCP arg, a config list) — is cleaned
78
+ * identically before it is ever compared against REFUSED_TOOL_IDS or ID_SHAPE.
79
+ * @param {unknown[]} raw @returns {string[]}
80
+ */
81
+ function normalizeIds(raw) {
82
+ return raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
83
+ }
84
+
85
+ /**
86
+ * The one shape-error message text both doors use, so it has a single source.
87
+ * @param {string[]} ids @returns {string|null}
88
+ */
89
+ function shapeErrorFor(ids) {
90
+ const bad = ids.filter((id) => !ID_SHAPE.test(id));
91
+ return bad.length ? `--tools: not a tool id: ${bad.join(', ')}` : null;
92
+ }
93
+
94
+ /**
95
+ * The `--tools` flag's value → ids. Shape only; refusals and the engine check
96
+ * live in resolveSeatTools so every door reaches them.
97
+ * @param {unknown} value
98
+ * @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
99
+ */
100
+ function parseToolsFlag(value) {
101
+ if (typeof value !== 'string' || !value.trim()) {
102
+ return { ok: false, message: '--tools requires a comma-separated list of tool ids (e.g. --tools webfetch,read)' };
103
+ }
104
+ const ids = [...new Set(normalizeIds(value.split(',')))];
105
+ if (!ids.length) { return { ok: false, message: '--tools requires at least one tool id' }; }
106
+ const shapeError = shapeErrorFor(ids);
107
+ if (shapeError) { return { ok: false, message: shapeError }; }
108
+ return { ok: true, ids };
109
+ }
110
+
111
+ /**
112
+ * The REFUSED_TOOL_IDS check, factored out of resolveSeatTools (review r1
113
+ * P2-R20) so it has one message and one caller list: resolveSeatTools (CLI
114
+ * defaults + opt-in) and resolveRemoteOnlyTools (the MCP door) must refuse
115
+ * `task`/`skill`/… identically — a permanently-refused id is refused for the
116
+ * SAME reason regardless of which door it arrived through, never described
117
+ * as a placement (local-vs-remote) problem the way an ordinary local tool is.
118
+ * `ids` must already be normalized (lowercase/trimmed) — every caller here
119
+ * goes through normalizeIds or parseToolsFlag first.
120
+ * @param {string[]} ids @returns {{ok: false, code: 'BAD_ARGS', message: string}|null}
121
+ */
122
+ function refusalFor(ids) {
123
+ const refused = ids.filter((id) => Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id));
124
+ if (!refused.length) { return null; }
125
+ return {
126
+ ok: false, code: 'BAD_ARGS',
127
+ message: `--tools: ${refused.map((id) => `${id} (${REFUSED_TOOL_IDS[id]})`).join('; ')} — refused for council seats; ` +
128
+ `${ESCAPE_HATCH} runs every leg on the engine's full Build agent instead`,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * The MCP door's tool policy (spec 2026-09-11 §4, ledger P2-R2): over MCP the
134
+ * run directory must stay inside the project (the fence in mcp-council-run.js),
135
+ * so a seat cannot be placed there with a LOCAL tool — refused with a message
136
+ * naming the CLI command that DOES allow it (an out-of-project --out-dir). A
137
+ * PERMANENTLY-refused id (review r1 P2-R20: task/skill/question/invalid/edit/
138
+ * write/apply_patch) is refused first, with refusalFor's reason — it is not a
139
+ * placement problem, and NON_LOCAL_TOOL_IDS.includes(id) is false for every one
140
+ * of them, so without this check the local-tools branch below caught them too
141
+ * and suggested an --out-dir command that would ALSO fail (resolveSeatTools
142
+ * refuses these ids unconditionally, everywhere, run-directory or not).
143
+ * Tools that never touch the tree (webfetch, websearch, todowrite) carry no
144
+ * placement requirement and ride through. Shape-checked the same way the CLI flag is (parseToolsFlag), so
145
+ * `mcp-council-run.js` never re-implements comma-splitting/normalizing for an
146
+ * input that happens to arrive as an array instead of a flag string. An empty
147
+ * array is treated as absent (`{ok: true, ids: []}`) rather than the
148
+ * `--tools`-with-nothing-typed shape error parseToolsFlag('') would raise —
149
+ * the MCP schema's `z.array(z.string().min(1))` allows `[]` (`.min(1)`
150
+ * constrains each string, not the array), so a caller can send it without
151
+ * ever having typed a flag at all.
152
+ * @param {string[]|string} input MCP `tools` input: an array (the declared
153
+ * schema shape) or a string (defense-in-depth for a caller that bypasses it).
154
+ * @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
155
+ */
156
+ function resolveRemoteOnlyTools(input) {
157
+ if (Array.isArray(input) && !input.length) { return { ok: true, ids: [] }; }
158
+ const parsed = parseToolsFlag(Array.isArray(input) ? input.join(',') : String(input));
159
+ if (!parsed.ok) { return { ok: false, message: parsed.message }; }
160
+ const refusal = refusalFor(parsed.ids);
161
+ if (refusal) { return { ok: false, message: refusal.message }; }
162
+ // Named mutant MCPLOCALLEAK: dropping this filter/refusal lets a local id
163
+ // (e.g. `read`) ride through as `ok: true`, reaching the spawned CLI child
164
+ // whose run dir is fenced INSIDE the project — the exact placement spec §4
165
+ // forbids. Reddens 'a local id is refused with a message naming the CLI
166
+ // (MCPLOCALLEAK target)' in tests/council/seat-tools.test.js and 'a local
167
+ // tool over MCP is refused before anything spawns — the MCP run dir must
168
+ // stay inside the project' in tests/mcp-council-run.test.js.
169
+ const local = parsed.ids.filter((id) => !NON_LOCAL_TOOL_IDS.includes(id));
170
+ if (local.length) {
171
+ return {
172
+ ok: false,
173
+ message: `tools: ${local.join(', ')} are local tools; over MCP the run directory must stay inside the project, ` +
174
+ 'and a seat with local tools must not run there. Use `amicus council run --tools ' + parsed.ids.join(',') +
175
+ ' --out-dir <dir outside the project>` from the CLI.',
176
+ };
177
+ }
178
+ return { ok: true, ids: parsed.ids };
179
+ }
180
+
181
+ /**
182
+ * Decide the seat's tool list: `defaultToolsFor(intent) ∪ optIn`, minus nothing —
183
+ * a refused, malformed, or unknown id fails the whole run BEFORE any spend.
184
+ * @param {{intent?: 'task'|undefined, optIn?: string[], declaredIds?: string[]|null}} args
185
+ * `declaredIds` null/absent = the engine has not been asked yet (shape + refusals only).
186
+ * @returns {{ok: true, tools: string[], local: boolean}|{ok: false, code: 'BAD_ARGS', message: string}}
187
+ */
188
+ function resolveSeatTools({ intent, optIn = [], declaredIds = null } = {}) {
189
+ if (!Array.isArray(optIn)) { return { ok: false, code: 'BAD_ARGS', message: `--tools must be a list of tool ids (got ${typeof optIn})` }; }
190
+ // Normalize + shape-check optIn BEFORE anything else, so a door that never goes
191
+ // through parseToolsFlag (an MCP arg array, a config list) reaches the same gates
192
+ // the --tools flag does. Skipping this is a named mutant: OPTINRAW — 'Task' would
193
+ // then dodge the refusal check below (REFUSED_TOOL_IDS keys are lowercase) and
194
+ // '../x' would dodge the shape check whenever declaredIds is null; reddens 'a
195
+ // case-variant refused id is caught without the engine list' and 'an opted-in id
196
+ // with a bad shape is refused without the engine list' in seat-tools.test.js.
197
+ const normalizedOptIn = normalizeIds(optIn);
198
+ const shapeError = shapeErrorFor(normalizedOptIn);
199
+ if (shapeError) { return { ok: false, code: 'BAD_ARGS', message: shapeError }; }
200
+ const requested = [...new Set([...defaultToolsFor(intent), ...normalizedOptIn])];
201
+ const refusal = refusalFor(requested);
202
+ if (refusal) { return refusal; }
203
+ if (Array.isArray(declaredIds)) {
204
+ const unknown = requested.filter((id) => !declaredIds.includes(id));
205
+ if (unknown.length) {
206
+ const offered = declaredIds.filter((id) => !Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id)).sort();
207
+ return {
208
+ ok: false, code: 'BAD_ARGS',
209
+ message: `--tools: the engine does not declare ${unknown.join(', ')}; it declares: ${offered.join(', ')}`,
210
+ };
211
+ }
212
+ }
213
+ const tools = requested.slice().sort();
214
+ return { ok: true, tools, local: isLocal(tools) };
215
+ }
216
+
217
+ /**
218
+ * The two agents the run's server registers (spec §4). `'*': false` is the
219
+ * engine's wildcard (measured 2026-09-12: renders a `*=deny` rule, and each
220
+ * `true` renders `<id>=allow` after it). The `webfetch` permission key below
221
+ * follows the `tools` map by construction, not by separate measurement — the
222
+ * 2026-09-12 pass measured `edit`/`bash`/`external_directory`, not `webfetch`;
223
+ * Task 7's engine probe is what asserts the map and the permission stay in
224
+ * agreement on the pinned engine.
225
+ *
226
+ * `.env` files (review r1 P2-R9, superseding the brief's "read[*.env]=ask → B53
227
+ * stall" sentence): the engine evaluates permission with `findLast` over the
228
+ * merged rule list (opencode v1.18.15, packages/opencode/src/permission/index.ts),
229
+ * and its own `read[*.env]=ask` default rule renders BEFORE the seat's tools-map
230
+ * `read=allow` — so a bare `tools.includes('read')` would let a seat granted
231
+ * `read` read `.env` outright, not stall; that is a secrets exposure, not a
232
+ * documented limitation. Measured fix: a nested `permission.read` object renders
233
+ * AFTER the tools map and wins under `findLast`. `read: { '*': 'allow', '*.env':
234
+ * 'deny', '*.env.*': 'deny' }` is measured to deny `.env`/`.env.*` and allow
235
+ * everything else; the `'*': 'allow'` entry is REQUIRED — measured without it,
236
+ * the object REPLACES rather than refines the tools-derived allow and ordinary
237
+ * reads fall through to `*=deny`. `grep` and `bash` have no such fence: opting
238
+ * either in trusts the seat with the tree's contents, `.env` included. Never
239
+ * emits `chat`.
240
+ * @param {{tools: string[]}} args
241
+ * @returns {{'council-seat': object, 'council-support': object}}
242
+ */
243
+ function buildCouncilAgents({ tools = [] } = {}) {
244
+ const allow = Object.fromEntries(tools.map((id) => [id, true]));
245
+ return {
246
+ 'council-support': {
247
+ description: 'Council support role (repair, judge, debate, chair): no tools — the material is in the briefing.',
248
+ mode: 'primary',
249
+ tools: { '*': false },
250
+ permission: { edit: 'deny', bash: 'deny', webfetch: 'deny', external_directory: 'deny' },
251
+ },
252
+ 'council-seat': {
253
+ description: `Council stage-1 seat: tools ${tools.length ? tools.join(', ') : 'none'}.`,
254
+ mode: 'primary',
255
+ tools: { '*': false, ...allow },
256
+ permission: {
257
+ edit: 'deny',
258
+ bash: tools.includes('bash') ? 'allow' : 'deny',
259
+ webfetch: tools.includes('webfetch') ? 'allow' : 'deny',
260
+ // council-seat always denies external directories; a tree cannot own
261
+ // that key — even a webfetch-only seat gets this rule (measured 2026-09-12).
262
+ external_directory: 'deny',
263
+ // The '*': 'allow' entry is load-bearing, not decorative (measured 2026-09-12):
264
+ // a nested `read` object REPLACES, rather than refines, the tools-map's
265
+ // read=allow under the engine's findLast evaluation. Dropping it is a named
266
+ // mutant: ENVALLOWDROP — every ordinary read would then fall through to the
267
+ // wildcard *=deny; reddens 'a read seat gets a nested .env-denying read
268
+ // permission' in seat-tools.test.js.
269
+ ...(tools.includes('read') ? { read: { '*': 'allow', ...Object.fromEntries(SEAT_READ_DENY_PATTERNS.map((p) => [p, 'deny'])) } } : {}),
270
+ },
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The briefing line a seat gets about its tools (spec §4 "briefing lines").
277
+ * No tools → the shared no-tools sentence (briefings-chair.js), forked only on
278
+ * its last word, exactly like the chair's. Config enforces; this informs (E1:
279
+ * told not to, gemini complied). Under `--agent` (ruling P2-R31), `tools` is ignored.
280
+ * @param {string[]} tools @param {'review'|'answer'} kind @param {{agent?: string}} [opts]
281
+ */
282
+ function seatToolsSentence(tools, kind, { agent } = {}) {
283
+ // Named mutant OVERRIDESENTENCEDROP: dropping this branch would brief an
284
+ // --agent-override seat as if a computed allowlist still applied.
285
+ if (agent) {
286
+ return `You run as the engine's ${agent} agent with its own tool set; use tools only where ` +
287
+ 'the deliverable needs them; if research is incomplete, say so in the deliverable rather ' +
288
+ 'than leave it unwritten.';
289
+ }
290
+ if (!tools || !tools.length) { return `${CHAIR_NO_TOOLS_LEAD}${kind}.`; }
291
+ const forbid = isLocal(tools) ? '' : ' — do not attempt to read files, search directories, or run commands';
292
+ return `Your tools: ${tools.join(', ')}. You have no others${forbid}; if research is incomplete, ` +
293
+ 'say so in the deliverable rather than leave it unwritten.';
294
+ }
295
+
296
+ module.exports = {
297
+ REFUSED_TOOL_IDS, REMOTE_TOOL_IDS: NON_LOCAL_TOOL_IDS, NON_LOCAL_TOOL_IDS, SEAT_READ_DENY_PATTERNS, defaultToolsFor,
298
+ parseToolsFlag, resolveSeatTools, resolveRemoteOnlyTools, buildCouncilAgents, seatToolsSentence, isLocal, agentToolsConflict,
299
+ };
@@ -14,15 +14,31 @@
14
14
  * repairs are not bench seats. `reviewed` is those whose leg completed: a
15
15
  * `timeout` is not a review any more than an `error` is.
16
16
  *
17
+ * `unverified` (#242 / spec §5, v4.9.8) is those bench seats whose findings came from a
18
+ * repair of a response with no parseable findings block — the LC-11 flag
19
+ * run-stages.js :: runStage1 sets on the row. The seat stays in `reviewed` (its leg
20
+ * completed) and is counted here too. ALWAYS written once the census is: 0 is a
21
+ * measurement, absence keeps its one meaning. Key order is reviewed / unverified / refused / of —
22
+ * the shape spec §5 names and the council-review check title prints. Not a stub count
23
+ * (study run B2: a real 19,064-byte review with a malformed trailing block carried the
24
+ * flag). A refused repair (`repairRefused`) is NOT counted under `unverified` — that seat
25
+ * tallied no findings at all — and since council #248 round 2 it has its OWN number,
26
+ * `refused` (P3-R17): a seat that contributed nothing is visible on the same gate surfaces
27
+ * (title, footer, stderr) and never reads as a full reviewer on them.
28
+ *
29
+ * `unverified ⊆ reviewed` and "bench rows only" are STRUCTURAL (council #248 round 1, B1/C2/D2):
30
+ * `isUnverifiedSeat` requires a bench role, `status: 'complete'` and the literal `true`, and
31
+ * report-lost-rows.js :: lostRowsOf uses that same function — so the census and the report agree
32
+ * on every input, engine-written or hand-assembled, and `unverified` can never exceed `reviewed`.
33
+ * On engine-written records the gate is a no-op: run-launch.js :: materializeReviews drops every
34
+ * non-complete leg before a repair can run, and seats.js :: buildSeats / run-stages.js :: roleFor
35
+ * mint only bench roles (V6/V14 show why hand-assembled records reach buildVerdict at all).
36
+ *
17
37
  * A LEAF: it requires nothing, matching its seat-loss sibling.
18
38
  */
19
39
 
20
40
  'use strict';
21
41
 
22
- /**
23
- * @param {Array<object>|undefined} runStats
24
- * @returns {{seatsReviewed?: {reviewed: number, of: number}}}
25
- */
26
42
  /**
27
43
  * Is this runStats row a BENCH seat — something that was asked to review?
28
44
  *
@@ -41,6 +57,47 @@ function isBenchRole(role) {
41
57
  || (typeof role === 'string' && role.startsWith('lens:'));
42
58
  }
43
59
 
60
+ /**
61
+ * The ONE predicate for "this row is an unverified review", shared with
62
+ * report-lost-rows.js :: lostRowsOf so the census and the report can never disagree
63
+ * (council #248 round 1, B1/C2/D2: `unverified` counted flagged rows regardless of
64
+ * status, so a hand-assembled or MCP record could publish `reviewed 0 · unverified 1`
65
+ * and CI would print `seats 0/1 (1 unverified)`). STRUCTURAL, not producer trust: a
66
+ * bench role, a COMPLETED leg, and the literal `true` tally.js emits. A flagged row that
67
+ * is not a completed bench seat is an unverified review of nothing — counted nowhere and
68
+ * rendered nowhere; a real dead leg has the sink's own dead-leg row. `unverified` can
69
+ * therefore never exceed `reviewed` (V15/V16). Named mutant: SUBSETBLIND
70
+ * (`&& r.status === 'complete'` deleted from this function).
71
+ */
72
+ // !Array.isArray: an array carrying named properties is not a row — lostRowsOf's own plain-object guard already skips it, and the census must agree.
73
+ function isUnverifiedSeat(r) {
74
+ return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role)
75
+ && r.status === 'complete' && r.findingsUnverified === true;
76
+ }
77
+
78
+ /** Its sibling for a refused repair: the same gate, and `repairRefused` a plain object. */
79
+ function isRefusedSeat(r) {
80
+ return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role) && r.status === 'complete'
81
+ && !!r.repairRefused && typeof r.repairRefused === 'object' && !Array.isArray(r.repairRefused);
82
+ }
83
+
84
+ /**
85
+ * The row's label on every human surface — the report rows and the end-of-run stderr line —
86
+ * so the two can never name one row differently (council #248 r2, A3): the seat id when the
87
+ * bench repeats an alias, else the alias (the `seat || model` rule the street-cred rows use in
88
+ * both renderers), and 'unknown' for a hand-assembled row carrying neither — silence would
89
+ * hide the flag.
90
+ */
91
+ function seatLabel(r) {
92
+ if (r && typeof r.seat === 'string' && r.seat) { return r.seat; }
93
+ if (r && typeof r.model === 'string' && r.model) { return r.model; }
94
+ return 'unknown';
95
+ }
96
+
97
+ /**
98
+ * @param {Array<object>|undefined} runStats
99
+ * @returns {{seatsReviewed?: {reviewed: number, unverified: number, refused: number, of: number}}}
100
+ */
44
101
  function seatsReviewedOf(runStats) {
45
102
  // ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
46
103
  // externally-supplied records that never touched tally() in-process — the MCP
@@ -49,12 +106,25 @@ function seatsReviewedOf(runStats) {
49
106
  // truthy non-array sails past `||` and throws on `.filter`, turning a missing
50
107
  // census into a crashed verdict build. The closed-literal comment further down
51
108
  // makes the same argument about the same caller.
52
- const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
109
+ // A row is a PLAIN OBJECT: an array wearing row properties is not a seat (council #248 r2, A5)
110
+ // the same guard the shared predicates carry, so of/reviewed/unverified/refused and the report
111
+ // rows all agree on every input.
112
+ const seats = (Array.isArray(runStats) ? runStats : [])
113
+ .filter(r => !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role));
53
114
  if (seats.length === 0) { return {}; }
54
115
  return { seatsReviewed: {
55
116
  reviewed: seats.filter(r => r.status === 'complete').length,
117
+ // The shared predicate (isUnverifiedSeat above): `=== true` matching tally.js's
118
+ // emit-when-true (V14), a completed leg (V15/V16). Named mutants: CENSUSZERO
119
+ // (`unverified: 0`) and SUBSETBLIND — tests/council/verdict.test.js.
120
+ unverified: seats.filter(isUnverifiedSeat).length,
121
+ // council #248 round 2 (A2/C2, ruling P3-R17): a refused repair — the seat tallied NO findings
122
+ // — was invisible on every gate surface while counting as a full reviewer. Its own number,
123
+ // always written, never more than `reviewed` (the same predicate gate), NOT folded into
124
+ // `unverified` (a seat with no findings has no unverified findings).
125
+ refused: seats.filter(isRefusedSeat).length,
56
126
  of: seats.length,
57
127
  } };
58
128
  }
59
129
 
60
- module.exports = { seatsReviewedOf };
130
+ module.exports = { seatsReviewedOf, isBenchRole, isUnverifiedSeat, isRefusedSeat, seatLabel };
package/src/headless.js CHANGED
@@ -877,6 +877,29 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
877
877
  // block comment beside `sessionId`) so the catch-all return can carry it.
878
878
  let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
879
879
  let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
880
+ // 2026-09-11 spec §3: what session.status said on the most recent read this
881
+ // poll. The stable-idle heuristic below defers to it — a busy engine with no
882
+ // live tool is a model still answering, not a dead leg (study run D0: three
883
+ // deliverables discarded 39–107 s before they finished while this said busy).
884
+ let lastSdkStatus = 'unread';
885
+ // One trace line per flat stretch the veto holds open (spec §3 as amended 2026-09-11):
886
+ // cleared whenever a poll progresses, set on the first vetoed poll of the stretch.
887
+ let vetoLoggedThisStretch = false;
888
+ // Council #246 (2026-09-11): the `next` half of the bound finding. When session.status
889
+ // is `retry`, the engine's own timestamp for the next attempt — epoch ms: opencode's
890
+ // session/processor.ts sets `next: Date.now() + delay`. If it lies past this leg's
891
+ // deadline, waiting cannot produce a
892
+ // deliverable; the leg ends at once with the named reason RETRY_BEYOND_DEADLINE and the
893
+ // session is aborted post-loop like the backstop path. Under a relative reading of
894
+ // `next` the comparison stays inert (a delay in ms never exceeds an epoch deadline),
895
+ // so the only failure mode is "no early exit". Named mutant "NEXTIGNORED" (drop the
896
+ // comparison) reddens exactly the retry-beyond-deadline case in headless-idle-completion
897
+ // — measured 1 of 13, killed by that case's own 10 s jest timeout because the leg then
898
+ // runs to its 60 s --timeout instead. "FINISHEDRETRYEXIT" (drop the assistantFinished
899
+ // guard) reddens the finalized-plus-retry case. "RETRYOVERTOOL" (drop the
900
+ // liveTools guard) reddens the retry-with-live-tool case.
901
+ let lastSdkRetryNext = null;
902
+ let retryBeyondDeadline = false;
880
903
 
881
904
  // ---- v4.4 B4 part 1: the tool-settle deferral -----------------------------
882
905
  // Recomputed once per poll (see the loop body) so every completion gate in a
@@ -1201,9 +1224,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1201
1224
  break;
1202
1225
  }
1203
1226
 
1204
- // Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
1205
- // Gate on real output so a pre-processing 'idle' cannot end the run early.
1206
- // Best-effort: on any error, fall back to the activity heuristic below.
1227
+ // Authoritative signal from the OpenCode SDK: `idle` ends the leg here; `busy` or
1228
+ // `retry` vetoes the activity heuristic below (2026-09-11 spec §3) unless a tool
1229
+ // call is live, in which case the B4 ceiling governs. Once the message has finalized
1230
+ // the stable-finished path ends it regardless of status. Gate on real output so a
1231
+ // pre-processing 'idle' cannot end the run early. On any error the heuristic
1232
+ // runs as the fallback it was always meant to be.
1207
1233
  if (mirror.output.length > 0) {
1208
1234
  try {
1209
1235
  const remainingForStatus = deadline - Date.now();
@@ -1213,6 +1239,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1213
1239
  'getSessionStatus'
1214
1240
  );
1215
1241
  const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
1242
+ lastSdkStatus = (s && typeof s.type === 'string') ? s.type : 'other';
1243
+ lastSdkRetryNext = (s && s.type === 'retry' && Number.isFinite(s.next)) ? s.next : null;
1244
+ // Council #246 round 2 (C1/B2): never on a finalized last message — the
1245
+ // stable-finished path ends that leg in two polls whatever the session-level
1246
+ // status says (a `retry` here belongs to the engine's next step), and its text
1247
+ // must not be discarded. Named mutant "FINISHEDRETRYEXIT" (drop
1248
+ // `!assistantFinished`) reddens the finalized-plus-retry case.
1249
+ // Council #246 round 3 (D1): never while a tool call is live either — the B4
1250
+ // bounded tool-settle ceiling governs a live tool everywhere else in this loop and
1251
+ // must here too; a retry-with-live-tool is not a shape the engine produces, but a
1252
+ // lagging mirror can show one. Named mutant "RETRYOVERTOOL" (drop the liveTools
1253
+ // guard) reddens the live-tool case.
1254
+ if (!assistantFinished && liveTools.length === 0 && lastSdkRetryNext !== null && lastSdkRetryNext > deadline) {
1255
+ retryBeyondDeadline = true;
1256
+ sessionError = `RETRY_BEYOND_DEADLINE: the engine schedules the next attempt at ${new Date(lastSdkRetryNext).toISOString()}, after this leg's deadline ${new Date(deadline).toISOString()}${formatSessionStatusSuffix(s)}`;
1257
+ logger.warn('Provider backoff exceeds the leg deadline; ending the leg now instead of waiting', {
1258
+ taskId, attempt: s.attempt, next: lastSdkRetryNext, deadline,
1259
+ });
1260
+ break;
1261
+ }
1216
1262
  if (s && s.type === 'idle' && !deferForUnsettledTools('sdk-idle')) {
1217
1263
  logger.debug('Session reported idle by SDK — completing', { sessionId });
1218
1264
  completed = true;
@@ -1220,6 +1266,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1220
1266
  }
1221
1267
  } catch (statusErr) {
1222
1268
  logger.debug('session.status unavailable; using activity heuristic', { error: statusErr.message });
1269
+ lastSdkStatus = 'unavailable';
1270
+ lastSdkRetryNext = null;
1223
1271
  }
1224
1272
  }
1225
1273
 
@@ -1271,7 +1319,51 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1271
1319
  if (!progressed) {
1272
1320
  // Require real output before counting toward completion — the SDK creates an
1273
1321
  // empty assistant-message placeholder on promptAsync that is NOT a finished response.
1274
- if (currentAssistantMsgId !== null && mirror.output.length > 0) {
1322
+ //
1323
+ // 2026-09-11 spec §3: the heuristic is the FALLBACK for when session.status is
1324
+ // unavailable, not a second opinion on it. While the engine says `busy` or
1325
+ // `retry` (provider backoff — the engine has not given up; utils/session-status.js
1326
+ // reads the same arm) and no tool call is live, the model is generating —
1327
+ // in-flight parts are invisible to this poller (measured: in-flight parts never
1328
+ // grow outputLength — 0 for the whole stream without tools, A1/E1; flat at the
1329
+ // narration length with them, D0), so flat output here is not silence.
1330
+ // `liveTools.length === 0` keeps the v4.4 B4 bounded tool-settle ceiling (below)
1331
+ // in charge whenever a tool IS live: that path fires through this gate while busy,
1332
+ // then aborts the session (LC-2). The trace line fires on the first vetoed poll of
1333
+ // each flat stretch and whenever a non-zero count is reset, and "Polling loop
1334
+ // exited" carries the last SDK status, so a leg that is held and then dies by
1335
+ // --timeout leaves a record of what the engine said.
1336
+ // Named mutants, ALL RE-MEASURED 2026-09-11 for the council #246 fix set — every
1337
+ // count below is what was observed, not what was expected. "BUSYIGNORED" (the veto
1338
+ // never fires: replace the SDK-status test with `false`) reddens FIVE cases in
1339
+ // headless-idle-completion — the three D0-shape cases (busy, retry, settled tool
1340
+ // part) plus the wedge-until-timeout and status-flip-flop cases this fix set added
1341
+ // — and nothing else in the four suites (5 failed / 149). "VETOOVERCEILING" (drop
1342
+ // the liveTools guard) reddens the 12 B4 ceiling/abort tests in premature-completion,
1343
+ // every `stuck()` call site, and NOT the ALREADY-terminal case (12 failed / 42).
1344
+ // "RETRYHARVEST" (drop the retry arm) reddens exactly the retry case (1 of 13) — the
1345
+ // retry-beyond-deadline case SURVIVES it, because that exit fires in the status-read
1346
+ // block above, before this gate ever runs. "FLAPSILENT" (drop `|| stablePolls > 0`
1347
+ // from the latch below) reddens exactly the flip-flop case (1 of 13). Three further
1348
+ // mutants are documented at their own sites and were measured in the same pass:
1349
+ // "FALLBACKSILENT" (2 of 13, the stable-idle exit below), "NEXTIGNORED" (1 of 13, the
1350
+ // status-read block above) and "BACKOFFNOTFORCED" (1 of 13, failedWithNoUsableOutput
1351
+ // at the finalization). "FINISHEDVETO" (drop
1352
+ // `!assistantFinished`) is pinned by premature-completion "message FINALIZES" and
1353
+ // "ALREADY terminal" and by headless.test.js's BL-7 case: a finalized message with
1354
+ // a session-level `busy` must still end on the stable-finished path. A tool part
1355
+ // with no `state` is
1356
+ // not live here (pendingTools > 0, liveTools === 0), so that mock-only shape is now
1357
+ // bounded by B53's stall detector rather than the 30-poll heuristic; the SDK always
1358
+ // carries `state`.
1359
+ if (currentAssistantMsgId !== null && mirror.output.length > 0
1360
+ && !assistantFinished && (lastSdkStatus === 'busy' || lastSdkStatus === 'retry') && liveTools.length === 0) {
1361
+ if (!vetoLoggedThisStretch || stablePolls > 0) {
1362
+ logger.debug('Idle heuristic reset: SDK busy, no live tools', { taskId, stablePolls, sdkStatus: lastSdkStatus });
1363
+ vetoLoggedThisStretch = true;
1364
+ }
1365
+ stablePolls = 0;
1366
+ } else if (currentAssistantMsgId !== null && mirror.output.length > 0) {
1275
1367
  stablePolls++;
1276
1368
  const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
1277
1369
  // v4.4 B4 part 1 — THE MEASURED DEFECT SITE. This is the gate that
@@ -1288,6 +1380,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1288
1380
  // Gating it would add pure hang risk for no truth gained.
1289
1381
  if (stablePolls >= threshold
1290
1382
  && !(!assistantFinished && deferForUnsettledTools('stable-idle'))) {
1383
+ if (!assistantFinished && liveTools.length === 0) {
1384
+ // Council #246 C2/D2: the fallback ended an UNFINALIZED message with no tool
1385
+ // live — the D0 shape the veto exists for — reachable only because
1386
+ // session.status did not say busy/retry (the read threw, or returned a type
1387
+ // this gate does not know). Loud at warn level, so a stuttering status
1388
+ // endpoint cannot re-open the mid-answer harvest silently. A live tool is
1389
+ // excluded because that exit is the B4 ceiling, which is loud on its own.
1390
+ // Named mutant "FALLBACKSILENT" (drop this warning, keep the `if`) reddens
1391
+ // exactly the two fallback-warning cases in headless-idle-completion
1392
+ // (measured 2 of 13).
1393
+ logger.warn('Idle heuristic ended an unfinalized message on the fallback path', {
1394
+ taskId, stablePolls, sdkStatus: lastSdkStatus, outputLength: mirror.output.length,
1395
+ });
1396
+ }
1291
1397
  logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
1292
1398
  completed = true;
1293
1399
  break;
@@ -1299,6 +1405,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1299
1405
  }
1300
1406
  } else {
1301
1407
  stablePolls = 0;
1408
+ vetoLoggedThisStretch = false;
1302
1409
  }
1303
1410
  lastAssistantMsgId = currentAssistantMsgId;
1304
1411
 
@@ -1328,6 +1435,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1328
1435
  aborted,
1329
1436
  pollCount,
1330
1437
  stablePolls,
1438
+ sdkStatus: lastSdkStatus,
1331
1439
  outputLength: mirror.output.length,
1332
1440
  elapsed: Date.now() - startTime,
1333
1441
  hasAssistantMsg: lastAssistantMsgId !== null,
@@ -1343,7 +1451,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1343
1451
  // signal per leg: statusFromResult() (src/utils/result-schema.js) checks
1344
1452
  // timedOut BEFORE error, so a leg carrying both would misreport as an
1345
1453
  // ordinary 'timeout' instead of the distinctly-named backstop reason.
1346
- if (!completed && !aborted && !backstopFired && (Date.now() - startTime) >= timeoutMs) {
1454
+ // Council #246 re-review: `!retryBeyondDeadline` for the same reason as
1455
+ // `!backstopFired`. The status read that sets it may legally return
1456
+ // at deadline−ε (its budget runs to the deadline), and without
1457
+ // this guard a same-pass timeout would double-classify the named
1458
+ // RETRY_BEYOND_DEADLINE error as `timeout` in run.json and abort the
1459
+ // session twice; its own block below aborts exactly once.
1460
+ if (!completed && !aborted && !backstopFired && !retryBeyondDeadline && (Date.now() - startTime) >= timeoutMs) {
1347
1461
  timedOut = true;
1348
1462
  logger.warn('Task timed out', { taskId, elapsed: Date.now() - startTime });
1349
1463
 
@@ -1371,6 +1485,18 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1371
1485
  }
1372
1486
  }
1373
1487
 
1488
+ // Council #246: a retry scheduled past the deadline ended the leg early. Abort the
1489
+ // session exactly like the backstop path — the engine would otherwise keep retrying.
1490
+ if (retryBeyondDeadline && !completed && !aborted) {
1491
+ try {
1492
+ const { abortSession } = require('./opencode-client');
1493
+ await abortSession(client, sessionId, ...dirArgs);
1494
+ logger.info('Session aborted after retry-beyond-deadline exit', { taskId, sessionId });
1495
+ } catch (abortErr) {
1496
+ logger.warn('Failed to abort session after retry-beyond-deadline exit', { error: abortErr.message });
1497
+ }
1498
+ }
1499
+
1374
1500
  watchdog.cancel();
1375
1501
  if (uninstallSignals) { uninstallSignals(); }
1376
1502
 
@@ -1661,7 +1787,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1661
1787
  // #218 PR 3: an OUTPUT_LENGTH death can have a non-empty mirror.output -- a
1662
1788
  // tool loop's earlier message text, or reasoning promoted before the answer
1663
1789
  // was known -- and must still fail.
1664
- const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath));
1790
+ // Council #246 (2026-09-11): `|| retryBeyondDeadline` names the retry-past-deadline death
1791
+ // through the same channel — without it the leg left as Incomplete with its narration
1792
+ // promoted and the reason lost. Named mutant "BACKOFFNOTFORCED" (drop it) reddens exactly
1793
+ // the beyond-deadline case in headless-idle-completion (measured 1 of 13).
1794
+ const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath || retryBeyondDeadline));
1665
1795
  const { resolveTerminalState } = require('./sidecar/session-finalize');
1666
1796
  const terminalStage = resolveTerminalState({
1667
1797
  completed,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @module mcp-council-pack-map
3
+ * COUNCIL_PACK_PARAM_MAP, split out of mcp-council-run.js for the 300-line size gate (P2-R16).
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
10
+ * arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
11
+ * applyPackToMcpInput can reuse those tables unchanged. `template` has no
12
+ * Zod-declared counterpart on this tool (MCP has no template param of its
13
+ * own — template/apply.js's own docblock: "MCP has no template params of its
14
+ * own") — a pack's briefing.template is the ONLY way a template reaches this
15
+ * handler, carried through as a plain (non-schema) `input.template` property
16
+ * consumed by the render step in mcp-council-run.js.
17
+ */
18
+ const COUNCIL_PACK_PARAM_MAP = {
19
+ models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
20
+ debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
21
+ template: 'template',
22
+ };
23
+
24
+ module.exports = { COUNCIL_PACK_PARAM_MAP };