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
@@ -25,10 +25,11 @@ const runState = require('./run-state');
25
25
  const { runStage2 } = require('./run-stage2');
26
26
  const { launchStage1 } = require('./run-stage1-launch');
27
27
  const { buildRunStatsEntry } = require('./run-assemble');
28
-
29
- function slug(text) {
30
- return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
31
- }
28
+ const { pushDeadSeatRows } = require('./run-stage1-rows');
29
+ const { bindStage1Waves, orphanLegNote, missingSeatDeadWave } = require('./stage1-bind');
30
+ // slug lives in ./seats (v4.8 PR1) so that module can stay require-free;
31
+ // re-exported below — run-stages.test.js imports it from here.
32
+ const { slug } = require('./seats');
32
33
 
33
34
  /** Role of a seat by its input alias. */
34
35
  function roleFor(o, alias) {
@@ -60,35 +61,67 @@ function roleFor(o, alias) {
60
61
  */
61
62
  async function runStage1(ctx) {
62
63
  const { o } = ctx;
63
- const { aborted, legs, deadWaves } = await launchStage1(ctx);
64
+ const { aborted, legs, deadWaves, waves } = await launchStage1(ctx);
64
65
  if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false, extraRows: [] }; }
65
66
 
66
- const firstPass = materializeReviews(o.runDir, legs);
67
+ // Per-wave binding, before anything reads a leg. Orphans are announced now —
68
+ // they are not a loss, so the "never degrade for a seat the retry saves" rule
69
+ // below does not apply to them.
70
+ const { seatOf, missingSeats, orphanLegs } = bindStage1Waves(waves);
71
+ for (const { waveId, leg } of orphanLegs) { ctx.degrade.note(orphanLegNote(waveId, leg)); }
72
+ // R-B: a launched seat whose leg never came back is a LOSS — it lands in
73
+ // neither deadLegs (no leg object) nor deadWaves (the wave DID produce legs).
74
+ // It reaches the retry as a single-seat dead wave flagged `partial` so its
75
+ // prose stays true, and is announced only if the retry cannot save it.
76
+ const allDeadWaves = [...deadWaves, ...missingSeats.map(missingSeatDeadWave)];
77
+
78
+ const firstPass = materializeReviews(o.runDir, legs, seatOf);
67
79
  const alive0 = new Set(firstPass.map(m => m.leg));
68
80
  const deadLegs0 = legs.filter(l => !alive0.has(l));
69
81
 
70
82
  // SL-2: one retry BEFORE anything is recorded lost — the sink never
71
83
  // un-flips, so a degrade for a seat the retry saves must never fire at all.
72
- const retry = await retryStage1Losses(ctx, { deadWaves, deadLegs: deadLegs0,
73
- counts: { reviewed: firstPass.length, total: legs.length } });
84
+ // `total` counts SEATS: legs.length alone renders "1 of 1 seats reviewed"
85
+ // beside a degrade when a seat's leg never returned.
86
+ const retry = await retryStage1Losses(ctx, { deadWaves: allDeadWaves, deadLegs: deadLegs0, seatOf,
87
+ counts: { reviewed: firstPass.length, total: legs.length + missingSeats.length } });
74
88
  if (retry.aborted) {
75
89
  // Final whole-branch review: same bug class as the post-retry-repair
76
90
  // abort fixed ~87 lines below ("Must be the post-retry set") — subtract
77
91
  // whatever retry.recoveredLegs already healed before this abort landed.
78
- const healed = new Set(retry.recoveredLegs.map(l => l.modelInput || l.model));
92
+ // SEAT-keyed since v4.8 H4: twin seats now retry INDEPENDENTLY, so an alias
93
+ // Set marks BOTH healed the moment one of them is — the still-dead twin
94
+ // silently disappears from deadLegs AND deadWaves[].models, and run.js
95
+ // persists that return into stage-1 state as if it had reviewed.
96
+ const keyOf = (l, bind) => { const s = bind.get(l); return s ? s.id : (l.modelInput || l.model); };
97
+ const healed = new Set(retry.recoveredLegs.map(l => keyOf(l, retry.seatOf)));
98
+ // `seats` must be narrowed in LOCKSTEP with `models`: a bare `...w` carries
99
+ // the FULL roster past a narrowed models list, and run.js persists this
100
+ // record before the abort short-circuit — so index i of each would name a
101
+ // different seat in run.json. Omitted entirely when the source had none.
79
102
  return { aborted: retry.aborted, reviews: [], degraded: false, extraRows: [],
80
- deadLegs: deadLegs0.filter(l => !healed.has(l.modelInput || l.model)),
81
- deadWaves: deadWaves.map(w => ({ ...w, models: (w.models || []).filter(m => !healed.has(m)) })).filter(w => w.models.length > 0) };
103
+ deadLegs: deadLegs0.filter(l => !healed.has(keyOf(l, seatOf))),
104
+ deadWaves: allDeadWaves.map((w) => {
105
+ const keep = (w.models || []).map((m, i) => [m, (w.seats || [])[i] || null])
106
+ .filter(([m, s]) => !healed.has(s ? s.id : m));
107
+ return { ...w, models: keep.map(x => x[0]), ...(w.seats ? { seats: keep.map(x => x[1]) } : {}) };
108
+ }).filter(w => w.models.length > 0) };
82
109
  }
83
110
 
84
111
  for (const d of retry.skippedDeadWaves) {
112
+ // A `partial` record is one seat of a wave that DID produce legs, so the
113
+ // plain dead-wave sentence would be false. `seat` rides only on that shape:
114
+ // adding it unconditionally breaks an exact toEqual on a real dead wave.
85
115
  ctx.degrade.note({
86
- channel: 'dead-wave',
87
- what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
116
+ channel: d.partial ? 'seat-unbound' : 'dead-wave',
117
+ what: d.partial
118
+ ? `seat ${(d.models || [])[0]} did not review`
119
+ : `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
88
120
  why: d.reason,
89
121
  effect: 'Those seats are NOT in this council. The run continues with the bench that did '
90
122
  + 'launch and will exit degraded (2)',
91
- data: { waveId: d.waveId, models: d.models, reason: d.reason },
123
+ data: { waveId: d.waveId, models: d.models, reason: d.reason,
124
+ ...(d.partial ? { seat: (d.models || [])[0] } : {}) },
92
125
  });
93
126
  }
94
127
  for (const leg of retry.skippedDeadLegs) {
@@ -96,20 +129,28 @@ async function runStage1(ctx) {
96
129
  channel: 'dead-leg',
97
130
  what: `seat ${leg.modelInput || leg.model} did not review`,
98
131
  why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
99
- effect: `${firstPass.length} of ${legs.length} seats reviewed; `
132
+ effect: `${firstPass.length} of ${legs.length + missingSeats.length} seats reviewed; `
100
133
  + 'the run continues with the bench that did and will exit degraded (2)',
101
134
  data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
102
135
  });
103
136
  }
104
137
  for (const rec of retry.stillDeadNotes) { ctx.degrade.note(rec); }
138
+ // Same shape: the retry pass BUILDS its orphan records, the caller EMITS them
139
+ // (that module emits heals only and never notes a degrade, by construction).
140
+ for (const { waveId, leg } of retry.orphanLegs) { ctx.degrade.note(orphanLegNote(waveId, leg)); }
105
141
 
106
142
  // Invariant this merge relies on: retry.recoveredLegs only ever names seats
107
143
  // that actually lost their seat on the first pass (run-retry.js's recovery
108
144
  // loop drops any leg for a seat with no firstFailures entry) — so `legs`
109
145
  // and `recoveredLegs` can never both carry a leg for the same seat here.
110
- // materializeReviews re-writing an already-materialized recovered leg's
111
- // review-*.md a second time is accepted as an idempotent no-op, not a bug.
112
- const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs]);
146
+ // A recovered leg is a RETRY-wave object, absent from Stage-1's object-keyed
147
+ // seatOf, so this union is mandatory rather than tidiness: without it every
148
+ // healed seat re-materializes with seat:null and its role falls back to
149
+ // roleFor's alias shim. Re-writing an already-materialized recovered leg's review file
150
+ // is only an idempotent no-op while the name is the seat's — under the alias
151
+ // it is the twin clobber this PR removes (two healed twins, one file).
152
+ const allSeatOf = new Map([...seatOf, ...retry.seatOf]);
153
+ const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs], allSeatOf);
113
154
  const stillDeadLegs = [...retry.skippedDeadLegs, ...retry.stillDeadLegs];
114
155
  const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
115
156
 
@@ -220,63 +261,25 @@ async function runStage1(ctx) {
220
261
  }
221
262
  }
222
263
  reviews.push({
223
- model: m.modelInput, modelInput: m.modelInput, role: roleFor(o, m.modelInput),
264
+ model: m.modelInput, modelInput: m.modelInput, seat: m.seat || null,
265
+ // Seat-space role (spec §4.5), read off the SEAT — NOT roleAt(o.seats):
266
+ // run-stage1-launch.js re-derives the table when o.seats is absent, so
267
+ // m.seat is truthy while o.seats is not, and roleAt's unknown-id 'seat'
268
+ // collapses every critic/lens role. Unbound legs keep the roleFor shim.
269
+ role: m.seat ? m.seat.role : roleFor(o, m.modelInput),
224
270
  text: m.text, findings: res.ok ? res.findings : [], conformance, leg: m.leg,
225
271
  ...(unverified ? { findingsUnverified: true } : {}),
226
272
  ...(repairRefused ? { repairRefused } : {}),
227
273
  });
228
274
  }
229
275
 
230
- // v4.7 D2/E4 superseded rows: a leg-origin seat's FIRST leg stops being
231
- // primary the moment a retry was actually attempted for it, healed or not
232
- // (deadLegs0 × recovered-or-still-dead seats mirrors the healed-set idiom
233
- // above, extended to the still-dead half E4 also requires). A skipped seat
234
- // (cost ceiling / unmappable never got a second leg) keeps NO superseded
235
- // row: nothing replaced it. Wave-origin seats never had a first leg at all,
236
- // so they can never appear here regardless of healed/dead outcome (E4).
237
- const supersededAliases = new Set([
238
- ...retry.recoveredLegs.map(l => l.modelInput || l.model),
239
- ...retry.stillDeadLegs.map(l => l.modelInput || l.model),
240
- ]);
241
- for (const dead of deadLegs0) {
242
- const alias = dead.modelInput || dead.model;
243
- if (supersededAliases.has(alias)) {
244
- extraRows.push(buildRunStatsEntry({ leg: dead, model: alias, role: 'superseded', wasChair: false }));
245
- }
246
- }
247
-
248
- // v4.7 D2/E4 — primary error rows: one per seat with NO surviving review
249
- // (every alias still in stillDeadLegs/stillDeadWaves after retry). E5
250
- // amended (Task-4 review, owner-ruled): run-retry.js now surfaces the real
251
- // retry leg (stillDeadRetryLegs), from the ONE branch it exists in —
252
- // retryLegStillDeadNote, a retry leg that came back unusable. Prefer that
253
- // REAL leg: status/waveId/usage/duration all real, all from the SAME
254
- // attempt (no more pairing a retry's waveId with a different attempt's
255
- // status). The other two dead-leg note classes — srcLegStillDeadNote (retry
256
- // wave died wholesale, zero legs) and missingLegStillDeadNote (partial
257
- // return never named this seat) — never get a real leg, so `leg: null`
258
- // (no phantom waveId: one must never appear without a real billed leg).
259
- // No 'dead-leg' note at all ⇒ never retried (skipped) ⇒ the original dead
260
- // leg is this seat's only, and therefore final, leg.
261
- const retryLegByAlias = new Map();
262
- for (const leg of retry.stillDeadRetryLegs) { retryLegByAlias.set(leg.modelInput || leg.model, leg); }
263
- const attemptedAliases = new Set();
264
- for (const n of retry.stillDeadNotes) {
265
- if (n.channel === 'dead-leg' && n.data && n.data.seat) { attemptedAliases.add(n.data.seat); }
266
- }
267
- const deadAliases = new Set([
268
- ...stillDeadLegs.map(l => l.modelInput || l.model),
269
- ...stillDeadWaves.flatMap(w => w.models || []),
270
- ]);
271
- for (const alias of deadAliases) {
272
- let finalLeg = retryLegByAlias.get(alias);
273
- if (!finalLeg) {
274
- finalLeg = attemptedAliases.has(alias)
275
- ? null // retried; no leg at all for this seat
276
- : (deadLegs0.find(l => (l.modelInput || l.model) === alias) || null); // never retried
277
- }
278
- extraRows.push(buildRunStatsEntry({ leg: finalLeg, model: alias, role: roleFor(o, alias), wasChair: false }));
279
- }
276
+ // Superseded + dead-seat rows live in ./run-stage1-rows (v4.8 PR0 size-gate split).
277
+ // ⚠️ allSeatOf, never the Stage-1 `seatOf`: retry.recoveredLegs and
278
+ // retry.stillDeadRetryLegs are retry-wave objects that map has never seen.
279
+ // ⚠️ `retry.twins`, never a fresh `twinAliases(o.seats)`: the row keys asked for here must be
280
+ // minted from the SAME collection the retry pass filled `attemptedSeats` with (v4.8 T-A6).
281
+ pushDeadSeatRows({ o, retry, deadLegs0, stillDeadLegs, stillDeadWaves, extraRows,
282
+ roleFor, seatOf: allSeatOf, degrade: ctx.degrade, twins: retry.twins });
280
283
 
281
284
  return { aborted: null, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
282
285
  degraded: stillDeadLegs.length > 0 || stillDeadWaves.length > 0, extraRows };
@@ -96,7 +96,7 @@ function initCouncilRun(o) {
96
96
  initRun(o.runDir, {
97
97
  schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
98
98
  bench: o.models.slice(), chair: o.chair, critic: o.critic, lenses: o.lenses,
99
- labelMap: null,
99
+ labelMap: null, seats: null, criticSeat: null,
100
100
  // Seeded ONLY under --debate (a `debate:null` seed would both break the v4.0
101
101
  // "no debate key" contract and fail the object-typed schema), and with a VALID
102
102
  // outcome from the first write so a run killed mid-debate stays schema-valid.
@@ -0,0 +1,71 @@
1
+ // src/council/run-stats-entry.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-stats-entry
6
+ * One runStats row from a leg run document. Extracted verbatim from
7
+ * ./run-assemble (v4.8 Phase 1 T1.1) to give that file headroom under the
8
+ * 300-line gate, and re-exported there so every existing call spelling —
9
+ * `asm.buildRunStatsEntry(...)` and pulling `buildRunStatsEntry` off
10
+ * `./run-assemble`'s own exports — survives the move untouched.
11
+ *
12
+ * ⚠️ This module is REQUIRE-FREE by design, like ./seats. Consumers that cannot
13
+ * take run-assemble's graph (./debate.js is dependency-injection-free) must be
14
+ * able to import it. Do not add a `require` call here — the pin (P3,
15
+ * tests/council/run-stats-entry.test.js) scans this file's raw text for the
16
+ * word `require` immediately followed by an opening parenthesis, so it fires
17
+ * on that sequence ANYWHERE, including inside a comment, not only inside a
18
+ * real statement.
19
+ */
20
+
21
+ /**
22
+ * One runStats row from a leg run document. Verbatim copies only — a missing
23
+ * leg doc yields durationMs/usage null (never invent a value). `model` (the
24
+ * council alias) overrides leg.model (the resolved executable id) so ledger
25
+ * rows join meta.models by exact string (ledger.js:20-24).
26
+ * `resolvedModel` (v4.7 GOA-7) preserves leg.model — the executable id that
27
+ * actually served, post-fallback-substitution — emit-only-when-set and never
28
+ * sourced from modelInput (an alias must never masquerade as a resolved id).
29
+ *
30
+ * ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
31
+ * class of fact as `conformance` and ride the same row. They are the two halves
32
+ * of the repair contract's outcome: `findingsUnverified` marks a 'repaired' seat
33
+ * whose contract could NOT be checked (the original block was absent or
34
+ * unparseable, so there was no finding count to compare), and `repairRefused`
35
+ * ({code, detail}) marks the stronger case — the contract WAS checked and broken,
36
+ * which is otherwise indistinguishable from a seat that never emitted JSON at
37
+ * all. Both are additive and present only when set, so a run without either is
38
+ * byte-for-byte unchanged.
39
+ *
40
+ * `seat` (v4.8 PR4c §3.1) is the seat OBJECT — {id, alias, role, lens, position}
41
+ * or null — never an id string. Callers pass `r.seat` / the dead-seat loop's own
42
+ * `seat` verbatim, so the object IS the contract instead of a prose one.
43
+ */
44
+ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
45
+ repairRefused, seat }) {
46
+ return {
47
+ model: model !== undefined ? model : (leg ? leg.model : null),
48
+ role,
49
+ wasChair: !!wasChair,
50
+ conformance: conformance || 'clean',
51
+ ...(findingsUnverified ? { findingsUnverified: true } : {}),
52
+ ...(repairRefused ? { repairRefused } : {}),
53
+ ...(leg && leg.waveId ? { waveId: leg.waveId } : {}),
54
+ ...(leg && leg.model ? { resolvedModel: leg.model } : {}),
55
+ // v4.8 PR4c §3.1 / R4c-9: emit-when-DIFFERENT, compared against the seat's
56
+ // OWN alias — never against `model`. buildSeats mints `alias#N` only when
57
+ // an alias repeats (seats.js:67), so `id !== alias` IS "the bench repeats
58
+ // this alias": the single predicate all four seat-emit producers now share,
59
+ // which is what stops them disagreeing. `model` is the LEG's modelInput,
60
+ // which is NOT the alias when a leg reports none (it falls back to the
61
+ // RESOLVED id, the same fallback run-launch.js :: materializeReviews uses)
62
+ // or when a --council preset carries a padded member — either would ship a
63
+ // seat id with no seat table behind it, on a bench with no twin at all.
64
+ ...(seat && seat.id !== seat.alias ? { seat: seat.id } : {}),
65
+ status: leg ? leg.status : 'error',
66
+ durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
67
+ usage: (leg && leg.usage) || null,
68
+ };
69
+ }
70
+
71
+ module.exports = { buildRunStatsEntry };
@@ -0,0 +1,52 @@
1
+ // src/council/run-verdict-files.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-verdict-files
6
+ * The verdict half of the run-dir artifact set: verdict.json (with the nullable
7
+ * overallVerdict and the derived seatLoss), report.html and chair-output.md.
8
+ *
9
+ * Lifted VERBATIM out of run-assemble.js for the 300-line gate (v4.8 PR4c
10
+ * Task 2), on the same precedent as seats.js/preflightSeats: the body lives
11
+ * here and run-assemble re-exports it, so `asm.writeVerdictFiles(...)` — the
12
+ * only production call spelling (run-finish.js:63) — is untouched and no test
13
+ * moved. This was the only consumer of run-assemble's `./verdict` and
14
+ * `./report` requires, so the two came with it.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { buildVerdict, summarizeSeatLoss, deriveSeatLoss, writeVerdictAtomic } = require('./verdict');
20
+ const { buildReport } = require('./report');
21
+
22
+ /**
23
+ * Undecided verdict + deterministic report. Sets the nullable overallVerdict
24
+ * (council family v2, Plan A) on buildVerdict's output — independent of
25
+ * buildVerdict's own signature.
26
+ * @param {{runDir: string, record: object, overallVerdict?: (string|null),
27
+ * chairText?: string, critic?: string, deadWaves?: Array<object>,
28
+ * degrades?: Array<object>}} o `degrades` (v4.6 Plan 2), when present, is
29
+ * both carried onto the verdict and used to DERIVE `seatLoss` (deriveSeatLoss)
30
+ * in preference to summarizing it from `deadWaves` (summarizeSeatLoss).
31
+ * @returns {object} the verdict written to disk
32
+ */
33
+ function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves, degrades }) {
34
+ // v4.6 Plan 2 (spec D3): when the sink's records are available they are the
35
+ // single source of truth — seatLoss derives from them so it can never
36
+ // disagree with degrades[]. deadWaves remains the fallback for direct
37
+ // callers that predate the sink (their tests pass unedited).
38
+ const seatLoss = degrades
39
+ ? deriveSeatLoss({ runId: record.meta.runId, critic, degrades })
40
+ : summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
41
+ const verdict = buildVerdict(record, [], { seatLoss, degrades });
42
+ verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
43
+ writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
44
+ const html = buildReport({ verdict }, { format: 'html' });
45
+ fs.writeFileSync(path.join(runDir, 'report.html'), html, { mode: 0o600 });
46
+ if (chairText) {
47
+ fs.writeFileSync(path.join(runDir, 'chair-output.md'), chairText, { mode: 0o600 });
48
+ }
49
+ return verdict;
50
+ }
51
+
52
+ module.exports = { writeVerdictFiles };
@@ -27,13 +27,13 @@ const { createLaunchers } = require('./run-launch');
27
27
  const { runStage1, runStage2 } = require('./run-stages'); // stage 2 lives in ./run-stage2 (300-line gate), re-exported there
28
28
  const { runChair, pickFallbackChair } = require('./run-chair');
29
29
  const { runDebateStage } = require('./run-debate-stage'); // debate orchestration lives there (300-line gate), extracted from here (v4.6 Plan 1 Task 1)
30
- const { decorateRecord } = require('./debate');
31
30
  const asm = require('./run-assemble');
32
31
  const { createBudget } = require('./run-budget');
33
32
  const { emitRunStarted, emitStageStarted, emitStageTerminal } = require('../observe/events');
34
33
  // v4.4.1 CA-6: the whole exit-code vocabulary (SIGNAL_EXIT, statusForExit and the
35
34
  // degradation in resolveTerminalExit) lives in ./run-finalize — see its docblock.
36
35
  const { writeRunTerminal, resolveTerminalExit, SIGNAL_EXIT } = require('./run-finalize');
36
+ const { finishRun } = require('./run-finish');
37
37
 
38
38
  /**
39
39
  * @param {object} options {briefing, models, chair, critic?, lenses?, project, runId,
@@ -125,6 +125,15 @@ async function runCouncil(options, deps = {}) {
125
125
  if (pre.error) { return finalize(1, pre.error); }
126
126
  const claudeReview = pre.claudeReview;
127
127
 
128
+ // v4.8 §4.3: seats are derived pre-spend from data run.json already holds,
129
+ // then checkpointed — initCouncilRun ran ~50 lines earlier, so they cannot
130
+ // ride the seed.
131
+ const seatPre = asm.preflightSeats(o);
132
+ if (seatPre.error) { return finalize(1, seatPre.error); }
133
+ o.seats = seatPre.seats;
134
+ o.criticSeat = seatPre.criticSeat;
135
+ runState.checkpoint(o.runDir, { seats: o.seats, criticSeat: o.criticSeat });
136
+
128
137
  // Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
129
138
  fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
130
139
  briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
@@ -172,12 +181,25 @@ async function runCouncil(options, deps = {}) {
172
181
 
173
182
  // ---- Stage 2: anonymized cross-review ----
174
183
  // A file-sourced Claude review is ALWAYS the last label — review N+1 (§4.4).
175
- const labels = assignLabels(s1.reviews.map(r => r.model).concat(claudeReview ? ['claude'] : []));
184
+ // v4.8 T3.2: seats travel in lockstep with models — both are s1.reviews in
185
+ // the same pass, not the positional join anonymize.js :: assignLabels's
186
+ // own docblock explains is forbidden elsewhere.
187
+ const labels = assignLabels(s1.reviews.map(r => r.model).concat(claudeReview ? ['claude'] : []),
188
+ s1.reviews.map(r => r.seat).concat(claudeReview ? [null] : []));
176
189
  runState.checkpoint(o.runDir, { labelMap: labels.labelMap });
177
190
  // Attach each review's run-global findings (buildTallyInput reads
178
191
  // r.globalFindings per review, not a bare parallel array).
179
192
  s1.reviews.forEach((r, i) => {
180
- r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings);
193
+ // v4.8 PR3 Task 5, re-based by PR4c R4c-9: pass the seat id ONLY when it
194
+ // differs from the seat's OWN alias — the naive `r.seat ? r.seat.id :
195
+ // null` form would emit raiserSeat on every finding of every run, a
196
+ // universal artifact-shape change. The operand was `r.model`, the leg's
197
+ // modelInput — not the alias when a leg reports none (it falls back to
198
+ // the RESOLVED id) or when a --council preset carries a padded member.
199
+ // There it emitted a seat id byte-equal to its own alias, on a bench with
200
+ // no twin, with no seat table able to resolve it.
201
+ r.globalFindings = toGlobalFindings(labels.entries[i].letter, r.model, r.findings,
202
+ r.seat && r.seat.id !== r.seat.alias ? r.seat.id : null);
181
203
  });
182
204
  const globalFindings = s1.reviews.flatMap(r => r.globalFindings)
183
205
  .concat(claudeReview ? asm.labelClaudeReview(claudeReview, labels) : []);
@@ -200,9 +222,17 @@ async function runCouncil(options, deps = {}) {
200
222
  }
201
223
 
202
224
  // Merge Stage-2 judging conformance into each seat's row (worst wins).
203
- const byJudge = new Map(s2.judgeResults.map(j => [j.judge, j]));
225
+ // Seat-keyed (v4.8 PR3 Task 4): an alias-only key collapses a twin bench
226
+ // onto ONE Map entry, so the last-wins judge silently overwrote every
227
+ // twin's row (D7). j.seat can still be null (an orphaned -s2 leg,
228
+ // seats.js:130-146) even when r.seat is a real bound seat, so the lookup
229
+ // falls back to r.model on a miss rather than assuming the two arrays stay
230
+ // symmetric — a naive seatKey on both sides would make that orphan's
231
+ // conformance unreachable instead of merged, a silent total loss.
232
+ const seatKey = (s, alias) => (s ? s.id : alias);
233
+ const byJudge = new Map(s2.judgeResults.map(j => [seatKey(j.seat, j.judge), j]));
204
234
  for (const r of s1.reviews) {
205
- const j = byJudge.get(r.model);
235
+ const j = byJudge.get(r.seat ? r.seat.id : r.model) || byJudge.get(r.model);
206
236
  if (j) { r.conformance = asm.worseConformance(r.conformance, j.conformance); }
207
237
  }
208
238
 
@@ -216,6 +246,11 @@ async function runCouncil(options, deps = {}) {
216
246
  runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
217
247
  reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
218
248
  extraRows: [...s1.extraRows, ...s2.extraRows],
249
+ // v4.8 PR4c §3.2: the ONLY production caller of buildTallyInput, so this is
250
+ // the single seam meta.seats enters through. The final input is derived by
251
+ // SPREAD (run-finish.js:36), not rebuilt, so both the provisional and the
252
+ // debated input inherit it from here.
253
+ seats: o.seats,
219
254
  });
220
255
  const provisionalInput = mkInput(null, o.chair);
221
256
  const provisional = tally(provisionalInput);
@@ -237,54 +272,9 @@ async function runCouncil(options, deps = {}) {
237
272
  packet, degrade, statsFn, isSignalled: () => signalled,
238
273
  });
239
274
  if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
240
- const { chairLeg, actualChair, chairText, chairConformance, overallVerdict, chairRows, chairAttempts } = chairRes;
241
275
 
242
- // ---- Final tally (chair row included) + ledger + artifacts ----
243
- const chairStats = chairLeg ? asm.buildRunStatsEntry({
244
- leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
245
- conformance: chairConformance,
246
- }) : null;
247
- // v4.7 D2: a give-up (no chairLeg) with at least one recorded attempt gets
248
- // an explicit error row so the walk's outcome isn't silently absorbed.
249
- // Keyed on chairAttempts, NOT chairRows — attempts that die pre-wave (no
250
- // money spent) record an outcome but yield no row (errata E3).
251
- const giveUpRow = (!chairLeg && chairAttempts && chairAttempts.length)
252
- ? asm.buildRunStatsEntry({ leg: null, model: o.chair, role: 'chair', wasChair: false })
253
- : null;
254
- // Built on the (possibly debated) input so the debate's amended claims, replaced
255
- // adjudications and rebuttal/revote runStats rows all reach the final record.
256
- const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
257
- // Item 8, final-review consolidated wave: was three sequential
258
- // reassignments (chairStats, then chairRows, then giveUpRow), each
259
- // rebuilding finalInput.runStats from scratch — collapsed into the one
260
- // spread that was always the net effect. The `|| []` fallbacks were
261
- // dead: `runStats` is a real array on every debatedInput
262
- // (asm.buildTallyInput always returns one via .map()), never undefined.
263
- finalInput.runStats = [
264
- ...finalInput.runStats,
265
- ...(chairStats ? [chairStats] : []),
266
- ...chairRows,
267
- ...(giveUpRow ? [giveUpRow] : []),
268
- ];
269
- const record = tally(finalInput);
270
- if (debateFindings) { decorateRecord(record, debateFindings); }
271
- if (!o.lenses) {
272
- // Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
273
- try { appendRunFn(record); }
274
- catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
275
- }
276
- asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
277
- const tallyStage = o.debate ? 'tally-final' : 'tally';
278
- runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
279
- emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
280
- emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
281
- // Verdict assembly is the degrade cut-off: anything noted after this line
282
- // reaches stderr + run.json but not verdict.json (spec §6 rule 1).
283
- asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
284
- critic: o.critic, deadWaves, degrades: degrade.all() });
285
- runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
286
- emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
287
- emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
276
+ // Final tally + ledger + artifacts live in ./run-finish (v4.8 PR0 size-gate split).
277
+ finishRun({ o, chairRes, debatedInput, debateFindings, appendRunFn, degrade, deadWaves, now });
288
278
 
289
279
  return finalize(degraded.value ? 2 : 0);
290
280
  } catch (err) {