amicus 4.6.2 → 4.7.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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
@@ -20,6 +20,7 @@ const { parseChairVerdict } = 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
+ const { buildRunStatsEntry } = require('./run-assemble');
23
24
 
24
25
  /**
25
26
  * Chair fallback promotion (spec §4): the highest peers-only street-cred
@@ -31,15 +32,33 @@ const { emitStageStarted, emitStageTerminal } = require('../observe/events');
31
32
  * a --claude-review run puts a real 'claude' row in the ledger, so without this
32
33
  * filter a LATER run could promote it and walk straight past the pre-flight
33
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.
34
40
  * @returns {string|null}
35
41
  */
36
42
  function pickFallbackChair(statsRows, bench, failedChair) {
37
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);
38
56
  const candidates = (statsRows || [])
39
- .filter(r => r.model !== 'claude' && !benchSet.has(r.model) && r.model !== failedChair
40
- && typeof r.avgStreetCredPeersOnly === 'number')
57
+ .filter(r => !excluded(r) && typeof r.avgStreetCredPeersOnly === 'number')
41
58
  .sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
42
- return candidates.length ? candidates[0].model : null;
59
+ if (!candidates.length) { return null; }
60
+ const top = candidates[0];
61
+ return (Array.isArray(top.aliases) && top.aliases.length) ? top.aliases[0] : top.model;
43
62
  }
44
63
 
45
64
  /**
@@ -98,6 +117,7 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
98
117
  // v4.3 Task 3 (spec §7.2 named defect): without this, chair spend is
99
118
  // ledgered with councilRunId:null and is unattributable.
100
119
  councilRunId: o.runId, councilName: o.councilName,
120
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
101
121
  });
102
122
  addWave(solo.wave);
103
123
  const ok = solo.leg && solo.leg.status === 'complete'
@@ -117,12 +137,24 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
117
137
  // cost-skipped chair (the `if` branch) simply never calls recordAttempt —
118
138
  // chairAttempts is never checkpointed and the key stays absent on run.json.
119
139
  const chairAttempts = [];
140
+ // v4.7 D2 (spec "the count is the count"): a non-primary row per launch so
141
+ // spend is fully attributable — one 'chair-attempt' row per FAILED attempt
142
+ // that produced a rawLeg (null rawLeg = no wave = no money = no row), plus
143
+ // one 'repair' row for a launched ch4 (pushed below, after the ch4 block).
144
+ // The eventual SUCCESSFUL attempt's leg is never pushed here — it becomes
145
+ // the primary 'chair' row (wasChair:true) via run.js's own chairStats.
146
+ const chairRows = [];
120
147
  const recordAttempt = (attempt, waveId, model) => {
121
148
  const cls = classifyChairAttempt(attempt.rawLeg, attempt.errorDoc);
122
149
  chairAttempts.push({ waveId, model, outcome: cls.outcome, reason: cls.reason });
123
150
  // Checkpointed HERE, before the caller's own isAbortExit bail — a mid-walk
124
151
  // kill must not lose the attempts already resolved (spec §8 kill-mid-walk).
125
152
  runState.checkpoint(o.runDir, { chairAttempts });
153
+ if (!attempt.leg && attempt.rawLeg) {
154
+ chairRows.push(buildRunStatsEntry({
155
+ leg: attempt.rawLeg, model, role: 'chair-attempt', wasChair: false,
156
+ }));
157
+ }
126
158
  };
127
159
  if (overBudget()) {
128
160
  // Ceiling hit after the tally is computable: skip the chair, write the
@@ -143,12 +175,14 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
143
175
  emitStageStarted(o.runDir, o.runId, 'chair', null, o.follow);
144
176
  // Fallback chain (spec §4): retry same chair once → promote best
145
177
  // non-bench model from the ledger → give up (no Claude fallback headless).
146
- let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
147
- recordAttempt(attempt, `${o.runId}-ch1`, o.chair);
178
+ const waveId1 = `${o.runId}-ch1`;
179
+ let attempt = await attemptChair(o.chair, waveId1);
180
+ recordAttempt(attempt, waveId1, o.chair);
148
181
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
149
182
  if (!attempt.leg && !overBudget()) {
150
- attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
151
- recordAttempt(attempt, `${o.runId}-ch2`, o.chair);
183
+ const waveId2 = `${o.runId}-ch2`;
184
+ attempt = await attemptChair(o.chair, waveId2);
185
+ recordAttempt(attempt, waveId2, o.chair);
152
186
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
153
187
  }
154
188
  if (attempt.leg) { actualChair = o.chair; }
@@ -157,8 +191,9 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
157
191
  try { statsRows = statsFn(); } catch { /* no ledger yet */ }
158
192
  const fallback = pickFallbackChair(statsRows, o.models, o.chair);
159
193
  if (fallback) {
160
- attempt = await attemptChair(fallback, `${o.runId}-ch3`);
161
- recordAttempt(attempt, `${o.runId}-ch3`, fallback);
194
+ const waveId3 = `${o.runId}-ch3`;
195
+ attempt = await attemptChair(fallback, waveId3);
196
+ recordAttempt(attempt, waveId3, fallback);
162
197
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
163
198
  if (attempt.leg) { actualChair = fallback; }
164
199
  }
@@ -180,19 +215,37 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
180
215
  // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
181
216
  let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
182
217
  if (chairText && !overallVerdict && !overBudget()) {
183
- runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
218
+ const waveId4 = `${o.runId}-ch4`;
219
+ runState.appendStageWave(o.runDir, 'chair', waveId4);
184
220
  const repair = await launchers.launchSolo({
185
221
  // ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
186
222
  // VERDICT line is missing — so a fresh repair session that cannot see the
187
223
  // synthesis is picking a verdict on an artifact it has never read.
188
224
  model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
189
- project: o.runDir, waveId: `${o.runId}-ch4`,
225
+ project: o.runDir, waveId: waveId4,
190
226
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
191
227
  noCostGate: o.noCostGate,
192
228
  councilRunId: o.runId, councilName: o.councilName,
229
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
193
230
  });
194
231
  addWave(repair.wave);
195
232
  if (isAbortExit(repair.exitCode) || isSignalled()) { return bail(repair.exitCode || isSignalled()); }
233
+ // repair.leg is the raw leg document — launchSolo DOES null it, but only
234
+ // on a wave-less failure (a pre-flight refusal with no wave launched at
235
+ // all: run-launch.js's launchSolo derives `leg` from `wave.legs[0]`, so
236
+ // no wave means no leg, no waveId, no money spent — the errata E3 "no
237
+ // leg = no wave = no money = no row" case). A wave that DID launch
238
+ // always yields a leg document, whatever its status. The `if
239
+ // (repair.leg)` guard below is therefore load-bearing on that exact
240
+ // distinction: a launched ch4 (a leg document exists, whatever its
241
+ // status) gets its own row so the repair's spend is attributed even when
242
+ // it never supplies a VERDICT; a ch4 that never even launched gets no
243
+ // row at all, because there is nothing billed to attribute.
244
+ if (repair.leg) {
245
+ chairRows.push(buildRunStatsEntry({
246
+ leg: repair.leg, model: actualChair, role: 'repair', wasChair: false,
247
+ }));
248
+ }
196
249
  overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
197
250
  chairConformance = overallVerdict ? 'repaired' : 'unstructured';
198
251
  }
@@ -213,6 +266,12 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
213
266
 
214
267
  return {
215
268
  aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
269
+ // Additive (v4.7 D2): chairRows holds the non-primary rows (attempts +
270
+ // repair); chairAttempts is handed back too so run.js can key the
271
+ // give-up row on "the walk actually happened" without re-reading disk —
272
+ // NOT on chairRows, since attempts that die pre-wave record an outcome
273
+ // but produce no row (errata E3: no wave = no money = no row).
274
+ chairRows, chairAttempts,
216
275
  };
217
276
  }
218
277
 
@@ -13,62 +13,38 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const dbrief = require('./briefings-debate');
15
15
  const { parseDebateDefense, parseRevote } = require('./parse-stage2');
16
- const { applyDebate, debateRunStatsRows, PAST_TENSE } = require('./debate');
16
+ const { applyDebate, debateRunStatsRows, PAST_TENSE,
17
+ allNoResponse, nothingToDebate, disputingJudges, debateTargets, bundleFor } = require('./debate');
17
18
  const { materializeDebate } = require('./run-launch');
18
19
  const { tally } = require('./tally');
19
20
  const { isAbortExit } = require('./run-stages');
20
21
  const runState = require('./run-state');
21
22
  const { emitStageStarted } = require('../observe/events');
22
23
 
23
- /** Spec §5.7 fallback: a dead/unparseable defense means every bundled id's original stands. */
24
- function allNoResponse(ids) {
25
- const byId = {};
26
- for (const id of ids) { byId[id] = { action: 'no-response' }; }
27
- return byId;
28
- }
29
-
30
- /** True when there is nothing to challenge (spec §5.1). */
31
- function nothingToDebate(provisionalRecord) {
32
- if (!provisionalRecord || provisionalRecord.judged === false) { return true; }
33
- const n = provisionalRecord.findings.filter(f => f.tier === 'Contested' || f.tier === 'Disputed').length;
34
- return n === 0;
35
- }
36
-
37
- /** Judges whose provisional adjudications dispute at least one bundled id. */
38
- function disputingJudges(provisionalRecord, bundledIds) {
39
- const ids = new Set(bundledIds);
40
- const judges = new Set();
41
- for (const f of provisionalRecord.findings) {
42
- if (!ids.has(f.id)) { continue; }
43
- for (const adj of f.adjudications || []) {
44
- if (adj.verdict === 'dispute') { judges.add(adj.judge); }
45
- }
46
- }
47
- return [...judges];
48
- }
49
-
50
- /** Group Contested+Disputed findings by raiser (defense targets). */
51
- function debateTargets(provisionalRecord, tallyInput) {
52
- const claimById = new Map(tallyInput.findings.map(f => [f.id, f]));
53
- const byRaiser = {};
54
- const previousTier = {};
55
- for (const f of provisionalRecord.findings) {
56
- if (f.tier !== 'Contested' && f.tier !== 'Disputed') { continue; }
57
- previousTier[f.id] = f.tier;
58
- const src = claimById.get(f.id) || {};
59
- const peerVerdicts = (f.adjudications || []).filter(a => a.judge !== f.raiser).map(a => a.verdict);
60
- (byRaiser[f.raiser] = byRaiser[f.raiser] || []).push({ id: f.id, claim: src.claim,
61
- severity: f.severity, location: src.location, peerVerdicts, disputeReasons: [] });
62
- }
63
- return { byRaiser, previousTier };
64
- }
65
-
66
24
  /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
67
25
  function legOpts(ctx, waveId) {
68
26
  return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
69
27
  noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
70
28
  // v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
71
- councilRunId: ctx.o.runId, councilName: ctx.o.councilName };
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
+ }
32
+
33
+ /**
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.
40
+ */
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: '' };
72
48
  }
73
49
 
74
50
  async function runDefenseSolo(ctx, raiser, findings, idx) {
@@ -90,6 +66,12 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
90
66
  let parsed = leg ? parseDebateDefense(leg.summary, expectedIds)
91
67
  : { ok: false, byId: allNoResponse(expectedIds), errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
92
68
  let conformance = leg ? 'clean' : 'unstructured';
69
+ // v4.7 D2/E4: the repair's loser leg — the ORIGINAL when the repair produced a
70
+ // usable (complete) leg (today's leg-swap below is unchanged), or the failed
71
+ // repair attempt itself when it did not — retained so runDebate can turn it
72
+ // into an extra debate-defense runStats row. Both stay null when no repair is
73
+ // attempted at all (today's single-row shape, byte-identical).
74
+ let supersededLeg = null, repairLeg = null;
93
75
  if (leg && !parsed.ok) {
94
76
  const repairId = `${waveId}r`;
95
77
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
@@ -103,13 +85,17 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
103
85
  const leg2 = res2.leg && res2.leg.status === 'complete' ? res2.leg : null;
104
86
  parsed = leg2 ? parseDebateDefense(leg2.summary, expectedIds) : parsed;
105
87
  conformance = parsed.ok ? 'repaired' : 'unstructured';
106
- if (leg2) { leg = leg2; }
88
+ if (leg2) { supersededLeg = legRow(raiser, leg, 'unstructured'); leg = leg2; }
89
+ else { repairLeg = legRow(raiser, res2.leg, 'unstructured'); }
107
90
  }
108
91
  // A dead leg (no complete summary) OR an 'unstructured' conformance after the one
109
92
  // repair is a debate degradation (spec §5.7) — surfaced via the returned leg.
110
93
  const stub = { model: raiser, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
111
94
  return { raiser, byId: parsed.byId,
112
- leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage, conformance, summary: leg.summary } : stub };
95
+ leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage,
96
+ conformance, summary: leg.summary, waveId: leg.waveId,
97
+ ...(leg.model ? { resolvedModel: leg.model } : {}) } : stub,
98
+ supersededLeg, repairLeg };
113
99
  }
114
100
 
115
101
  async function runRevoteWave(ctx, judges, bundleFindings) {
@@ -131,6 +117,9 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
131
117
  ctx.addWave(res.wave);
132
118
  if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
133
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 = [];
134
123
  for (const leg of ((res.wave && res.wave.legs) || [])) {
135
124
  // The council ALIAS, not the resolved executable id — runStats rows join
136
125
  // meta.models by exact string (run-assemble.js's buildRunStatsEntry).
@@ -154,27 +143,15 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
154
143
  conformance = parsed.ok ? 'repaired' : 'unstructured';
155
144
  // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
156
145
  // revote-<model>.md and the runStats row keep the PRE-repair output.
157
- if (leg2) { outLeg = leg2; }
146
+ if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
147
+ else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
158
148
  }
159
149
  byJudge[judge] = parsed.byId;
160
- legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage, conformance, summary: outLeg.summary || '' });
161
- }
162
- return { byJudge, legs };
163
- }
164
-
165
- /** The re-vote bundle: defended-or-amended findings ONLY (spec §5.1 — withdrawn never appear). */
166
- function bundleFor(defenseResults, tallyInput) {
167
- const out = [];
168
- for (const dr of defenseResults) {
169
- for (const [id, resp] of Object.entries(dr.byId)) {
170
- if (resp.action !== 'defend' && resp.action !== 'amend') { continue; }
171
- const src = tallyInput.findings.find(f => f.id === id) || {};
172
- out.push({ id, severity: src.severity, amended: resp.action === 'amend',
173
- claim: resp.action === 'amend' ? resp.claim : src.claim,
174
- argument: resp.argument || 'defended without extra argument' });
175
- }
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 } : {}) });
176
153
  }
177
- return out;
154
+ return { byJudge, legs, supersededLegs, repairLegs };
178
155
  }
179
156
 
180
157
  /**
@@ -221,7 +198,7 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
221
198
  const stampedInput = { ...tallyInput, findings: tallyInput.findings.map(f => ({ ...f, previousTier: previousTier[f.id] })) };
222
199
 
223
200
  // ---- Re-vote mini-wave (disputing judges only) ----
224
- let revoteByJudge = {}, revoteLegs = [];
201
+ let revoteByJudge = {}, revoteLegs = [], revoteSuperseded = [], revoteRepairs = [];
225
202
  const defendedOrAmended = bundleFor(defenseResults, tallyInput);
226
203
  const judges = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
227
204
  // A re-vote is warranted only when something was defended/amended AND ≥1 judge disputed it.
@@ -238,6 +215,8 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
238
215
  if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
239
216
  revoteByJudge = rv.byJudge;
240
217
  revoteLegs = rv.legs;
218
+ revoteSuperseded = rv.supersededLegs;
219
+ revoteRepairs = rv.repairLegs;
241
220
  // revote-<model>.md per surviving judge leg, mirroring rebuttal-<model>.md
242
221
  // (spec §5.1 'raw outputs revote-<model>.md').
243
222
  materializeDebate(ctx.o.runDir, revoteLegs, 'revote');
@@ -247,7 +226,12 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
247
226
  const { input: debatedInput, debateFindings } = applyDebate({
248
227
  tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
249
228
  debatedInput.runStats = [...(debatedInput.runStats || []),
250
- ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs })];
229
+ ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs,
230
+ // v4.7 D2/E4: the retained loser legs from every raiser's defense repair
231
+ // plus every judge's re-vote repair — same append, no new channel into
232
+ // buildTallyInput.
233
+ supersededLegs: [...defenseResults.map(d => d.supersededLeg).filter(Boolean), ...revoteSuperseded],
234
+ repairLegs: [...defenseResults.map(d => d.repairLeg).filter(Boolean), ...revoteRepairs] })];
251
235
 
252
236
  // verdictChanges: findings whose tier moved from provisional to debated.
253
237
  const provTierById = new Map(provisionalRecord.findings.map(f => [f.id, f.tier]));
@@ -63,10 +63,13 @@ function createLaunchers(deps = {}) {
63
63
  /**
64
64
  * @param {{models: string[], prompt: string, project: string, waveId: string,
65
65
  * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
66
- * councilRunId?: string, councilName?: string, fallback?: object, catalog?: Array}} opts
66
+ * councilRunId?: string, councilName?: string, tag?: string, fallback?: object,
67
+ * catalog?: Array}} opts
67
68
  * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
68
69
  * ids forwarded verbatim into the runFanout call so it can stamp them onto
69
- * every leg. fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
70
+ * every leg. tag (v4.7 F8 D16) rides the same forward — every call site
71
+ * below that sets councilRunId/councilName sets `tag: o.tag` alongside it.
72
+ * fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
70
73
  * additive/opt-in — omitted by callers that must never substitute (the
71
74
  * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
72
75
  * @returns {Promise<{wave: object|null, exitCode: number}>}
@@ -111,6 +114,10 @@ function createLaunchers(deps = {}) {
111
114
  noValidateModel: opts.noValidateModel,
112
115
  councilRunId: opts.councilRunId,
113
116
  councilName: opts.councilName,
117
+ // v4.7 F8 D16: rides the SAME forward as councilRunId/councilName above —
118
+ // undefined when no --tag, so stampLegAttribution's `if (options.tag)`
119
+ // guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
120
+ tag: opts.tag,
114
121
  // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
115
122
  // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
116
123
  // fallback path only activates when `fallback.enabled` is true.
@@ -141,7 +141,8 @@ function briefingFor(o, unit) {
141
141
  async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts = { reviewed: 0, total: 0 } } = {}) {
142
142
  const { o, launchers } = ctx;
143
143
  const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
144
- stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [] };
144
+ stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
145
+ stillDeadRetryLegs: [] };
145
146
 
146
147
  for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
147
148
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
@@ -168,6 +169,7 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
168
169
  const common = { project: o.runDir, timeout: o.timeout, gateway: o.gateway,
169
170
  noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
170
171
  councilRunId: o.runId, councilName: o.councilName,
172
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
171
173
  fallback: o.fallback, catalog: o.catalog,
172
174
  waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit) };
173
175
  // Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
@@ -234,6 +236,7 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
234
236
  data: { seat, retryWaveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, firstFailure: ff } });
235
237
  } else {
236
238
  out.stillDeadNotes.push(retryLegStillDeadNote(seat, ff, leg, unit, counts));
239
+ out.stillDeadRetryLegs.push(leg);
237
240
  if (ff && ff.class === 'wave') {
238
241
  if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
239
242
  lostWaveSeats.get(ff.waveId).push(seat);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Stage-1 launch pass for the council engine.
3
+ *
4
+ * launchStage1 moved verbatim from run-stages.js (v4.7 PR1 Task 1) to free
5
+ * gate headroom before the row-per-launch edits land there.
6
+ */
7
+ 'use strict';
8
+
9
+ const briefings = require('./briefings');
10
+ const runState = require('./run-state');
11
+ const { isAbortExit } = require('./run-launch');
12
+
13
+ /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
14
+ async function launchStage1(ctx) {
15
+ const { o, launchers } = ctx;
16
+ // `noCostGate` rides EVERY launch object in this file (here, the findings
17
+ // repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
18
+ const common = {
19
+ project: o.runDir, timeout: o.timeout, gateway: o.gateway,
20
+ noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
21
+ // v4.3 Task 3 (spec §7.2): attribution ids, forwarded verbatim to runFanout
22
+ // via run-launch.js so every Stage-1 leg's ledger row carries them.
23
+ councilRunId: o.runId, councilName: o.councilName,
24
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
25
+ // v4.3 Task 18 (spec §6.2): fallback chains apply to STAGE legs only —
26
+ // the chair (run-chair.js) and debate legs (run-debate.js) never receive
27
+ // this, so they never substitute via chains.
28
+ fallback: o.fallback, catalog: o.catalog,
29
+ };
30
+ const launches = [];
31
+ const seated = []; // parallel to `launches`: what each one was SUPPOSED to seat
32
+ // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
33
+ // stages[].waveIds, so an id written after the launch leaves that leg
34
+ // reachable only by the pid kill (no per-leg abort marker).
35
+ const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
36
+ if (o.lenses) {
37
+ o.models.forEach((m, i) => {
38
+ const waveId = `${o.runId}-l${i + 1}`;
39
+ record(waveId);
40
+ seated.push({ waveId, models: [m] });
41
+ launches.push(launchers.launchSolo({
42
+ ...common, model: m, waveId,
43
+ prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
44
+ }));
45
+ });
46
+ } else {
47
+ const seats = o.models.filter(m => m !== o.critic);
48
+ if (seats.length > 0) {
49
+ record(`${o.runId}-s1`);
50
+ seated.push({ waveId: `${o.runId}-s1`, models: seats.slice() });
51
+ launches.push(launchers.launchWave({
52
+ ...common, models: seats, waveId: `${o.runId}-s1`,
53
+ prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
+ }));
55
+ }
56
+ if (o.critic) {
57
+ record(`${o.runId}-c1`);
58
+ seated.push({ waveId: `${o.runId}-c1`, models: [o.critic] });
59
+ launches.push(launchers.launchSolo({
60
+ ...common, model: o.critic, waveId: `${o.runId}-c1`,
61
+ prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
62
+ }));
63
+ }
64
+ }
65
+ const results = await Promise.all(launches);
66
+ let aborted = null;
67
+ const legs = [];
68
+ const deadWaves = [];
69
+ results.forEach((r, i) => {
70
+ ctx.addWave(r.wave);
71
+ const abort = isAbortExit(r.exitCode);
72
+ if (abort) { aborted = r.exitCode; }
73
+ const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
74
+ legs.push(...got);
75
+ // ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
76
+ // never started; `database is locked`) contributes NOTHING to `legs`, so
77
+ // deadLegs cannot see it either — which is how run v441plan01 recorded
78
+ // stage1 'complete' with four seats missing and no trace of them. In lens
79
+ // mode every seat is its own wave, so a run could lose seats and still exit
80
+ // 0; the quorum gate only catches the non-lens seat wave. A budget refusal
81
+ // has its own louder channel already (run-budget.noteBudgetRefusal) and
82
+ // must not be double-counted here.
83
+ if (got.length > 0 || abort) { return; }
84
+ if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
85
+ deadWaves.push({
86
+ waveId: seated[i].waveId, models: seated[i].models,
87
+ reason: (r.wave && (r.wave.reason || r.wave.error))
88
+ || (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
89
+ });
90
+ });
91
+ return { aborted, legs, deadWaves };
92
+ }
93
+
94
+ module.exports = { launchStage1 };
@@ -23,6 +23,7 @@ const stage2 = require('./briefings-stage2');
23
23
  const { parseJudgeOutput } = require('./parse-stage2');
24
24
  const { sanitizeName, isAbortExit } = require('./run-launch');
25
25
  const runState = require('./run-state');
26
+ const { buildRunStatsEntry } = require('./run-assemble');
26
27
 
27
28
  /**
28
29
  * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
@@ -31,7 +32,12 @@ const runState = require('./run-state');
31
32
  * extraLabeled?: Array<{label: string, text: string}>}} args
32
33
  * `extraLabeled` (v4.1 §4.4) are labeled reviews sourced from a FILE rather than
33
34
  * a leg (the Claude review): they join the judged BUNDLE, never the judge ROSTER.
34
- * @returns {Promise<{aborted: number|null, judgeResults: Array}>}
35
+ * @returns {Promise<{aborted: number|null, judgeResults: Array, extraRows: Array}>}
36
+ * `extraRows` (v4.7 D2, mirroring runStage1's channel) is one `role:'repair'`
37
+ * row per `-q<N>` judge-repair solo (error status when the repair itself
38
+ * failed) — the judge's own judgeResults entry keeps attributing its ORIGINAL
39
+ * Stage-2 wave leg throughout (the #83 comment below), so a repair never
40
+ * overwrites the primary judge row; it only adds this separate one.
35
41
  */
36
42
  async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled = [] }) {
37
43
  const { o } = ctx;
@@ -59,12 +65,18 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
59
65
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
60
66
  noCostGate: o.noCostGate,
61
67
  councilRunId: o.runId, councilName: o.councilName,
68
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
62
69
  fallback: o.fallback, catalog: o.catalog,
63
70
  });
64
71
  ctx.addWave(wave);
65
- if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [] }; }
72
+ if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [], extraRows: [] }; }
66
73
 
67
74
  const judgeResults = [];
75
+ // v4.7 D2: every judge-repair launch is a billed leg of its own, distinct from
76
+ // the judge's original Stage-2 wave leg it is trying to fix — it gets its own
77
+ // row so its cost is never folded into, or lost from, the judge's row (mirrors
78
+ // runStage1's -p<N> extraRows, ./run-stages.js:117-120).
79
+ const extraRows = [];
68
80
  let repairSeq = 0;
69
81
  for (const leg of (wave && wave.legs) || []) {
70
82
  const judge = leg.modelInput || leg.model;
@@ -95,10 +107,19 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
95
107
  project: ctx.scratchDir, waveId, timeout: o.timeout,
96
108
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
97
109
  councilRunId: o.runId, councilName: o.councilName,
110
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
98
111
  fallback: o.fallback, catalog: o.catalog,
99
112
  });
100
113
  ctx.addWave(solo.wave);
101
- if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, judgeResults }; }
114
+ if (isAbortExit(solo.exitCode)) {
115
+ // Abort paths add no rows (aborted runs never reach tally) — extraRows
116
+ // is returned only for shape consistency, never read past this point.
117
+ return { aborted: solo.exitCode, judgeResults, extraRows };
118
+ }
119
+ // Every -q<N> launch gets a row — INCLUDING a repair that failed: the
120
+ // error status rides naturally off solo.leg (null/'error'-status leg ⇒
121
+ // buildRunStatsEntry's own never-invent defaults), no special-casing needed.
122
+ extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: judge, role: 'repair', wasChair: false }));
102
123
  const out = (solo.leg && solo.leg.summary) || '';
103
124
  if (out.trim()) { judging = out; }
104
125
  parsed = parseJudgeOutput(out, parseCtx);
@@ -120,7 +141,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
120
141
  judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
121
142
  leg: leg || null });
122
143
  }
123
- return { aborted: null, judgeResults };
144
+ return { aborted: null, judgeResults, extraRows };
124
145
  }
125
146
 
126
147
  module.exports = { runStage2 };