amicus 4.8.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +249 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +36 -15
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +18 -8
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +1 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +63 -15
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/template/render.js +16 -7
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +11 -3
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- 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 };
|
package/src/council/debate.js
CHANGED
|
@@ -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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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')),
|
package/src/council/findings.js
CHANGED
|
@@ -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:
|
|
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"
|
|
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.
|
package/src/council/ledger.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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.
|
|
@@ -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
|
|
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
|
-
*
|
|
88
|
-
* line matches when the text after
|
|
89
|
-
* starts with one followed by a word boundary (trailing rationale,
|
|
90
|
-
* `VERDICT: Fix these first — <gaps>`). Returns the canonical phrase,
|
|
91
|
-
* trailing text.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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(
|
|
116
|
+
const m = line.match(lineRe);
|
|
99
117
|
if (!m) { continue; }
|
|
100
118
|
const rest = m[1];
|
|
101
|
-
for (let i = 0; i <
|
|
102
|
-
if (rest ===
|
|
103
|
-
found =
|
|
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
|
|
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
|
|
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>' : ''}
|
package/src/council/report-md.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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);
|
package/src/council/report.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* file's own job that narrowed.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
// is not a loss (spec D4, §8),
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|