amicus 4.9.7 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/architecture-map.md +41 -6
- package/docs/configuration.md +14 -8
- package/docs/council.md +140 -3
- package/docs/usage.md +29 -6
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli-handlers.js +8 -1
- package/src/cli.js +34 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/setup.js +20 -18
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/degrade.js +8 -0
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- package/src/utils/text-sanitize.js +27 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// src/council/seat-tools.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/seat-tools
|
|
6
|
+
* Per-run tool policy for council seats (spec 2026-09-11 §4, PR 2 of 3).
|
|
7
|
+
*
|
|
8
|
+
* The study (§1) found two of the three leg-loss classes share one precondition:
|
|
9
|
+
* a stage-1 seat reached for a tool it did not need (gemini grep/glob over the
|
|
10
|
+
* global install, cohere `task {}`). Tool access is a property of the RUN, set
|
|
11
|
+
* by the caller according to whether the seat must go and get its material
|
|
12
|
+
* (§2.1): task mode defaults to `webfetch`, review to nothing, `--tools` opts
|
|
13
|
+
* more in, and two ids are never available to a headless seat without the
|
|
14
|
+
* `--agent` override. This module is pure: it decides, and builds the two agent
|
|
15
|
+
* configs the run's OpenCode server registers (run-server.js). Nothing here
|
|
16
|
+
* talks to the engine — the engine's declared ids are passed IN (`declaredIds`)
|
|
17
|
+
* by run.js after the server is up, so the accepted set is never hand-listed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { CHAIR_NO_TOOLS_LEAD } = require('./briefings-chair');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ids a council seat may never opt into. Hand-listed on purpose: each names why.
|
|
24
|
+
* Everything else is validated against the engine's own `tool.ids()`.
|
|
25
|
+
*/
|
|
26
|
+
const REFUSED_TOOL_IDS = Object.freeze({
|
|
27
|
+
task: 'spawns child sessions amicus cannot observe',
|
|
28
|
+
skill: 'is where a seat starts reading the harness instead of the brief',
|
|
29
|
+
question: 'asks a human, and a headless leg has none',
|
|
30
|
+
invalid: 'is the engine\'s error surface, not a tool',
|
|
31
|
+
edit: 'a council seat never modifies the tree',
|
|
32
|
+
write: 'a council seat never modifies the tree',
|
|
33
|
+
apply_patch: 'a council seat never modifies the tree',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Ids that never touch the local tree: the remote fetchers, plus the engine's own todo list. */
|
|
37
|
+
const NON_LOCAL_TOOL_IDS = Object.freeze(['webfetch', 'websearch', 'todowrite']);
|
|
38
|
+
const SEAT_READ_DENY_PATTERNS = Object.freeze(['*.env', '*.env.*', '*.envrc']); // measured 2026-09-12: patterns a granted `read` must never cover
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True if `tools` contains anything outside NON_LOCAL_TOOL_IDS. Single source for the
|
|
42
|
+
* local/remote predicate — resolveSeatTools and seatToolsSentence both call this
|
|
43
|
+
* instead of each re-writing the same `.some()` (review r1 P2-R8: before this fix,
|
|
44
|
+
* `buildCouncilAgents` accepted a `local` flag that could contradict its own `tools`,
|
|
45
|
+
* silently dropping `external_directory: 'deny'`; it now denies that key
|
|
46
|
+
* unconditionally and takes no `local` flag). A third caller (PR 2
|
|
47
|
+
* Task 5 review r1, P2-R19): cli-council-run-tools.js's out-dir fence, which decides
|
|
48
|
+
* whether a run's directory may sit outside the project tree.
|
|
49
|
+
* @param {string[]} tools @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function isLocal(tools) {
|
|
52
|
+
return tools.some((id) => !NON_LOCAL_TOOL_IDS.includes(id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `--tools`/`--agent` are mutually exclusive (ruling P2-R28, supersedes P2-R25's
|
|
57
|
+
* MCP-only short-circuit): the override already runs every leg on its own agent.
|
|
58
|
+
* @param {string|null|undefined} agent @param {string[]|undefined} tools
|
|
59
|
+
* @returns {string|null} the refusal message, or null when there is no conflict
|
|
60
|
+
*/
|
|
61
|
+
function agentToolsConflict(agent, tools) {
|
|
62
|
+
if (!agent || !Array.isArray(tools) || !tools.length) { return null; }
|
|
63
|
+
return `--tools cannot be combined with --agent: the override runs every leg on the engine's own ${agent} agent with that agent's own tool set; drop one of them`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ESCAPE_HATCH = '--agent Build';
|
|
67
|
+
const ID_SHAPE = /^[a-z][a-z0-9_]*$/;
|
|
68
|
+
|
|
69
|
+
/** @param {'task'|undefined} intent @returns {string[]} */
|
|
70
|
+
function defaultToolsFor(intent) {
|
|
71
|
+
return intent === 'task' ? ['webfetch'] : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize raw values the way `--tools` does: String, trim, lowercase, drop empties.
|
|
76
|
+
* Shared by parseToolsFlag and resolveSeatTools so a value from EITHER door — the CLI
|
|
77
|
+
* flag's comma string or a direct `optIn` array (MCP arg, a config list) — is cleaned
|
|
78
|
+
* identically before it is ever compared against REFUSED_TOOL_IDS or ID_SHAPE.
|
|
79
|
+
* @param {unknown[]} raw @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
function normalizeIds(raw) {
|
|
82
|
+
return raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one shape-error message text both doors use, so it has a single source.
|
|
87
|
+
* @param {string[]} ids @returns {string|null}
|
|
88
|
+
*/
|
|
89
|
+
function shapeErrorFor(ids) {
|
|
90
|
+
const bad = ids.filter((id) => !ID_SHAPE.test(id));
|
|
91
|
+
return bad.length ? `--tools: not a tool id: ${bad.join(', ')}` : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The `--tools` flag's value → ids. Shape only; refusals and the engine check
|
|
96
|
+
* live in resolveSeatTools so every door reaches them.
|
|
97
|
+
* @param {unknown} value
|
|
98
|
+
* @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
|
|
99
|
+
*/
|
|
100
|
+
function parseToolsFlag(value) {
|
|
101
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
102
|
+
return { ok: false, message: '--tools requires a comma-separated list of tool ids (e.g. --tools webfetch,read)' };
|
|
103
|
+
}
|
|
104
|
+
const ids = [...new Set(normalizeIds(value.split(',')))];
|
|
105
|
+
if (!ids.length) { return { ok: false, message: '--tools requires at least one tool id' }; }
|
|
106
|
+
const shapeError = shapeErrorFor(ids);
|
|
107
|
+
if (shapeError) { return { ok: false, message: shapeError }; }
|
|
108
|
+
return { ok: true, ids };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The REFUSED_TOOL_IDS check, factored out of resolveSeatTools (review r1
|
|
113
|
+
* P2-R20) so it has one message and one caller list: resolveSeatTools (CLI
|
|
114
|
+
* defaults + opt-in) and resolveRemoteOnlyTools (the MCP door) must refuse
|
|
115
|
+
* `task`/`skill`/… identically — a permanently-refused id is refused for the
|
|
116
|
+
* SAME reason regardless of which door it arrived through, never described
|
|
117
|
+
* as a placement (local-vs-remote) problem the way an ordinary local tool is.
|
|
118
|
+
* `ids` must already be normalized (lowercase/trimmed) — every caller here
|
|
119
|
+
* goes through normalizeIds or parseToolsFlag first.
|
|
120
|
+
* @param {string[]} ids @returns {{ok: false, code: 'BAD_ARGS', message: string}|null}
|
|
121
|
+
*/
|
|
122
|
+
function refusalFor(ids) {
|
|
123
|
+
const refused = ids.filter((id) => Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id));
|
|
124
|
+
if (!refused.length) { return null; }
|
|
125
|
+
return {
|
|
126
|
+
ok: false, code: 'BAD_ARGS',
|
|
127
|
+
message: `--tools: ${refused.map((id) => `${id} (${REFUSED_TOOL_IDS[id]})`).join('; ')} — refused for council seats; ` +
|
|
128
|
+
`${ESCAPE_HATCH} runs every leg on the engine's full Build agent instead`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The MCP door's tool policy (spec 2026-09-11 §4, ledger P2-R2): over MCP the
|
|
134
|
+
* run directory must stay inside the project (the fence in mcp-council-run.js),
|
|
135
|
+
* so a seat cannot be placed there with a LOCAL tool — refused with a message
|
|
136
|
+
* naming the CLI command that DOES allow it (an out-of-project --out-dir). A
|
|
137
|
+
* PERMANENTLY-refused id (review r1 P2-R20: task/skill/question/invalid/edit/
|
|
138
|
+
* write/apply_patch) is refused first, with refusalFor's reason — it is not a
|
|
139
|
+
* placement problem, and NON_LOCAL_TOOL_IDS.includes(id) is false for every one
|
|
140
|
+
* of them, so without this check the local-tools branch below caught them too
|
|
141
|
+
* and suggested an --out-dir command that would ALSO fail (resolveSeatTools
|
|
142
|
+
* refuses these ids unconditionally, everywhere, run-directory or not).
|
|
143
|
+
* Tools that never touch the tree (webfetch, websearch, todowrite) carry no
|
|
144
|
+
* placement requirement and ride through. Shape-checked the same way the CLI flag is (parseToolsFlag), so
|
|
145
|
+
* `mcp-council-run.js` never re-implements comma-splitting/normalizing for an
|
|
146
|
+
* input that happens to arrive as an array instead of a flag string. An empty
|
|
147
|
+
* array is treated as absent (`{ok: true, ids: []}`) rather than the
|
|
148
|
+
* `--tools`-with-nothing-typed shape error parseToolsFlag('') would raise —
|
|
149
|
+
* the MCP schema's `z.array(z.string().min(1))` allows `[]` (`.min(1)`
|
|
150
|
+
* constrains each string, not the array), so a caller can send it without
|
|
151
|
+
* ever having typed a flag at all.
|
|
152
|
+
* @param {string[]|string} input MCP `tools` input: an array (the declared
|
|
153
|
+
* schema shape) or a string (defense-in-depth for a caller that bypasses it).
|
|
154
|
+
* @returns {{ok: true, ids: string[]}|{ok: false, message: string}}
|
|
155
|
+
*/
|
|
156
|
+
function resolveRemoteOnlyTools(input) {
|
|
157
|
+
if (Array.isArray(input) && !input.length) { return { ok: true, ids: [] }; }
|
|
158
|
+
const parsed = parseToolsFlag(Array.isArray(input) ? input.join(',') : String(input));
|
|
159
|
+
if (!parsed.ok) { return { ok: false, message: parsed.message }; }
|
|
160
|
+
const refusal = refusalFor(parsed.ids);
|
|
161
|
+
if (refusal) { return { ok: false, message: refusal.message }; }
|
|
162
|
+
// Named mutant MCPLOCALLEAK: dropping this filter/refusal lets a local id
|
|
163
|
+
// (e.g. `read`) ride through as `ok: true`, reaching the spawned CLI child
|
|
164
|
+
// whose run dir is fenced INSIDE the project — the exact placement spec §4
|
|
165
|
+
// forbids. Reddens 'a local id is refused with a message naming the CLI
|
|
166
|
+
// (MCPLOCALLEAK target)' in tests/council/seat-tools.test.js and 'a local
|
|
167
|
+
// tool over MCP is refused before anything spawns — the MCP run dir must
|
|
168
|
+
// stay inside the project' in tests/mcp-council-run.test.js.
|
|
169
|
+
const local = parsed.ids.filter((id) => !NON_LOCAL_TOOL_IDS.includes(id));
|
|
170
|
+
if (local.length) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
message: `tools: ${local.join(', ')} are local tools; over MCP the run directory must stay inside the project, ` +
|
|
174
|
+
'and a seat with local tools must not run there. Use `amicus council run --tools ' + parsed.ids.join(',') +
|
|
175
|
+
' --out-dir <dir outside the project>` from the CLI.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, ids: parsed.ids };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Decide the seat's tool list: `defaultToolsFor(intent) ∪ optIn`, minus nothing —
|
|
183
|
+
* a refused, malformed, or unknown id fails the whole run BEFORE any spend.
|
|
184
|
+
* @param {{intent?: 'task'|undefined, optIn?: string[], declaredIds?: string[]|null}} args
|
|
185
|
+
* `declaredIds` null/absent = the engine has not been asked yet (shape + refusals only).
|
|
186
|
+
* @returns {{ok: true, tools: string[], local: boolean}|{ok: false, code: 'BAD_ARGS', message: string}}
|
|
187
|
+
*/
|
|
188
|
+
function resolveSeatTools({ intent, optIn = [], declaredIds = null } = {}) {
|
|
189
|
+
if (!Array.isArray(optIn)) { return { ok: false, code: 'BAD_ARGS', message: `--tools must be a list of tool ids (got ${typeof optIn})` }; }
|
|
190
|
+
// Normalize + shape-check optIn BEFORE anything else, so a door that never goes
|
|
191
|
+
// through parseToolsFlag (an MCP arg array, a config list) reaches the same gates
|
|
192
|
+
// the --tools flag does. Skipping this is a named mutant: OPTINRAW — 'Task' would
|
|
193
|
+
// then dodge the refusal check below (REFUSED_TOOL_IDS keys are lowercase) and
|
|
194
|
+
// '../x' would dodge the shape check whenever declaredIds is null; reddens 'a
|
|
195
|
+
// case-variant refused id is caught without the engine list' and 'an opted-in id
|
|
196
|
+
// with a bad shape is refused without the engine list' in seat-tools.test.js.
|
|
197
|
+
const normalizedOptIn = normalizeIds(optIn);
|
|
198
|
+
const shapeError = shapeErrorFor(normalizedOptIn);
|
|
199
|
+
if (shapeError) { return { ok: false, code: 'BAD_ARGS', message: shapeError }; }
|
|
200
|
+
const requested = [...new Set([...defaultToolsFor(intent), ...normalizedOptIn])];
|
|
201
|
+
const refusal = refusalFor(requested);
|
|
202
|
+
if (refusal) { return refusal; }
|
|
203
|
+
if (Array.isArray(declaredIds)) {
|
|
204
|
+
const unknown = requested.filter((id) => !declaredIds.includes(id));
|
|
205
|
+
if (unknown.length) {
|
|
206
|
+
const offered = declaredIds.filter((id) => !Object.prototype.hasOwnProperty.call(REFUSED_TOOL_IDS, id)).sort();
|
|
207
|
+
return {
|
|
208
|
+
ok: false, code: 'BAD_ARGS',
|
|
209
|
+
message: `--tools: the engine does not declare ${unknown.join(', ')}; it declares: ${offered.join(', ')}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const tools = requested.slice().sort();
|
|
214
|
+
return { ok: true, tools, local: isLocal(tools) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The two agents the run's server registers (spec §4). `'*': false` is the
|
|
219
|
+
* engine's wildcard (measured 2026-09-12: renders a `*=deny` rule, and each
|
|
220
|
+
* `true` renders `<id>=allow` after it). The `webfetch` permission key below
|
|
221
|
+
* follows the `tools` map by construction, not by separate measurement — the
|
|
222
|
+
* 2026-09-12 pass measured `edit`/`bash`/`external_directory`, not `webfetch`;
|
|
223
|
+
* Task 7's engine probe is what asserts the map and the permission stay in
|
|
224
|
+
* agreement on the pinned engine.
|
|
225
|
+
*
|
|
226
|
+
* `.env` files (review r1 P2-R9, superseding the brief's "read[*.env]=ask → B53
|
|
227
|
+
* stall" sentence): the engine evaluates permission with `findLast` over the
|
|
228
|
+
* merged rule list (opencode v1.18.15, packages/opencode/src/permission/index.ts),
|
|
229
|
+
* and its own `read[*.env]=ask` default rule renders BEFORE the seat's tools-map
|
|
230
|
+
* `read=allow` — so a bare `tools.includes('read')` would let a seat granted
|
|
231
|
+
* `read` read `.env` outright, not stall; that is a secrets exposure, not a
|
|
232
|
+
* documented limitation. Measured fix: a nested `permission.read` object renders
|
|
233
|
+
* AFTER the tools map and wins under `findLast`. `read: { '*': 'allow', '*.env':
|
|
234
|
+
* 'deny', '*.env.*': 'deny' }` is measured to deny `.env`/`.env.*` and allow
|
|
235
|
+
* everything else; the `'*': 'allow'` entry is REQUIRED — measured without it,
|
|
236
|
+
* the object REPLACES rather than refines the tools-derived allow and ordinary
|
|
237
|
+
* reads fall through to `*=deny`. `grep` and `bash` have no such fence: opting
|
|
238
|
+
* either in trusts the seat with the tree's contents, `.env` included. Never
|
|
239
|
+
* emits `chat`.
|
|
240
|
+
* @param {{tools: string[]}} args
|
|
241
|
+
* @returns {{'council-seat': object, 'council-support': object}}
|
|
242
|
+
*/
|
|
243
|
+
function buildCouncilAgents({ tools = [] } = {}) {
|
|
244
|
+
const allow = Object.fromEntries(tools.map((id) => [id, true]));
|
|
245
|
+
return {
|
|
246
|
+
'council-support': {
|
|
247
|
+
description: 'Council support role (repair, judge, debate, chair): no tools — the material is in the briefing.',
|
|
248
|
+
mode: 'primary',
|
|
249
|
+
tools: { '*': false },
|
|
250
|
+
permission: { edit: 'deny', bash: 'deny', webfetch: 'deny', external_directory: 'deny' },
|
|
251
|
+
},
|
|
252
|
+
'council-seat': {
|
|
253
|
+
description: `Council stage-1 seat: tools ${tools.length ? tools.join(', ') : 'none'}.`,
|
|
254
|
+
mode: 'primary',
|
|
255
|
+
tools: { '*': false, ...allow },
|
|
256
|
+
permission: {
|
|
257
|
+
edit: 'deny',
|
|
258
|
+
bash: tools.includes('bash') ? 'allow' : 'deny',
|
|
259
|
+
webfetch: tools.includes('webfetch') ? 'allow' : 'deny',
|
|
260
|
+
// council-seat always denies external directories; a tree cannot own
|
|
261
|
+
// that key — even a webfetch-only seat gets this rule (measured 2026-09-12).
|
|
262
|
+
external_directory: 'deny',
|
|
263
|
+
// The '*': 'allow' entry is load-bearing, not decorative (measured 2026-09-12):
|
|
264
|
+
// a nested `read` object REPLACES, rather than refines, the tools-map's
|
|
265
|
+
// read=allow under the engine's findLast evaluation. Dropping it is a named
|
|
266
|
+
// mutant: ENVALLOWDROP — every ordinary read would then fall through to the
|
|
267
|
+
// wildcard *=deny; reddens 'a read seat gets a nested .env-denying read
|
|
268
|
+
// permission' in seat-tools.test.js.
|
|
269
|
+
...(tools.includes('read') ? { read: { '*': 'allow', ...Object.fromEntries(SEAT_READ_DENY_PATTERNS.map((p) => [p, 'deny'])) } } : {}),
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The briefing line a seat gets about its tools (spec §4 "briefing lines").
|
|
277
|
+
* No tools → the shared no-tools sentence (briefings-chair.js), forked only on
|
|
278
|
+
* its last word, exactly like the chair's. Config enforces; this informs (E1:
|
|
279
|
+
* told not to, gemini complied). Under `--agent` (ruling P2-R31), `tools` is ignored.
|
|
280
|
+
* @param {string[]} tools @param {'review'|'answer'} kind @param {{agent?: string}} [opts]
|
|
281
|
+
*/
|
|
282
|
+
function seatToolsSentence(tools, kind, { agent } = {}) {
|
|
283
|
+
// Named mutant OVERRIDESENTENCEDROP: dropping this branch would brief an
|
|
284
|
+
// --agent-override seat as if a computed allowlist still applied.
|
|
285
|
+
if (agent) {
|
|
286
|
+
return `You run as the engine's ${agent} agent with its own tool set; use tools only where ` +
|
|
287
|
+
'the deliverable needs them; if research is incomplete, say so in the deliverable rather ' +
|
|
288
|
+
'than leave it unwritten.';
|
|
289
|
+
}
|
|
290
|
+
if (!tools || !tools.length) { return `${CHAIR_NO_TOOLS_LEAD}${kind}.`; }
|
|
291
|
+
const forbid = isLocal(tools) ? '' : ' — do not attempt to read files, search directories, or run commands';
|
|
292
|
+
return `Your tools: ${tools.join(', ')}. You have no others${forbid}; if research is incomplete, ` +
|
|
293
|
+
'say so in the deliverable rather than leave it unwritten.';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
REFUSED_TOOL_IDS, REMOTE_TOOL_IDS: NON_LOCAL_TOOL_IDS, NON_LOCAL_TOOL_IDS, SEAT_READ_DENY_PATTERNS, defaultToolsFor,
|
|
298
|
+
parseToolsFlag, resolveSeatTools, resolveRemoteOnlyTools, buildCouncilAgents, seatToolsSentence, isLocal, agentToolsConflict,
|
|
299
|
+
};
|
|
@@ -14,15 +14,31 @@
|
|
|
14
14
|
* repairs are not bench seats. `reviewed` is those whose leg completed: a
|
|
15
15
|
* `timeout` is not a review any more than an `error` is.
|
|
16
16
|
*
|
|
17
|
+
* `unverified` (#242 / spec §5, v4.9.8) is those bench seats whose findings came from a
|
|
18
|
+
* repair of a response with no parseable findings block — the LC-11 flag
|
|
19
|
+
* run-stages.js :: runStage1 sets on the row. The seat stays in `reviewed` (its leg
|
|
20
|
+
* completed) and is counted here too. ALWAYS written once the census is: 0 is a
|
|
21
|
+
* measurement, absence keeps its one meaning. Key order is reviewed / unverified / refused / of —
|
|
22
|
+
* the shape spec §5 names and the council-review check title prints. Not a stub count
|
|
23
|
+
* (study run B2: a real 19,064-byte review with a malformed trailing block carried the
|
|
24
|
+
* flag). A refused repair (`repairRefused`) is NOT counted under `unverified` — that seat
|
|
25
|
+
* tallied no findings at all — and since council #248 round 2 it has its OWN number,
|
|
26
|
+
* `refused` (P3-R17): a seat that contributed nothing is visible on the same gate surfaces
|
|
27
|
+
* (title, footer, stderr) and never reads as a full reviewer on them.
|
|
28
|
+
*
|
|
29
|
+
* `unverified ⊆ reviewed` and "bench rows only" are STRUCTURAL (council #248 round 1, B1/C2/D2):
|
|
30
|
+
* `isUnverifiedSeat` requires a bench role, `status: 'complete'` and the literal `true`, and
|
|
31
|
+
* report-lost-rows.js :: lostRowsOf uses that same function — so the census and the report agree
|
|
32
|
+
* on every input, engine-written or hand-assembled, and `unverified` can never exceed `reviewed`.
|
|
33
|
+
* On engine-written records the gate is a no-op: run-launch.js :: materializeReviews drops every
|
|
34
|
+
* non-complete leg before a repair can run, and seats.js :: buildSeats / run-stages.js :: roleFor
|
|
35
|
+
* mint only bench roles (V6/V14 show why hand-assembled records reach buildVerdict at all).
|
|
36
|
+
*
|
|
17
37
|
* A LEAF: it requires nothing, matching its seat-loss sibling.
|
|
18
38
|
*/
|
|
19
39
|
|
|
20
40
|
'use strict';
|
|
21
41
|
|
|
22
|
-
/**
|
|
23
|
-
* @param {Array<object>|undefined} runStats
|
|
24
|
-
* @returns {{seatsReviewed?: {reviewed: number, of: number}}}
|
|
25
|
-
*/
|
|
26
42
|
/**
|
|
27
43
|
* Is this runStats row a BENCH seat — something that was asked to review?
|
|
28
44
|
*
|
|
@@ -41,6 +57,47 @@ function isBenchRole(role) {
|
|
|
41
57
|
|| (typeof role === 'string' && role.startsWith('lens:'));
|
|
42
58
|
}
|
|
43
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The ONE predicate for "this row is an unverified review", shared with
|
|
62
|
+
* report-lost-rows.js :: lostRowsOf so the census and the report can never disagree
|
|
63
|
+
* (council #248 round 1, B1/C2/D2: `unverified` counted flagged rows regardless of
|
|
64
|
+
* status, so a hand-assembled or MCP record could publish `reviewed 0 · unverified 1`
|
|
65
|
+
* and CI would print `seats 0/1 (1 unverified)`). STRUCTURAL, not producer trust: a
|
|
66
|
+
* bench role, a COMPLETED leg, and the literal `true` tally.js emits. A flagged row that
|
|
67
|
+
* is not a completed bench seat is an unverified review of nothing — counted nowhere and
|
|
68
|
+
* rendered nowhere; a real dead leg has the sink's own dead-leg row. `unverified` can
|
|
69
|
+
* therefore never exceed `reviewed` (V15/V16). Named mutant: SUBSETBLIND
|
|
70
|
+
* (`&& r.status === 'complete'` deleted from this function).
|
|
71
|
+
*/
|
|
72
|
+
// !Array.isArray: an array carrying named properties is not a row — lostRowsOf's own plain-object guard already skips it, and the census must agree.
|
|
73
|
+
function isUnverifiedSeat(r) {
|
|
74
|
+
return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role)
|
|
75
|
+
&& r.status === 'complete' && r.findingsUnverified === true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Its sibling for a refused repair: the same gate, and `repairRefused` a plain object. */
|
|
79
|
+
function isRefusedSeat(r) {
|
|
80
|
+
return !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role) && r.status === 'complete'
|
|
81
|
+
&& !!r.repairRefused && typeof r.repairRefused === 'object' && !Array.isArray(r.repairRefused);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The row's label on every human surface — the report rows and the end-of-run stderr line —
|
|
86
|
+
* so the two can never name one row differently (council #248 r2, A3): the seat id when the
|
|
87
|
+
* bench repeats an alias, else the alias (the `seat || model` rule the street-cred rows use in
|
|
88
|
+
* both renderers), and 'unknown' for a hand-assembled row carrying neither — silence would
|
|
89
|
+
* hide the flag.
|
|
90
|
+
*/
|
|
91
|
+
function seatLabel(r) {
|
|
92
|
+
if (r && typeof r.seat === 'string' && r.seat) { return r.seat; }
|
|
93
|
+
if (r && typeof r.model === 'string' && r.model) { return r.model; }
|
|
94
|
+
return 'unknown';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {Array<object>|undefined} runStats
|
|
99
|
+
* @returns {{seatsReviewed?: {reviewed: number, unverified: number, refused: number, of: number}}}
|
|
100
|
+
*/
|
|
44
101
|
function seatsReviewedOf(runStats) {
|
|
45
102
|
// ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
|
|
46
103
|
// externally-supplied records that never touched tally() in-process — the MCP
|
|
@@ -49,12 +106,25 @@ function seatsReviewedOf(runStats) {
|
|
|
49
106
|
// truthy non-array sails past `||` and throws on `.filter`, turning a missing
|
|
50
107
|
// census into a crashed verdict build. The closed-literal comment further down
|
|
51
108
|
// makes the same argument about the same caller.
|
|
52
|
-
|
|
109
|
+
// A row is a PLAIN OBJECT: an array wearing row properties is not a seat (council #248 r2, A5) —
|
|
110
|
+
// the same guard the shared predicates carry, so of/reviewed/unverified/refused and the report
|
|
111
|
+
// rows all agree on every input.
|
|
112
|
+
const seats = (Array.isArray(runStats) ? runStats : [])
|
|
113
|
+
.filter(r => !!r && typeof r === 'object' && !Array.isArray(r) && isBenchRole(r.role));
|
|
53
114
|
if (seats.length === 0) { return {}; }
|
|
54
115
|
return { seatsReviewed: {
|
|
55
116
|
reviewed: seats.filter(r => r.status === 'complete').length,
|
|
117
|
+
// The shared predicate (isUnverifiedSeat above): `=== true` matching tally.js's
|
|
118
|
+
// emit-when-true (V14), a completed leg (V15/V16). Named mutants: CENSUSZERO
|
|
119
|
+
// (`unverified: 0`) and SUBSETBLIND — tests/council/verdict.test.js.
|
|
120
|
+
unverified: seats.filter(isUnverifiedSeat).length,
|
|
121
|
+
// council #248 round 2 (A2/C2, ruling P3-R17): a refused repair — the seat tallied NO findings
|
|
122
|
+
// — was invisible on every gate surface while counting as a full reviewer. Its own number,
|
|
123
|
+
// always written, never more than `reviewed` (the same predicate gate), NOT folded into
|
|
124
|
+
// `unverified` (a seat with no findings has no unverified findings).
|
|
125
|
+
refused: seats.filter(isRefusedSeat).length,
|
|
56
126
|
of: seats.length,
|
|
57
127
|
} };
|
|
58
128
|
}
|
|
59
129
|
|
|
60
|
-
module.exports = { seatsReviewedOf };
|
|
130
|
+
module.exports = { seatsReviewedOf, isBenchRole, isUnverifiedSeat, isRefusedSeat, seatLabel };
|
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
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|