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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- 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 };
|
package/src/council/report.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
27
|
-
//
|
|
28
|
-
// set: SKILL.md:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|