amicus 4.9.1 → 4.9.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.1",
3
+ "version": "4.9.2",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,63 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.2] - 2026-08-27
7
+
8
+ *The instrument existed; nothing could read it.*
9
+
10
+ Issue #202 deferred its retry-policy decision to evidence: "v4.9 W13 records per-leg
11
+ time-to-first-token in `runStats`, so the next rev can derive this from observation instead of
12
+ argument." That probe had never reported a value into any artifact CI uploads — `tally.js`'s
13
+ hand-maintained allowlist stripped it one hop before `tally.json`. Reading it changed the
14
+ diagnosis: first tokens on the CI egress are a continuous heavy tail (8.0 s to 384.2 s, no gap),
15
+ not an upstream that accepts and never serves. This release fixes the instrument, then the
16
+ kill switches that were set inside that tail.
17
+
18
+ ### Fixed
19
+
20
+ - **`ttftMs` survives the runStats re-projection (#202).** `tally.js :: tally` re-projects every
21
+ row through a hand-maintained allowlist that never named the field, and `verdict.js` copies that
22
+ array verbatim — so the W13 probe emitted correctly into `tally-input.json` and was destroyed
23
+ before `tally.json` and `verdict.json`, the only artifacts CI uploads. MEASURED, run
24
+ 33030485388: 11 of 12 rows carried it going in, 0 of 12 coming out. `tally.js` becomes the fifth
25
+ emit gate and the fourth importer of the shared `isMeasuredTtft` predicate. A drift pin now fails
26
+ for ANY future `buildRunStatsEntry` key the allowlist is not taught to carry.
27
+ - **A zero-output leg now names its cause (#202).** `getSessionStatus` was called only inside
28
+ `if (mirror.output.length > 0)` — a gate a leg that produced nothing never satisfies — so the one
29
+ leg needing diagnosis was the only one that never asked. A bounded, non-throwing read now runs at
30
+ the two backstop firing sites (a living leg makes no extra call) and appends `busy` /`idle`/
31
+ `retry` with the upstream message. `busy` means provider-side, `idle` means engine-side, `retry`
32
+ names the cause; none is suppressed. Untrusted provider text is sanitized.
33
+ - **Dead Stage-2 judge legs are announced (#202).** A dead judge leg still binds to its seat, so it
34
+ was neither `orphan` nor `unbound` and Stage 2 had no third case: it fell through into
35
+ `judgeResults` unremarked. Run 32956900910 shipped a four-column adjudication matrix that two of
36
+ its four judges never voted in, with no degrade recorded. New `stage2-judge` channel.
37
+ - **B53 no longer kills healthy, billing legs.** `TOOL_CALL_STALL_MS` was 180 s, condemned by this
38
+ repo's own measurement — a real 190.6 s `task` call recorded in `headless.js`, taken on a
39
+ developer machine. That measurement had corrected its neighbour (the settle deferral) and left
40
+ its own subject alone. Now 300 s, with CI overriding to 480 s.
41
+ - **The Stage-1 retry backstop no longer ties with the leg timeout.** `min(2 * backstop, legCap)`
42
+ made the deadlines equal whenever `2 * backstop >= legCap`; the backstop won only by the poll
43
+ loop's ordering. It now clamps strictly below, so a retry death keeps its named diagnosis
44
+ instead of degrading to a generic `timeout`.
45
+
46
+ ### Added
47
+
48
+ - **`verdict.json` publishes `seatsReviewed {reviewed, of}`.** `deriveSeatLoss` returns null when
49
+ no `--critic` was requested, and CI requests none, so seat loss was structurally absent from
50
+ every CI verdict while a two-seat bench published a four-model street-cred table. Derived from
51
+ `runStats`, counting the bench roles `buildSeats` mints (`seat`, `critic`, `lens:<slug>`).
52
+ Surfaced in the check-run title and the sticky comment footer.
53
+
54
+ ### Changed
55
+
56
+ - **A dead Stage-2 judge now degrades the run, so it exits 2.** Previously a half-adjudicated
57
+ verdict could exit 0. This changes CI signal: runs that passed before will now report degraded
58
+ when a judge dies.
59
+ - **CI council caps.** Per-leg `--timeout` 10 -> 16 min and `timeout-minutes` 45 -> 75, to give the
60
+ tool-stall detector real reach (120 s -> 480 s of a leg). The job cap covers a worst case of
61
+ four leg caps; that figure is a floor, since Stage-2 repairs are serial.
62
+
6
63
  ## [4.9.1] - 2026-08-27
7
64
 
8
65
  *A silent provider failure, and the unservable model ids it produced.*
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.1",
458
+ "version": "4.9.2",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/usage.md CHANGED
@@ -538,7 +538,7 @@ $ amicus status demo123 --json
538
538
  "taskId": "demo123",
539
539
  "status": "complete",
540
540
  "elapsed": "5m 0s",
541
- "version": "4.9.1",
541
+ "version": "4.9.2",
542
542
  "model": "google/gemini-2.5-flash",
543
543
  "phase": "terminal"
544
544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.1",
3
+ "version": "4.9.2",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -229,6 +229,16 @@
229
229
  "criticSeated"
230
230
  ]
231
231
  },
232
+ "seatsReviewed": {
233
+ "type": "object",
234
+ "description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted.",
235
+ "properties": {
236
+ "reviewed": { "type": "integer", "minimum": 0, "description": "Bench seats whose leg completed." },
237
+ "of": { "type": "integer", "minimum": 1, "description": "Bench seats benched, post-retry." }
238
+ },
239
+ "required": ["reviewed", "of"],
240
+ "additionalProperties": false
241
+ },
232
242
  "degrades": {
233
243
  "description": "v4.6 Plan 2: what this run lost — copied verbatim from the sink at verdict assembly. Additive; absent when the sink recorded nothing. v4.9 widened kind with 'info': an announcement that is neither a loss nor a recovery (e.g. a task run's ledger-skipped note) — carried here without degrading the run.",
234
244
  "type": "array",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module council/run-retry-window
3
+ * The Stage-1 retry's no-output window: how long a RELAUNCHED leg may stay
4
+ * silent before the backstop kills it.
5
+ *
6
+ * ⚠️ EXTRACTED, not shaved (release Constraint 6, and the 300-line gate that put
7
+ * `verdict-seat-loss.js` in its own leaf): #219's correction took run-retry.js to
8
+ * 314/300. Its own module also makes the property directly testable.
9
+ *
10
+ * SL-2 Task 5 (#129): a retry re-runs the SAME model under the SAME conditions,
11
+ * so a latency failure is structurally unhealable — double the window rather
12
+ * than repeat it. ⚠️ #135 C0 took the base 240s -> 600s; deliberate, see
13
+ * CHANGELOG (council A1, PR #182).
14
+ *
15
+ * ⚠️ CLAMPED STRICTLY BELOW the leg timeout, not TO it (#219, council gpt
16
+ * major). `Math.min(2 * backstop, legTimeoutMs)` made the two deadlines EQUAL
17
+ * whenever `2 * backstop >= legTimeoutMs` — exactly CI today (2 x 480000 ===
18
+ * 960000 === `--timeout 16`). The backstop still won, but only by epsilon and
19
+ * only because the poll loop tests its deadline BEFORE sleeping, so the final
20
+ * poll lands just past the wall. That is an undocumented accident of loop order;
21
+ * if it ever lost, the leg would die a generic `timeout` and throw away the
22
+ * named NO_OUTPUT_BACKSTOP diagnosis this clamp exists to preserve.
23
+ *
24
+ * A PROPORTIONAL headroom, not a fixed subtraction: a constant large enough to
25
+ * beat a poll cycle (seconds) would drive a small leg cap to zero or negative,
26
+ * and `ms <= 0` is the documented DISABLE hatch — silently disabling the backstop
27
+ * is far worse than the race it fixes. 5% of any realistic leg cap clears the 2 s
28
+ * poll interval by a wide margin.
29
+ *
30
+ * `2 * 0 === 0` still disables, because `Math.min(0, anything positive) === 0`.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /**
36
+ * @param {number} baseBackstopMs the first attempt's resolved no-output window
37
+ * @param {number} legTimeoutMs the per-leg hard cap ((o.timeout || 15) * 60_000)
38
+ * @returns {number} the retry's window: doubled, clamped strictly below the cap
39
+ */
40
+ function retryBackstopMs(baseBackstopMs, legTimeoutMs) {
41
+ // ⚠️ NOT floored at the first attempt's window, and #219 round 2 (glm) asked
42
+ // for exactly that — correctly observing that when `legTimeoutMs <= 2 * base`
43
+ // the retry window comes out slightly SHORTER than the attempt it exists to
44
+ // give room to (480000/480000 -> 456000). The observation is right; the remedy
45
+ // is worse than what it fixes, MEASURED across all three regimes:
46
+ //
47
+ // regime first(effective) unfloored floored unfloored gives
48
+ // cap = 2x base 480000 912000 912000 NAMED backstop
49
+ // cap = base 480000 456000 480000 NAMED backstop
50
+ // cap < base (t=3) 180000 171000 180000 NAMED backstop
51
+ //
52
+ // Flooring pins the window ONTO the leg cap in both degenerate regimes, which
53
+ // is the tie the headroom exists to break — so the leg dies a generic
54
+ // `timeout` and the named diagnosis is lost. That diagnosis is this module's
55
+ // entire purpose. The unfloored cost is bounded at 5% of the window (24 s at
56
+ // CI scale, 9 s at `--timeout 3`), and it is paid only where the leg cap
57
+ // already dominates the backstop. Trading ≤5% of one retry's patience for a
58
+ // named cause on every retry death is the right side of that trade.
59
+ return Math.min(2 * baseBackstopMs, Math.floor(legTimeoutMs * 0.95));
60
+ }
61
+
62
+ module.exports = { retryBackstopMs };
@@ -17,6 +17,7 @@
17
17
  const { materializeReviews, isAbortExit } = require('./run-launch');
18
18
  const runState = require('./run-state');
19
19
  const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
20
+ const { retryBackstopMs } = require('./run-retry-window');
20
21
  const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
21
22
  = require('./run-retry-notes');
22
23
  // briefingFor + bindRetryWave live in ./run-retry-launch (v4.8 T-A2 split); the pad/bind core it wraps is stage1-bind.js :: bindPaddedWave (SI-27).
@@ -54,17 +55,13 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
54
55
  const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [], twins,
55
56
  stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
56
57
  stillDeadRetryLegs: [], seatOf: new Map(), orphanLegs: [], attemptedSeats: new Set() };
57
- // Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
58
- // latency failure is structurally unhealable. Double the window, clamped to
59
- // the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
60
- // silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
61
- // the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
62
- // ⚠️ #135 C0 took this 240s -> 600s; deliberate, see CHANGELOG (council A1, PR #182).
58
+ // The retry window: doubled and clamped strictly below the leg timeout.
59
+ // Reasoning (and #219's correction) lives in ./run-retry-window extracted
60
+ // for the 300-line gate. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
63
61
  const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
64
- const escalatedBackstopMs = Math.min(
65
- 2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
66
- legTimeoutMs,
67
- );
62
+ const escalatedBackstopMs = retryBackstopMs(
63
+ Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs(),
64
+ legTimeoutMs);
68
65
 
69
66
  for (const unit of groupStage1Losses(o, deadWaves, deadLegs, seatOf, twins)) {
70
67
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
@@ -23,6 +23,9 @@ const stage2 = require('./briefings-stage2');
23
23
  const { parseJudgeOutput } = require('./parse-stage2');
24
24
  const { sanitizeName, isAbortExit } = require('./run-launch');
25
25
  const runState = require('./run-state');
26
+ // #219 (council, glm minor): `leg.error` is UNTRUSTED provider text. The house
27
+ // sanitizer — one sanitizer, one dialect (utils/text-sanitize.js).
28
+ const { collapseExcerpt } = require('../utils/text-sanitize');
26
29
  const { buildRunStatsEntry } = require('./run-assemble');
27
30
  // v4.8 PR3 Task 4: seat binding. artifactName is NOT re-exported from
28
31
  // run-launch.js (its exports stop at sanitizeName/isAbortExit), so it comes
@@ -169,9 +172,16 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
169
172
  fs.writeFileSync(path.join(o.runDir, name), leg.summary, { mode: 0o600 });
170
173
  }
171
174
  let conformance = 'clean';
172
- let parsed = (leg.status === 'complete' && leg.summary)
173
- ? parseJudgeOutput(leg.summary, parseCtx)
174
- : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
175
+ // #202: ONE predicate for "this judge never answered at all", shared by the
176
+ // DEAD_LEG classification below and by the degrade it now raises. Spelling it
177
+ // twice is how the two would drift into disagreeing about which judges died —
178
+ // and note it is NOT `leg.status !== 'complete'`: a leg that completes with an
179
+ // EMPTY summary produced nothing either, and the DEAD_LEG arm has always
180
+ // treated it that way.
181
+ const legDied = !(leg.status === 'complete' && leg.summary);
182
+ let parsed = legDied
183
+ ? { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] }
184
+ : parseJudgeOutput(leg.summary, parseCtx);
175
185
  let attempts = 0;
176
186
  // ⚠️ LC-12: the judging text the repair prompt must carry, tracked exactly like
177
187
  // Stage-1's `repairing` so `judging` and `parsed.errors` always describe the SAME
@@ -216,6 +226,40 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
216
226
  if (parsed.ok) { conformance = 'repaired'; }
217
227
  }
218
228
  if (!parsed.ok) {
229
+ // #202: THE MISSING THIRD CASE. A dead judge leg still comes back as a leg
230
+ // object, so bindPaddedWave binds it — it is neither `orphan` nor
231
+ // `unbound`, and stage 2 had no case for it. It fell through into
232
+ // judgeResults with `ok:false` and vanished: MEASURED on CI run
233
+ // 32956900910 (wave 9d8029c8-s2), where glm and qwen judges died at +300s
234
+ // with zero tokens and run.json recorded no degrade at all, while the
235
+ // verdict shipped a four-column adjudication matrix two of them never
236
+ // voted in. The one net that might have caught it, `thin-cross-review`,
237
+ // fires only at `usableJudges < 2`; that run had exactly 2 of 4.
238
+ //
239
+ // ⚠️ Emitted with the default kind ('degrade'), so run-degrade.js's sink
240
+ // sets `degraded.value` and the run exits 2. That is a deliberate
241
+ // behaviour change (owner's call): before it, a half-adjudicated verdict
242
+ // could exit 0, and W11 only exited 2 because of an unrelated
243
+ // cost-accounting degrade. An unparseable-but-ANSWERED judge is a
244
+ // different fact and is deliberately excluded — it already darkens the
245
+ // seat's row via `conformance: 'unstructured'`, and it is repairable.
246
+ if (legDied) {
247
+ ctx.degrade.note({
248
+ channel: 'stage2-judge',
249
+ what: `judge ${judge} did not adjudicate`,
250
+ // #219: `why` is PROSE — it renders into run.json, the report and the
251
+ // sticky PR comment — so the provider's text is collapsed to one
252
+ // bounded line. `data.reason` below stays VERBATIM on purpose: it is
253
+ // the machine surface, it is JSON (nothing to inject), and truncating
254
+ // it would cost exactly the fidelity a reader opens run.json for.
255
+ why: `its Stage-2 leg ended '${leg.status}'`
256
+ + (leg.error ? `: ${collapseExcerpt(leg.error, 200)}` : ''),
257
+ effect: `the cross-review was adjudicated by fewer than the ${judges.length} judges the `
258
+ + 'bench implies; the run continues and will exit degraded (2)',
259
+ data: { judge, seat: seat ? seat.id : null, waveId: `${o.runId}-s2`,
260
+ status: leg.status, reason: leg.error || null },
261
+ });
262
+ }
219
263
  judgeResults.push({ judge, seat, ok: false, order: null, orderSeats: null, adjudications: null,
220
264
  conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
221
265
  // #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
@@ -6,6 +6,11 @@ const { peersOf, unattributedPeerDrops } = require('./peer-split');
6
6
  // the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
7
7
  // computeStreetCred is re-exported below, so no existing import path moved.
8
8
  const { computeStreetCred } = require('./street-cred');
9
+ // #202: the TTFT probe's LAST emit gate — a RE-PROJECTION, so omitting the field
10
+ // here destroyed one already produced rather than failing to produce it. Through
11
+ // v4.9.1 utils/ttft.js's docblock enumerated only the four PRODUCER gates and
12
+ // stopped one short of this one; it now names all five.
13
+ const { isMeasuredTtft } = require('../utils/ttft');
9
14
 
10
15
  /**
11
16
  * Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
@@ -181,6 +186,13 @@ function tally(input) {
181
186
  ...(r.seat ? { seat: r.seat } : {}),
182
187
  status: r.status || 'unknown',
183
188
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
189
+ // #202: emit-when-VALID, in buildRunStatsEntry's own slot (between
190
+ // durationMs and usage) so G7b's key-order invariant holds for a row that
191
+ // carries it. NOT `durationMs`'s null-coercion above: a null here would be
192
+ // read as a measurement, and absence must keep its one meaning — "no
193
+ // substantive tick was ever observed". The shared predicate is imported
194
+ // rather than hand-spelled; this file has no require-free pin.
195
+ ...(isMeasuredTtft(r.ttftMs) ? { ttftMs: r.ttftMs } : {}),
184
196
  usage: r.usage || null,
185
197
  })),
186
198
  tierCounts: countTiers(outFindings),
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module council/verdict-seats-reviewed
3
+ * #202: the bench-seat census for verdict.json, as a spreadable fragment.
4
+ *
5
+ * ⚠️ EXTRACTED, not shaved — release Constraint 6, and the same 300-line gate
6
+ * that put `verdict-seat-loss.js` in its own leaf: adding this to verdict.js
7
+ * took that file to 313/300. It could not join verdict-seat-loss.js either —
8
+ * that module is pinned to export EXACTLY its two functions.
9
+ *
10
+ * This is the ONE place that decides what "a bench seat" means, so the
11
+ * emit-when-set rule and the role filter cannot drift apart. `of` is every
12
+ * `role:'seat'` row — one per bench seat POST-retry, so a healed seat counts
13
+ * once while its first attempt is `role:'superseded'`; judges, chair and
14
+ * repairs are not bench seats. `reviewed` is those whose leg completed: a
15
+ * `timeout` is not a review any more than an `error` is.
16
+ *
17
+ * A LEAF: it requires nothing, matching its seat-loss sibling.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ /**
23
+ * @param {Array<object>|undefined} runStats
24
+ * @returns {{seatsReviewed?: {reviewed: number, of: number}}}
25
+ */
26
+ /**
27
+ * Is this runStats row a BENCH seat — something that was asked to review?
28
+ *
29
+ * ⚠️ These are exactly the three roles `seats.js :: buildSeats` mints, and that
30
+ * is the point: it is the producer, so this mirrors it rather than guessing.
31
+ * `role === 'seat'` alone (#219) counted ZERO on a `--lenses` run, where every
32
+ * seat carries `lens:<slug>` — so emit-when-set silently omitted the census from
33
+ * the runs using the richest bench. A critic counts too: it is an adversarial
34
+ * seat, but it reviews.
35
+ *
36
+ * An ALLOWLIST, not a denylist of judge/chair/repair/superseded: a new
37
+ * non-bench role added later must not silently inflate the denominator.
38
+ */
39
+ function isBenchRole(role) {
40
+ return role === 'seat' || role === 'critic'
41
+ || (typeof role === 'string' && role.startsWith('lens:'));
42
+ }
43
+
44
+ function seatsReviewedOf(runStats) {
45
+ // ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
46
+ // externally-supplied records that never touched tally() in-process — the MCP
47
+ // `record` param of mcp-tools.js :: amicus_verdict is `z.record(z.any())`,
48
+ // fully permissive — and this file's own tests hand it `runStats: {}`. A
49
+ // truthy non-array sails past `||` and throws on `.filter`, turning a missing
50
+ // census into a crashed verdict build. The closed-literal comment further down
51
+ // makes the same argument about the same caller.
52
+ const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
53
+ if (seats.length === 0) { return {}; }
54
+ return { seatsReviewed: {
55
+ reviewed: seats.filter(r => r.status === 'complete').length,
56
+ of: seats.length,
57
+ } };
58
+ }
59
+
60
+ module.exports = { seatsReviewedOf };
@@ -13,6 +13,10 @@ const { summarizeSeatLoss, deriveSeatLoss } = require('./verdict-seat-loss');
13
13
  // opts.overallVerdict, null in every Stage-4 manual path).
14
14
  const VERDICT_SCHEMA_VERSION = 2;
15
15
 
16
+ // #202: the bench-seat census leaf (the 300-line gate — same reason
17
+ // verdict-seat-loss.js is its own module).
18
+ const { seatsReviewedOf } = require('./verdict-seats-reviewed');
19
+
16
20
  /**
17
21
  * Merge a tally record with Claude's Stage-4 decisions into the verdict record.
18
22
  * @param {object} record tally() output
@@ -123,6 +127,25 @@ function buildVerdict(record, decisions = [], opts = {}) {
123
127
  // Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
124
128
  // was requested, so its absence never has to be interpreted.
125
129
  ...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
130
+ // #202: how much of the bench actually reviewed. DERIVED here rather than
131
+ // passed in, because every caller that could pass it already has the same
132
+ // `runStats` this reads — and a parameter is one more thing a rebuild path
133
+ // can forget (the `intent` key needed a SECOND carrier for exactly that).
134
+ //
135
+ // Its sibling `seatLoss` cannot serve: `deriveSeatLoss` returns null when no
136
+ // `--critic` was requested, and CI runs `CRITIC: ''` — so seat loss is
137
+ // STRUCTURALLY absent from every CI verdict. MEASURED on run 4424218c, a
138
+ // two-seat bench that published a four-model street-cred table with the dead
139
+ // seats rendered `n/a`, indistinguishable from the legend's "neutral".
140
+ //
141
+ // Counts the BENCH roles buildSeats mints — `seat`, `critic` and `lens:<slug>`
142
+ // (#219 r2: this said "`role:'seat'` ONLY" after the filter was widened, and
143
+ // two seats caught the stale sentence). One row per bench seat POST-retry, so a
144
+ // healed seat is counted once (its first attempt is `role:'superseded'`), and
145
+ // judges/chair/repairs are not bench seats. Emit-when-set — a record with no
146
+ // bench rows carries no key, because `0 of 0` would read as a measurement of
147
+ // an empty bench rather than as the absence it is.
148
+ ...seatsReviewedOf(record.runStats),
126
149
  // v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
127
150
  // OPTIONAL — present only when the run actually degraded, so a clean run's
128
151
  // verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
package/src/headless.js CHANGED
@@ -25,6 +25,8 @@ const { envNumber } = require('./utils/env-num');
25
25
  const { engineErrorForSession } = require('./utils/engine-log');
26
26
  // v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
27
27
  const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
28
+ // #202: the session-status clause on a death report (see utils/session-status.js).
29
+ const { formatSessionStatusSuffix } = require('./utils/session-status');
28
30
  // v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
29
31
  // emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
30
32
  const { isMeasuredTtft } = require('./utils/ttft');
@@ -85,7 +87,18 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
85
87
  const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
86
88
  const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
87
89
  const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
88
- const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
90
+ // B53: wedged tool call w/ no progress.
91
+ // ⚠️ 180000 -> 300000 (#219, council glm minor). 180 s was condemned by this
92
+ // file's OWN measurement — the 190.6 s `task` call recorded below, taken on a
93
+ // developer machine, not on CI. #202 widened only the CI override and left every
94
+ // local and library consumer on the number the evidence had already disproved.
95
+ // 300000 matches the sibling constant that same measurement set (USAGE/settle
96
+ // deferral below), so one measurement now governs both windows it bears on.
97
+ const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 300000;
98
+ // #202: budget for the ONE session-status read on a death report. Short on
99
+ // purpose — this runs on a leg already known to be dying, so the report must not
100
+ // wait on the same engine that just failed to produce anything.
101
+ const STATUS_PROBE_MS = 5000;
89
102
  /**
90
103
  * v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
91
104
  * SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
@@ -120,9 +133,11 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
120
133
  * unbounded wait is not an option.
121
134
  *
122
135
  * WHY 5 MINUTES. The measured duration of the real subagent call that exposed
123
- * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — already longer
124
- * than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill a
125
- * healthy `task` leg 10 s short of its answer. 300 s clears the measured case
136
+ * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — which was longer
137
+ * than B53's THEN-180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
138
+ * a healthy `task` leg 10 s short of its answer. (#219 finally moved B53 itself
139
+ * to 300 s for this exact reason; for four releases this measurement corrected
140
+ * the neighbour and left its own subject alone.) 300 s clears the measured case
126
141
  * with margin and still lands far inside the 15-minute default `--timeout`.
127
142
  * Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
128
143
  *
@@ -228,7 +243,7 @@ function withTimeout(promise, ms, label) {
228
243
  * engineSkew?: {server: string, installed: string}|null}} args
229
244
  * @returns {string}
230
245
  */
231
- function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
246
+ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew, sessionStatus }) {
232
247
  const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
233
248
  + `${Math.round(ms / 1000)}s — `
234
249
  + (fromEnv
@@ -237,7 +252,11 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
237
252
  // Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
238
253
  const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
239
254
  // Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
240
- return `${quoted}${formatSkewSuffix(engineSkew)}`;
255
+ // #202 adds a THIRD clause on the same terms, and LAST so both clauses above
256
+ // stay byte-stable: no status (or an unreadable one) ⇒ '' — see
257
+ // utils/session-status.js. With none of the three the string is byte-for-byte
258
+ // what it was before any of them existed.
259
+ return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
241
260
  }
242
261
 
243
262
  /**
@@ -259,6 +278,45 @@ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
259
278
  }
260
279
  }
261
280
 
281
+ /**
282
+ * #202: read the engine's session status FOR A DEATH REPORT — best-effort and
283
+ * bounded, with the same "never become the failure it reports on" discipline as
284
+ * `engineErrorExcerptSafe` above, and one more constraint that read does not
285
+ * have: this one does I/O against the very engine that just failed to produce
286
+ * anything, so it must also be unable to HANG. Both belts are load-bearing and
287
+ * both are pinned (S-W4 rejects, S-W5 hangs).
288
+ *
289
+ * A failed probe returns `null`, which `formatSessionStatusSuffix` renders as
290
+ * '' — so a leg whose status could not be read carries the byte-for-byte reason
291
+ * string it carried before #202, rather than a clause claiming nothing was
292
+ * happening. Absence keeps its one meaning.
293
+ *
294
+ * `readStatus` is a parameter rather than a module import because
295
+ * `getSessionStatus` is destructured inside runHeadless from the injectable
296
+ * client module — taking it here keeps this helper pure and directly testable.
297
+ * @returns {Promise<object|null>}
298
+ */
299
+ async function sessionStatusSafe(readStatus, client, sessionId, dirArgs, ms) {
300
+ // `!(ms > 0)` covers 0 (the documented disable), negatives and NaN — and it is
301
+ // why 0 is never handed to withTimeout, which would read it as UNBOUNDED.
302
+ if (typeof readStatus !== 'function' || !sessionId || !(ms > 0)) { return null; }
303
+ try {
304
+ return await withTimeout(
305
+ readStatus(client, sessionId, ...(dirArgs || [])), ms, 'getSessionStatus(death-report)');
306
+ } catch (err) {
307
+ // #219 (council, deepseek minor): returning null is right for the REPORT —
308
+ // absence keeps its one meaning — but it made a probe that timed out on a
309
+ // loaded engine indistinguishable from a leg whose engine reported nothing,
310
+ // i.e. a silent revert to pre-#202 behaviour. The engine log is where that
311
+ // belongs: the death report stays byte-identical, and the degradation
312
+ // becomes diagnosable instead of invisible.
313
+ logger.debug('session-status probe failed; the death report will carry no session clause', {
314
+ sessionId, error: err && err.message,
315
+ });
316
+ return null;
317
+ }
318
+ }
319
+
262
320
  /**
263
321
  * Wait for the OpenCode server to be ready using SDK health check
264
322
  */
@@ -616,6 +674,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
616
674
  // "time since the leg asked for output" (see the block comment above), so
617
675
  // the TTFT probe below measures from exactly the instant the backstop
618
676
  // starts counting — the two can never disagree about when the wait began.
677
+ // #202: resolved HERE, not with the poll-loop options below. The pre-send
678
+ // firing site calls noOutputBackstopReason() upstream of that block, so a
679
+ // later `const` put this in its TDZ and the death report became
680
+ // "Cannot access 'statusProbeMs' before initialization" — the exact class of
681
+ // silent-diagnosis loss #202 exists to remove. Pinned by S-W6.
682
+ // ⚠️ `=== undefined`, not `||` (#219 round 2, deepseek): 0 is a meaningful
683
+ // value in this codebase's convention (usageSettlePolls,
684
+ // AMICUS_NO_OUTPUT_BACKSTOP_MS) and must survive injection. It cannot be
685
+ // FORWARDED as 0 though — withTimeout reads `ms <= 0` as NO timeout, so an
686
+ // honest-looking 0 would make this probe unbounded on a leg already known to
687
+ // be dying. sessionStatusSafe therefore SKIPS on a non-positive window.
688
+ const statusProbeMs = options.statusProbeMs === undefined
689
+ ? STATUS_PROBE_MS : options.statusProbeMs;
619
690
  const outputClockStartedAt = Date.now();
620
691
  const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
621
692
  let backstopFired = false;
@@ -642,10 +713,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
642
713
  // was fixed mid-run, or one that belongs to another server this process
643
714
  // also talks to, cannot ride out on this death report. The read is a Map
644
715
  // lookup and does no I/O, so unlike the log read it needs no guard.
645
- const noOutputBackstopReason = () => formatNoOutputBackstopReason({
716
+ //
717
+ // #202 (piece 4): the closure is now ASYNC, because the third clause costs
718
+ // one bounded HTTP call. The engine's session status is asked for at
719
+ // :~1045 only when `mirror.output.length > 0` — a gate a zero-output leg
720
+ // never satisfies — so the leg that most needs diagnosing was the only one
721
+ // that never asked, and every silent death reported a window with no cause.
722
+ // Asked for HERE instead, at the two firing sites and nowhere else, so a
723
+ // living leg still makes no extra call. The read is best-effort and
724
+ // bounded: `sessionStatusSafe` can neither throw nor hang (S-W4/S-W5).
725
+ const noOutputBackstopReason = async () => formatNoOutputBackstopReason({
646
726
  ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
647
727
  engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
648
728
  engineSkew: currentEngineSkew(client),
729
+ sessionStatus: await sessionStatusSafe(getSessionStatus, client, sessionId, dirArgs, statusProbeMs),
649
730
  });
650
731
 
651
732
  // Send prompt asynchronously (returns immediately, we poll for results) —
@@ -696,7 +777,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
696
777
  // skipped entirely on this path — see the while-condition and the
697
778
  // backstop-abort block further down).
698
779
  if (backstopFired) {
699
- sessionError = noOutputBackstopReason();
780
+ sessionError = await noOutputBackstopReason();
700
781
  }
701
782
 
702
783
  // Hard provider failure detected at the client boundary (#37): a non-2xx /
@@ -1065,7 +1146,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1065
1146
  // loop; the post-loop block below mirrors the timeout path.
1066
1147
  if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
1067
1148
  backstopFired = true;
1068
- sessionError = noOutputBackstopReason();
1149
+ sessionError = await noOutputBackstopReason();
1069
1150
  logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
1070
1151
  break;
1071
1152
  }
@@ -24,6 +24,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
24
24
  // a leg that DID match a slot but whose join key names no judge the wave launched.
25
25
  // Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
26
26
  'seat-unbound',
27
+ // #202: a Stage-2 JUDGE leg that came back dead — bound to its seat, so
28
+ // neither `seat-unbound` nor an orphan, and until now it had no channel at all
29
+ // and no case in run-stage2.js. Deliberately its own channel rather than
30
+ // `dead-leg`: that one is the Stage-1 BENCH roster's, feeds the retry pass and
31
+ // the seat-loss surface, and a judge death reused on it would be counted as a
32
+ // lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
33
+ // already gates the Stage-2 notes out of `seat-unbound` for the same reason).
34
+ 'stage2-judge',
27
35
  'internal',
28
36
  // doctor channels
29
37
  'doctor-check-failed', 'doctor-fix',
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module utils/session-status
3
+ * #202: render the engine's SESSION STATUS as a clause on a leg's death report.
4
+ *
5
+ * ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
6
+ * `utils/ttft.js` / `utils/text-sanitize.js` / `utils/engine-skew.js`:
7
+ * `scripts/generate-docs.js` only reads a block comment that starts at byte
8
+ * zero, so a `// path` line above it would leave this module's CLAUDE.md row
9
+ * blank.
10
+ *
11
+ * WHY THIS EXISTS. `headless.js` asks the engine for session status only inside
12
+ * `if (mirror.output.length > 0)` — a gate a zero-output leg never satisfies. So
13
+ * the one leg that needs diagnosing is precisely the one that never asks, and
14
+ * every silent death was reported as "no output in Ns" with no cause attached.
15
+ * The pinned SDK publishes `SessionStatus` as
16
+ * `{type:'idle'} | {type:'retry', attempt, message, next} | {type:'busy'}`, and
17
+ * the `retry` arm carries the upstream error verbatim.
18
+ *
19
+ * NONE of the three types is suppressed as uninteresting — they point in
20
+ * DIFFERENT directions, and which one comes back is the discrimination #202
21
+ * spent six CI runs failing to make by argument:
22
+ * · `busy` — the engine is still waiting on the provider ⇒ provider-side.
23
+ * · `idle` — the engine believes it is DONE having produced nothing ⇒
24
+ * engine-side, which is the shape #133 turned out to be.
25
+ * · `retry` — the engine is re-attempting, and says why ⇒ the named cause.
26
+ *
27
+ * ⚠️ APPEND-ONLY, exactly like `engine-skew.js :: formatSkewSuffix`: no status
28
+ * (or an unusable one) returns `''`, so a reason string built without one is
29
+ * byte-for-byte what it was before this module existed, and
30
+ * `sidecar/models-probe.js`'s `/^NO_OUTPUT_BACKSTOP:/` classification — a PREFIX
31
+ * test — is unaffected either way.
32
+ *
33
+ * ⚠️ `message` is UNTRUSTED third-party text: it originates at the provider,
34
+ * lands in run.json, and on CI is rendered into a sticky PR comment. It goes
35
+ * through the house sanitizer (`text-sanitize.js :: collapseExcerpt`) at a short
36
+ * cap rather than being trusted to the workflow's downstream sed rules — one
37
+ * sanitizer, one dialect, per that module's own ruling.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { collapseExcerpt } = require('./text-sanitize');
43
+
44
+ /** Short cap: this is a clause on a one-line death report, not a log dump. */
45
+ const MAX_STATUS_MESSAGE_CHARS = 200;
46
+
47
+ /**
48
+ * The death-report clause for an engine session status.
49
+ * @param {*} status - an SDK SessionStatus, or anything at all
50
+ * @returns {string} ` (session: …)`, or '' when nothing usable was observed
51
+ */
52
+ function formatSessionStatusSuffix(status) {
53
+ if (!status || typeof status !== 'object') { return ''; }
54
+ // A non-string `type` is DROPPED rather than coerced: `String({})` renders
55
+ // '[object Object]', which would read as an observation rather than as the
56
+ // absence it actually is.
57
+ if (typeof status.type !== 'string') { return ''; }
58
+ // ⚠️ CLASSIFY on the RAW value, RENDER the sanitized one (#219 round 2,
59
+ // deepseek). Branching on the sanitized type let the sanitizer's own
60
+ // normalisation decide the arm — anything collapsing to 'retry' took the retry
61
+ // path — so a future SDK identifier could be misclassified by a function whose
62
+ // job is display, not semantics. Only the exact published identifier routes.
63
+ const type = collapseExcerpt(status.type, 40);
64
+ if (!type) { return ''; }
65
+ // An unrecognised type is still reported. A future SDK arm must not read as
66
+ // "no status was observed" — that silence is what this clause removes.
67
+ if (status.type !== 'retry') { return ` (session: ${type})`; }
68
+ const attempt = Number.isFinite(status.attempt) ? ` attempt ${status.attempt}` : '';
69
+ const raw = collapseExcerpt(status.message, MAX_STATUS_MESSAGE_CHARS);
70
+ return ` (session: retry${attempt}${raw ? ` — ${raw}` : ''})`;
71
+ }
72
+
73
+ module.exports = { formatSessionStatusSuffix, MAX_STATUS_MESSAGE_CHARS };
package/src/utils/ttft.js CHANGED
@@ -9,12 +9,23 @@
9
9
  * blank the way `utils/result-schema.js`'s already is.
10
10
  *
11
11
  * `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
12
- * `Date.now()` delta — and then passes four EMIT GATES on its way to a
12
+ * `Date.now()` delta — and then passes five EMIT GATES on its way to a
13
13
  * document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
14
- * `utils/result-schema.js :: buildRunResult`, and
15
- * `council/run-stats-entry.js :: buildRunStatsEntry`. Every gate used to spell
16
- * its own `typeof x === 'number'` test, which is four chances to disagree and
17
- * four ways to publish a value both schemas forbid.
14
+ * `utils/result-schema.js :: buildRunResult`,
15
+ * `council/run-stats-entry.js :: buildRunStatsEntry`, and
16
+ * `council/tally.js :: tally`'s runStats re-projection. Every gate used to spell
17
+ * its own `typeof x === 'number'` test, which is five chances to disagree and
18
+ * five ways to publish a value both schemas forbid.
19
+ *
20
+ * ⚠️ The fifth gate is different in KIND from the four above it, which is why it
21
+ * was missed for a release (#202). Those four are PRODUCERS — each writes the
22
+ * field onto a document it is building. `tally.js` is a RE-PROJECTION: it copies
23
+ * an already-built row through a hand-maintained allowlist, so omitting the field
24
+ * there does not fail to produce it, it DESTROYS one already produced. Between
25
+ * v4.9.0 and v4.9.1 that is exactly what happened — the probe wrote real values
26
+ * into tally-input.json and every one was stripped before tally.json and
27
+ * verdict.json, the only run artifacts CI uploads (MEASURED, run 33030485388:
28
+ * 11 of 12 rows carried it going in, 0 of 12 coming out).
18
29
  *
19
30
  * ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
20
31
  * `schemas/council-tally.schema.json` both declare this field
@@ -35,7 +46,7 @@
35
46
  * already means "no honest measurement was made", and a dishonest number is
36
47
  * exactly that. `0` itself stays a real, emittable measurement.
37
48
  *
38
- * ⚠️ Three of the four gates import this. The fourth,
49
+ * ⚠️ Four of the five gates import this. The one that does not,
39
50
  * `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
40
51
  * tests/council/run-stats-entry.test.js — the pin fires on the character
41
52
  * sequence anywhere in that file, comments included) so require-free consumers