amicus 4.7.0 → 4.8.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 (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +948 -1
  3. package/README.md +3 -2
  4. package/docs/CITATIONS.md +114 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +181 -34
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +6 -4
  13. package/docs/usage.md +15 -11
  14. package/electron/setup-ui-aliases.js +2 -2
  15. package/electron/workspace-ui/index.html +9 -0
  16. package/electron/workspace-ui/live-dead-seats.js +228 -0
  17. package/electron/workspace-ui/live-model.js +10 -236
  18. package/electron/workspace-ui/live-seats.js +126 -0
  19. package/electron/workspace-ui/workspace-app.js +6 -41
  20. package/electron/workspace-ui/workspace-banners.js +95 -0
  21. package/electron/workspace-ui/workspace-lazy.js +55 -12
  22. package/electron/workspace-ui/workspace-matrix.js +2 -2
  23. package/electron/workspace-ui/workspace-panels.js +42 -10
  24. package/electron/workspace-ui/workspace-render.js +2 -2
  25. package/electron/workspace-ui/workspace-seats.js +101 -17
  26. package/package.json +6 -3
  27. package/schemas/council-run-live.schema.json +1 -0
  28. package/schemas/council-run.schema.json +19 -0
  29. package/schemas/council-tally.schema.json +34 -2
  30. package/schemas/council-verdict.schema.json +15 -0
  31. package/scripts/postinstall.js +6 -3
  32. package/scripts/setup-hooks.js +49 -3
  33. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  34. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  35. package/skills/second-opinion/SKILL.md +25 -11
  36. package/skills/sidecar/SKILL.md +3 -1
  37. package/src/cli-handlers-doctor.js +7 -0
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/peer-split.js +196 -0
  47. package/src/council/report-html.js +12 -5
  48. package/src/council/report-md.js +146 -0
  49. package/src/council/report.js +188 -112
  50. package/src/council/run-assemble.js +100 -79
  51. package/src/council/run-chair.js +17 -1
  52. package/src/council/run-debate-revote.js +268 -0
  53. package/src/council/run-debate.js +92 -102
  54. package/src/council/run-finish.js +70 -0
  55. package/src/council/run-launch.js +56 -18
  56. package/src/council/run-retry-group.js +266 -0
  57. package/src/council/run-retry-keys.js +74 -0
  58. package/src/council/run-retry-launch.js +55 -0
  59. package/src/council/run-retry-notes.js +65 -13
  60. package/src/council/run-retry.js +179 -162
  61. package/src/council/run-stage1-launch.js +27 -10
  62. package/src/council/run-stage1-rows.js +220 -0
  63. package/src/council/run-stage1-superseded.js +156 -0
  64. package/src/council/run-stage2.js +65 -5
  65. package/src/council/run-stages.js +72 -69
  66. package/src/council/run-state.js +1 -1
  67. package/src/council/run-stats-entry.js +71 -0
  68. package/src/council/run-verdict-files.js +52 -0
  69. package/src/council/run.js +42 -52
  70. package/src/council/seats.js +262 -0
  71. package/src/council/stage1-bind.js +142 -0
  72. package/src/council/street-cred.js +258 -0
  73. package/src/council/tally.js +100 -49
  74. package/src/council/verdict.js +49 -1
  75. package/src/headless.js +83 -6
  76. package/src/mcp-tools.js +71 -1
  77. package/src/observe/council-legs.js +7 -1
  78. package/src/observe/live-doc.js +3 -3
  79. package/src/sidecar/continue.js +8 -23
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-retry.js +15 -3
  82. package/src/sidecar/fanout-wave-io.js +26 -1
  83. package/src/sidecar/fanout.js +4 -10
  84. package/src/sidecar/leg-ids.js +19 -0
  85. package/src/sidecar/models-probe.js +7 -4
  86. package/src/sidecar/reopen-spend.js +32 -0
  87. package/src/sidecar/resume.js +1 -1
  88. package/src/sidecar/setup.js +13 -2
  89. package/src/utils/config.js +109 -11
  90. package/src/utils/curated-models.js +17 -2
  91. package/src/utils/degrade.js +5 -0
  92. package/src/utils/doctor-engine-check.js +32 -0
  93. package/src/utils/engine-install-scan.js +98 -15
  94. package/src/utils/engine-repair.js +96 -2
  95. package/src/utils/no-output-backstop.js +1 -1
  96. package/src/utils/remediation-hints.js +42 -0
  97. package/src/utils/session-index-prune.js +297 -0
  98. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  99. package/src/workspace/artifact-guard.js +8 -114
  100. package/src/workspace/artifact-names.js +222 -0
  101. package/src/workspace/fold-format.js +9 -6
  102. package/src/workspace/live-normalize.js +6 -2
  103. package/src/workspace/matrix-model.js +141 -19
  104. package/src/workspace/run-detail.js +27 -1
  105. package/src/workspace/seat-space.js +143 -0
@@ -0,0 +1,146 @@
1
+ // src/council/report-md.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/report-md
6
+ * The markdown renderer for the neutral report model. Extracted verbatim from
7
+ * ./report (v4.8 Phase 1 T1.2) to give that file headroom under the 300-line
8
+ * gate — it was at 298/300, two lines from blocking its own next edit.
9
+ * Mirrors ./report-html exactly: one renderer, one exported function, taking
10
+ * the model ./report's toModel builds, and requiring TIER_ORDER/SYMBOL back
11
+ * from ./report — safe because buildReport requires this module lazily.
12
+ */
13
+
14
+ const { formatCost } = require('../utils/pricing');
15
+ const { formatDuration } = require('../utils/format-duration');
16
+ const { TIER_ORDER, SYMBOL } = require('./report');
17
+ const { formatDegrade } = require('../utils/degrade');
18
+
19
+ function fmtNum(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
20
+ function fmtDur(ms) { return formatDuration(ms, '—'); }
21
+
22
+ function renderMd(m) {
23
+ const h = m.header;
24
+ const out = [];
25
+ out.push(`# Council Report — ${h.runType} (${h.runId})`);
26
+ const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
27
+ h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).join(' · ');
28
+ out.push(`\n_${meta}_\n`);
29
+
30
+ out.push('## Verdict summary\n');
31
+ out.push('| Tier | Count |\n|---|---|');
32
+ for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
33
+
34
+ // Heading-over-nothing: emitted ONLY when the run actually degraded, so a
35
+ // clean verdict's report stays byte-identical to before this section
36
+ // existed. Losses are headline news, so they sit directly under the
37
+ // summary, before the reader reaches the adjudication detail.
38
+ if (m.degrades.length) {
39
+ out.push('\n## What was lost\n');
40
+ // ONE voice (Plan 1's formatDegrade) — the report must not grow a dialect.
41
+ for (const d of m.degrades) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
42
+ }
43
+
44
+ out.push('\n## Adjudication matrix\n');
45
+ out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
46
+ out.push(`|---|---|---|${m.judges.map(() => '---').join('|')}|---|---|`);
47
+ for (const f of m.findings) {
48
+ const cells = m.judges.map((j) => {
49
+ const v = f.byJudge[j];
50
+ return (v ? SYMBOL[v] : ' ') + (j === f.raiser ? '*' : '');
51
+ });
52
+ // v4.8 PR5a T6 (R5-10): the R8 marker rides the TIER cell. It qualifies the tier's
53
+ // implicit claim of independent corroboration, which is exactly what R8 exists to stop
54
+ // overstating — and the other two candidate placements (the finding row, the raiser
55
+ // cell) are both pinned by seat-matrix.test.js on the one fixture that carries the flag.
56
+ const tier = f.sameModelCorroboration ? `${f.tier}†` : f.tier;
57
+ out.push(`| ${f.id} | ${f.severity} | ${f.raiser} | ${cells.join(' | ')} | ${tier} | ${f.decision || ''} |`);
58
+ }
59
+ out.push('\n_Legend: ✓ agree · ✗ dispute · – neutral · `*` raiser\'s own vote_\n');
60
+ // ⚠️ GATED. Written unconditionally this line shifts every subsequent line of a
61
+ // unique-alias report, breaking byte-identity on EVERY run and reddening the existing
62
+ // pins and all four snapshots. Only a twin bench can raise the flag, so only a twin
63
+ // bench gets the line.
64
+ if (m.findings.some(f => f.sameModelCorroboration)) {
65
+ out.push('_`†` corroborated only by another seat running the SAME model — concurrence, not independent support._\n');
66
+ }
67
+
68
+ out.push('## Street-cred (peers-only; lower = better)\n');
69
+ out.push('| Model | peers-only | with-self |\n|---|---|---|');
70
+ // v4.8 SI-22.4 rider (R22.4-6): key the row by its SEAT, falling back to the
71
+ // alias — the same `s.seat || s.model` fallback SI-25 used at the chair
72
+ // packet's rendering sites. A twin bench emits one street-cred row PER SEAT
73
+ // (street-cred.js :: computeStreetCred), each with its own numbers, so keying
74
+ // on `s.model` printed two DIFFERENT numbers under one identical label with
75
+ // nothing to say which seat was which. `seat` is emit-when-DIFFERENT, so a
76
+ // unique-alias bench has no `seat` KEY AT ALL and this line is byte-identical
77
+ // to what it wrote before — which is what keeps the report snapshots green.
78
+ // ⚠️ The key's absence comes from `street-cred.js :: computeStreetCred`'s
79
+ // row literal (`...(seat ? { seat } : {})`), NOT from `credSeats`. This
80
+ // comment used to cite `credSeats: seat: id === m ? null : id`, which is a
81
+ // real expression but the wrong mechanism: credSeats emits `seat: null` —
82
+ // the property PRESENT and null — and computeStreetCred is what drops it.
83
+ // The pin asserts absence (`'seat' in r === false`), so cite the producer
84
+ // that actually makes it absent.
85
+ // Named mutant: tests/council/preset-trim-mutants.js :: ROWSEATDROP.
86
+ for (const s of m.streetCred) { out.push(`| ${s.seat || s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
87
+
88
+ out.push('\n## Findings by tier\n');
89
+ // LC-10 fast-follow (review minor M3): m.findings can legitimately be EMPTY
90
+ // (every seat honestly reported nothing) — TIER_ORDER's four groups are then
91
+ // all empty too, and the loop below emits nothing, leaving this heading with
92
+ // no content beneath it before '## Cost'. Same heading-over-nothing class
93
+ // Task 3 closed in the Stage-2 prompts (buildJudgeBundle/buildChairPacket),
94
+ // human-facing here rather than model-facing. State the clean bench instead
95
+ // of leaving the heading to dangle.
96
+ if (!m.findings.length) {
97
+ out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
98
+ } else {
99
+ for (const t of TIER_ORDER) {
100
+ const group = m.findings.filter(f => f.tier === t);
101
+ if (!group.length) { continue; }
102
+ out.push(`### ${t}`);
103
+ for (const f of group) {
104
+ const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
105
+ out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
106
+ }
107
+ out.push('');
108
+ }
109
+ }
110
+
111
+ // Defensive: never emit the heading unless at least one grouping has
112
+ // content — a heading over nothing is worse than no heading.
113
+ if (m.debate.present && (m.debate.withdrawn.length || m.debate.movements.length || m.debate.noResponse.length)) {
114
+ out.push('\n## Debate round\n');
115
+ if (m.debate.withdrawn.length) {
116
+ out.push('**Withdrawn by raiser:**');
117
+ for (const w of m.debate.withdrawn) {
118
+ const arrow = w.previousTier && w.previousTier !== w.tier ? `${w.previousTier} → ${w.tier}` : (w.previousTier || w.tier);
119
+ out.push(`- ${w.id}: ${arrow} (withdrawn — no longer live)`);
120
+ }
121
+ out.push('');
122
+ }
123
+ if (m.debate.movements.length) {
124
+ out.push('**Tier movements after re-vote:**');
125
+ for (const mv of m.debate.movements) { out.push(`- ${mv.id}: ${mv.previousTier} → ${mv.tier} (${mv.action})`); }
126
+ out.push('');
127
+ }
128
+ if (m.debate.noResponse.length) {
129
+ out.push('**No response (raiser did not defend):**');
130
+ for (const nr of m.debate.noResponse) {
131
+ const arrow = nr.previousTier && nr.previousTier !== nr.tier ? `${nr.previousTier} → ${nr.tier}` : (nr.previousTier || nr.tier);
132
+ out.push(`- ${nr.id}: ${arrow} (no response — original stands)`);
133
+ }
134
+ out.push('');
135
+ }
136
+ }
137
+
138
+ out.push('## Cost\n');
139
+ out.push('| Model | Status | Duration | Cost |\n|---|---|---|---|');
140
+ for (const r of m.cost.rows) { out.push(`| ${r.model} | ${r.status} | ${fmtDur(r.durationMs)} | ${formatCost(r.cost)} |`); }
141
+ out.push(`| **Wave total** | | | ${formatCost(m.cost.total)} |`);
142
+
143
+ return out.join('\n') + '\n';
144
+ }
145
+
146
+ module.exports = { renderMd };
@@ -7,14 +7,106 @@
7
7
  * verdict.json (+ optional wave.json for the cost total) and produces a single
8
8
  * self-contained Markdown or HTML string. Renders deterministic data only — no
9
9
  * scoring, anonymization, or synthesis (that stays in Claude).
10
+ *
11
+ * Since v4.8 Phase 1 T1.2 this file renders NOTHING itself: it builds the
12
+ * neutral model (`toModel`) and `buildReport` dispatches to `./report-md` or
13
+ * `./report-html`, which own the two string formats. That description of
14
+ * `buildReport`'s API above is still exactly true from the outside — it is the
15
+ * file's own job that narrowed.
10
16
  */
11
17
 
12
- const { formatCost, sumWaveUsage } = require('../utils/pricing');
13
- const { formatDuration } = require('../utils/format-duration');
14
- const { formatDegrade } = require('../utils/degrade');
18
+ const { sumWaveUsage } = require('../utils/pricing');
15
19
 
16
20
  const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
17
- const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
21
+ // __proto__: null an inherited/unknown vote key (e.g. "toString") must fold as unrecognized, never resolve off Object.prototype.
22
+ const SYMBOL = { __proto__: null, agree: '✓', dispute: '✗', neutral: '–' };
23
+ // v4.8 T-C1 (SI-22.5, ruling R18): the ONE column every vote whose key names no
24
+ // column folds into. Deliberately NOT exported: R17 took the narrow option —
25
+ // src/workspace/matrix-model.js gets the same rule as a SEPARATE implementation
26
+ // in its own task, so nothing is extracted or shared for it here.
27
+ const UNATTRIBUTED = 'UNATTRIBUTED';
28
+
29
+ /**
30
+ * Is a document in SEAT SPACE — does it carry a USABLE seat table?
31
+ *
32
+ * v4.8 PR4c §3.6 (R4c-8): ONE flag decides THREE readers of the seat space —
33
+ * the roster, the vote key, and the RAISER. Gating any two of them ships a
34
+ * self-contradicting artifact: gate the roster and the vote key only and the
35
+ * star vanishes (`'deepseek' === 'deepseek#1'` is false for both columns);
36
+ * gate the raiser only and the Raiser cell renders a seat id while every
37
+ * column header renders the alias. `verdict.seats` is NEW in PR4c while
38
+ * `adjudications[].seat` shipped in PR3, so independent fallbacks would leave
39
+ * EVERY vote cell blank on the twin verdicts already on disk.
40
+ *
41
+ * `Array.isArray`, not `?.`/`??`: `[]` is not nullish, so `??` would delete
42
+ * every judge column, and a non-array `seats` throws where HEAD renders. The
43
+ * per-element check exists because every path that reaches a renderer with an
44
+ * on-disk document is schema-free — `council report <verdict.json>` and
45
+ * `council verdict <tally.json> --render` are raw JSON.parse, and
46
+ * `amicus_verdict` takes `record: z.record(z.any())` — while R4c-5 widened the
47
+ * MCP tally schema to `z.array(z.any()).nullable()` on purpose. So
48
+ * `seats: [null,…]` and `seats: ["deepseek#1",…]` both arrive: the first makes
49
+ * `s.id` THROW where HEAD renders, the second yields `undefined` columns. A
50
+ * malformed table falls back to alias space WHOLE instead.
51
+ *
52
+ * ⚠️ EXPORTED AND SHARED, not copied (council A3/B1). `workspace/matrix-model.js`
53
+ * makes the identical decision over `tally.meta.seats`, and two verbatim copies
54
+ * are a maintenance coupling: edit one and the two renderers disagree about
55
+ * which space a document is in. This is NOT `ledger.js :: CONFORMANCE_RANK`'s
56
+ * documented-copy case — that copy is paid for because `ledger.js` (plus its two
57
+ * extractions) never reaches findings/anonymize/seats, while its sibling pulls
58
+ * all three. Measured here: requiring `workspace/matrix-model.js` already loads
59
+ * four first-party modules (v4.8 Phase 1 T1.2 moved renderMd's own
60
+ * pricing/format-duration/degrade requires out to report-md.js, which this path
61
+ * never reaches — report-md is required lazily from inside buildReport, not at
62
+ * module load) and THIS file is one of them (it has imported SYMBOL since v4.4,
63
+ * for the same single-source reason), so sharing costs ZERO new require edges.
64
+ * Guarded by tests/council/seat-matrix.test.js's A3/B1 table.
65
+ */
66
+ function isSeatSpace(seats) {
67
+ return Array.isArray(seats) && seats.length > 0
68
+ && seats.every(s => s && typeof s.id === 'string');
69
+ }
70
+
71
+ /**
72
+ * A finding's adjudications AS AN ARRAY, whatever the document actually carries.
73
+ *
74
+ * ⚠️ ONE expression, TWO readers, and that is the whole reason it exists.
75
+ * `toModel`'s pre-pass and its per-finding map both walk this list, and for one
76
+ * commit they disagreed about the type: `.some` is Array-only while `for...of`
77
+ * takes any iterable, so `adjudications: "abc"` RENDERED at c8867b48 and THREW at
78
+ * 774dcdc2. Measured at c8867b48: the string rendered
79
+ * `| A1 | major | gpt | | * | Contested | |` with a junk `"undefined"` key in
80
+ * `byJudge`; `{}` and `42` threw there too.
81
+ *
82
+ * `Array.isArray` refuses all three the same way — a malformed value contributes
83
+ * no votes — which renders the string case BYTE-IDENTICALLY to c8867b48 in both
84
+ * formats (measured) and WIDENS `{}`/`42` from throw to that same vote-less
85
+ * render. The widening is deliberate: it is the direction this file already
86
+ * points. `isSeatSpace` above answers a malformed seats table by falling back
87
+ * WHOLE rather than throwing, for the same reason — the three schema-free
88
+ * `JSON.parse` entry points can deliver any shape. Pinned, all three shapes, in
89
+ * tests/council/seat-matrix.test.js.
90
+ *
91
+ * ⚠️ `.filter(Boolean)` is v4.8 T-C4, and it is `matrix-model.js`'s `!adj` guard
92
+ * spelled a SECOND time rather than shared — R17 keeps the rule in two places.
93
+ * A falsy ELEMENT has no `.seat` and no `.judge`, so `columnFor` called it
94
+ * unattributable and grew a column whose cell — `adj.verdict` — was `undefined`
95
+ * too, i.e. blank: a column announcing a vote it could not show. `matrix-model.js`
96
+ * skipped the same element and grew no column, so ONE document rendered two ways.
97
+ * Measured at ed5c0c02: no column on either side, so that desync arrived with
98
+ * this release rather than being inherited.
99
+ * ⚠️ It also drops `null`/`undefined`, which THREW here in both phases. Same
100
+ * throw -> render widening as the container above, named on purpose.
101
+ * ⚠️ `Boolean`, NOT `a && typeof a === 'object'`. Measured over ten element types
102
+ * against the live `matrix-model.js`: this predicate disagrees on 0 of 10, that
103
+ * one on 3 of 10 — it drops TRUTHY non-objects (`42`, `'x'`, `true`) which the
104
+ * other consumer keeps, closing one divergence by opening three. Those shapes
105
+ * still grow a blank column on BOTH consumers; that is filed, and pinned here as
106
+ * the agreement it is, not fixed by one-sided strictness.
107
+ * Named mutant, with its measured red set: tests/council/seat-matrix.test.js :: ELEMKEEP.
108
+ */
109
+ function adjOf(f) { return Array.isArray(f.adjudications) ? f.adjudications.filter(Boolean) : []; }
18
110
 
19
111
  /** Build a neutral, render-agnostic model from a verdict (+ optional wave). */
20
112
  function toModel(verdict, wave) {
@@ -23,9 +115,9 @@ function toModel(verdict, wave) {
23
115
  }
24
116
  const council = verdict.council || [];
25
117
  // 'council' (verdict.council / meta.models) is the street-cred universe and
26
- // legitimately includes 'claude' on a --claude-review run (run-assemble.js:
27
- // 123-125, docs/council.md:326). 'judges' is the adjudication-matrix column
28
- // set: SKILL.md:482 / run-stages.js:162-163 guarantee Claude is judged but
118
+ // legitimately includes 'claude' on a --claude-review run (buildTallyInput's
119
+ // 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
29
121
  // never judges, so its reserved seat must never grow a matrix column — filter
30
122
  // it out ONLY when claudeInCouncil is true. This is name+flag gated, not
31
123
  // vote-derived: a bench judge that cast zero adjudications (dead/unstructured
@@ -33,15 +125,90 @@ function toModel(verdict, wave) {
33
125
  // its (blank) column — deriving the roster from "who actually voted" would
34
126
  // silently delete that column too and break the byte-unchanged-artifact
35
127
  // contract for degraded v4.0.1-shaped runs.
36
- const judges = verdict.claudeInCouncil === true ? council.filter(j => j !== 'claude') : council;
128
+ const aliasJudges = verdict.claudeInCouncil === true ? council.filter(j => j !== 'claude') : council;
129
+ // v4.8 PR4c §3.6 (R4c-8): ONE flag, THREE readers below — the roster, the
130
+ // vote key, and the RAISER. Shared with workspace/matrix-model.js; the whole
131
+ // rationale (and why `??` is wrong) lives on isSeatSpace above.
132
+ const seatSpace = isSeatSpace(verdict.seats);
133
+ // No claude filter needed in seat space: seats[] is bench-only (seats.js
134
+ // excludes the reserved claude seat), so it can never grow a claude column.
135
+ const bench = seatSpace ? verdict.seats.map(s => s.id) : aliasJudges;
136
+ const columns = new Set(bench);
137
+ // v4.8 T-C1 (SI-22.5): REFUSE a key that identifies nothing. PR4c wrote
138
+ // `byJudge[(seatSpace && adj.seat) || adj.judge]` whatever that expression
139
+ // produced, and six shapes were measured at c8867b48 landing as a key no
140
+ // column reads: an orphaned seat id, `''` and an absent `judge` in either
141
+ // space — the absent one arriving as the STRING "undefined", a JS coercion
142
+ // artifact and never a name — and a non-string judge. The three conjuncts
143
+ // refuse, in that order, a non-string, the empty string, and a string naming
144
+ // no column; `columns.has` is the whole orphan test, because the roster is
145
+ // the only thing that makes a key mean a column. A key that survives all
146
+ // three is written exactly where PR4c wrote it: this is a refusal, not a
147
+ // re-key, and ruling R3 leaves the vote counted in `basis` either way.
148
+ // ⚠️ `typeof key === 'string'` does not refuse only junk, and the one shape it
149
+ // COSTS is disclosed rather than discovered: a NUMERIC key coerced to a column
150
+ // at c8867b48 — `council: [42,'gpt']` with `judge: 42` put a ✓ in the `42`
151
+ // column — and now folds instead, leaving that column blank. Brief class 3 says
152
+ // "any non-string" unconditionally; this is that, measured.
153
+ // ⚠️ `key !== ''` is NOT redundant with `columns.has`, and is not asserted from
154
+ // structure: `isSeatSpace` accepts `{id: ''}`, so a roster CAN hold `''`, and on
155
+ // `seats: [{id:''},…]` with `judge: ''` the two spellings measurably diverge —
156
+ // with the conjunct the vote folds, without it `byJudge['']` takes it.
157
+ // Named mutants, with their measured red sets:
158
+ // tests/council/seat-matrix.test.js :: JUNKKEY and
159
+ // tests/council/seat-matrix.test.js :: EMPTYOK.
160
+ const columnFor = (adj) => {
161
+ const key = (seatSpace && adj.seat) || adj.judge;
162
+ return (typeof key === 'string' && key !== '' && columns.has(key)) ? key : UNATTRIBUTED;
163
+ };
164
+ // ⚠️ TWO-PHASE, and it cannot be one: the roster is what CLASSIFIES a vote,
165
+ // and whether any vote was refused is what decides the roster. Phase 1 is the
166
+ // bench roster plus this pre-pass over every finding; phase 2 is the map
167
+ // below, which seeds `byJudge` from the FINAL roster. Deciding inside that map
168
+ // would seed the column onto some findings and not others — which is only
169
+ // OBSERVABLE on a MULTI-finding document, so the pin that separates the two
170
+ // shapes carries two findings on purpose.
171
+ // Named mutant, with its measured red set: tests/council/seat-matrix.test.js :: PERFINDING.
172
+ // ⚠️ CONDITIONAL, the same shape as T2.3's emit-only-when-`> 0`: added
173
+ // unconditionally it grows a column on every report that has no such vote and
174
+ // breaks the byte-unchanged-artifact contract.
175
+ // ⚠️ `concat`, never `push`. In alias space with `claudeInCouncil !== true`,
176
+ // `bench` IS `verdict.council` BY REFERENCE (measured at c8867b48:
177
+ // `toModel(v).judges === v.council` reads true), so appending in place would
178
+ // write the label into the caller's own document and into `header.council` —
179
+ // which both renderers print as `council: …` on the meta line.
180
+ // ⚠️ `!columns.has(...)`: a bench model literally aliased UNATTRIBUTED already
181
+ // owns that column and R18 says ONE column. It then SHARES its cell with the
182
+ // folded votes — disclosed, not fixed, because the roster entry is also the
183
+ // `byJudge` key and separating them needs a renderer change.
184
+ // Named mutant, with its measured red set: tests/council/seat-matrix.test.js :: ALWAYSCOL.
185
+ const folded = verdict.findings.some(f => adjOf(f).some(a => columnFor(a) === UNATTRIBUTED));
186
+ const judges = folded && !columns.has(UNATTRIBUTED) ? bench.concat(UNATTRIBUTED) : bench;
37
187
  const findings = verdict.findings.map((f) => {
38
188
  const byJudge = {};
39
189
  for (const j of judges) { byJudge[j] = null; }
40
- for (const adj of (f.adjudications || [])) { byJudge[adj.judge] = adj.verdict; }
190
+ // LAST-WINS, and still is: two votes sharing one column overwrite each
191
+ // other. Before PR4c (b9a98a0f) this key was the bare alias, which is what
192
+ // collapsed a twin bench — still pinned, on a seat-table-less document, by
193
+ // seat-matrix.test.js's T21 block. T-C1 changed only WHICH key a REFUSED
194
+ // vote gets: every refusal folds into the single UNATTRIBUTED column, so two
195
+ // refused votes on one finding show ONE verdict. Measured, and pinned as
196
+ // measured rather than claimed to be more. `basis` is untouched either way.
197
+ for (const adj of adjOf(f)) { byJudge[columnFor(adj)] = adj.verdict; }
41
198
  return {
42
- id: f.id, severity: f.severity, raiser: f.raiser, tier: f.tier,
199
+ // The raiser re-key IS the star fix, and it is why report-html.js needs
200
+ // zero edits: renderMd's cell map and report-html.js's both test
201
+ // `j === f.raiser` against THIS field, so both become seat-correct at
202
+ // once — and the Raiser column follows instead of contradicting them.
203
+ id: f.id, severity: f.severity, raiser: seatSpace ? (f.raiserSeat || f.raiser) : f.raiser, tier: f.tier,
43
204
  basis: f.basis || { a: 0, d: 0, n: 0 }, decision: f.decision || null,
44
205
  applied: f.applied === true, byJudge, debate: f.debate || null,
206
+ // ⚠️ v4.8 PR5a T6: this literal is CLOSED — it names every key it copies off `f` and
207
+ // copies nothing else, so a field added upstream is invisible to BOTH renderers until
208
+ // it is named here. tally.js has stamped R8's sameModelCorroboration since PR4c and
209
+ // verdict.js carries it, but no renderer could see it. Emit-when-true, matching the
210
+ // producer: it is never written as `false`.
211
+ ...(f.sameModelCorroboration ? { sameModelCorroboration: true } : {}),
45
212
  };
46
213
  });
47
214
  // 'movements' is deliberately re-vote-only (defended/amended): a withdrawn or
@@ -82,8 +249,13 @@ function toModel(verdict, wave) {
82
249
  ROLE_SUFFIX['chair-attempt'] = 'chair-attempt';
83
250
  ROLE_SUFFIX.repair = 'repair';
84
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.
85
257
  const costRows = runStats.map(r => ({
86
- model: ROLE_SUFFIX[r.role] ? `${r.model} (${ROLE_SUFFIX[r.role]})` : r.model,
258
+ model: ROLE_SUFFIX[r.role] ? `${r.seat || r.model} (${ROLE_SUFFIX[r.role]})` : (r.seat || r.model),
87
259
  status: r.status, durationMs: r.durationMs,
88
260
  cost: r.usage && r.usage.cost ? r.usage.cost : null,
89
261
  }));
@@ -107,105 +279,6 @@ function toModel(verdict, wave) {
107
279
  };
108
280
  }
109
281
 
110
- function fmtNum(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
111
- function fmtDur(ms) { return formatDuration(ms, '—'); }
112
-
113
- function renderMd(m) {
114
- const h = m.header;
115
- const out = [];
116
- out.push(`# Council Report — ${h.runType} (${h.runId})`);
117
- const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
118
- h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).join(' · ');
119
- out.push(`\n_${meta}_\n`);
120
-
121
- out.push('## Verdict summary\n');
122
- out.push('| Tier | Count |\n|---|---|');
123
- for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
124
-
125
- // Heading-over-nothing: emitted ONLY when the run actually degraded, so a
126
- // clean verdict's report stays byte-identical to before this section
127
- // existed. Losses are headline news, so they sit directly under the
128
- // summary, before the reader reaches the adjudication detail.
129
- if (m.degrades.length) {
130
- out.push('\n## What was lost\n');
131
- // ONE voice (Plan 1's formatDegrade) — the report must not grow a dialect.
132
- for (const d of m.degrades) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
133
- }
134
-
135
- out.push('\n## Adjudication matrix\n');
136
- out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
137
- out.push(`|---|---|---|${m.judges.map(() => '---').join('|')}|---|---|`);
138
- for (const f of m.findings) {
139
- const cells = m.judges.map((j) => {
140
- const v = f.byJudge[j];
141
- return (v ? SYMBOL[v] : ' ') + (j === f.raiser ? '*' : '');
142
- });
143
- out.push(`| ${f.id} | ${f.severity} | ${f.raiser} | ${cells.join(' | ')} | ${f.tier} | ${f.decision || ''} |`);
144
- }
145
- out.push('\n_Legend: ✓ agree · ✗ dispute · – neutral · `*` raiser\'s own vote_\n');
146
-
147
- out.push('## Street-cred (peers-only; lower = better)\n');
148
- out.push('| Model | peers-only | with-self |\n|---|---|---|');
149
- for (const s of m.streetCred) { out.push(`| ${s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
150
-
151
- out.push('\n## Findings by tier\n');
152
- // LC-10 fast-follow (review minor M3): m.findings can legitimately be EMPTY
153
- // (every seat honestly reported nothing) — TIER_ORDER's four groups are then
154
- // all empty too, and the loop below emits nothing, leaving this heading with
155
- // no content beneath it before '## Cost'. Same heading-over-nothing class
156
- // Task 3 closed in the Stage-2 prompts (buildJudgeBundle/buildChairPacket),
157
- // human-facing here rather than model-facing. State the clean bench instead
158
- // of leaving the heading to dangle.
159
- if (!m.findings.length) {
160
- out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
161
- } else {
162
- for (const t of TIER_ORDER) {
163
- const group = m.findings.filter(f => f.tier === t);
164
- if (!group.length) { continue; }
165
- out.push(`### ${t}`);
166
- for (const f of group) {
167
- const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
168
- out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
169
- }
170
- out.push('');
171
- }
172
- }
173
-
174
- // Defensive: never emit the heading unless at least one grouping has
175
- // content — a heading over nothing is worse than no heading.
176
- if (m.debate.present && (m.debate.withdrawn.length || m.debate.movements.length || m.debate.noResponse.length)) {
177
- out.push('\n## Debate round\n');
178
- if (m.debate.withdrawn.length) {
179
- out.push('**Withdrawn by raiser:**');
180
- for (const w of m.debate.withdrawn) {
181
- const arrow = w.previousTier && w.previousTier !== w.tier ? `${w.previousTier} → ${w.tier}` : (w.previousTier || w.tier);
182
- out.push(`- ${w.id}: ${arrow} (withdrawn — no longer live)`);
183
- }
184
- out.push('');
185
- }
186
- if (m.debate.movements.length) {
187
- out.push('**Tier movements after re-vote:**');
188
- for (const mv of m.debate.movements) { out.push(`- ${mv.id}: ${mv.previousTier} → ${mv.tier} (${mv.action})`); }
189
- out.push('');
190
- }
191
- if (m.debate.noResponse.length) {
192
- out.push('**No response (raiser did not defend):**');
193
- for (const nr of m.debate.noResponse) {
194
- const arrow = nr.previousTier && nr.previousTier !== nr.tier ? `${nr.previousTier} → ${nr.tier}` : (nr.previousTier || nr.tier);
195
- out.push(`- ${nr.id}: ${arrow} (no response — original stands)`);
196
- }
197
- out.push('');
198
- }
199
- }
200
-
201
- out.push('## Cost\n');
202
- out.push('| Model | Status | Duration | Cost |\n|---|---|---|---|');
203
- for (const r of m.cost.rows) { out.push(`| ${r.model} | ${r.status} | ${fmtDur(r.durationMs)} | ${formatCost(r.cost)} |`); }
204
- out.push(`| **Wave total** | | | ${formatCost(m.cost.total)} |`);
205
-
206
- return out.join('\n') + '\n';
207
- }
208
-
209
282
  /**
210
283
  * @param {{verdict:object, wave?:object, tallyRecord?:object}} sources
211
284
  * @param {{format:'md'|'html'}} opts
@@ -213,8 +286,11 @@ function renderMd(m) {
213
286
  */
214
287
  function buildReport(sources, opts = {}) {
215
288
  const model = toModel(sources.verdict, sources.wave);
289
+ // ⚠️ Both requires MUST stay lazy — nothing lints this. Each renderer requires ./report back at
290
+ // load for TIER_ORDER/SYMBOL; hoisting either resolves that back-require against THIS file's
291
+ // not-yet-assigned module.exports, so the sibling gets undefined and renders a TypeError.
216
292
  if (opts.format === 'html') { return require('./report-html').renderHtml(model); }
217
- return renderMd(model);
293
+ return require('./report-md').renderMd(model);
218
294
  }
219
295
 
220
- module.exports = { buildReport, toModel, TIER_ORDER, SYMBOL };
296
+ module.exports = { buildReport, toModel, TIER_ORDER, SYMBOL, isSeatSpace };