amicus 4.8.1 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +36 -15
  10. package/electron/offer-session.js +51 -0
  11. package/electron/setup-ui.js +18 -8
  12. package/electron/workspace-ui/live-dead-seats.js +163 -91
  13. package/electron/workspace-ui/workspace-banners.js +30 -7
  14. package/electron/workspace-ui/workspace-matrix.js +23 -3
  15. package/electron/workspace-ui/workspace-seats.js +95 -79
  16. package/package.json +1 -1
  17. package/schemas/council-run.schema.json +2 -2
  18. package/schemas/council-tally.schema.json +17 -1
  19. package/schemas/council-verdict.schema.json +12 -4
  20. package/schemas/run.schema.json +6 -1
  21. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  22. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  23. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  24. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  25. package/skills/second-opinion/SKILL.md +151 -36
  26. package/src/cli-council-run-bench.js +98 -6
  27. package/src/cli-handlers-council-run.js +18 -6
  28. package/src/cli-handlers-council.js +57 -7
  29. package/src/cli-handlers-doctor.js +1 -1
  30. package/src/cli.js +3 -1
  31. package/src/council/anonymize.js +2 -1
  32. package/src/council/briefings-chair-task.js +161 -0
  33. package/src/council/briefings-chair.js +33 -8
  34. package/src/council/briefings-debate.js +79 -13
  35. package/src/council/briefings-stage2-task.js +236 -0
  36. package/src/council/briefings-stage2.js +103 -26
  37. package/src/council/briefings-task.js +167 -0
  38. package/src/council/briefings.js +41 -4
  39. package/src/council/chair-fallback.js +95 -0
  40. package/src/council/debate.js +38 -21
  41. package/src/council/findings.js +3 -2
  42. package/src/council/ledger.js +2 -2
  43. package/src/council/parse-stage2.js +63 -15
  44. package/src/council/report-cost.js +61 -0
  45. package/src/council/report-html.js +26 -4
  46. package/src/council/report-md.js +30 -2
  47. package/src/council/report.js +40 -37
  48. package/src/council/run-assemble.js +21 -6
  49. package/src/council/run-chair.js +44 -95
  50. package/src/council/run-debate-revote.js +81 -49
  51. package/src/council/run-debate.js +51 -34
  52. package/src/council/run-finish.js +5 -3
  53. package/src/council/run-retry-keys.js +4 -4
  54. package/src/council/run-retry-launch.js +4 -4
  55. package/src/council/run-retry-notes.js +72 -15
  56. package/src/council/run-stage1-launch.js +4 -4
  57. package/src/council/run-stage1-rows.js +9 -6
  58. package/src/council/run-stage2.js +81 -47
  59. package/src/council/run-stages.js +9 -21
  60. package/src/council/run-stats-entry.js +46 -1
  61. package/src/council/run.js +28 -13
  62. package/src/council/seats.js +2 -2
  63. package/src/council/stage1-bind.js +3 -2
  64. package/src/council/verdict-seat-loss.js +124 -0
  65. package/src/council/verdict.js +108 -99
  66. package/src/headless.js +256 -49
  67. package/src/mcp-council-bench.js +64 -3
  68. package/src/mcp-council-run.js +10 -3
  69. package/src/mcp-server.js +52 -12
  70. package/src/mcp-tools.js +41 -5
  71. package/src/observe/council-legs.js +2 -2
  72. package/src/opencode-client.js +19 -1
  73. package/src/pack/pack-forward.js +15 -12
  74. package/src/pack/pack-resolve.js +1 -1
  75. package/src/prompt-builder.js +17 -1
  76. package/src/sidecar/fanout-leg.js +26 -0
  77. package/src/sidecar/fanout.js +1 -1
  78. package/src/sidecar/list-council.js +178 -0
  79. package/src/sidecar/list-limit.js +3 -1
  80. package/src/sidecar/list-search.js +2 -1
  81. package/src/sidecar/models.js +8 -1
  82. package/src/sidecar/read.js +34 -10
  83. package/src/template/render.js +16 -7
  84. package/src/utils/alias-shadow-writer.js +220 -0
  85. package/src/utils/alias-shadow.js +294 -0
  86. package/src/utils/config.js +1 -1
  87. package/src/utils/curated-models.js +11 -3
  88. package/src/utils/degrade.js +12 -5
  89. package/src/utils/doctor-alias-check.js +2 -5
  90. package/src/utils/engine-log-parse.js +289 -0
  91. package/src/utils/engine-log-tail.js +114 -0
  92. package/src/utils/engine-log.js +250 -0
  93. package/src/utils/engine-skew-records.js +146 -0
  94. package/src/utils/engine-skew.js +300 -0
  95. package/src/utils/gateway-router.js +10 -2
  96. package/src/utils/model-catalog.js +1 -1
  97. package/src/utils/result-schema.js +10 -0
  98. package/src/utils/text-sanitize.js +81 -0
  99. package/src/utils/ttft.js +57 -0
  100. package/src/utils/untrusted-fence.js +111 -1
  101. package/src/workspace/fold-format.js +28 -7
  102. package/src/workspace/live-normalize.js +2 -1
  103. package/src/workspace/matrix-model.js +6 -2
  104. package/src/workspace/run-detail.js +33 -7
  105. package/src/workspace/seat-space.js +10 -6
@@ -102,10 +102,17 @@ function labelClaudeReview(claudeReview, labels) {
102
102
  /**
103
103
  * The synthesized runStats row for a review that never ran a leg (v4.1 §4.4).
104
104
  * durationMs/usage are null per the never-invent rule — nothing was launched.
105
+ *
106
+ * v4.9 W11 (PR1F-2): folded onto buildRunStatsEntry, byte-for-byte (pin G3,
107
+ * tests/council/runstats-byte-order.test.js). The one-key stand-in leg is the
108
+ * whole trick: this review COMPLETED (a file the orchestrator authored), so
109
+ * `status: 'complete'` is a fact, not an invention — and a leg carrying only a
110
+ * status leaves every other entry field on its never-invent default
111
+ * (durationMs/usage null, no waveId, no resolvedModel). Passing `leg: null`
112
+ * instead would emit `status: 'error'` and call a valid review dead.
105
113
  */
106
114
  function claudeRunStatsRow() {
107
- return { model: CLAUDE_SEAT, role: CLAUDE_SEAT, wasChair: false, conformance: 'clean',
108
- status: 'complete', durationMs: null, usage: null };
115
+ return buildRunStatsEntry({ leg: { status: 'complete' }, model: CLAUDE_SEAT, role: CLAUDE_SEAT });
109
116
  }
110
117
 
111
118
  /**
@@ -136,7 +143,7 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
136
143
  // ONLY when the bench repeats an alias — the one case where the `alias#N`
137
144
  // seat ids on findings[].raiserSeat, adjudications[].seat and runStats[].seat
138
145
  // resolve to nothing else in the document (meta.models is the ALIAS list).
139
- // ⚠️ `seats ?` alone would be VACUOUS: run.js:133 sets o.seats unconditionally
146
+ // ⚠️ `seats ?` alone would be VACUOUS: run.js:142 sets o.seats unconditionally
140
147
  // past the preflight and buildSeats always returns an ARRAY ([] is still
141
148
  // truthy), so that spelling writes a full table into tally-input.json,
142
149
  // tally.json AND verdict.json on every unique-alias bench.
@@ -152,7 +159,7 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
152
159
  // joined positionally to meta.models (`claude` is pushed onto that inside
153
160
  // run-assemble.js :: buildTallyInput) or to streetCred[].
154
161
  // .slice() is defence-in-depth only: the array is shared with
155
- // runState.checkpoint (run.js:135) and with both tally inputs. Nothing
162
+ // runState.checkpoint (run.js:144) and with both tally inputs. Nothing
156
163
  // mutates meta.seats — unlike models, which meta.models.push mutates below —
157
164
  // so no test can distinguish the copy from the reference.
158
165
  ...(Array.isArray(seats) && seats.some(s => s.id !== s.alias) ? { seats: seats.slice() } : {}),
@@ -207,8 +214,10 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
207
214
  // a repair solo's — run-stage2.js mirrors Stage-1's convention there); a judge
208
215
  // whose wave leg died still gets an honest error row.
209
216
  // v4.8 PR5a T4 (R5-8): the judge row carries its SEAT. PR4c withheld it because
210
- // `joinsLedger` has no 'judge' member, so nothing consumed it; report.js's cost
211
- // table does now, and without it a twin bench's two judge rows are identical.
217
+ // `joinsLedger` has no 'judge' member, so nothing consumed it;
218
+ // `report-cost.js :: buildCostModel` (the report's cost table, extracted from
219
+ // report.js in v4.9 W8) does now, and without it a twin bench's two judge rows
220
+ // are identical.
212
221
  // `buildRunStatsEntry` (run-stats-entry.js :: buildRunStatsEntry) applies the
213
222
  // shared emit-when-DIFFERENT predicate, so a unique bench stays byte-identical
214
223
  // and no new predicate enters the tree.
@@ -261,6 +270,12 @@ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, recor
261
270
  // line here and keeps every rendering decision in briefings-chair.js.
262
271
  findings: record.findings,
263
272
  tierCounts: record.tierCounts, date,
273
+ // v4.9 W7: the chair's intent is READ OFF the tallied record's meta rather than
274
+ // added to this signature — run.js already stamps meta.intent emit-when-'task'
275
+ // (run.js :: runCouncil's mkInput), and both the provisional and the debated
276
+ // record inherit it by spread. A record with no meta at all (hand-assembled
277
+ // input, and every pre-W7 caller) composes the review packet.
278
+ ...(record.meta && record.meta.intent === 'task' ? { intent: 'task' } : {}),
264
279
  }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
265
280
  fs.writeFileSync(path.join(runDir, 'chair-packet.md'), packet, { mode: 0o600 });
266
281
  return packet;
@@ -3,7 +3,7 @@
3
3
 
4
4
  /**
5
5
  * @module council/run-chair
6
- * Chair synthesis + VERDICT-line repair for the headless council driver,
6
+ * Chair synthesis + terminal-line repair (VERDICT | task ANSWER) for the driver,
7
7
  * lifted VERBATIM out of run.js for the 300-line gate (v4.1 Task 0.5). Pure
8
8
  * refactor: same launches, same waveIds, same run.json checkpoints, same
9
9
  * degradation rules. Launchers come in through `ctx` exactly as run-stages.js
@@ -16,96 +16,18 @@
16
16
  */
17
17
 
18
18
  const stage2 = require('./briefings-stage2');
19
- const { parseChairVerdict } = require('./parse-stage2');
19
+ const { parseChairTerminal } = require('./parse-stage2');
20
20
  const runState = require('./run-state');
21
21
  const { isAbortExit } = require('./run-stages');
22
22
  const { emitStageStarted, emitStageTerminal } = require('../observe/events');
23
23
  const { buildRunStatsEntry } = require('./run-assemble');
24
-
25
- /**
26
- * Chair fallback promotion (spec §4): the highest peers-only street-cred
27
- * model from `council stats` that is not a bench seat and not the failed
28
- * chair. "Highest street-cred" = BEST = numerically LOWEST mean rank
29
- * (deriveReliability's avgStreetCredPeersOnly; lower is better).
30
- *
31
- * The reserved seat name 'claude' is never eligible (v4.1 §4.4 "never chairs"):
32
- * a --claude-review run puts a real 'claude' row in the ledger, so without this
33
- * filter a LATER run could promote it and walk straight past the pre-flight
34
- * --chair claude guard — with no Claude leg to launch.
35
- *
36
- * v4.7 GOA-7 D11: exclusions test the group key AND aliases[]; the promoted
37
- * name is aliases[0] (most-recent alias) so the launch string stays routable
38
- * through the same alias policy both call sites (run.js mid-walk, run-server.js
39
- * pre-seed) already resolve.
40
- * @returns {string|null}
41
- */
42
- function pickFallbackChair(statsRows, bench, failedChair) {
43
- const benchSet = new Set(bench);
44
- // v4.7 GOA-7 D11: an aggregate's identity is its key PLUS every alias it was
45
- // observed under — post-D10 keys may be executable ids while bench/o.chair
46
- // stay alias-space, so every exclusion tests the whole name set (a bench
47
- // seat's resolved-keyed group must never be promoted as its own chair).
48
- // The LAUNCHED name is aliases[0] (most-recent alias): alias-space names
49
- // re-enter the router's alias bridge and current key/gateway policy; a raw
50
- // executable id would dodge them (divergent-vendor forms, openrouter-
51
- // literals under --gateway direct, dropped aliases). aliases[] is non-empty
52
- // for every ledger-derived group; the bare-model fallback covers pre-D10
53
- // aggregate shapes only.
54
- const names = (r) => [r.model, ...(Array.isArray(r.aliases) ? r.aliases : [])];
55
- const excluded = (r) => names(r).some(n => n === 'claude' || benchSet.has(n) || n === failedChair);
56
- // v4.8 PR4a: every sort term is read off the row, so CANDIDATE SELECTION is
57
- // independent of the order `statsRows` arrives in — previously that order
58
- // (deriveReliability's Map insertion order = council-ledger.jsonl row order)
59
- // silently decided every exact street-cred tie, and such ties are an ordinary
60
- // arithmetic outcome. Terms: street cred (lower mean rank = better), then
61
- // council appearances, then model id for a guaranteed total order. ⚠️ `runs`
62
- // is the count of DISTINCT runIds across the group's ledger rows (v4.8 PR4b
63
- // R4b-1, ledger-stats.js's countRuns) — one council run contributes 1 however many
64
- // seats that executable filled, and rows from `judged:false` runs that
65
- // contributed no street cred still count. It is a tie-break, never a ranking
66
- // signal. Always present on deriveReliability output; the default serves
67
- // fixtures.
68
- // Full rationale + the tie arithmetic: tests/council/run-chair.test.js.
69
- const runsOf = (r) => (typeof r.runs === 'number' ? r.runs : 0);
70
- const candidates = (statsRows || [])
71
- .filter(r => !excluded(r) && typeof r.avgStreetCredPeersOnly === 'number')
72
- .sort((a, b) => (a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly)
73
- || (runsOf(b) - runsOf(a))
74
- || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
75
- if (!candidates.length) { return null; }
76
- const top = candidates[0];
77
- return (Array.isArray(top.aliases) && top.aliases.length) ? top.aliases[0] : top.model;
78
- }
79
-
80
- /**
81
- * Outcome taxonomy for one fallback-walk attempt (spec §8, LC-5). The ch4
82
- * VERDICT repair is deliberately NOT an attempt: its chair leg already
83
- * completed — only the verdict line is being re-prompted — and the outcome
84
- * enum has no honest value for it.
85
- * @param {object|null} rawLeg the UNFILTERED leg (attemptChair nulls `leg` on
86
- * failure; this is the one before that narrowing, so a failed leg document
87
- * is still visible here)
88
- * @param {object|null} [errorDoc] set when the launch never produced a wave
89
- * at all (pre-flight refusal) — the only source of a reason in that case
90
- * @returns {{outcome: 'completed'|'error'|'timeout'|'no-output', reason: string|null}}
91
- */
92
- function classifyChairAttempt(rawLeg, errorDoc) {
93
- if (!rawLeg) {
94
- const reason = (errorDoc && (errorDoc.message || errorDoc.reason)) || 'no leg document';
95
- return { outcome: 'error', reason };
96
- }
97
- if (rawLeg.status === 'timeout') { return { outcome: 'timeout', reason: rawLeg.reason || null }; }
98
- if (rawLeg.status === 'complete') {
99
- const hasOutput = rawLeg.summary && String(rawLeg.summary).trim();
100
- return hasOutput ? { outcome: 'completed', reason: null }
101
- : { outcome: 'no-output', reason: rawLeg.reason || null };
102
- }
103
- return { outcome: 'error', reason: rawLeg.reason || rawLeg.error || String(rawLeg.status) };
104
- }
24
+ // v4.9 W4: pickFallbackChair + classifyChairAttempt moved to chair-fallback.js
25
+ // (size-gate split); re-exported below so no caller changes.
26
+ const { pickFallbackChair, classifyChairAttempt } = require('./chair-fallback');
105
27
 
106
28
  /**
107
29
  * Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
108
- * single VERDICT-line repair re-prompt.
30
+ * single terminal-line repair re-prompt (VERDICT, or ANSWER when o.intent is 'task').
109
31
  * @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
110
32
  * @param {{packet: string, degrade: {note: Function}, statsFn: Function,
111
33
  * isSignalled: function(): (number|null)}} args
@@ -126,7 +48,7 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
126
48
  const solo = await launchers.launchSolo({
127
49
  model, prompt: packet, project: o.runDir, waveId,
128
50
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
129
- // v4.1 §4.5d: the chair chain (ch1/ch2/ch3) and the ch4 VERDICT repair
51
+ // v4.1 §4.5d: the chair chain (ch1/ch2/ch3) and the ch4 terminal repair
130
52
  // below are the launches a re-armed price gate would refuse LAST, after
131
53
  // the whole bench has already been paid for.
132
54
  noCostGate: o.noCostGate,
@@ -203,6 +125,27 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
203
125
  }
204
126
  if (attempt.leg) { actualChair = o.chair; }
205
127
  else if (!overBudget()) {
128
+ // v4.9 W5.4 (V5): the promotion below draws on the reliability ledger,
129
+ // which task runs never feed (run-finish.js gate 1) — announced once,
130
+ // kind 'info' (speech, not loss: the sink never flips `degraded` on it).
131
+ // ⚠️ PR #200 round-3 finding A2 read "announced once" as a promise a flag
132
+ // had to keep, and reported the note firing once per FAILED ATTEMPT.
133
+ // MEASURED and refuted: "once" here is STRUCTURAL. This is the
134
+ // else-arm of the whole fallback walk — ch1 and ch2 have already resolved
135
+ // above — so it is reached at most once per runChair, which is called
136
+ // exactly once per run (run.js :: runCouncil). A walk with ch1, ch2 AND
137
+ // ch3 all failing emits exactly ONE note; pinned in run-chair.test.js
138
+ // ("task run reaching the promotion step emits EXACTLY ONE info note").
139
+ // No flag: a flag would only be able to make a once-reached branch fire
140
+ // less than once.
141
+ if (o.intent === 'task') {
142
+ degrade.note({
143
+ kind: 'info', channel: 'ledger-skipped',
144
+ what: 'task runs write no reliability rows',
145
+ why: 'ledger-driven chair promotion draws only on review-run history — task rankings measure concurrence, never defect confirmation',
146
+ effect: 'fallback candidates come from review runs only; a task-only install has none',
147
+ });
148
+ }
206
149
  let statsRows = [];
207
150
  try { statsRows = statsFn(); } catch { /* no ledger yet */ }
208
151
  const fallback = pickFallbackChair(statsRows, o.models, o.chair);
@@ -228,16 +171,16 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
228
171
  const chairText = chairLeg ? chairLeg.summary : null;
229
172
  let chairConformance = 'clean';
230
173
 
231
- // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
232
- let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
174
+ // ---- Chair terminal line VERDICT, or ANSWER on a task run (v4.9 W7) ----
175
+ let overallVerdict = chairText ? parseChairTerminal(chairText, o.intent) : null;
233
176
  if (chairText && !overallVerdict && !overBudget()) {
234
177
  const waveId4 = `${o.runId}-ch4`;
235
178
  runState.appendStageWave(o.runDir, 'chair', waveId4);
236
179
  const repair = await launchers.launchSolo({
237
180
  // ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
238
- // VERDICT line is missing — so a fresh repair session that cannot see the
181
+ // terminal line is missing — so a fresh repair session that cannot see the
239
182
  // synthesis is picking a verdict on an artifact it has never read.
240
- model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
183
+ model: actualChair, prompt: stage2.chairRepairPromptFor(o.intent, { synthesis: chairText }),
241
184
  project: o.runDir, waveId: waveId4,
242
185
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
243
186
  noCostGate: o.noCostGate,
@@ -255,15 +198,17 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
255
198
  // (repair.leg)` guard below is therefore load-bearing on that exact
256
199
  // distinction: a launched ch4 (a leg document exists, whatever its
257
200
  // status) gets its own row so the repair's spend is attributed even when
258
- // it never supplies a VERDICT; a ch4 that never even launched gets no
259
- // row at all, because there is nothing billed to attribute.
201
+ // it never supplies a terminal line; a ch4 that never launched gets no
202
+ // row at all, because there is nothing billed to attribute. The push sits
203
+ // AFTER the verdict parse so it stamps the ch4 leg's own measured outcome (PR 199 D1).
204
+ overallVerdict = parseChairTerminal((repair.leg && repair.leg.summary) || '', o.intent);
205
+ chairConformance = overallVerdict ? 'repaired' : 'unstructured';
260
206
  if (repair.leg) {
261
207
  chairRows.push(buildRunStatsEntry({
262
208
  leg: repair.leg, model: actualChair, role: 'repair', wasChair: false,
209
+ conformance: overallVerdict ? 'clean' : 'unstructured',
263
210
  }));
264
211
  }
265
- overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
266
- chairConformance = overallVerdict ? 'repaired' : 'unstructured';
267
212
  }
268
213
  // A completed chair whose verdict never parsed is 'unstructured' even when
269
214
  // the repair was skipped (e.g. the chair leg itself tripped --max-cost).
@@ -273,10 +218,14 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
273
218
  channel: 'chair-failed',
274
219
  what: 'the council has no chair synthesis',
275
220
  why: chairLeg
276
- ? 'the chair ran but its output carried no parseable VERDICT: line'
221
+ ? `the chair ran but its output carried no parseable ${o.intent === 'task' ? 'ANSWER' : 'VERDICT'}: line`
277
222
  : `no chair leg completed after the fallback walk — ${chairAttempts.map(a =>
278
223
  `${a.waveId.split('-').pop()} ${a.model}: ${a.reason || a.outcome}`).join(' · ')}`,
279
- effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
224
+ // v4.9 W12 (PR #200 tail C4): the noun forks the way `why` above already
225
+ // does — a task run is told its ANSWER was lost, not its verdict. The
226
+ // `overallVerdict` KEY does not fork (verdict.js emits it on both scales),
227
+ // and `what` above is intent-neutral, so this is the only word that moves.
228
+ effect: `the ${o.intent === 'task' ? 'answer' : 'verdict'} is written with overallVerdict null; will exit degraded (2)`,
280
229
  });
281
230
  }
282
231
 
@@ -3,19 +3,22 @@
3
3
 
4
4
  /**
5
5
  * @module council/run-debate-revote
6
- * v4.8 PR3 Task 1: `legOpts`, `legRow`, and `runRevoteWave` extracted verbatim
7
- * out of run-debate.js (283/300 — the 300-line-per-file gate) with NO
8
- * behaviour change. run-debate.js requires all three back; runDebate still
9
- * calls runRevoteWave for the re-vote mini-wave (spec §5.1).
6
+ * v4.8 PR3 Task 1: `legOpts`, `legRow`, and `runRevoteWave` extracted verbatim out of
7
+ * run-debate.js (283/300 — the 300-line-per-file gate) with NO behaviour change.
8
+ * run-debate.js requires all three back; runDebate still calls runRevoteWave for the
9
+ * re-vote mini-wave (spec §5.1).
10
10
  *
11
- * That "verbatim" claim held for all three only through Task 1. `legOpts` and
12
- * `legRow` are still byte-identical to the extraction. `runRevoteWave` is NOT:
13
- * PR3 Task 6 gave it real seat-binding behaviour the padded roster + bind
14
- * (`stage1-bind.js :: bindPaddedWave` since v4.8 SI-27; an inline `bindSeats`
15
- * call before it), the seat-keyed `byJudge`, the `sanitizeName`'d per-seat
16
- * repair id, and the `seat` field on the pushed legs (see the function's own
17
- * docblock below). Do not treat `runRevoteWave` as a behaviour-neutral mirror
18
- * of the old run-debate.js code only `legOpts`/`legRow` still are.
11
+ * That "verbatim" claim held for all three only through Task 1 and now holds for
12
+ * `legOpts` ALONE: v4.9 W11 (PR1F-2) folded `legRow`'s body onto `buildRunStatsEntry`
13
+ * (its own docblock points at the diff), so it is no longer byte-identical either.
14
+ * `runRevoteWave` diverged first PR3 Task 6 gave it real seat-binding behaviour:
15
+ * the padded roster + bind (`stage1-bind.js :: bindPaddedWave` since v4.8 SI-27;
16
+ * an inline `bindSeats` call before it), the seat-keyed `byJudge`, the
17
+ * `sanitizeName`'d per-seat repair id, and the `seat` field on the pushed legs
18
+ * (see the function's own docblock below). Do not treat `runRevoteWave` as a
19
+ * behaviour-neutral mirror of the old run-debate.js code. Since v4.9 W2 (SI-16)
20
+ * the one-bounded-repair block lives in the in-file `repairRevoteLeg`, called from
21
+ * runRevoteWave's leg loop — a structural split only, no behaviour change.
19
22
  *
20
23
  * `isAbortExit` comes from ./run-launch, NEVER from ./run-stages: run-stage2.js:12
21
24
  * records that taking it from run-launch.js "is what dissolved the old cycle
@@ -38,6 +41,15 @@ const { sanitizeName } = require('./seats');
38
41
  // ./seats, so this leaf stays cycle-free (see the module docblock's cycle-class
39
42
  // paragraph above — named, not line-numbered, so it cannot rot).
40
43
  const { bindPaddedWave } = require('./stage1-bind');
44
+ // v4.9 W3 (SI-DUP disposition b): the wave's join key for one leg — the bound
45
+ // seat's id, else the bare alias. Was a local `function seatKey` here; now the
46
+ // run-retry-keys.js export (same rule, one home). ./run-retry-keys is
47
+ // REQUIRE-FREE by design (its own docblock), so this leaf stays cycle-free.
48
+ const { seatKey } = require('./run-retry-keys');
49
+ // v4.9 W11 (PR1F-2): the ONE runStats row builder. ./run-stats-entry is REQUIRE-FREE
50
+ // by design, so this leaf stays cycle-free — taking the same function off
51
+ // ./run-assemble would drag that module's whole graph in.
52
+ const { buildRunStatsEntry } = require('./run-stats-entry');
41
53
 
42
54
  /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
43
55
  function legOpts(ctx, waveId) {
@@ -50,24 +62,21 @@ function legOpts(ctx, waveId) {
50
62
 
51
63
  /**
52
64
  * v4.7 D2/E4: normalize a raw (possibly leg-absent) leg into the shape
53
- * debateRunStatsRows' superseded/repair lists expect. Same never-invent-a-waveId
54
- * discipline as buildRunStatsEntry (run-assemble.js) only spread `waveId` when
55
- * the leg genuinely carries one — but keyed on an explicit `model` (the raiser or
56
- * judge identity), since a leg-absent attempt has no `.model` of its own to read.
57
- * Threads resolvedModel (the raw leg's .model, the executable id) emit-only-when-set v4.7 GOA-7 D8.
65
+ * debateRunStatsRows' superseded/repair lists expect keyed on an explicit `model` (the
66
+ * raiser or judge identity), since a leg-absent attempt has no `.model` of its own to
67
+ * read, and threading resolvedModel emit-only-when-set (v4.7 GOA-7 D8).
68
+ *
69
+ * v4.9 W11 (PR1F-2): the never-invent-a-waveId / never-invent-a-duration discipline
70
+ * this restated is now INHERITED — this is a call to buildRunStatsEntry, whose `leg`
71
+ * contract this argument already satisfies exactly (a raw leg doc: `.model` IS the
72
+ * resolved id, `model` here IS the alias). `role` is deliberately not passed: which
73
+ * role this is depends on which list the caller pushes it onto, and `debate.js :: mk`
74
+ * stamps it. Its byte diff + the pin that all of it is invisible to `mk`: "FOLD DIFF #2" and G6, tests/council/runstats-byte-order.test.js.
58
75
  */
59
76
  function legRow(model, leg, conformance) {
60
- return leg
61
- ? { model, status: leg.status, durationMs: typeof leg.durationMs === 'number' ? leg.durationMs : null,
62
- usage: leg.usage || null, conformance, summary: leg.summary || '',
63
- ...(leg.waveId ? { waveId: leg.waveId } : {}),
64
- ...(leg.model ? { resolvedModel: leg.model } : {}) }
65
- : { model, status: 'error', durationMs: null, usage: null, conformance, summary: '' };
77
+ return buildRunStatsEntry({ leg, model, conformance, summary: leg && leg.summary });
66
78
  }
67
79
 
68
- /** The wave's join key for one leg: the bound seat's id, else the bare alias. */
69
- function seatKey(seat, alias) { return seat ? seat.id : alias; }
70
-
71
80
  /**
72
81
  * T5.1 (owner ruling R8), narrowed by T5.5: announce a re-vote leg whose key
73
82
  * this wave cannot account for — its key names none of the judges this wave
@@ -132,6 +141,46 @@ function reVoteUnboundNote(waveId, judge, key, leg) {
132
141
  };
133
142
  }
134
143
 
144
+ /**
145
+ * v4.9 W2 (SI-16): the one bounded repair for an ALIVE-but-unparseable re-vote
146
+ * leg (spec §5.7 — only ONE repair is spent), split out of runRevoteWave's leg
147
+ * loop. The CALLER owns the `alive && !parsed.ok` gate; this function always
148
+ * launches exactly one repair solo. It returns the post-repair view the caller
149
+ * records from there on — `parsed`, `conformance`, `outLeg` (the repaired leg
150
+ * when the repair completed, else the original) — plus exactly one non-null
151
+ * row: `supersededRow` (completed repair — the pre-repair leg's row) or
152
+ * `repairRow` (dead repair — the failed attempt's own row), for the caller to
153
+ * push. A user abort mid-repair returns `{ aborted: <exitCode> }` alone, which
154
+ * the caller propagates as its own return.
155
+ */
156
+ async function repairRevoteLeg(ctx, { waveId, key, judge, leg, parsed, expectedIds }) {
157
+ // One repair, solo, to that judge. The id is built from the SEAT key so
158
+ // two twins never share one repair id (and one never overwrites the
159
+ // other's run-state entry). ⚠️ The trailing `r` is load-bearing: it is what
160
+ // stops bindSeats' `/^(.*)-(\d+)$/` matching a repair id whose judge alias
161
+ // is a bare number (`r1-rv-2r` does not match; `r1-rv-2` would). Dropping
162
+ // it re-arms a collision. sanitizeName also fixes the pre-existing slash
163
+ // bug (D4) — `r1-rv-openrouter/deepseek/deepseek-chatr` stops nesting
164
+ // three directory levels — and is a no-op for every plain alias.
165
+ const repairId = `${waveId}-${sanitizeName(key)}r`;
166
+ runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
167
+ const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
168
+ // ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
169
+ prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
170
+ ctx.addWave(r2.wave);
171
+ if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
172
+ const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
173
+ parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
174
+ const conformance = parsed.ok ? 'repaired' : 'unstructured';
175
+ // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
176
+ // revote-<model>.md and the runStats row keep the PRE-repair output.
177
+ return leg2
178
+ ? { aborted: null, parsed, conformance, outLeg: leg2,
179
+ supersededRow: legRow(judge, leg, 'unstructured'), repairRow: null }
180
+ : { aborted: null, parsed, conformance, outLeg: leg,
181
+ supersededRow: null, repairRow: legRow(judge, r2.leg, 'unstructured') };
182
+ }
183
+
135
184
  /**
136
185
  * The re-vote mini-wave (spec §5.1).
137
186
  *
@@ -151,7 +200,7 @@ function reVoteUnboundNote(waveId, judge, key, leg) {
151
200
  * @param {function(string): string} aliasOf seat id → bench alias
152
201
  */
153
202
  async function runRevoteWave(ctx, judgeKeys, bundleFindings, judgeSeats, aliasOf) {
154
- const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date });
203
+ const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date, intent: ctx.o.intent });
155
204
  // spec §5.1 names `revote-bundle.md` a run-dir artifact: the shared re-vote prompt goes to
156
205
  // disk exactly like Stage 2's bundle-stage2.md, so the round's model-facing input is
157
206
  // auditable alongside briefing-stage1.md and chair-packet.md.
@@ -194,28 +243,11 @@ async function runRevoteWave(ctx, judgeKeys, bundleFindings, judgeSeats, aliasOf
194
243
  : { ok: false, byId: {}, errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
195
244
  let conformance = alive ? 'clean' : 'unstructured';
196
245
  if (alive && !parsed.ok) {
197
- // One repair, solo, to that judge. The id is built from the SEAT key so
198
- // two twins never share one repair id (and one never overwrites the
199
- // other's run-state entry). ⚠️ The trailing `r` is load-bearing: it is what
200
- // stops bindSeats' `/^(.*)-(\d+)$/` matching a repair id whose judge alias
201
- // is a bare number (`r1-rv-2r` does not match; `r1-rv-2` would). Dropping
202
- // it re-arms a collision. sanitizeName also fixes the pre-existing slash
203
- // bug (D4) — `r1-rv-openrouter/deepseek/deepseek-chatr` stops nesting
204
- // three directory levels — and is a no-op for every plain alias.
205
- const repairId = `${waveId}-${sanitizeName(key)}r`;
206
- runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
207
- const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
208
- // ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
209
- prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
210
- ctx.addWave(r2.wave);
211
- if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
212
- const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
213
- parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
214
- conformance = parsed.ok ? 'repaired' : 'unstructured';
215
- // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
216
- // revote-<model>.md and the runStats row keep the PRE-repair output.
217
- if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
218
- else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
246
+ const rep = await repairRevoteLeg(ctx, { waveId, key, judge, leg, parsed, expectedIds });
247
+ if (rep.aborted) { return { aborted: rep.aborted }; }
248
+ ({ parsed, conformance, outLeg } = rep);
249
+ if (rep.supersededRow) { supersededLegs.push(rep.supersededRow); }
250
+ if (rep.repairRow) { repairLegs.push(rep.repairRow); }
219
251
  }
220
252
  // ⚠️ Two DIFFERENT values on the same iteration: `byJudge`'s key is the SEAT
221
253
  // (applyDebate joins it against `(a.seat || a.judge)`), while the leg's
@@ -35,7 +35,7 @@ const { legOpts, legRow, runRevoteWave } = require('./run-debate-revote');
35
35
  * `raiserKey` survives in exactly two places here: the returned `{raiser}` (which
36
36
  * becomes defenseByRaiser's key — §3.3: that MUST stay seat-keyed, because
37
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.
38
+ * debate row plus its amended claim) and the `seat` runDefenseWave derives from it.
39
39
  * Everything else — both launch sites, both legRow calls, the stub and the
40
40
  * returned leg's `model` — is `raiserAlias`.
41
41
  *
@@ -47,7 +47,7 @@ const { legOpts, legRow, runRevoteWave } = require('./run-debate-revote');
47
47
  */
48
48
  async function runDefenseSolo(ctx, raiserKey, findings, idx, aliasOf) {
49
49
  const raiserAlias = aliasOf(raiserKey);
50
- const brief = dbrief.buildDefenseBrief({ findings, date: ctx.o.date });
50
+ const brief = dbrief.buildDefenseBrief({ findings, date: ctx.o.date, intent: ctx.o.intent });
51
51
  const waveId = `${ctx.o.runId}-d${idx + 1}`;
52
52
  const expectedIds = findings.map(f => f.id);
53
53
  // Record the sub-wave BEFORE launching: `amicus abort` cascades over stages[].waveIds
@@ -97,6 +97,51 @@ async function runDefenseSolo(ctx, raiserKey, findings, idx, aliasOf) {
97
97
  supersededLeg, repairLeg };
98
98
  }
99
99
 
100
+ /**
101
+ * Defense mini-wave: ONE CONCURRENT solo per raiser (spec §5.1).
102
+ *
103
+ * Concurrent, not sequential: every raiser gets its OWN briefing, so this is N independent
104
+ * solos rather than one fanout wave. No per-leg budget check interleaves between them — the
105
+ * cost ceiling is a WHOLE-ROUND gate run.js applies BEFORE calling runDebate
106
+ * ('skipped-cost-ceiling' is a round-level outcome in spec §5.1's enum, not a per-leg one).
107
+ * `appendStageWave` is sync fs and each solo registers its waveId before its first await,
108
+ * so concurrency cannot interleave a read-modify-write of run.json.
109
+ * v4.1 §4.4: the reserved seat 'claude' is a FILE-sourced review with no leg to
110
+ * launch, so it is never asked to defend — its contested findings simply stand
111
+ * (the same "originals stand" outcome as a no-response).
112
+ *
113
+ * @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
114
+ * @param {{byRaiser: object, aliasOf: function(string): string, seatById: Map<string, object>}} args
115
+ * @returns {Promise<{aborted: (number|null), defenseResults: Array<object>, defenseByRaiser: (object|null)}>}
116
+ * `aborted` is an abort exit code (defenseByRaiser is null then) or null on a full wave.
117
+ */
118
+ async function runDefenseWave(ctx, { byRaiser, aliasOf, seatById }) {
119
+ const raisers = Object.keys(byRaiser).filter(m => m !== 'claude');
120
+ const defenseResults = await Promise.all(
121
+ raisers.map((raiserKey, i) => runDefenseSolo(ctx, raiserKey, byRaiser[raiserKey], i, aliasOf)));
122
+ // A signal during the defense wave aborts the whole finalization (spec §5.7): return the
123
+ // abort code — runDebate maps it onto its own abort return — so run.js finalizes
124
+ // 'aborted' with NO tally-final / NO ledger.
125
+ const abortedDefense = defenseResults.find(d => d.aborted);
126
+ if (abortedDefense) { return { aborted: abortedDefense.aborted, defenseResults, defenseByRaiser: null }; }
127
+ // ⚠️ This literal lives in runDefenseWave, where `raiserAlias` does not exist — the
128
+ // projection is `aliasOf(d.raiser)`. `model` must stay ALIAS-valued (R3-1);
129
+ // `seat` is what gives two twins two files instead of one clobbered one (R3-3).
130
+ materializeDebate(ctx.o.runDir, defenseResults.map(d => ({ model: aliasOf(d.raiser),
131
+ summary: d.leg.summary, seat: seatById.get(d.raiser) || null })), 'rebuttal');
132
+
133
+ const defenseByRaiser = {};
134
+ for (const dr of defenseResults) { defenseByRaiser[dr.raiser] = { ...dr.byId }; }
135
+ // v4.1 §4.4: claude never gets a defense leg (raisers filter above), but its
136
+ // contested/disputed findings still need an audit trail — the SAME spec §5.7
137
+ // "originals stand" fallback a dead/unrepaired defense leg gets. Seeded into
138
+ // defenseByRaiser ONLY (never defenseResults, which feeds runDebate's `bad(l)`
139
+ // degraded check — a claude entry there would wrongly flip a clean run to
140
+ // degraded/exit 2).
141
+ if (byRaiser.claude) { defenseByRaiser.claude = allNoResponse(byRaiser.claude.map(f => f.id)); }
142
+ return { aborted: null, defenseResults, defenseByRaiser };
143
+ }
144
+
100
145
  /**
101
146
  * Full Stage-2.5 sequence (spec §5.1). Returns everything run.js needs. Cost gate: run.js
102
147
  * checks overBudget before invoking; this checks again before the re-vote wave (spec §5.7).
@@ -128,38 +173,10 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
128
173
  const seatById = new Map(seatTable.map(s => [s.id, s]));
129
174
  const aliasOf = (key) => { const s = seatById.get(key); return s ? s.alias : key; };
130
175
 
131
- // ---- Defense mini-wave: ONE CONCURRENT solo per raiser (spec §5.1) ----
132
- // Concurrent, not sequential: every raiser gets its OWN briefing, so this is N independent
133
- // solos rather than one fanout wave. No per-leg budget check interleaves between them — the
134
- // cost ceiling is a WHOLE-ROUND gate run.js applies BEFORE calling runDebate
135
- // ('skipped-cost-ceiling' is a round-level outcome in spec §5.1's enum, not a per-leg one).
136
- // `appendStageWave` is sync fs and each solo registers its waveId before its first await,
137
- // so concurrency cannot interleave a read-modify-write of run.json.
138
- // v4.1 §4.4: the reserved seat 'claude' is a FILE-sourced review with no leg to
139
- // launch, so it is never asked to defend — its contested findings simply stand
140
- // (the same "originals stand" outcome as a no-response).
141
- const raisers = Object.keys(byRaiser).filter(m => m !== 'claude');
142
- const defenseResults = await Promise.all(
143
- raisers.map((raiserKey, i) => runDefenseSolo(ctx, raiserKey, byRaiser[raiserKey], i, aliasOf)));
144
- // A signal during the defense wave aborts the whole finalization (spec §5.7):
145
- // return the abort code so run.js finalizes 'aborted' with NO tally-final / NO ledger.
146
- const abortedDefense = defenseResults.find(d => d.aborted);
147
- if (abortedDefense) { return { aborted: abortedDefense.aborted, contested, disputed }; }
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');
153
-
154
- const defenseByRaiser = {};
155
- for (const dr of defenseResults) { defenseByRaiser[dr.raiser] = { ...dr.byId }; }
156
- // v4.1 §4.4: claude never gets a defense leg (raisers filter above), but its
157
- // contested/disputed findings still need an audit trail — the SAME spec §5.7
158
- // "originals stand" fallback a dead/unrepaired defense leg gets. Seeded into
159
- // defenseByRaiser ONLY (never defenseResults, which feeds the `bad(l)`
160
- // degraded check below — a claude entry there would wrongly flip a clean run
161
- // to degraded/exit 2).
162
- if (byRaiser.claude) { defenseByRaiser.claude = allNoResponse(byRaiser.claude.map(f => f.id)); }
176
+ // ---- Defense mini-wave (runDefenseWave): abort short-circuits the round (spec §5.7) ----
177
+ const dw = await runDefenseWave(ctx, { byRaiser, aliasOf, seatById });
178
+ if (dw.aborted) { return { aborted: dw.aborted, contested, disputed }; }
179
+ const { defenseResults, defenseByRaiser } = dw;
163
180
  // Stamp previousTier onto the tally input: applyDebate reads it off tallyInput.findings[]
164
181
  // (it ignores the provisional record), so without this every row's previousTier is null.
165
182
  const stampedInput = { ...tallyInput, findings: tallyInput.findings.map(f => ({ ...f, previousTier: previousTier[f.id] })) };
@@ -4,7 +4,7 @@
4
4
  // Moved verbatim from run.js@6b0c3b6b:242-288 (v4.8 PR0 size-gate split, zero
5
5
  // behavior). NOT run-finalize.js — that sibling owns exit codes and the
6
6
  // terminal write; this module builds the final tally, appends the run
7
- // record to the ledger (skipped for lens runs), and writes the
7
+ // record to the ledger (skipped for lens runs and task runs), and writes the
8
8
  // tally/verdict artifact files + their stage events.
9
9
  const { tally } = require('./tally');
10
10
  const { decorateRecord } = require('./debate');
@@ -48,8 +48,10 @@ function finishRun({ o, chairRes, debatedInput, debateFindings, appendRunFn, deg
48
48
  ];
49
49
  const record = tally(finalInput);
50
50
  if (debateFindings) { decorateRecord(record, debateFindings); }
51
- if (!o.lenses) {
52
- // Lens runs never feed cross-run reliability stats (spec §4 / skill rule).
51
+ if (!o.lenses && o.intent !== 'task') {
52
+ // Lens runs never feed cross-run reliability stats (spec §4 / skill rule);
53
+ // task runs neither (v4.9 W5.4 gate 1 — task rankings measure concurrence,
54
+ // never defect confirmation, so a task row would poison chair promotion).
53
55
  try { appendRunFn(record); }
54
56
  catch (e) { process.stderr.write(`Notice: council ledger append failed: ${e.message}\n`); }
55
57
  }
@@ -7,10 +7,10 @@
7
7
  // run-retry-group.js re-exports all four, so no import path in the tree moved.
8
8
 
9
9
  /**
10
- * The one seat-key rule: a seat's id when it was identified, its alias otherwise.
11
- * Exported so `run-retry-group.js :: recordFailure` and run-retry.js both consume it
12
- * rather than re-spelling it two readers of one rule that drift apart is how the
13
- * alias/seat-id keyspace splits in the first place.
10
+ * The one seat-key rule: a seat's id when it was identified, its alias otherwise. Exported
11
+ * so `run-retry-group.js :: recordFailure`, run-retry.js and since v4.9 W3 (SI-DUP
12
+ * disposition b)run.js, run-debate-revote.js and run-stage1-rows.js all consume it
13
+ * rather than re-spelling it: readers of one rule that drift apart is how it splits.
14
14
  */
15
15
  const seatKey = (s, alias) => (s ? s.id : alias);
16
16
 
@@ -15,13 +15,13 @@
15
15
  const briefings = require('./briefings');
16
16
  const { bindPaddedWave } = require('./stage1-bind');
17
17
 
18
- /** The briefing a retry unit re-issues — same builders Stage 1 used. */
18
+ /** The briefing a retry unit re-issues — same intent-aware dispatchers Stage 1 used (v4.9 W6). */
19
19
  function briefingFor(o, unit) {
20
- if (unit.unit === 'critic') { return briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }); }
20
+ if (unit.unit === 'critic') { return briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date }); }
21
21
  if (unit.unit === 'lens') {
22
- return briefings.buildLensBriefing({ lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
22
+ return briefings.stage1LensBriefing(o.intent, { lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
23
23
  }
24
- return briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date });
24
+ return briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date });
25
25
  }
26
26
 
27
27
  /**