amicus 4.8.0 → 4.9.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 (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +10 -5
  4. package/docs/CITATIONS.md +13 -5
  5. package/docs/ROADMAP.md +101 -10
  6. package/docs/configuration.md +55 -5
  7. package/docs/council.md +102 -14
  8. package/docs/troubleshooting.md +9 -2
  9. package/docs/usage.md +128 -12
  10. package/electron/ipc-setup.js +39 -2
  11. package/electron/main.js +46 -3
  12. package/electron/offer-session.js +51 -0
  13. package/electron/setup-ui-model.js +99 -9
  14. package/electron/setup-ui-styles.js +22 -0
  15. package/electron/setup-ui.js +244 -32
  16. package/electron/workspace-ui/live-dead-seats.js +163 -91
  17. package/electron/workspace-ui/live-seats.js +4 -4
  18. package/electron/workspace-ui/workspace-banners.js +30 -7
  19. package/electron/workspace-ui/workspace-matrix.js +23 -3
  20. package/electron/workspace-ui/workspace-seats.js +95 -79
  21. package/package.json +2 -1
  22. package/schemas/council-run.schema.json +2 -2
  23. package/schemas/council-tally.schema.json +17 -1
  24. package/schemas/council-verdict.schema.json +12 -4
  25. package/schemas/run.schema.json +6 -1
  26. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  28. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  29. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  30. package/skills/second-opinion/SKILL.md +151 -36
  31. package/src/cli-council-run-bench.js +98 -6
  32. package/src/cli-handlers-council-run.js +18 -6
  33. package/src/cli-handlers-council.js +57 -7
  34. package/src/cli-handlers-doctor.js +12 -15
  35. package/src/cli.js +3 -1
  36. package/src/council/anonymize.js +2 -1
  37. package/src/council/briefings-chair-task.js +161 -0
  38. package/src/council/briefings-chair.js +33 -8
  39. package/src/council/briefings-debate.js +79 -13
  40. package/src/council/briefings-stage2-task.js +236 -0
  41. package/src/council/briefings-stage2.js +103 -26
  42. package/src/council/briefings-task.js +167 -0
  43. package/src/council/briefings.js +41 -4
  44. package/src/council/chair-fallback.js +95 -0
  45. package/src/council/debate.js +38 -21
  46. package/src/council/findings.js +3 -2
  47. package/src/council/ledger.js +2 -2
  48. package/src/council/parse-stage2.js +64 -16
  49. package/src/council/report-cost.js +61 -0
  50. package/src/council/report-html.js +26 -4
  51. package/src/council/report-md.js +30 -2
  52. package/src/council/report.js +40 -37
  53. package/src/council/run-assemble.js +21 -6
  54. package/src/council/run-chair.js +44 -95
  55. package/src/council/run-debate-revote.js +81 -49
  56. package/src/council/run-debate.js +51 -34
  57. package/src/council/run-finish.js +5 -3
  58. package/src/council/run-retry-keys.js +4 -4
  59. package/src/council/run-retry-launch.js +4 -4
  60. package/src/council/run-retry-notes.js +72 -15
  61. package/src/council/run-stage1-launch.js +4 -4
  62. package/src/council/run-stage1-rows.js +9 -6
  63. package/src/council/run-stage2.js +81 -47
  64. package/src/council/run-stages.js +9 -21
  65. package/src/council/run-stats-entry.js +46 -1
  66. package/src/council/run.js +28 -13
  67. package/src/council/seats.js +2 -2
  68. package/src/council/stage1-bind.js +3 -2
  69. package/src/council/verdict-seat-loss.js +124 -0
  70. package/src/council/verdict.js +108 -99
  71. package/src/headless.js +256 -49
  72. package/src/mcp-council-bench.js +64 -3
  73. package/src/mcp-council-run.js +10 -3
  74. package/src/mcp-server.js +52 -12
  75. package/src/mcp-tools.js +41 -5
  76. package/src/observe/council-legs.js +2 -2
  77. package/src/opencode-client.js +19 -1
  78. package/src/pack/pack-forward.js +15 -12
  79. package/src/pack/pack-resolve.js +1 -1
  80. package/src/prompt-builder.js +17 -1
  81. package/src/sidecar/fanout-leg.js +26 -0
  82. package/src/sidecar/fanout.js +1 -1
  83. package/src/sidecar/list-council.js +178 -0
  84. package/src/sidecar/list-limit.js +3 -1
  85. package/src/sidecar/list-search.js +2 -1
  86. package/src/sidecar/models.js +8 -1
  87. package/src/sidecar/read.js +34 -10
  88. package/src/sidecar/setup.js +124 -0
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +81 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +16 -8
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +149 -0
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +10 -2
  103. package/src/utils/model-canonicalization.js +64 -0
  104. package/src/utils/model-catalog.js +1 -1
  105. package/src/utils/model-shortlist.js +100 -0
  106. package/src/utils/provider-default-picker.js +93 -45
  107. package/src/utils/provider-default-prompt.js +1 -1
  108. package/src/utils/quick-picks.js +2 -2
  109. package/src/utils/remediation-hints.js +24 -0
  110. package/src/utils/result-schema.js +10 -0
  111. package/src/utils/text-sanitize.js +81 -0
  112. package/src/utils/ttft.js +57 -0
  113. package/src/utils/untrusted-fence.js +111 -1
  114. package/src/workspace/fold-format.js +28 -7
  115. package/src/workspace/live-normalize.js +2 -1
  116. package/src/workspace/matrix-model.js +6 -2
  117. package/src/workspace/run-detail.js +35 -9
  118. package/src/workspace/seat-space.js +10 -6
@@ -0,0 +1,95 @@
1
+ // src/council/chair-fallback.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/chair-fallback
6
+ * Chair fallback promotion + attempt classification for the headless chair
7
+ * walk. Moved verbatim from run-chair.js@eb0ff79c:25-104 (v4.9 W4 size-gate
8
+ * split, zero behavior): pickFallbackChair (spec §4 promotion) and
9
+ * classifyChairAttempt (spec §8 LC-5 outcome taxonomy), each with its full
10
+ * docblock. run-chair.js re-exports both, so every existing import path
11
+ * (run.js mid-walk, run-server.js pre-seed, the test suites) is unchanged.
12
+ */
13
+
14
+ /**
15
+ * Chair fallback promotion (spec §4): the highest peers-only street-cred
16
+ * model from `council stats` that is not a bench seat and not the failed
17
+ * chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
18
+ * (deriveReliability's avgStreetCredPeersOnly; lower is better).
19
+ *
20
+ * The reserved seat name 'claude' is never eligible (v4.1 §4.4 "never chairs"):
21
+ * a --claude-review run puts a real 'claude' row in the ledger, so without this
22
+ * filter a LATER run could promote it and walk straight past the pre-flight
23
+ * --chair claude guard — with no Claude leg to launch.
24
+ *
25
+ * v4.7 GOA-7 D11: exclusions test the group key AND aliases[]; the promoted
26
+ * name is aliases[0] (most-recent alias) so the launch string stays routable
27
+ * through the same alias policy both call sites (run.js mid-walk, run-server.js
28
+ * pre-seed) already resolve.
29
+ * @returns {string|null}
30
+ */
31
+ function pickFallbackChair(statsRows, bench, failedChair) {
32
+ const benchSet = new Set(bench);
33
+ // v4.7 GOA-7 D11: an aggregate's identity is its key PLUS every alias it was
34
+ // observed under — post-D10 keys may be executable ids while bench/o.chair
35
+ // stay alias-space, so every exclusion tests the whole name set (a bench
36
+ // seat's resolved-keyed group must never be promoted as its own chair).
37
+ // The LAUNCHED name is aliases[0] (most-recent alias): alias-space names
38
+ // re-enter the router's alias bridge and current key/gateway policy; a raw
39
+ // executable id would dodge them (divergent-vendor forms, openrouter-
40
+ // literals under --gateway direct, dropped aliases). aliases[] is non-empty
41
+ // for every ledger-derived group; the bare-model fallback covers pre-D10
42
+ // aggregate shapes only.
43
+ const names = (r) => [r.model, ...(Array.isArray(r.aliases) ? r.aliases : [])];
44
+ const excluded = (r) => names(r).some(n => n === 'claude' || benchSet.has(n) || n === failedChair);
45
+ // v4.8 PR4a: every sort term is read off the row, so CANDIDATE SELECTION is
46
+ // independent of the order `statsRows` arrives in — previously that order
47
+ // (deriveReliability's Map insertion order = council-ledger.jsonl row order)
48
+ // silently decided every exact street-cred tie, and such ties are an ordinary
49
+ // arithmetic outcome. Terms: street cred (lower mean rank = better), then
50
+ // council appearances, then model id for a guaranteed total order. ⚠️ `runs`
51
+ // is the count of DISTINCT runIds across the group's ledger rows (v4.8 PR4b
52
+ // R4b-1, ledger-stats.js's countRuns) — one council run contributes 1 however many
53
+ // seats that executable filled, and rows from `judged:false` runs that
54
+ // contributed no street cred still count. It is a tie-break, never a ranking
55
+ // signal. Always present on deriveReliability output; the default serves
56
+ // fixtures.
57
+ // Full rationale + the tie arithmetic: tests/council/run-chair.test.js.
58
+ const runsOf = (r) => (typeof r.runs === 'number' ? r.runs : 0);
59
+ const candidates = (statsRows || [])
60
+ .filter(r => !excluded(r) && typeof r.avgStreetCredPeersOnly === 'number')
61
+ .sort((a, b) => (a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly)
62
+ || (runsOf(b) - runsOf(a))
63
+ || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
64
+ if (!candidates.length) { return null; }
65
+ const top = candidates[0];
66
+ return (Array.isArray(top.aliases) && top.aliases.length) ? top.aliases[0] : top.model;
67
+ }
68
+
69
+ /**
70
+ * Outcome taxonomy for one fallback-walk attempt (spec §8, LC-5). The ch4
71
+ * VERDICT repair is deliberately NOT an attempt: its chair leg already
72
+ * completed — only the verdict line is being re-prompted — and the outcome
73
+ * enum has no honest value for it.
74
+ * @param {object|null} rawLeg the UNFILTERED leg (attemptChair nulls `leg` on
75
+ * failure; this is the one before that narrowing, so a failed leg document
76
+ * is still visible here)
77
+ * @param {object|null} [errorDoc] set when the launch never produced a wave
78
+ * at all (pre-flight refusal) — the only source of a reason in that case
79
+ * @returns {{outcome: 'completed'|'error'|'timeout'|'no-output', reason: string|null}}
80
+ */
81
+ function classifyChairAttempt(rawLeg, errorDoc) {
82
+ if (!rawLeg) {
83
+ const reason = (errorDoc && (errorDoc.message || errorDoc.reason)) || 'no leg document';
84
+ return { outcome: 'error', reason };
85
+ }
86
+ if (rawLeg.status === 'timeout') { return { outcome: 'timeout', reason: rawLeg.reason || null }; }
87
+ if (rawLeg.status === 'complete') {
88
+ const hasOutput = rawLeg.summary && String(rawLeg.summary).trim();
89
+ return hasOutput ? { outcome: 'completed', reason: null }
90
+ : { outcome: 'no-output', reason: rawLeg.reason || null };
91
+ }
92
+ return { outcome: 'error', reason: rawLeg.reason || rawLeg.error || String(rawLeg.status) };
93
+ }
94
+
95
+ module.exports = { pickFallbackChair, classifyChairAttempt };
@@ -12,6 +12,11 @@
12
12
  */
13
13
 
14
14
  const { peersOf, unattributedPeerDrops } = require('./peer-split');
15
+ // v4.9 W11 (PR1F-2): the ONE runStats row builder. This module is DI-free, not
16
+ // require-free (it already takes ./peer-split), and ./run-stats-entry is
17
+ // require-FREE by its own design contract precisely so consumers outside
18
+ // ./run-assemble's graph can import it — so this adds only a leaf edge.
19
+ const { buildRunStatsEntry } = require('./run-stats-entry');
15
20
 
16
21
  // __proto__: null — an inherited/unknown action (e.g. "toString") must fall
17
22
  // through the `|| 'no-response'` guards below and in run-debate.js, never
@@ -142,33 +147,45 @@ function decorateRecord(record, debateFindings) {
142
147
  }
143
148
 
144
149
  /**
145
- * runStats rows for the debate legs (spec §5.5), plus v4.7 D2/E4's row-per-launch
146
- * extras: role is 'rebuttal' | 'revote' for the primary defense/re-vote legs,
147
- * 'superseded' for an original leg a successful repair replaced, and 'repair' for
148
- * a repair attempt that itself never became usable (error status rides naturally
149
- * off the raw leg). The rebuttal/revote legs never enter meta.models, so the
150
- * ledger stays one row per (run × model × resolvedModel) pair a debate round
151
- * can never ADD a row (v4.8 PR4b: meta.models is still the row driver, and the
152
- * pair fan-out only splits an alias whose own joinable rows resolved
153
- * differently). DEBATE_ROLES remains the debate-role
154
- * vocabulary (rebuttal/revote); the ledger's overwrite protection for ALL FOUR
155
- * of these row-per-launch roles — rebuttal, revote, superseded AND repair —
156
- * lives in ledger.js's own LEDGER_JOIN_ROLES allowlist (v4.7 D4, Task 7):
157
- * a role not named there never joins, full stop, regardless of which module
150
+ * runStats rows for the debate legs (spec §5.5), plus v4.7 D2/E4's row-per-launch extras: role is
151
+ * 'rebuttal' | 'revote' for the primary defense/re-vote legs, 'superseded' for an original leg a
152
+ * successful repair replaced, and 'repair' for a repair attempt that itself never became usable
153
+ * (error status rides naturally off the raw leg). The rebuttal/revote legs never enter meta.models,
154
+ * so the ledger stays one row per (run × model × resolvedModel) pair a debate round can never ADD
155
+ * a row (v4.8 PR4b: meta.models is still the row driver, and the pair fan-out only splits an alias
156
+ * whose own joinable rows resolved differently). DEBATE_ROLES remains the debate-role vocabulary
157
+ * (rebuttal/revote); the ledger's overwrite protection for ALL FOUR of these row-per-launch roles —
158
+ * rebuttal, revote, superseded AND repair — lives in ledger.js's own LEDGER_JOIN_ROLES allowlist
159
+ * (v4.7 D4, Task 7): a role not named there never joins, full stop, regardless of which module
158
160
  * produced the row or whether it is even in DEBATE_ROLES.
159
161
  * @param {{defenseLegs: Array, revoteLegs: Array, supersededLegs?: Array,
160
162
  * repairLegs?: Array}} args leg metadata
161
163
  * @returns {Array<object>}
162
164
  */
163
165
  function debateRunStatsRows({ defenseLegs, revoteLegs, supersededLegs, repairLegs }) {
164
- const mk = (role) => (l) => ({
165
- model: l.model, role, wasChair: false, conformance: l.conformance || 'clean',
166
- status: l.status || 'unknown',
167
- durationMs: typeof l.durationMs === 'number' ? l.durationMs : null,
168
- usage: l.usage || null,
169
- ...(l.waveId ? { waveId: l.waveId } : {}),
170
- ...(l.resolvedModel ? { resolvedModel: l.resolvedModel } : {}),
171
- });
166
+ // v4.9 W11 (PR1F-2): ONE builder debate rows take the entry's key order, its defaults and its
167
+ // emit-when-set rules instead of a fourth hand-rolled body. ⚠️ Nothing propagates on its OWN (claim
168
+ // corrected in W14): `mk` hands the entry a SYNTHETIC leg of five fields plus three explicit params,
169
+ // so seat/findingsUnverified/repairRefused/summary and any future leg-sourced field — reach these
170
+ // rows ONLY by widening THIS list. MEASURED, already shipped: W13's `ttftMs` rides the leg into the
171
+ // entry and debate rows do not carry it. Widening is a behaviour change needing its own pins; filed.
172
+ // ⚠️ The four lists hold NORMALIZED rows, not leg docs, and their model fields MIRROR the entry's
173
+ // `leg` contract: `l.model` is the ALIAS and `l.resolvedModel` the executable id, where the entry
174
+ // reads `leg.model` AS the resolved id and takes the alias as its own `model` — passing `l`
175
+ // unchanged would stamp the alias into `resolvedModel` on 118 of the 137 measured rows. Holding
176
+ // `summary`/`seat` back is deliberate too: no runStats row has ever carried review prose, and
177
+ // `seat` here is a materializeDebate filename input, not a seat OBJECT (pin G1b). ⚠️ The re-key's
178
+ // ONE divergence from the hand-rolled body is MEASURED-DEAD — a normalized row with `model`
179
+ // undefined but `resolvedModel` set now emits the resolved id AS `model`, where the old body left
180
+ // the key out of the JSON; `l.model` was an alias STRING on 137/137 census invocations, so no
181
+ // producer emits that shape today (pin G1d — dead, not impossible). ⚠️ `l.status || 'unknown'` is
182
+ // GONE, not moved: it never fired and cannot — result-schema.js :: buildRunResult applies its own
183
+ // `metadata.status || 'unknown'` a layer below. The MEASURED census and every pin named here live
184
+ // in tests/council/runstats-byte-order.test.js.
185
+ const mk = (role) => (l) => buildRunStatsEntry({
186
+ leg: { status: l.status, durationMs: l.durationMs, usage: l.usage,
187
+ waveId: l.waveId, model: l.resolvedModel },
188
+ model: l.model, role, conformance: l.conformance });
172
189
  return [
173
190
  ...(defenseLegs || []).map(mk('rebuttal')),
174
191
  ...(revoteLegs || []).map(mk('revote')),
@@ -142,7 +142,7 @@ function validateFindings(jsonText) {
142
142
  // ⚠️ v4.4.1 FINAL-REVIEW C — a body that parses to nothing usable.
143
143
  // `JSON.parse('null')` SUCCEEDS: it returns null and throws nothing, so the catch
144
144
  // above never sees it and every `parsed.<key>` below threw
145
- // `TypeError: Cannot read properties of null`. Because run-stages.js:164 calls this
145
+ // `TypeError: Cannot read properties of null`. Because run-stages.js:152 calls this
146
146
  // from inside run.js's try/catch, ONE seat emitting a `null` body aborted an entire
147
147
  // PAID council as exit 1 rather than degrading that seat — the fail-closed shape
148
148
  // this release exists to remove.
@@ -166,7 +166,8 @@ function validateFindings(jsonText) {
166
166
 
167
167
  // ⚠️ LC-10 (owner ruling, 2026-07-26). A review that read the material and found
168
168
  // nothing is a VALID review — the anti-sycophancy clause shipped in every Stage-1
169
- // briefing says so verbatim ("An empty severity category is a valid result"), and
169
+ // review briefing says so verbatim ("An empty severity category is a valid result";
170
+ // task briefings state the same rule in claims wording — v4.9 W6), and
170
171
  // rejecting it structurally pressured models into inventing findings to satisfy
171
172
  // the schema. costgate01's grok did exactly that, and the fabrication reached
172
173
  // tally.json, the street-cred rankings, the chair synthesis and a human decision.
@@ -105,8 +105,8 @@ const { benchLegs, credFor, splitFindingsBySeat } = require('./ledger-join');
105
105
  * most-recently-seen-first, and pickFallbackChair LAUNCHES `aliases[0]`, so
106
106
  * first-occurrence anchoring (what a naive `new Map(model+'\0'+resolved)`
107
107
  * gives for free) promotes the executable-id-shaped name over the short alias
108
- * on `--models gpt-5,openai/gpt-5,gpt-5` — the form run-chair.js:48-52 argues
109
- * against. On a bench where no alias repeats AND no alias has more than one
108
+ * on `--models gpt-5,openai/gpt-5,gpt-5` — the form run-chair.js :: pickFallbackChair
109
+ * (body in chair-fallback.js since the v4.9 W4 split) argues against. On a bench where no alias repeats AND no alias has more than one
110
110
  * joinable runStats row, lastIndexOf === indexOf and the row set and its order
111
111
  * are unchanged from pre-PR4b.
112
112
  *
@@ -4,8 +4,9 @@
4
4
  /**
5
5
  * @module council/parse-stage2
6
6
  * Stage-2 output parsing for the headless council engine (spec §5): the
7
- * judge's trailing JSON block ({ranking, adjudications}) and the chair's
8
- * final `VERDICT:` line. Shares last-JSON-block extraction with findings.js.
7
+ * judge's trailing JSON block ({ranking, adjudications}) and the chair's final
8
+ * terminal line — `VERDICT:` on a review run, `ANSWER:` on a task run (v4.9
9
+ * W7 #146). Shares last-JSON-block extraction with findings.js.
9
10
  * Pure — the ≤2-repair loop lives in run-stages.js; the tri-state
10
11
  * (clean|repaired|unstructured) is recorded by the driver.
11
12
  */
@@ -14,6 +15,16 @@ const { lastJsonBlock } = require('./findings');
14
15
 
15
16
  const JUDGE_VERDICTS = ['agree', 'dispute', 'neutral'];
16
17
  const CHAIR_VERDICTS = ['Ship it', 'Fix these first', 'Fundamental rethink'];
18
+ /**
19
+ * The TASK chair's scale (v4.9 W7, #146). Independently spelled from
20
+ * briefings-chair-task.js :: CHAIR_ANSWER_VALUES exactly as CHAIR_VERDICTS is
21
+ * from briefings-chair.js :: CHAIR_VERDICT_VALUES; both pairs are drift-pinned
22
+ * in tests/council/chair-scale-drift.test.js (named mutant ANSWERSCALEDRIFT).
23
+ * ⚠️ The two scales MUST stay disjoint — that disjointness plus the distinct
24
+ * keyword is what makes parseChairVerdict and parseChairAnswer unable to read
25
+ * each other's terminal line.
26
+ */
27
+ const CHAIR_ANSWERS = ['Converged', 'Split', 'Insufficient'];
17
28
 
18
29
  /**
19
30
  * Parse + shape-validate one judge's output.
@@ -38,7 +49,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
38
49
  // ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS — it returns null and
39
50
  // throws nothing — so a body of literal `null` sailed past the catch above and
40
51
  // `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
41
- // (:129) and parseRevote (:167) below already carried this `!parsed` guard; the judge
52
+ // (parse-stage2.js :: parseDebateDefense) and parseRevote (parse-stage2.js :: parseRevote) below already carried this `!parsed` guard; the judge
42
53
  // path and findings.js's validateFindings did not, which made it an asymmetry among
43
54
  // five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
44
55
  // a `null` body reports exactly what a keyless `{}` body already reported —
@@ -79,28 +90,35 @@ function parseJudgeOutput(text, { labels, findingIds }) {
79
90
  }
80
91
 
81
92
  /** Per-phrase `^<phrase>(?![A-Za-z0-9])` matchers — prefix-anchored, case-sensitive. */
82
- const CHAIR_VERDICT_PREFIXES = CHAIR_VERDICTS.map(
93
+ const phrasePrefixes = (phrases) => phrases.map(
83
94
  (v) => new RegExp('^' + v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9])')
84
95
  );
96
+ const CHAIR_VERDICT_PREFIXES = phrasePrefixes(CHAIR_VERDICTS);
97
+ const CHAIR_ANSWER_PREFIXES = phrasePrefixes(CHAIR_ANSWERS);
98
+ const VERDICT_LINE = /^\s*VERDICT:\s*(.+?)\s*$/;
99
+ const ANSWER_LINE = /^\s*ANSWER:\s*(.+?)\s*$/;
85
100
 
86
101
  /**
87
- * Parse the chair's final verdict line. Last matching `VERDICT:` line wins. A
88
- * line matches when the text after `VERDICT:` equals a canonical phrase, or
89
- * starts with one followed by a word boundary (trailing rationale, e.g.
90
- * `VERDICT: Fix these first — <gaps>`). Returns the canonical phrase, not the
91
- * trailing text.
92
- * @param {string} text
93
- * @returns {string|null} one of CHAIR_VERDICTS, or null
102
+ * The chair's terminal line, whichever keyword carries it. Last matching line
103
+ * wins. A line matches when the text after the keyword equals a canonical
104
+ * phrase, or starts with one followed by a word boundary (trailing rationale,
105
+ * e.g. `VERDICT: Fix these first — <gaps>`). Returns the canonical phrase,
106
+ * never the trailing text.
107
+ *
108
+ * v4.9 W7 generalized the matcher rather than copying it, on the W6
109
+ * `composeWith` precedent: the review path delegates, so it is byte-identical
110
+ * by construction and every tolerance this parser earned in production
111
+ * (live-gate bug runId b89b67d1 among them) holds for both intents at once.
94
112
  */
95
- function parseChairVerdict(text) {
113
+ function parseTerminalLine(text, lineRe, phrases, prefixes) {
96
114
  let found = null;
97
115
  for (const line of String(text || '').split('\n')) {
98
- const m = line.match(/^\s*VERDICT:\s*(.+?)\s*$/);
116
+ const m = line.match(lineRe);
99
117
  if (!m) { continue; }
100
118
  const rest = m[1];
101
- for (let i = 0; i < CHAIR_VERDICTS.length; i++) {
102
- if (rest === CHAIR_VERDICTS[i] || CHAIR_VERDICT_PREFIXES[i].test(rest)) {
103
- found = CHAIR_VERDICTS[i];
119
+ for (let i = 0; i < phrases.length; i++) {
120
+ if (rest === phrases[i] || prefixes[i].test(rest)) {
121
+ found = phrases[i];
104
122
  break;
105
123
  }
106
124
  }
@@ -108,6 +126,35 @@ function parseChairVerdict(text) {
108
126
  return found;
109
127
  }
110
128
 
129
+ /**
130
+ * Parse the chair's final `VERDICT:` line (review intent).
131
+ * @param {string} text
132
+ * @returns {string|null} one of CHAIR_VERDICTS, or null
133
+ */
134
+ function parseChairVerdict(text) {
135
+ return parseTerminalLine(text, VERDICT_LINE, CHAIR_VERDICTS, CHAIR_VERDICT_PREFIXES);
136
+ }
137
+
138
+ /**
139
+ * Parse the chair's final `ANSWER:` line (task intent, #146).
140
+ * @param {string} text
141
+ * @returns {string|null} one of CHAIR_ANSWERS, or null
142
+ */
143
+ function parseChairAnswer(text) {
144
+ return parseTerminalLine(text, ANSWER_LINE, CHAIR_ANSWERS, CHAIR_ANSWER_PREFIXES);
145
+ }
146
+
147
+ /**
148
+ * The run-intent dispatcher run-chair.js parses through. `intent` is the W5
149
+ * channel (`'task'` | absent); anything else reads the VERDICT line
150
+ * (fail-closed), so a review run is byte-identical to the direct call.
151
+ * @param {string} text
152
+ * @param {string} [intent]
153
+ */
154
+ function parseChairTerminal(text, intent) {
155
+ return intent === 'task' ? parseChairAnswer(text) : parseChairVerdict(text);
156
+ }
157
+
111
158
  const DEBATE_ACTIONS = ['defend', 'amend', 'withdraw'];
112
159
  const REVOTE_VERDICTS = ['agree', 'dispute', 'neutral'];
113
160
 
@@ -189,5 +236,6 @@ function parseRevote(text, expectedIds) {
189
236
 
190
237
  module.exports = {
191
238
  parseJudgeOutput, parseChairVerdict, CHAIR_VERDICTS, JUDGE_VERDICTS,
239
+ parseChairAnswer, parseChairTerminal, CHAIR_ANSWERS,
192
240
  parseDebateDefense, parseRevote, DEBATE_ACTIONS, REVOTE_VERDICTS,
193
241
  };
@@ -0,0 +1,61 @@
1
+ // src/council/report-cost.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/report-cost
6
+ * The report model's COST TABLE: `runStats[]` (+ the optional wave total) to the
7
+ * `{rows, total}` half of the neutral model `council/report.js :: toModel`
8
+ * returns. Extracted from ./report (v4.9 W8 T-A) for the same reason
9
+ * ./report-md exists — report.js was at 296/300 and the task-intent fork it had
10
+ * to grow did not fit. Nothing here is task-aware: this moved to make ROOM, and
11
+ * the rendered bytes of every existing report are unchanged (measured: the four
12
+ * report suites, including both .snap documents, are green across the move).
13
+ *
14
+ * ⚠️ NO back-require of ./report, unlike ./report-md and ./report-html — this
15
+ * module needs neither TIER_ORDER nor SYMBOL, so report.js requires it eagerly
16
+ * at load and there is no cycle to keep lazy.
17
+ */
18
+
19
+ const { sumWaveUsage } = require('../utils/pricing');
20
+
21
+ // Cost-row role tag (Plan 2 final review F1, extended v4.7 D6): #83 gave
22
+ // judges their own runStats row, so a bench model can now appear twice
23
+ // (seat + judge), indistinguishable by `model` alone. v4.7's row-per-launch
24
+ // producers (chair-attempt/repair/superseded) create the exact same
25
+ // collision for their model. Tag ONLY these four roles — old verdicts have
26
+ // none of them, so chair/critic/lens/seat rows stay byte-identical to their
27
+ // historical rendering (report.test.js:189-199 pins the judge case exactly).
28
+ // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
29
+ // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
30
+ // function via bracket lookup instead of `undefined` — silently corrupting that row's
31
+ // rendered model label. A null-prototype object has no inherited keys to collide with.
32
+ // ⚠️ Module scope, where toModel rebuilt it per call: it is a constant lookup that no
33
+ // caller can reach (not exported) and nothing mutates. Behaviour is identical — the
34
+ // digests in the T-A step-1 evidence are byte-for-byte the pre-move ones.
35
+ const ROLE_SUFFIX = Object.create(null);
36
+ ROLE_SUFFIX.judge = 'judge';
37
+ ROLE_SUFFIX['chair-attempt'] = 'chair-attempt';
38
+ ROLE_SUFFIX.repair = 'repair';
39
+ ROLE_SUFFIX.superseded = 'superseded';
40
+
41
+ /**
42
+ * @param {Array<object>} runStats the verdict's runStats rows (already defaulted to [])
43
+ * @param {object} [wave] optional wave.json — its usage total WINS when it carries one
44
+ * @returns {{rows: Array<{model: string, status: string, durationMs: number, cost: object|null}>, total: object|null}}
45
+ */
46
+ function buildCostModel(runStats, wave) {
47
+ // v4.8 PR5a T5: name the row by its SEAT when it has one. On a twin bench the four
48
+ // seat/judge rows were previously indistinguishable. Depends on T4 — with T5 alone only
49
+ // the two seat rows separate, because judge rows carried no seat until then.
50
+ // ⚠️ Only seat and judge rows carry one: repair, superseded and debate rows still
51
+ // collapse on a twin, and the chair row is not a bench seat at all. Disclosed, not fixed.
52
+ const rows = runStats.map(r => ({
53
+ model: ROLE_SUFFIX[r.role] ? `${r.seat || r.model} (${ROLE_SUFFIX[r.role]})` : (r.seat || r.model),
54
+ status: r.status, durationMs: r.durationMs,
55
+ cost: r.usage && r.usage.cost ? r.usage.cost : null,
56
+ }));
57
+ const total = (wave && wave.usage && wave.usage.cost) ? wave.usage.cost : sumWaveUsage(runStats).cost;
58
+ return { rows, total };
59
+ }
60
+
61
+ module.exports = { buildCostModel };
@@ -65,17 +65,39 @@ function renderHtml(m) {
65
65
  // formatDegrade) rendered into the row, not a second HTML dialect.
66
66
  const lostRows = (m.degrades || []).map(d =>
67
67
  `<tr><td>${esc(d.channel)}</td><td>${esc(formatDegrade(d).trimEnd())}</td></tr>`).join('');
68
+ // v4.9 W8 T-A: kind:'info' records are announcements, not losses (`report.js ::
69
+ // toModel` splits them). A <ul>, not a second table: the debate lists below use
70
+ // exactly this idiom, and a note carries no channel column's worth of weight.
71
+ const noteItems = (m.notes || []).map(d => `<li>${esc(formatDegrade(d).trimEnd())}</li>`).join('');
68
72
  const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
69
73
  h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).map(esc).join(' · ');
70
74
 
71
75
  // Heading-over-nothing, same guard idiom as debateSection below: absent or
72
76
  // empty degrades ⇒ no section at all, so a clean verdict's HTML stays
73
77
  // byte-identical to before this section existed. Losses are headline news,
74
- // so the section sits directly after the Verdict-summary table (report-md.js's
75
- // renderMd mirrors this placement immediately after the tier loop).
78
+ // so the section sits directly after the summary tier table (report-md.js's
79
+ // renderMd mirrors this placement immediately after the tier loop). ⚠️ Named
80
+ // by ROLE, not by heading text: that heading forks with intent (see
81
+ // `summaryHeading` below), so 'the Verdict-summary table' was true of review
82
+ // runs only from B1 onward.
76
83
  const lostSection = lostRows
77
84
  ? `<h2>What was lost</h2><table><tr><th>Channel</th><th>Notice</th></tr>${lostRows}</table>`
78
85
  : '';
86
+ // Same heading-over-nothing guard, one level quieter: no info records ⇒ nothing
87
+ // at all, so every report that has none is byte-identical to before this existed.
88
+ const notesSection = noteItems ? `\n<p><strong>Notes:</strong></p><ul>${noteItems}</ul>` : '';
89
+ // v4.9 W8 T-A (spec §5.4): the concurrence qualifier rides the tier table on a
90
+ // TASK run only, placed where a reader of the tiers cannot miss it — the mirror
91
+ // of report-md.js :: renderMd's placement, pinned SEPARATELY per renderer so
92
+ // neither can regress silently. Named mutant: QUALIFIERDROP.
93
+ const qualifier = m.intent === 'task'
94
+ ? '\n<p class="legend">Tiers report peer concurrence, never verification.</p>' : '';
95
+ // v4.9 PR #200 round-4 B1: the primary summary heading is named for what the
96
+ // run PRODUCED — a task run answers, it does not adjudicate a verdict. The
97
+ // mirror of report-md.js :: renderMd's fork, pinned SEPARATELY per renderer.
98
+ // A review run renders the identical old string, so both .snap documents and
99
+ // every shipped review report stay byte-identical. Named mutant: SUMMARYLABEL.
100
+ const summaryHeading = m.intent === 'task' ? 'Answer summary' : 'Verdict summary';
79
101
 
80
102
  // m.debate is absent on hand-built models (tests/council/report.test.js calls
81
103
  // renderHtml directly with no debate key) — the guard must tolerate that, and
@@ -134,8 +156,8 @@ td.c { text-align: center; }
134
156
  </style></head><body>
135
157
  <h1>Council Report — ${esc(h.runType)} (${esc(h.runId)})</h1>
136
158
  <p class="meta">${meta}</p>
137
- <h2>Verdict summary</h2>
138
- <table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>${lostSection}
159
+ <h2>${summaryHeading}</h2>
160
+ <table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>${qualifier}${lostSection}${notesSection}
139
161
  <h2>Adjudication matrix</h2>
140
162
  <table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
141
163
  <p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>${m.findings.some(f => f.sameModelCorroboration) ? '\n<p class="legend"><sup>†</sup> corroborated only by another seat running the SAME model — concurrence, not independent support.</p>' : ''}
@@ -27,9 +27,25 @@ function renderMd(m) {
27
27
  h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).join(' · ');
28
28
  out.push(`\n_${meta}_\n`);
29
29
 
30
- out.push('## Verdict summary\n');
30
+ // v4.9 PR #200 round-4 B1: the section carrying the run's PRIMARY summary is
31
+ // named for what the run PRODUCED. A task run has no verdict — its chair closes
32
+ // with `ANSWER:` (`parse-stage2.js :: parseChairTerminal` picks that parser off
33
+ // the same intent, `verdict.js :: CHAIR_ANSWERS` is its scale) — so 'Verdict
34
+ // summary' named the wrong artifact on the one heading a skimmer reads. Forked
35
+ // in BOTH renderers, pinned SEPARATELY per renderer (the R8/street-cred rule:
36
+ // a shared pin would let either regress silently). Review runs keep the exact
37
+ // old string, which is what holds both .snap documents byte-identical.
38
+ // Named mutant: SUMMARYLABEL (tests/council/report-intent.test.js).
39
+ out.push(m.intent === 'task' ? '## Answer summary\n' : '## Verdict summary\n');
31
40
  out.push('| Tier | Count |\n|---|---|');
32
41
  for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
42
+ // v4.9 W8 T-A (spec §5.4): the concurrence qualifier, on a TASK run only, and
43
+ // directly under the counts it qualifies — a reader who reads only the tier
44
+ // table must not be able to miss it. Gated exactly as the `†` legend below is:
45
+ // written unconditionally it would shift every later line of every review
46
+ // report and redden both .snap documents. Named mutant: QUALIFIERDROP
47
+ // (tests/council/report-intent.test.js).
48
+ if (m.intent === 'task') { out.push('\n_Tiers report peer concurrence, never verification._'); }
33
49
 
34
50
  // Heading-over-nothing: emitted ONLY when the run actually degraded, so a
35
51
  // clean verdict's report stays byte-identical to before this section
@@ -40,6 +56,16 @@ function renderMd(m) {
40
56
  // ONE voice (Plan 1's formatDegrade) — the report must not grow a dialect.
41
57
  for (const d of m.degrades) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
42
58
  }
59
+ // v4.9 W8 T-A: kind:'info' records are announcements, not losses (`report.js ::
60
+ // toModel` splits them), so they get their own list and NOT the '## What was
61
+ // lost' heading. A bold lead-in rather than an `##` heading — the same weight
62
+ // the debate sub-lists below carry, because a note is not headline news.
63
+ // `m.notes &&` matches the `m.degrades || []` tolerance report-html.js already
64
+ // has: hand-built models in the report suites carry neither key.
65
+ if (m.notes && m.notes.length) {
66
+ out.push('\n**Notes:**\n');
67
+ for (const d of m.notes) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
68
+ }
43
69
 
44
70
  out.push('\n## Adjudication matrix\n');
45
71
  out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
@@ -94,7 +120,9 @@ function renderMd(m) {
94
120
  // human-facing here rather than model-facing. State the clean bench instead
95
121
  // of leaving the heading to dangle.
96
122
  if (!m.findings.length) {
97
- out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
123
+ out.push(m.intent === 'task'
124
+ ? '_No adjudicable claims were declared on this bench — an answer whose reasoning is fully inline is a valid answer._\n'
125
+ : '_No findings were raised on this bench — a clean review is a valid review._\n');
98
126
  } else {
99
127
  for (const t of TIER_ORDER) {
100
128
  const group = m.findings.filter(f => f.tier === t);
@@ -15,7 +15,9 @@
15
15
  * file's own job that narrowed.
16
16
  */
17
17
 
18
- const { sumWaveUsage } = require('../utils/pricing');
18
+ // v4.9 W8 T-A: the cost table's model lives in ./report-cost (extraction, this
19
+ // file's headroom). Eager, not lazy: that module back-requires nothing here.
20
+ const { buildCostModel } = require('./report-cost');
19
21
 
20
22
  const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
21
23
  // __proto__: null — an inherited/unknown vote key (e.g. "toString") must fold as unrecognized, never resolve off Object.prototype.
@@ -117,11 +119,12 @@ function toModel(verdict, wave) {
117
119
  // 'council' (verdict.council / meta.models) is the street-cred universe and
118
120
  // legitimately includes 'claude' on a --claude-review run (buildTallyInput's
119
121
  // run-assemble.js:226, docs/council.md:326). 'judges' is the matrix column
120
- // set: SKILL.md:448 / run-stage2.js:61-62 guarantee Claude is judged but
122
+ // set: SKILL.md:448 / run-stage2.js :: runStage2 (the ROSTER-not-bundle
123
+ // derivation of `judges`) guarantee Claude is judged but
121
124
  // never judges, so its reserved seat must never grow a matrix column — filter
122
125
  // it out ONLY when claudeInCouncil is true. This is name+flag gated, not
123
126
  // vote-derived: a bench judge that cast zero adjudications (dead/unstructured
124
- // leg, run-stages.js:204-207) is still in council/judges and must still get
127
+ // leg, run-stages.js:192-195) is still in council/judges and must still get
125
128
  // its (blank) column — deriving the roster from "who actually voted" would
126
129
  // silently delete that column too and break the byte-unchanged-artifact
127
130
  // contract for degraded v4.0.1-shaped runs.
@@ -232,50 +235,50 @@ function toModel(verdict, wave) {
232
235
  noResponse: verdict.findings.filter(f => f.debate && f.debate.action === 'no-response')
233
236
  .map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
234
237
  };
235
- const runStats = verdict.runStats || [];
236
- // Cost-row role tag (Plan 2 final review F1, extended v4.7 D6): #83 gave
237
- // judges their own runStats row, so a bench model can now appear twice
238
- // (seat + judge), indistinguishable by `model` alone. v4.7's row-per-launch
239
- // producers (chair-attempt/repair/superseded) create the exact same
240
- // collision for their model. Tag ONLY these four roles — old verdicts have
241
- // none of them, so chair/critic/lens/seat rows stay byte-identical to their
242
- // historical rendering (report.test.js:189-199 pins the judge case exactly).
243
- // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
244
- // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
245
- // function via bracket lookup instead of `undefined` — silently corrupting that row's
246
- // rendered model label. A null-prototype object has no inherited keys to collide with.
247
- const ROLE_SUFFIX = Object.create(null);
248
- ROLE_SUFFIX.judge = 'judge';
249
- ROLE_SUFFIX['chair-attempt'] = 'chair-attempt';
250
- ROLE_SUFFIX.repair = 'repair';
251
- ROLE_SUFFIX.superseded = 'superseded';
252
- // v4.8 PR5a T5: name the row by its SEAT when it has one. On a twin bench the four
253
- // seat/judge rows were previously indistinguishable. Depends on T4 — with T5 alone only
254
- // the two seat rows separate, because judge rows carried no seat until then.
255
- // ⚠️ Only seat and judge rows carry one: repair, superseded and debate rows still
256
- // collapse on a twin, and the chair row is not a bench seat at all. Disclosed, not fixed.
257
- const costRows = runStats.map(r => ({
258
- model: ROLE_SUFFIX[r.role] ? `${r.seat || r.model} (${ROLE_SUFFIX[r.role]})` : (r.seat || r.model),
259
- status: r.status, durationMs: r.durationMs,
260
- cost: r.usage && r.usage.cost ? r.usage.cost : null,
261
- }));
262
- const total = (wave && wave.usage && wave.usage.cost) ? wave.usage.cost : sumWaveUsage(runStats).cost;
238
+ // v4.9 W8 T-A: ONE read of the run's intent, TWO consumers below — the model's
239
+ // own `intent` (both renderers fork the spec §5.4 concurrence qualifier on it)
240
+ // and the header WORD. `=== 'task'` fails everything else CLOSED to review, the
241
+ // same direction `verdict.js :: buildVerdict` points: it emits the key ONLY as
242
+ // the literal 'task', so every pre-v4.9 and every review verdict takes this
243
+ // default and renders byte-identically to HEAD.
244
+ const intent = verdict.intent === 'task' ? 'task' : 'review';
263
245
  return {
264
246
  header: {
265
- runType: verdict.runType || 'review', runId: verdict.runId, date: verdict.date,
247
+ // The header WORD, not the key: `runType` is meta's free-form transport
248
+ // string ('headless' on every wired path), which on a task run names how the
249
+ // wave ran and not what it was — and the title is the first thing a reader
250
+ // sees. Forking the VALUE here forks both renderers, which print `h.runType`;
251
+ // renaming the key would edit two files to say the same thing.
252
+ runType: intent === 'task' ? 'task' : (verdict.runType || 'review'),
253
+ runId: verdict.runId, date: verdict.date,
266
254
  chair: verdict.chair, council, claudeInCouncil: verdict.claudeInCouncil === true,
267
255
  },
256
+ intent,
268
257
  tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
269
258
  judges, findings, debate,
270
259
  streetCred: verdict.streetCred || [],
271
260
  // v4.6 Plan 2: additive and OPTIONAL on the verdict (verdict.js only sets
272
261
  // it when the run actually degraded), so a clean verdict's model — and
273
262
  // therefore its rendered report — is byte-for-byte unchanged.
274
- // Plan 2 final review F2: LOSSES ONLY a heal is announced on stderr/run.json but
275
- // is not a loss (spec D4, §8), so it must never render under "What was
276
- // lost". deriveSeatLoss (verdict.js) applies the same kind !== 'heal' filter.
277
- degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal'),
278
- cost: { rows: costRows, total },
263
+ // Plan 2 final review F2 + v4.9 W8 T-A: LOSSES ONLY. A heal is announced on stderr/run.json
264
+ // but is not a loss (spec D4, §8), and neither is v4.9's kind:'info' `ledger-skipped` says
265
+ // a task run wrote no reliability rows, which is speech, not damage. Info records ride
266
+ // `notes`, which both renderers list APART from "What was lost".
267
+ // ⚠️ NOT the positive `kind === 'degrade'`, and the difference is measured: a record with NO
268
+ // kind key — hand-written, or parsed off a verdict older than kinds, which
269
+ // `utils/degrade.js :: formatDegrade` still deliberately serves as 'Notice' — is a loss at
270
+ // HEAD, and the positive spelling drops it from BOTH lists. Pinned in report-intent.test.js
271
+ // (named mutant LEGACYDROP, measured).
272
+ // ⚠️ The other three consumers of this rule AGREE with the line above about kind-less records
273
+ // as of the v4.9 W9 fix round (council C4): `verdict-seat-loss.js :: deriveSeatLoss` and both
274
+ // Workspace renderers went from the positive `kind === 'degrade'` — which W9 shipped, and
275
+ // which silently dropped every pre-kind record — to `kind === undefined || kind ===
276
+ // 'degrade'`, citing THIS lesson by name. Their kind LISTS still differ from this one's, and
277
+ // deliberately (over there the question is narrower: which announcements imply a LOST SEAT).
278
+ // Align the treatment of an ABSENT kind; never the lists.
279
+ degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal' && d.kind !== 'info'),
280
+ notes: (verdict.degrades || []).filter(d => d.kind === 'info'),
281
+ cost: buildCostModel(verdict.runStats || [], wave),
279
282
  };
280
283
  }
281
284