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
@@ -12,42 +12,41 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const dbrief = require('./briefings-debate');
15
- const { parseDebateDefense, parseRevote } = require('./parse-stage2');
15
+ const { parseDebateDefense } = require('./parse-stage2');
16
16
  const { applyDebate, debateRunStatsRows, PAST_TENSE,
17
17
  allNoResponse, nothingToDebate, disputingJudges, debateTargets, bundleFor } = require('./debate');
18
18
  const { materializeDebate } = require('./run-launch');
19
+ const { buildSeats } = require('./seats');
19
20
  const { tally } = require('./tally');
20
21
  const { isAbortExit } = require('./run-stages');
21
22
  const runState = require('./run-state');
22
- const { emitStageStarted } = require('../observe/events');
23
-
24
- /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
25
- function legOpts(ctx, waveId) {
26
- return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
27
- noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
28
- // v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
29
- councilRunId: ctx.o.runId, councilName: ctx.o.councilName,
30
- tag: ctx.o.tag }; // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
31
- }
23
+ const { legOpts, legRow, runRevoteWave } = require('./run-debate-revote');
32
24
 
33
25
  /**
34
- * v4.7 D2/E4: normalize a raw (possibly leg-absent) leg into the shape
35
- * debateRunStatsRows' superseded/repair lists expect. Same never-invent-a-waveId
36
- * discipline as buildRunStatsEntry (run-assemble.js)only spread `waveId` when
37
- * the leg genuinely carries one but keyed on an explicit `model` (the raiser or
38
- * judge identity), since a leg-absent attempt has no `.model` of its own to read.
39
- * Threads resolvedModel (the raw leg's .model, the executable id) emit-only-when-set v4.7 GOA-7 D8.
26
+ * One raiser's defense solo.
27
+ *
28
+ * v4.8 PR3 Task 6the raiser boundary. `raiserKey` is a SEAT key (debateTargets
29
+ * keys byRaiser on `f.raiserSeat || f.raiser`), so it is projected ONCE, here, to
30
+ * the routable bench alias. ⚠️ `aliasOf` is a `const` local to `runDebate`; this
31
+ * is a module-scope function, so under 'use strict' a bare `aliasOf(...)` would
32
+ * be a ReferenceError on the first defense solo of every debate run — it is
33
+ * threaded explicitly instead.
34
+ *
35
+ * `raiserKey` survives in exactly two places here: the returned `{raiser}` (which
36
+ * becomes defenseByRaiser's key — §3.3: that MUST stay seat-keyed, because
37
+ * re-keying it to the alias is last-wins and silently drops one twin's entire
38
+ * debate row plus its amended claim) and the `seat` runDebate derives from it.
39
+ * Everything else — both launch sites, both legRow calls, the stub and the
40
+ * returned leg's `model` — is `raiserAlias`.
41
+ *
42
+ * @param {object} ctx
43
+ * @param {string} raiserKey seat key of the raiser
44
+ * @param {Array<object>} findings that raiser's contested/disputed findings
45
+ * @param {number} idx defense-wave position (names the waveId)
46
+ * @param {function(string): string} aliasOf seat key → bench alias
40
47
  */
41
- function legRow(model, leg, conformance) {
42
- return leg
43
- ? { model, status: leg.status, durationMs: typeof leg.durationMs === 'number' ? leg.durationMs : null,
44
- usage: leg.usage || null, conformance, summary: leg.summary || '',
45
- ...(leg.waveId ? { waveId: leg.waveId } : {}),
46
- ...(leg.model ? { resolvedModel: leg.model } : {}) }
47
- : { model, status: 'error', durationMs: null, usage: null, conformance, summary: '' };
48
- }
49
-
50
- async function runDefenseSolo(ctx, raiser, findings, idx) {
48
+ async function runDefenseSolo(ctx, raiserKey, findings, idx, aliasOf) {
49
+ const raiserAlias = aliasOf(raiserKey);
51
50
  const brief = dbrief.buildDefenseBrief({ findings, date: ctx.o.date });
52
51
  const waveId = `${ctx.o.runId}-d${idx + 1}`;
53
52
  const expectedIds = findings.map(f => f.id);
@@ -56,9 +55,9 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
56
55
  // launch leaves an in-flight leg reachable only by the pid kill. The v4.0.1
57
56
  // abort-cascade fix must hold for debate stages too.
58
57
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', waveId);
59
- const res = await ctx.launchers.launchSolo({ ...legOpts(ctx, waveId), model: raiser, prompt: brief });
58
+ const res = await ctx.launchers.launchSolo({ ...legOpts(ctx, waveId), model: raiserAlias, prompt: brief });
60
59
  ctx.addWave(res.wave);
61
- if (isAbortExit(res.exitCode)) { return { raiser, aborted: res.exitCode }; }
60
+ if (isAbortExit(res.exitCode)) { return { raiser: raiserKey, aborted: res.exitCode }; }
62
61
  let leg = res.leg && res.leg.status === 'complete' ? res.leg : null;
63
62
  // A dead leg gets the SAME spec §5.7 fallback the parser applies to a block-level
64
63
  // failure — every expected id 'no-response', never an empty map, so the
@@ -76,84 +75,28 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
76
75
  const repairId = `${waveId}r`;
77
76
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
78
77
  const res2 = await ctx.launchers.launchSolo({
79
- ...legOpts(ctx, repairId), model: raiser,
78
+ ...legOpts(ctx, repairId), model: raiserAlias,
80
79
  // ⚠️ LC-12: a repair solo is a fresh session — the defense that failed rides along.
81
80
  prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors, defense: leg.summary }),
82
81
  });
83
82
  ctx.addWave(res2.wave);
84
- if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
83
+ if (isAbortExit(res2.exitCode)) { return { raiser: raiserKey, aborted: res2.exitCode }; }
85
84
  const leg2 = res2.leg && res2.leg.status === 'complete' ? res2.leg : null;
86
85
  parsed = leg2 ? parseDebateDefense(leg2.summary, expectedIds) : parsed;
87
86
  conformance = parsed.ok ? 'repaired' : 'unstructured';
88
- if (leg2) { supersededLeg = legRow(raiser, leg, 'unstructured'); leg = leg2; }
89
- else { repairLeg = legRow(raiser, res2.leg, 'unstructured'); }
87
+ if (leg2) { supersededLeg = legRow(raiserAlias, leg, 'unstructured'); leg = leg2; }
88
+ else { repairLeg = legRow(raiserAlias, res2.leg, 'unstructured'); }
90
89
  }
91
90
  // A dead leg (no complete summary) OR an 'unstructured' conformance after the one
92
91
  // repair is a debate degradation (spec §5.7) — surfaced via the returned leg.
93
- const stub = { model: raiser, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
94
- return { raiser, byId: parsed.byId,
95
- leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage,
92
+ const stub = { model: raiserAlias, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
93
+ return { raiser: raiserKey, byId: parsed.byId,
94
+ leg: leg ? { model: raiserAlias, status: leg.status, durationMs: leg.durationMs, usage: leg.usage,
96
95
  conformance, summary: leg.summary, waveId: leg.waveId,
97
96
  ...(leg.model ? { resolvedModel: leg.model } : {}) } : stub,
98
97
  supersededLeg, repairLeg };
99
98
  }
100
99
 
101
- async function runRevoteWave(ctx, judges, bundleFindings) {
102
- const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date });
103
- // spec §5.1 names `revote-bundle.md` a run-dir artifact: the shared re-vote prompt goes to
104
- // disk exactly like Stage 2's bundle-stage2.md, so the round's model-facing input is
105
- // auditable alongside briefing-stage1.md and chair-packet.md.
106
- fs.writeFileSync(path.join(ctx.o.runDir, 'revote-bundle.md'), bundle, { mode: 0o600 });
107
- const waveId = `${ctx.o.runId}-rv`;
108
- const expectedIds = bundleFindings.map(f => f.id);
109
- // run-debate — not run.js — owns this stage's `running` checkpoint AND its abort-cascade
110
- // id: only this function knows whether the wave actually launched (it is skipped when
111
- // nothing was defended/amended, or the cost ceiling hit).
112
- runState.updateStage(ctx.o.runDir, 'debate-revote',
113
- { status: 'running', startedAt: new Date().toISOString(), project: ctx.scratchDir, waveId });
114
- emitStageStarted(ctx.o.runDir, ctx.o.runId, 'debate-revote', waveId, ctx.o.follow);
115
- runState.appendStageWave(ctx.o.runDir, 'debate-revote', waveId);
116
- const res = await ctx.launchers.launchWave({ ...legOpts(ctx, waveId), models: judges, prompt: bundle });
117
- ctx.addWave(res.wave);
118
- if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
119
- const byJudge = {}, legs = [];
120
- // v4.7 D2/E4: mirrors runDefenseSolo's supersededLeg/repairLeg — one list each,
121
- // accumulated across every judge in this wave (most judges contribute neither).
122
- const supersededLegs = [], repairLegs = [];
123
- for (const leg of ((res.wave && res.wave.legs) || [])) {
124
- // The council ALIAS, not the resolved executable id — runStats rows join
125
- // meta.models by exact string (run-assemble.js's buildRunStatsEntry).
126
- const judge = leg.modelInput || leg.model;
127
- const alive = leg.status === 'complete' && leg.summary;
128
- let outLeg = leg; // the leg actually recorded (post-repair when there is one)
129
- let parsed = alive ? parseRevote(leg.summary, expectedIds)
130
- : { ok: false, byId: {}, errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
131
- let conformance = alive ? 'clean' : 'unstructured';
132
- if (alive && !parsed.ok) {
133
- // One repair, solo, to that judge.
134
- const repairId = `${waveId}-${judge}r`;
135
- runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
136
- const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
137
- // ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
138
- prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
139
- ctx.addWave(r2.wave);
140
- if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
141
- const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
142
- parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
143
- conformance = parsed.ok ? 'repaired' : 'unstructured';
144
- // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
145
- // revote-<model>.md and the runStats row keep the PRE-repair output.
146
- if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
147
- else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
148
- }
149
- byJudge[judge] = parsed.byId;
150
- legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage,
151
- conformance, summary: outLeg.summary || '', waveId: outLeg.waveId,
152
- ...(outLeg.model ? { resolvedModel: outLeg.model } : {}) });
153
- }
154
- return { byJudge, legs, supersededLegs, repairLegs };
155
- }
156
-
157
100
  /**
158
101
  * Full Stage-2.5 sequence (spec §5.1). Returns everything run.js needs. Cost gate: run.js
159
102
  * checks overBudget before invoking; this checks again before the re-vote wave (spec §5.7).
@@ -164,6 +107,26 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
164
107
  const { byRaiser, previousTier } = debateTargets(provisionalRecord, tallyInput);
165
108
  const contested = provisionalRecord.findings.filter(f => f.tier === 'Contested').length;
166
109
  const disputed = provisionalRecord.findings.filter(f => f.tier === 'Disputed').length;
110
+ // v4.8 PR3 Task 6 (spec §4.5): ONE seat→alias projection for the whole round —
111
+ // both debate waves, both repair solos, applyDebate's fail-open push and every
112
+ // artifact literal read it, so there is exactly one shape and one name.
113
+ // ⚠️ The table is RE-DERIVED when `o.seats` is absent, mirroring
114
+ // run-stage1-launch.js:20-22 verbatim. Falling back to an EMPTY map instead
115
+ // was the PR2b failure shape, not a safe default: that same Stage-1 fallback
116
+ // means a direct-require caller's findings/adjudications carry composed seat
117
+ // ids (`deepseek#1`) while `o.seats` is falsy, so `aliasOf` would be the
118
+ // identity over them and send a NON-ROUTABLE model name to three launchers
119
+ // (measured: r-d1 model="deepseek#1", r-d2 "deepseek#2", r-rv models
120
+ // ["gpt","deepseek#2","deepseek#1"]). buildSeats is pure and total, so the
121
+ // reconstruction is the same table Stage 1 bound against (spec §4.3).
122
+ // `aliasOf` is the identity only for a key that is no seat id at all: the
123
+ // reserved 'claude' key, and — because `s.alias === s.id` there — every bench
124
+ // without a repeated alias, i.e. every bench that has ever run.
125
+ const seatTable = Array.isArray(ctx.o.seats) && ctx.o.seats.length > 0
126
+ ? ctx.o.seats
127
+ : buildSeats(ctx.o.models, ctx.o.critic, ctx.o.lenses);
128
+ const seatById = new Map(seatTable.map(s => [s.id, s]));
129
+ const aliasOf = (key) => { const s = seatById.get(key); return s ? s.alias : key; };
167
130
 
168
131
  // ---- Defense mini-wave: ONE CONCURRENT solo per raiser (spec §5.1) ----
169
132
  // Concurrent, not sequential: every raiser gets its OWN briefing, so this is N independent
@@ -177,12 +140,16 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
177
140
  // (the same "originals stand" outcome as a no-response).
178
141
  const raisers = Object.keys(byRaiser).filter(m => m !== 'claude');
179
142
  const defenseResults = await Promise.all(
180
- raisers.map((raiser, i) => runDefenseSolo(ctx, raiser, byRaiser[raiser], i)));
143
+ raisers.map((raiserKey, i) => runDefenseSolo(ctx, raiserKey, byRaiser[raiserKey], i, aliasOf)));
181
144
  // A signal during the defense wave aborts the whole finalization (spec §5.7):
182
145
  // return the abort code so run.js finalizes 'aborted' with NO tally-final / NO ledger.
183
146
  const abortedDefense = defenseResults.find(d => d.aborted);
184
147
  if (abortedDefense) { return { aborted: abortedDefense.aborted, contested, disputed }; }
185
- materializeDebate(ctx.o.runDir, defenseResults.map(d => ({ model: d.raiser, summary: d.leg.summary })), 'rebuttal');
148
+ // ⚠️ This literal lives in runDebate, where `raiserAlias` does not exist — the
149
+ // projection is `aliasOf(d.raiser)`. `model` must stay ALIAS-valued (R3-1);
150
+ // `seat` is what gives two twins two files instead of one clobbered one (R3-3).
151
+ materializeDebate(ctx.o.runDir, defenseResults.map(d => ({ model: aliasOf(d.raiser),
152
+ summary: d.leg.summary, seat: seatById.get(d.raiser) || null })), 'rebuttal');
186
153
 
187
154
  const defenseByRaiser = {};
188
155
  for (const dr of defenseResults) { defenseByRaiser[dr.raiser] = { ...dr.byId }; }
@@ -200,18 +167,23 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
200
167
  // ---- Re-vote mini-wave (disputing judges only) ----
201
168
  let revoteByJudge = {}, revoteLegs = [], revoteSuperseded = [], revoteRepairs = [];
202
169
  const defendedOrAmended = bundleFor(defenseResults, tallyInput);
203
- const judges = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
170
+ // Seat ids (D6: one entry per disputing SEAT, so a twin bench launches two legs
171
+ // where one launched before). runRevoteWave needs the seat OBJECTS too — for the
172
+ // -rv bind roster and each leg's artifact name — so they are resolved here off
173
+ // the same table `aliasOf` reads, padded inside runRevoteWave per §3.4.
174
+ const judgeKeys = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
175
+ const judgeSeats = judgeKeys.map(k => seatById.get(k) || null);
204
176
  // A re-vote is warranted only when something was defended/amended AND ≥1 judge disputed it.
205
177
  // Skipping THAT case because the whole-run budget is spent is the 'skipped-cost-ceiling'
206
178
  // degradation branch (spec §5.7); skipping because there is simply nothing to re-vote is NOT.
207
- const wouldRevote = defendedOrAmended.length > 0 && judges.length > 0;
179
+ const wouldRevote = defendedOrAmended.length > 0 && judgeKeys.length > 0;
208
180
  const costCeiling = ctx.overBudget() && wouldRevote;
209
181
  // run.js needs to know whether the wave actually launched so it can
210
182
  // checkpoint debate-revote 'skipped' (not a false 'complete') when nothing
211
183
  // was defended/amended, or the cost ceiling skipped it (spec §5.7).
212
184
  const revoteLaunched = wouldRevote && !costCeiling;
213
185
  if (revoteLaunched) {
214
- const rv = await runRevoteWave(ctx, judges, defendedOrAmended);
186
+ const rv = await runRevoteWave(ctx, judgeKeys, defendedOrAmended, judgeSeats, aliasOf);
215
187
  if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
216
188
  revoteByJudge = rv.byJudge;
217
189
  revoteLegs = rv.legs;
@@ -223,8 +195,13 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
223
195
  }
224
196
 
225
197
  // ---- Pure reassembly ----
198
+ // ⚠️ `aliasOf` MUST ride along: without it applyDebate's fail-open push writes a
199
+ // SEAT ID into the alias-space `judge` field, which reaches
200
+ // peer-split.js :: peersOf's `v.judge !== f.raiser` (measured: basis {a:1,d:0} →
201
+ // tier Confirmed where the alias spelling gives Singleton) and, as an
202
+ // out-of-contract `judge`, report.js :: columnFor's vote→column join.
226
203
  const { input: debatedInput, debateFindings } = applyDebate({
227
- tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
204
+ tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge, aliasOf });
228
205
  debatedInput.runStats = [...(debatedInput.runStats || []),
229
206
  ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs,
230
207
  // v4.7 D2/E4: the retained loser legs from every raiser's defense repair
@@ -240,9 +217,19 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
240
217
  for (const f of debatedRec.findings) { if (provTierById.get(f.id) !== f.tier) { verdictChanges += 1; } }
241
218
 
242
219
  // ---- Artifacts + summary ----
220
+ // `revoteByJudge` is SEAT-keyed, but debate.json's `revotes[]` has a real
221
+ // consumer that joins on the ALIAS — electron/workspace-ui/workspace-panels.js's
222
+ // drillIntoJudge matches `r.judge === judgePair.model`, and its comment states
223
+ // that contract explicitly. So `judge` stays the alias and the seat rides
224
+ // beside it, emitted only when it differs (a unique bench writes today's
225
+ // byte-identical debate.json).
243
226
  const revotesJson = [];
244
- for (const [judge, perId] of Object.entries(revoteByJudge)) {
245
- for (const [id, rv] of Object.entries(perId)) { revotesJson.push({ judge, id, verdict: rv.verdict, reason: rv.reason || null, applied: true }); }
227
+ for (const [key, perId] of Object.entries(revoteByJudge)) {
228
+ const alias = aliasOf(key);
229
+ for (const [id, rv] of Object.entries(perId)) {
230
+ revotesJson.push({ judge: alias, ...(alias !== key ? { seat: key } : {}),
231
+ id, verdict: rv.verdict, reason: rv.reason || null, applied: true });
232
+ }
246
233
  }
247
234
  fs.writeFileSync(path.join(ctx.o.runDir, 'debate.json'),
248
235
  JSON.stringify({ findings: debateFindings, revotes: revotesJson }, null, 2), { mode: 0o600 });
@@ -265,14 +252,17 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
265
252
 
266
253
  // Chair-addendum outcomes (spec §5.3c). `action` is the PAST_TENSE form
267
254
  // buildDebateAddendum renders verbatim — only the four valid values ever reach it.
255
+ // ⚠️ priorVerdicts and revotes MUST share ONE key space: briefings-debate.js's
256
+ // renderer iterates Object.keys(revotes) and looks up prior[j], so a skew prints
257
+ // `no prior verdict` on EVERY line. Both are keyed seat-side here.
268
258
  const priorById = new Map(provisionalRecord.findings.map(
269
- f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.judge, a.verdict]))]));
259
+ f => [f.id, Object.fromEntries((f.adjudications || []).map(a => [a.seat || a.judge, a.verdict]))]));
270
260
  const addendumOutcomes = debateFindings.map(df => ({
271
261
  id: df.id, originalClaim: (tallyInput.findings.find(f => f.id === df.id) || {}).claim,
272
262
  action: PAST_TENSE[df.action] || PAST_TENSE['no-response'],
273
263
  amendedClaim: df.action === 'amend' ? df.claim : null,
274
264
  priorVerdicts: priorById.get(df.id) || {},
275
- revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.judge, r.verdict])),
265
+ revotes: Object.fromEntries(revotesJson.filter(r => r.id === df.id).map(r => [r.seat || r.judge, r.verdict])),
276
266
  }));
277
267
 
278
268
  return { debatedInput, debateFindings, debateSummary, addendumOutcomes,
@@ -0,0 +1,70 @@
1
+ // src/council/run-finish.js
2
+ 'use strict';
3
+ // Final tally (chair row included) + ledger gate + tally/verdict artifacts.
4
+ // Moved verbatim from run.js@6b0c3b6b:242-288 (v4.8 PR0 size-gate split, zero
5
+ // behavior). NOT run-finalize.js — that sibling owns exit codes and the
6
+ // terminal write; this module builds the final tally, appends the run
7
+ // record to the ledger (skipped for lens runs), and writes the
8
+ // tally/verdict artifact files + their stage events.
9
+ const { tally } = require('./tally');
10
+ const { decorateRecord } = require('./debate');
11
+ const runState = require('./run-state');
12
+ const asm = require('./run-assemble');
13
+ const { emitStageStarted, emitStageTerminal } = require('../observe/events');
14
+
15
+ /**
16
+ * Build the final tally, gate the ledger append, write tally+verdict
17
+ * artifacts and their stage checkpoints. Void — run.js's trailing
18
+ * `return finalize(...)` reads only degraded.value, which this never
19
+ * mutates.
20
+ */
21
+ function finishRun({ o, chairRes, debatedInput, debateFindings, appendRunFn, degrade, deadWaves, now }) {
22
+ const { chairLeg, actualChair, chairText, chairConformance, overallVerdict, chairRows, chairAttempts } = chairRes;
23
+ const chairStats = chairLeg ? asm.buildRunStatsEntry({
24
+ leg: chairLeg, model: actualChair, role: 'chair', wasChair: true,
25
+ conformance: chairConformance,
26
+ }) : null;
27
+ // v4.7 D2: a give-up (no chairLeg) with at least one recorded attempt gets
28
+ // an explicit error row so the walk's outcome isn't silently absorbed.
29
+ // Keyed on chairAttempts, NOT chairRows — attempts that die pre-wave (no
30
+ // money spent) record an outcome but yield no row (errata E3).
31
+ const giveUpRow = (!chairLeg && chairAttempts && chairAttempts.length)
32
+ ? asm.buildRunStatsEntry({ leg: null, model: o.chair, role: 'chair', wasChair: false })
33
+ : null;
34
+ // Built on the (possibly debated) input so the debate's amended claims, replaced
35
+ // adjudications and rebuttal/revote runStats rows all reach the final record.
36
+ const finalInput = { ...debatedInput, meta: { ...debatedInput.meta, chair: actualChair || o.chair } };
37
+ // Item 8, final-review consolidated wave: was three sequential
38
+ // reassignments (chairStats, then chairRows, then giveUpRow), each
39
+ // rebuilding finalInput.runStats from scratch — collapsed into the one
40
+ // spread that was always the net effect. The `|| []` fallbacks were
41
+ // dead: `runStats` is a real array on every debatedInput
42
+ // (asm.buildTallyInput always returns one via .map()), never undefined.
43
+ finalInput.runStats = [
44
+ ...finalInput.runStats,
45
+ ...(chairStats ? [chairStats] : []),
46
+ ...chairRows,
47
+ ...(giveUpRow ? [giveUpRow] : []),
48
+ ];
49
+ const record = tally(finalInput);
50
+ if (debateFindings) { decorateRecord(record, debateFindings); }
51
+ if (!o.lenses) {
52
+ // Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
53
+ try { appendRunFn(record); }
54
+ catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
55
+ }
56
+ asm.writeTallyFiles({ runDir: o.runDir, tallyInput: finalInput, record });
57
+ const tallyStage = o.debate ? 'tally-final' : 'tally';
58
+ runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
59
+ emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
60
+ emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
61
+ // Verdict assembly is the degrade cut-off: anything noted after this line
62
+ // reaches stderr + run.json but not verdict.json (spec §6 rule 1).
63
+ asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
64
+ critic: o.critic, deadWaves, degrades: degrade.all() });
65
+ runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
66
+ emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
67
+ emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
68
+ }
69
+
70
+ module.exports = { finishRun };
@@ -16,6 +16,10 @@
16
16
 
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
+ // sanitizeName lives in ./seats (v4.8 PR1) so that module can stay
20
+ // require-free; re-exported below — run-stage2.js and workspace/artifact-guard.js
21
+ // import it from here.
22
+ const { sanitizeName, artifactName } = require('./seats');
19
23
 
20
24
  /**
21
25
  * Did a launch exit because a SIGNAL killed it (130 = SIGINT, 143 = SIGTERM)
@@ -63,15 +67,22 @@ function createLaunchers(deps = {}) {
63
67
  /**
64
68
  * @param {{models: string[], prompt: string, project: string, waveId: string,
65
69
  * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
66
- * councilRunId?: string, councilName?: string, tag?: string, fallback?: object,
67
- * catalog?: Array}} opts
70
+ * councilRunId?: string, councilName?: string, tag?: string, seats?: Array<object>,
71
+ * fallback?: object, catalog?: Array, noOutputBackstopMs?: number}} opts
68
72
  * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
69
73
  * ids forwarded verbatim into the runFanout call so it can stamp them onto
70
74
  * every leg. tag (v4.7 F8 D16) rides the same forward — every call site
71
75
  * below that sets councilRunId/councilName sets `tag: o.tag` alongside it.
76
+ * seats (v4.8 R5 T4.1) rides the same forward again: the wave's launch
77
+ * roster, index-parallel with `models`. stampLegAttribution
78
+ * (fanout-wave-io.js, T4.2) consumes it to name each leg's seat; it is
79
+ * `undefined` for every caller that does not set it.
72
80
  * fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
73
81
  * additive/opt-in — omitted by callers that must never substitute (the
74
82
  * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
83
+ * noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
84
+ * Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
85
+ * to escalate the window on a Stage-1 retry.
75
86
  * @returns {Promise<{wave: object|null, exitCode: number}>}
76
87
  */
77
88
  async function launchWave(opts) {
@@ -118,6 +129,19 @@ function createLaunchers(deps = {}) {
118
129
  // undefined when no --tag, so stampLegAttribution's `if (options.tag)`
119
130
  // guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
120
131
  tag: opts.tag,
132
+ // v4.8 R5 T4.1: the per-wave roster, index-parallel with `models`.
133
+ // stampLegAttribution (fanout-wave-io.js, T4.2) consumes this key to
134
+ // name each leg's seat; `undefined` for every caller that does not set
135
+ // it (every non-council caller, and the chair/debate/repair launches).
136
+ seats: opts.seats,
137
+ // Task 5 (#129): spread-guarded on Number.isFinite, NOT on truthiness —
138
+ // an explicit 0 is this knob's documented disable hatch
139
+ // (no-output-backstop.js:13-15) and a truthiness guard would silently
140
+ // drop it. Guarding at all — rather than a plain
141
+ // `noOutputBackstopMs: opts.noOutputBackstopMs` — keeps the transport
142
+ // call key-identical for run-stage1-launch / run-stage2 / run-chair /
143
+ // run-debate, none of which set it.
144
+ ...(Number.isFinite(opts.noOutputBackstopMs) ? { noOutputBackstopMs: opts.noOutputBackstopMs } : {}),
121
145
  // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
122
146
  // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
123
147
  // fallback path only activates when `fallback.enabled` is true.
@@ -164,38 +188,51 @@ function createLaunchers(deps = {}) {
164
188
  return { launchWave, launchSolo };
165
189
  }
166
190
 
167
- /** Filesystem-safe model name for review-/judge- artifact filenames. */
168
- function sanitizeName(model) {
169
- return String(model).replace(/[^a-zA-Z0-9._-]/g, '-');
170
- }
171
-
172
191
  /**
173
- * Write `review-<modelInput>.md` per surviving Stage-1 leg (skill layout).
174
- * Dead legs and empty summaries are skipped — the caller applies the
175
- * wave-degrade rules to what remains.
192
+ * Write one review file per surviving Stage-1 leg (skill layout). Dead legs and
193
+ * empty summaries are skipped — the caller applies the wave-degrade rules to
194
+ * what remains, which is why a BOUND seat can still end up dead here.
195
+ *
196
+ * With `seatOf` the filename is the SEAT's (artifactName), byte-identical to the
197
+ * alias name for every bench that has ever run, and what stops two twins from
198
+ * clobbering one file. An unbound leg keeps its alias name rather than being
199
+ * dropped: it is unattributable, not unusable, and dropping it would lose a
200
+ * review that lands today.
201
+ *
176
202
  * @param {string} runDir
177
203
  * @param {Array<object>} legs run documents from the wave/solo docs
178
- * @returns {Array<{model: string, modelInput: string, file: string, text: string, leg: object}>}
204
+ * @param {Map<object, object>} [seatOf] leg document -> seat, keyed by object identity
205
+ * @returns {Array<{model: string, modelInput: string, file: string, text: string,
206
+ * leg: object, seat: ?object}>}
179
207
  */
180
- function materializeReviews(runDir, legs) {
208
+ function materializeReviews(runDir, legs, seatOf) {
181
209
  const out = [];
182
210
  for (const leg of legs) {
183
211
  if (!leg || leg.status !== 'complete') { continue; }
184
212
  const text = leg.summary;
185
213
  if (!text || !String(text).trim()) { continue; }
186
214
  const modelInput = leg.modelInput || leg.model;
187
- const file = path.join(runDir, `review-${sanitizeName(modelInput)}.md`);
215
+ const seat = (seatOf && seatOf.get(leg)) || null;
216
+ const name = seat ? artifactName(seat, 'review') : `review-${sanitizeName(modelInput)}.md`;
217
+ const file = path.join(runDir, name);
188
218
  fs.writeFileSync(file, text, { mode: 0o600 });
189
- out.push({ model: leg.model, modelInput, file, text, leg });
219
+ out.push({ model: leg.model, modelInput, file, text, leg, seat });
190
220
  }
191
221
  return out;
192
222
  }
193
223
 
194
224
  /**
195
- * Write per-leg debate artifacts: `<prefix>-<sanitizeName(model)>.md` for each
196
- * leg with a non-empty summary. Mirrors materializeReviews.
225
+ * Write per-leg debate artifacts for each leg with a non-empty summary. Mirrors
226
+ * materializeReviews but these are plain `{model, summary}` literals built by
227
+ * the debate callers, never leg documents, so materializeReviews' object-identity
228
+ * `seatOf` Map does not transfer: the seat rides ON the literal instead.
229
+ *
230
+ * With `seat` the filename is the SEAT's (artifactName, v4.8 PR3 Task 6),
231
+ * byte-identical to `<prefix>-<sanitizeName(model)>.md` for every bench without
232
+ * a repeated alias, and what stops two twins from clobbering one file. Without
233
+ * it the alias name is kept — today's exact behaviour.
197
234
  * @param {string} runDir
198
- * @param {Array<{model: string, summary: string}>} legs
235
+ * @param {Array<{model: string, summary: string, seat?: ?object}>} legs
199
236
  * @param {string} prefix 'rebuttal' | 'revote'
200
237
  * @returns {Array<{model: string, file: string}>}
201
238
  */
@@ -203,7 +240,8 @@ function materializeDebate(runDir, legs, prefix) {
203
240
  const out = [];
204
241
  for (const leg of legs) {
205
242
  if (!leg || !leg.summary || !leg.summary.trim()) { continue; }
206
- const file = path.join(runDir, `${prefix}-${sanitizeName(leg.model)}.md`);
243
+ const name = leg.seat ? artifactName(leg.seat, prefix) : `${prefix}-${sanitizeName(leg.model)}.md`;
244
+ const file = path.join(runDir, name);
207
245
  fs.writeFileSync(file, leg.summary, { mode: 0o600 });
208
246
  out.push({ model: leg.model, file });
209
247
  }