amicus 4.6.0 → 4.6.2

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +128 -0
  3. package/README.md +2 -2
  4. package/docs/ROADMAP.md +45 -8
  5. package/docs/configuration.md +13 -9
  6. package/docs/council.md +7 -2
  7. package/docs/publishing.md +1 -1
  8. package/docs/troubleshooting.md +49 -20
  9. package/docs/usage.md +21 -1
  10. package/electron/setup-ui-aliases.js +2 -2
  11. package/electron/workspace-ui/index.html +3 -0
  12. package/electron/workspace-ui/live-model.js +71 -0
  13. package/electron/workspace-ui/workspace-app.js +2 -2
  14. package/electron/workspace-ui/workspace-panels.js +9 -10
  15. package/electron/workspace-ui/workspace-seats.js +117 -0
  16. package/electron/workspace-ui/workspace-verbs.js +1 -0
  17. package/electron/workspace-ui/workspace.css +6 -0
  18. package/package.json +1 -1
  19. package/schemas/alias-audit.schema.json +6 -1
  20. package/schemas/council-run.schema.json +14 -0
  21. package/skills/second-opinion/MODEL-NOTES.md +182 -35
  22. package/src/cli-handlers-doctor.js +16 -4
  23. package/src/cli.js +4 -0
  24. package/src/council/run-chair.js +49 -3
  25. package/src/council/run-launch.js +4 -0
  26. package/src/council/run-retry-notes.js +74 -0
  27. package/src/council/run-retry.js +280 -0
  28. package/src/council/run-stages.js +41 -11
  29. package/src/council/verdict.js +8 -1
  30. package/src/headless.js +119 -9
  31. package/src/mcp-council-awareness.js +1 -0
  32. package/src/mcp-server.js +17 -2
  33. package/src/mcp-tools.js +5 -1
  34. package/src/opencode-client.js +21 -0
  35. package/src/sidecar/fanout-leg.js +2 -2
  36. package/src/sidecar/fanout.js +1 -1
  37. package/src/sidecar/models-probe.js +119 -0
  38. package/src/sidecar/models.js +81 -6
  39. package/src/utils/alias-audit.js +52 -1
  40. package/src/utils/base-url-classify.js +74 -0
  41. package/src/utils/council-presets.js +6 -2
  42. package/src/utils/curated-models.js +29 -10
  43. package/src/utils/degrade.js +1 -0
  44. package/src/utils/doctor-base-url-check.js +41 -0
  45. package/src/utils/model-fetcher.js +1 -0
  46. package/src/utils/model-tiers.js +28 -7
  47. package/src/utils/no-output-backstop.js +48 -0
  48. package/src/utils/remediation-hints.js +15 -11
  49. package/src/utils/result-schema.js +29 -2
  50. package/src/utils/update-notice.js +171 -0
  51. package/src/workspace/live-normalize.js +1 -0
@@ -12,6 +12,8 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
12
12
  // local-providers check body (v4.2 §4.7 C8) — split out to keep this file
13
13
  // under the gate (mirrors the engineCheck/mcpChecks split above).
14
14
  const localProvidersCheck = require('./utils/doctor-local-providers-check');
15
+ // v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
16
+ const baseUrlCheck = require('./utils/doctor-base-url-check');
15
17
 
16
18
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
17
19
 
@@ -38,6 +40,7 @@ function realDeps() {
38
40
  readCache: () => require('./utils/model-catalog').readCache(),
39
41
  collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
40
42
  findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
43
+ findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
41
44
  hasOpencodeBinary: () => {
42
45
  // Single source of truth shared with the runtime server-start guard.
43
46
  const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
@@ -148,12 +151,21 @@ async function runDoctorChecks(depsOverride = {}) {
148
151
  checks.push(guard('aliases', 'Model aliases', () => {
149
152
  const cache = d.readCache();
150
153
  const catalog = (cache && cache.models) || [];
151
- const stale = d.findStaleAliases(d.collectAliasSources(), catalog);
152
- return stale.length === 0
153
- ? { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null }
154
- : { id: 'aliases', name: 'Model aliases', status: 'warn', message: `${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`, hint: 'amicus models --check' };
154
+ const sources = d.collectAliasSources();
155
+ const stale = d.findStaleAliases(sources, catalog);
156
+ const drifted = d.findDriftedStoredAliases(sources, catalog);
157
+ if (stale.length === 0 && drifted.length === 0) {
158
+ return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
159
+ }
160
+ const parts = [];
161
+ if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
162
+ if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
163
+ return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
155
164
  }));
156
165
 
166
+ checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
167
+ () => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
168
+
157
169
  checks.push(guard('opencode-bin', 'OpenCode binary', () => (
158
170
  d.hasOpencodeBinary()
159
171
  ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
package/src/cli.js CHANGED
@@ -149,6 +149,7 @@ const BOOLEAN_FLAGS = [
149
149
  'md', // council report: emit Markdown (default)
150
150
  'fix', // doctor: self-heal fixable checks in place (#56)
151
151
  'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
152
+ 'live', // models --check: opt-in probe of stored aliases with real engine legs (v4.6.2 PR3, spec §6)
152
153
  'render', // council verdict: also refresh report.html next to the decided verdict
153
154
  'claude', // init: register for Claude Code only (Task 15)
154
155
  'desktop', // init: register for Claude Desktop only (Task 15)
@@ -501,6 +502,9 @@ Options for 'models':
501
502
  --strict With --check: also exit non-zero on curated
502
503
  per-gateway drift (stale/divergent direct or
503
504
  openrouter forms). Informational without it.
505
+ --live With --check: probe every stored alias with one real
506
+ engine leg (spends) — served / accepted-but-silent /
507
+ error. Requires --check.
504
508
  --json Machine-readable output
505
509
  `,
506
510
  list: `
@@ -42,6 +42,32 @@ function pickFallbackChair(statsRows, bench, failedChair) {
42
42
  return candidates.length ? candidates[0].model : null;
43
43
  }
44
44
 
45
+ /**
46
+ * Outcome taxonomy for one fallback-walk attempt (spec §8, LC-5). The ch4
47
+ * VERDICT repair is deliberately NOT an attempt: its chair leg already
48
+ * completed — only the verdict line is being re-prompted — and the outcome
49
+ * enum has no honest value for it.
50
+ * @param {object|null} rawLeg the UNFILTERED leg (attemptChair nulls `leg` on
51
+ * failure; this is the one before that narrowing, so a failed leg document
52
+ * is still visible here)
53
+ * @param {object|null} [errorDoc] set when the launch never produced a wave
54
+ * at all (pre-flight refusal) — the only source of a reason in that case
55
+ * @returns {{outcome: 'completed'|'error'|'timeout'|'no-output', reason: string|null}}
56
+ */
57
+ function classifyChairAttempt(rawLeg, errorDoc) {
58
+ if (!rawLeg) {
59
+ const reason = (errorDoc && (errorDoc.message || errorDoc.reason)) || 'no leg document';
60
+ return { outcome: 'error', reason };
61
+ }
62
+ if (rawLeg.status === 'timeout') { return { outcome: 'timeout', reason: rawLeg.reason || null }; }
63
+ if (rawLeg.status === 'complete') {
64
+ const hasOutput = rawLeg.summary && String(rawLeg.summary).trim();
65
+ return hasOutput ? { outcome: 'completed', reason: null }
66
+ : { outcome: 'no-output', reason: rawLeg.reason || null };
67
+ }
68
+ return { outcome: 'error', reason: rawLeg.reason || rawLeg.error || String(rawLeg.status) };
69
+ }
70
+
45
71
  /**
46
72
  * Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
47
73
  * single VERDICT-line repair re-prompt.
@@ -76,12 +102,28 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
76
102
  addWave(solo.wave);
77
103
  const ok = solo.leg && solo.leg.status === 'complete'
78
104
  && solo.leg.summary && solo.leg.summary.trim();
79
- return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
105
+ // rawLeg is the UN-nulled leg the classifier needs to see a failed leg
106
+ // document, not just the ok/null collapse the rest of the walk consumes.
107
+ return { leg: ok ? solo.leg : null, exitCode: solo.exitCode, errorDoc: solo.errorDoc, rawLeg: solo.leg };
80
108
  };
81
109
 
82
110
  let chairLeg = null;
83
111
  let actualChair = null;
84
112
  let skippedForCost = false;
113
+ // Additive on run.json (LC-5): one entry per resolved attempt (ch1/ch2/ch3;
114
+ // ch4 is a repair, not an attempt — see classifyChairAttempt). Declared here
115
+ // (not inside the else branch below) so it stays in scope for the
116
+ // chair-failed why enrichment after the branch closes, and so a
117
+ // cost-skipped chair (the `if` branch) simply never calls recordAttempt —
118
+ // chairAttempts is never checkpointed and the key stays absent on run.json.
119
+ const chairAttempts = [];
120
+ const recordAttempt = (attempt, waveId, model) => {
121
+ const cls = classifyChairAttempt(attempt.rawLeg, attempt.errorDoc);
122
+ chairAttempts.push({ waveId, model, outcome: cls.outcome, reason: cls.reason });
123
+ // Checkpointed HERE, before the caller's own isAbortExit bail — a mid-walk
124
+ // kill must not lose the attempts already resolved (spec §8 kill-mid-walk).
125
+ runState.checkpoint(o.runDir, { chairAttempts });
126
+ };
85
127
  if (overBudget()) {
86
128
  // Ceiling hit after the tally is computable: skip the chair, write the
87
129
  // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
@@ -102,9 +144,11 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
102
144
  // Fallback chain (spec §4): retry same chair once → promote best
103
145
  // non-bench model from the ledger → give up (no Claude fallback headless).
104
146
  let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
147
+ recordAttempt(attempt, `${o.runId}-ch1`, o.chair);
105
148
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
106
149
  if (!attempt.leg && !overBudget()) {
107
150
  attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
151
+ recordAttempt(attempt, `${o.runId}-ch2`, o.chair);
108
152
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
109
153
  }
110
154
  if (attempt.leg) { actualChair = o.chair; }
@@ -114,6 +158,7 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
114
158
  const fallback = pickFallbackChair(statsRows, o.models, o.chair);
115
159
  if (fallback) {
116
160
  attempt = await attemptChair(fallback, `${o.runId}-ch3`);
161
+ recordAttempt(attempt, `${o.runId}-ch3`, fallback);
117
162
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
118
163
  if (attempt.leg) { actualChair = fallback; }
119
164
  }
@@ -160,7 +205,8 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
160
205
  what: 'the council has no chair synthesis',
161
206
  why: chairLeg
162
207
  ? 'the chair ran but its output carried no parseable VERDICT: line'
163
- : 'no chair leg completed, including after the fallback chain',
208
+ : `no chair leg completed after the fallback walk — ${chairAttempts.map(a =>
209
+ `${a.waveId.split('-').pop()} ${a.model}: ${a.reason || a.outcome}`).join(' · ')}`,
164
210
  effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
165
211
  });
166
212
  }
@@ -170,4 +216,4 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
170
216
  };
171
217
  }
172
218
 
173
- module.exports = { runChair, pickFallbackChair };
219
+ module.exports = { runChair, pickFallbackChair, classifyChairAttempt };
@@ -94,6 +94,10 @@ function createLaunchers(deps = {}) {
94
94
  // part of that allowance in the meantime. This is the CLAIM that settles
95
95
  // it — synchronous by contract, so two callers can never interleave.
96
96
  ...(reserveBudget ? { reserveBudget: (est) => reserveBudget(opts.waveId, est) } : {}),
97
+ // SL-2: a Stage-1 retry names the wave it replaces; fanout threads this
98
+ // onto every leg and its spend-ledger row (v4.3 --retry-failed machinery).
99
+ // Spread-guarded so a normal launch's transport call stays byte-identical.
100
+ ...(opts.retryOfWaveId ? { retryOfWaveId: opts.retryOfWaveId } : {}),
97
101
  models: opts.models.join(','),
98
102
  prompt: opts.prompt,
99
103
  promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module council/run-retry-notes
5
+ * Pure note-builders for the SL-2 Stage-1 retry pass (split out of
6
+ * run-retry.js for the 300-line gate — same rationale as run-stage2.js
7
+ * splitting off run-stages.js, v4.4.1 Task 2). No I/O, no ctx: each function
8
+ * takes plain data and returns a still-dead note ready for
9
+ * `ctx.degrade.note(...)` (D5 final-failure granularity, spec §5). The heal
10
+ * note is built inline in run-retry.js's orchestrator (it is the one place
11
+ * that decides recovery, and stays small).
12
+ */
13
+
14
+ /** D-effect parity: still-dead leg notes reuse today's count phrasing, with the
15
+ * FIRST attempt's counts — the why carries the retry story (spec §5). */
16
+ const legEffect = (counts) =>
17
+ `${counts.reviewed} of ${counts.total} seats reviewed; `
18
+ + 'the run continues with the bench that did and will exit degraded (2)';
19
+
20
+ /** Wave-origin, retry wave died wholesale (D5 wave granularity). */
21
+ function waveStillDeadNote(w, unit) {
22
+ return { channel: 'dead-wave',
23
+ what: `Stage-1 wave ${w.waveId} (${(w.models || []).join(', ') || 'no models'}) produced NO legs`,
24
+ // Coordinator-review MINOR-7c: a falsy w.reason must not render as the
25
+ // literal string "undefined" in the why text.
26
+ why: `${w.reason || 'no reason recorded'}; the once-only retry wave also produced no legs`,
27
+ effect: 'Those seats are NOT in this council. The run continues with the bench that did '
28
+ + 'launch and will exit degraded (2)',
29
+ data: { waveId: w.waveId, models: w.models, reason: w.reason, retryWaveId: unit.waveId } };
30
+ }
31
+
32
+ /** Leg-origin, retry wave died wholesale (bench-batch case). */
33
+ function srcLegStillDeadNote(leg, unit, counts) {
34
+ const seat = leg.modelInput || leg.model;
35
+ return { channel: 'dead-leg', what: `seat ${seat} did not review`,
36
+ why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output; `
37
+ + 'its once-only retry wave produced no legs',
38
+ effect: legEffect(counts),
39
+ data: { seat, status: leg.status, reason: leg.error || null, retryWaveId: unit.waveId } };
40
+ }
41
+
42
+ /** Either origin, the retry produced legs but THIS seat's retry leg died. */
43
+ function retryLegStillDeadNote(seat, ff, retryLeg, unit, counts) {
44
+ const why = ff && ff.class === 'wave'
45
+ ? `its first wave ${ff.waveId} produced no legs (${ff.reason}); `
46
+ + `its once-only retry leg ended '${retryLeg.status}' with no usable output`
47
+ : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} `
48
+ + `with no usable output; its once-only retry also ended '${retryLeg.status}'`;
49
+ return { channel: 'dead-leg', what: `seat ${seat} did not review`, why,
50
+ effect: legEffect(counts),
51
+ data: { seat, status: retryLeg.status, reason: retryLeg.error || null,
52
+ firstFailure: ff, retryWaveId: unit.waveId } };
53
+ }
54
+
55
+ /**
56
+ * CRITICAL fix (coordinator review): a launched seat can be missing a leg
57
+ * record ENTIRELY from the retry response — a partial wave return (unit
58
+ * models [a,b], the wave comes back with only a's leg). This is distinct
59
+ * from `retryLegStillDeadNote` (the seat's retry leg came back but was
60
+ * unusable) — here there is no retry-attempt status/error to report at all,
61
+ * only the ORIGINAL first-failure fact plus the fact that nothing came back
62
+ * this time.
63
+ */
64
+ function missingLegStillDeadNote(seat, ff, unit, counts) {
65
+ const fact = ff && ff.class === 'wave'
66
+ ? `its first wave ${ff.waveId} produced no legs (${ff.reason})`
67
+ : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} with no usable output`;
68
+ return { channel: 'dead-leg', what: `seat ${seat} did not review`,
69
+ why: `${fact}; its once-only retry produced no leg for this seat`,
70
+ effect: legEffect(counts),
71
+ data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };
72
+ }
73
+
74
+ module.exports = { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote };
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module council/run-retry
5
+ * SL-2 (spec: docs/superpowers/specs/2026-08-03-sl2-stage1-retry-design.md):
6
+ * the Stage-1 once-only retry pass. A sub-wave that died before its legs
7
+ * existed, or a leg that ended with no usable output, is relaunched exactly
8
+ * once — serially, after every surviving launch settled — and the outcome is
9
+ * announced in the one voice: a `stage1-retry` HEAL per recovered seat; the
10
+ * ordinary dead-wave/dead-leg degrade, noted by the CALLER (run-stages.js),
11
+ * when the retry also died. This module emits heals only — it never notes a
12
+ * degrade and never touches `degraded.value`, so the sink invariant holds by
13
+ * construction. No retry of a retry: the pass consumes first-attempt losses
14
+ * only.
15
+ */
16
+
17
+ const briefings = require('./briefings');
18
+ const { materializeReviews, isAbortExit } = require('./run-launch');
19
+ const runState = require('./run-state');
20
+ const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
21
+ = require('./run-retry-notes');
22
+
23
+ /** 1-based lens index for a loss, from the waveId convention or the model. */
24
+ function lensIndexOf(o, waveId, model) {
25
+ const m = /-l(\d+)$/.exec(waveId || '');
26
+ if (m) { return Number(m[1]); }
27
+ const i = (o.models || []).indexOf(model);
28
+ return i === -1 ? null : i + 1;
29
+ }
30
+
31
+ /**
32
+ * Dedup helper (Task-4 review hardening): the same seat can arrive twice in
33
+ * one grouping pass — two dead legs naming it, or a dead wave and a dead leg
34
+ * both naming it. One seat must still mean ONE `firstFailures` entry — first
35
+ * occurrence wins — while every SOURCE record is kept regardless (srcWaves/
36
+ * srcLegs are the audit trail and are never deduped). The critic unit's
37
+ * `.models` is fixed at creation (there is only ever one critic seat), so
38
+ * only bench/lens units grow `.models` here — the critic call sites pass
39
+ * `trackModel: false`.
40
+ */
41
+ function recordFailure(unit, seat, ff, trackModel = true) {
42
+ if (unit.firstFailures.some(f => f.seat === seat)) { return; }
43
+ unit.firstFailures.push(ff);
44
+ if (trackModel) { unit.models.push(seat); }
45
+ }
46
+
47
+ /**
48
+ * Group Stage-1 losses into retry units. Pure — no I/O.
49
+ * Bench losses (a dead bench wave's models + dead bench legs) collapse into
50
+ * ONE retry wave; the critic and each lens retry as solos (their briefings
51
+ * differ). Stable order: bench, critic, lenses ascending. The critic matches
52
+ * on EITHER carrier — waveId convention or model — mirroring
53
+ * verdict.js summarizeSeatLoss.
54
+ */
55
+ function groupStage1Losses(o, deadWaves = [], deadLegs = []) {
56
+ const isCriticWave = (w) =>
57
+ w.waveId === `${o.runId}-c1` || (!!o.critic && (w.models || []).includes(o.critic));
58
+ const bench = { unit: 'bench', waveId: `${o.runId}-s1r1`, retryOfWaveId: `${o.runId}-s1`,
59
+ models: [], firstFailures: [], srcWaves: [], srcLegs: [] };
60
+ const lensUnits = new Map(); // lensIndex (number, or null for unmappable) -> unit
61
+ const criticUnit = { unit: 'critic', waveId: `${o.runId}-c1r1`, retryOfWaveId: `${o.runId}-c1`,
62
+ models: o.critic ? [o.critic] : [], firstFailures: [], srcWaves: [], srcLegs: [] };
63
+
64
+ const lensUnitFor = (i) => {
65
+ if (!lensUnits.has(i)) {
66
+ // Task-4 review hardening: an unmappable loss (lensIndexOf resolved
67
+ // neither the waveId convention nor a model-roster membership) must
68
+ // still be GROUPABLE — dropping it here would let it vanish before the
69
+ // orchestrator ever sees it — but must not manufacture a fake
70
+ // `-lnullr1` waveId. The orchestrator refuses to launch any unit with
71
+ // `lensIndex === null` and routes its sources to skipped instead.
72
+ lensUnits.set(i, i === null
73
+ ? { unit: 'lens', lensIndex: null, waveId: null, retryOfWaveId: null,
74
+ models: [], firstFailures: [], srcWaves: [], srcLegs: [] }
75
+ : { unit: 'lens', lensIndex: i, waveId: `${o.runId}-l${i}r1`,
76
+ retryOfWaveId: `${o.runId}-l${i}`, models: [], firstFailures: [], srcWaves: [], srcLegs: [] });
77
+ }
78
+ return lensUnits.get(i);
79
+ };
80
+
81
+ for (const w of deadWaves) {
82
+ const models = w.models || [];
83
+ if (o.lenses) {
84
+ const u = lensUnitFor(lensIndexOf(o, w.waveId, models[0]));
85
+ u.srcWaves.push(w);
86
+ models.forEach(seat => recordFailure(u, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
87
+ } else if (isCriticWave(w)) {
88
+ criticUnit.srcWaves.push(w);
89
+ recordFailure(criticUnit, o.critic,
90
+ { seat: o.critic, class: 'wave', waveId: w.waveId, reason: w.reason }, false);
91
+ } else {
92
+ bench.srcWaves.push(w);
93
+ models.forEach(seat => recordFailure(bench, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
94
+ }
95
+ }
96
+ for (const leg of deadLegs) {
97
+ const seat = leg.modelInput || leg.model;
98
+ const ff = { seat, class: 'leg', status: leg.status, reason: leg.error || null };
99
+ if (o.lenses) {
100
+ const u = lensUnitFor(lensIndexOf(o, null, seat));
101
+ u.srcLegs.push(leg);
102
+ recordFailure(u, seat, ff);
103
+ } else if (o.critic && seat === o.critic) {
104
+ criticUnit.srcLegs.push(leg);
105
+ recordFailure(criticUnit, seat, ff, false);
106
+ } else {
107
+ bench.srcLegs.push(leg);
108
+ recordFailure(bench, seat, ff);
109
+ }
110
+ }
111
+
112
+ const out = [];
113
+ // Task-4 review hardening: gate on whether the unit received any SOURCE
114
+ // record, not on firstFailures.length — a zero-model dead wave contributes
115
+ // a srcWaves entry but nothing to firstFailures/models (nothing for the
116
+ // `.forEach` above to iterate), and must still surface here so the
117
+ // orchestrator can route it to skipped instead of it vanishing silently.
118
+ if (bench.srcWaves.length > 0 || bench.srcLegs.length > 0) { out.push(bench); }
119
+ if (criticUnit.srcWaves.length > 0 || criticUnit.srcLegs.length > 0) { out.push(criticUnit); }
120
+ // Coordinator-review MINOR-7a: null sorts LAST (Infinity), not first (0) —
121
+ // an unmappable loss is not "lens index 0"; it should not perturb the
122
+ // ascending order of the real, well-indexed lens retries.
123
+ out.push(...[...lensUnits.values()].sort((a, b) => (a.lensIndex ?? Infinity) - (b.lensIndex ?? Infinity)));
124
+ return out;
125
+ }
126
+
127
+ /** The briefing a retry unit re-issues — same builders Stage 1 used. */
128
+ function briefingFor(o, unit) {
129
+ if (unit.unit === 'critic') { return briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }); }
130
+ if (unit.unit === 'lens') {
131
+ return briefings.buildLensBriefing({ lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
132
+ }
133
+ return briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date });
134
+ }
135
+
136
+ /**
137
+ * The retry pass. Serial by design (spec D-order: bench, critic, lenses) —
138
+ * the per-wave-fallback path, where waves actually die, is exactly where
139
+ * concurrent relaunches would race the same server start again.
140
+ */
141
+ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts = { reviewed: 0, total: 0 } } = {}) {
142
+ const { o, launchers } = ctx;
143
+ const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
144
+ stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [] };
145
+
146
+ for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
147
+ // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
148
+ // unmappable lens loss (no carrier resolved an index), a lens index
149
+ // outside the run's actual lens roster (coordinator-review MINOR-7b: a
150
+ // malformed waveId like "...-l99" must not become an out-of-range
151
+ // `o.lenses[98]` access inside briefingFor), or a unit whose sources
152
+ // named zero models — is never launched. Its sources fall back to the
153
+ // ordinary skipped-loss path so the caller's normal degrade notes still
154
+ // fire; being unmappable is not an exemption from the record.
155
+ const lensOutOfRange = unit.unit === 'lens' && unit.lensIndex !== null
156
+ && (unit.lensIndex < 1 || unit.lensIndex > (o.lenses || []).length);
157
+ if (unit.lensIndex === null || lensOutOfRange || unit.models.length === 0) {
158
+ out.skippedDeadWaves.push(...unit.srcWaves);
159
+ out.skippedDeadLegs.push(...unit.srcLegs);
160
+ continue;
161
+ }
162
+ if (ctx.overBudget()) { // D7: skip silently — the loss is already announced by the caller
163
+ out.skippedDeadWaves.push(...unit.srcWaves);
164
+ out.skippedDeadLegs.push(...unit.srcLegs);
165
+ continue;
166
+ }
167
+ runState.appendStageWave(o.runDir, 'stage1', unit.waveId); // BEFORE launch: abort cascade
168
+ const common = { project: o.runDir, timeout: o.timeout, gateway: o.gateway,
169
+ noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
170
+ councilRunId: o.runId, councilName: o.councilName,
171
+ fallback: o.fallback, catalog: o.catalog,
172
+ waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit) };
173
+ // Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
174
+ // even down to its last surviving seat — critic/lens are always solos).
175
+ // A model-count proxy (`models.length === 1`) is wrong for a bench unit
176
+ // that lost exactly one seat: it would route that retry through
177
+ // launchSolo, which no bench caller wires up.
178
+ const res = unit.unit === 'bench'
179
+ ? await launchers.launchWave({ ...common, models: unit.models.slice() })
180
+ : await launchers.launchSolo({ ...common, model: unit.models[0] });
181
+ ctx.addWave(res.wave); // reservation released + measured legs counted (run-budget)
182
+ if (isAbortExit(res.exitCode)) { out.aborted = res.exitCode; return out; }
183
+
184
+ const legs = (res.wave && Array.isArray(res.wave.legs)) ? res.wave.legs : [];
185
+ if (legs.length === 0) {
186
+ // The retry wave itself died wholesale — final failure keeps each
187
+ // source's granularity (D5): wave-origin stays a dead-wave, leg-origin
188
+ // stays a dead-leg, both enriched with the retry fact. Coordinator-
189
+ // review MINOR-4: emitted ONCE per SEAT — the grouping dedup (Task-4
190
+ // hardening) keeps BOTH src records when a seat arrives via a srcWave
191
+ // AND a srcLeg (or via two srcLegs), so without this a single lost
192
+ // seat could be announced twice. Waves are processed first (mirrors
193
+ // the "wave wins" precedent from the grouping-level dedup); a seat
194
+ // already covered by its wave's note is skipped when its srcLeg is
195
+ // reached.
196
+ const notedSeats = new Set();
197
+ for (const w of unit.srcWaves) {
198
+ out.stillDeadNotes.push(waveStillDeadNote(w, unit));
199
+ out.stillDeadWaves.push(w);
200
+ for (const m of (w.models || [])) { notedSeats.add(m); }
201
+ }
202
+ for (const l of unit.srcLegs) {
203
+ const seat = l.modelInput || l.model;
204
+ if (notedSeats.has(seat)) { continue; }
205
+ notedSeats.add(seat);
206
+ out.stillDeadNotes.push(srcLegStillDeadNote(l, unit, counts));
207
+ out.stillDeadLegs.push(l);
208
+ }
209
+ continue;
210
+ }
211
+ const usable = new Set(materializeReviews(o.runDir, legs).map(m => m.leg));
212
+ const seenSeats = new Set(legs.map(leg => leg.modelInput || leg.model));
213
+ const lostWaveSeats = new Map(); // waveId -> seats still lost from a wave-origin
214
+ for (const leg of legs) {
215
+ const seat = leg.modelInput || leg.model;
216
+ const ff = unit.firstFailures.find(f => f.seat === seat) || null;
217
+ // SL-2 fix-wave: a retry response should only ever name seats THIS unit
218
+ // launched for (unit.models is built in lockstep with firstFailures via
219
+ // groupStage1Losses's recordFailure) — but if a leg turns up for a seat
220
+ // with no firstFailures entry, that seat never lost its seat in the
221
+ // first place. Skip it entirely: no heal (it would fabricate an "ended
222
+ // 'unknown'" why for a seat that never failed) and no still-dead note —
223
+ // the seat's first-attempt review stands untouched, rather than this
224
+ // stray leg doubling it into a duplicate bench entry alongside the real one.
225
+ if (!ff) { continue; }
226
+ if (usable.has(leg)) {
227
+ out.recoveredLegs.push(leg);
228
+ ctx.degrade.note({ channel: 'stage1-retry', kind: 'heal',
229
+ what: `seat ${seat} reviewed on retry`,
230
+ why: ff && ff.class === 'wave'
231
+ ? `its first wave ${ff.waveId} produced no legs (${ff.reason}) and was relaunched once`
232
+ : `its first leg ended '${ff ? ff.status : 'unknown'}' with no usable output and was relaunched once`,
233
+ effect: 'The seat is in this council; nothing was lost',
234
+ data: { seat, retryWaveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, firstFailure: ff } });
235
+ } else {
236
+ out.stillDeadNotes.push(retryLegStillDeadNote(seat, ff, leg, unit, counts));
237
+ if (ff && ff.class === 'wave') {
238
+ if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
239
+ lostWaveSeats.get(ff.waveId).push(seat);
240
+ } else {
241
+ const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
242
+ if (src) { out.stillDeadLegs.push(src); }
243
+ }
244
+ }
245
+ }
246
+ // CRITICAL fix (coordinator review): the loop above only visits seats
247
+ // that came back WITH a leg record. A partial wave return (unit models
248
+ // [a,b], the wave comes back with only a's leg) leaves 'b' invisible to
249
+ // that loop entirely — no heal, no still-dead note, no skip: it would
250
+ // vanish from every array. Reconcile against the full launched-seat set
251
+ // (the union of unit.models, every srcWave's models, and every srcLeg's
252
+ // seat — not just unit.models alone, so this holds even if some future
253
+ // change to the grouping made unit.models an incomplete union) so every
254
+ // launched seat lands in exactly one of recovered / still-dead / skipped.
255
+ const launchedSeats = new Set(unit.models);
256
+ for (const w of unit.srcWaves) { for (const m of (w.models || [])) { launchedSeats.add(m); } }
257
+ for (const l of unit.srcLegs) { launchedSeats.add(l.modelInput || l.model); }
258
+ for (const seat of launchedSeats) {
259
+ if (seenSeats.has(seat)) { continue; } // already handled above (healed or still-dead)
260
+ const ff = unit.firstFailures.find(f => f.seat === seat) || null;
261
+ out.stillDeadNotes.push(missingLegStillDeadNote(seat, ff, unit, counts));
262
+ if (ff && ff.class === 'wave') {
263
+ if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
264
+ lostWaveSeats.get(ff.waveId).push(seat);
265
+ } else {
266
+ const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
267
+ if (src) { out.stillDeadLegs.push(src); }
268
+ }
269
+ }
270
+ // Wave-origin seats still lost: the return-contract wave entry carries only
271
+ // the still-lost subset (a partially healed wave is not wholly dead).
272
+ for (const w of unit.srcWaves) {
273
+ const lost = lostWaveSeats.get(w.waveId) || [];
274
+ if (lost.length > 0) { out.stillDeadWaves.push({ ...w, models: lost }); }
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ module.exports = { groupStage1Losses, retryStage1Losses };
@@ -20,6 +20,7 @@
20
20
  const { validateFindings, countAttemptedFindings } = require('./findings');
21
21
  const briefings = require('./briefings');
22
22
  const { materializeReviews, isAbortExit } = require('./run-launch');
23
+ const { retryStage1Losses } = require('./run-retry');
23
24
  const runState = require('./run-state');
24
25
  const { runStage2 } = require('./run-stage2');
25
26
 
@@ -133,7 +134,25 @@ async function runStage1(ctx) {
133
134
  const { aborted, legs, deadWaves } = await launchStage1(ctx);
134
135
  if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
135
136
 
136
- for (const d of deadWaves) {
137
+ const firstPass = materializeReviews(o.runDir, legs);
138
+ const alive0 = new Set(firstPass.map(m => m.leg));
139
+ const deadLegs0 = legs.filter(l => !alive0.has(l));
140
+
141
+ // SL-2: one retry BEFORE anything is recorded lost — the sink never
142
+ // un-flips, so a degrade for a seat the retry saves must never fire at all.
143
+ const retry = await retryStage1Losses(ctx, { deadWaves, deadLegs: deadLegs0,
144
+ counts: { reviewed: firstPass.length, total: legs.length } });
145
+ if (retry.aborted) {
146
+ // Final whole-branch review: same bug class as the post-retry-repair
147
+ // abort fixed ~87 lines below ("Must be the post-retry set") — subtract
148
+ // whatever retry.recoveredLegs already healed before this abort landed.
149
+ const healed = new Set(retry.recoveredLegs.map(l => l.modelInput || l.model));
150
+ return { aborted: retry.aborted, reviews: [], degraded: false,
151
+ deadLegs: deadLegs0.filter(l => !healed.has(l.modelInput || l.model)),
152
+ deadWaves: deadWaves.map(w => ({ ...w, models: (w.models || []).filter(m => !healed.has(m)) })).filter(w => w.models.length > 0) };
153
+ }
154
+
155
+ for (const d of retry.skippedDeadWaves) {
137
156
  ctx.degrade.note({
138
157
  channel: 'dead-wave',
139
158
  what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
@@ -143,21 +162,27 @@ async function runStage1(ctx) {
143
162
  data: { waveId: d.waveId, models: d.models, reason: d.reason },
144
163
  });
145
164
  }
146
-
147
- const materialized = materializeReviews(o.runDir, legs);
148
- const alive = new Set(materialized.map(m => m.leg));
149
- const deadLegs = legs.filter(l => !alive.has(l));
150
-
151
- for (const leg of deadLegs) {
165
+ for (const leg of retry.skippedDeadLegs) {
152
166
  ctx.degrade.note({
153
167
  channel: 'dead-leg',
154
168
  what: `seat ${leg.modelInput || leg.model} did not review`,
155
169
  why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
156
- effect: `${materialized.length} of ${legs.length} seats reviewed; `
170
+ effect: `${firstPass.length} of ${legs.length} seats reviewed; `
157
171
  + 'the run continues with the bench that did and will exit degraded (2)',
158
172
  data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
159
173
  });
160
174
  }
175
+ for (const rec of retry.stillDeadNotes) { ctx.degrade.note(rec); }
176
+
177
+ // Invariant this merge relies on: retry.recoveredLegs only ever names seats
178
+ // that actually lost their seat on the first pass (run-retry.js's recovery
179
+ // loop drops any leg for a seat with no firstFailures entry) — so `legs`
180
+ // and `recoveredLegs` can never both carry a leg for the same seat here.
181
+ // materializeReviews re-writing an already-materialized recovered leg's
182
+ // review-*.md a second time is accepted as an idempotent no-op, not a bug.
183
+ const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs]);
184
+ const stillDeadLegs = [...retry.skippedDeadLegs, ...retry.stillDeadLegs];
185
+ const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
161
186
 
162
187
  const reviews = [];
163
188
  let repairSeq = 0;
@@ -206,7 +231,12 @@ async function runStage1(ctx) {
206
231
  });
207
232
  ctx.addWave(solo.wave);
208
233
  if (isAbortExit(solo.exitCode)) {
209
- return { aborted: solo.exitCode, reviews, deadLegs, deadWaves, degraded: false };
234
+ // SL-2 fix-wave: this used to read the pre-retry `deadWaves` binding
235
+ // run.js persists this return into stage-1 state before the abort
236
+ // short-circuit, so a heal-then-abort run was recording seats as dead
237
+ // that had actually reviewed on retry. Must be the post-retry set,
238
+ // same as the normal-completion return below.
239
+ return { aborted: solo.exitCode, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves, degraded: false };
210
240
  }
211
241
  const repaired = (solo.leg && solo.leg.summary) || '';
212
242
  if (repaired.trim()) { repairing = repaired; }
@@ -255,8 +285,8 @@ async function runStage1(ctx) {
255
285
  ...(repairRefused ? { repairRefused } : {}),
256
286
  });
257
287
  }
258
- return { aborted: null, reviews, deadLegs, deadWaves,
259
- degraded: deadLegs.length > 0 || deadWaves.length > 0 };
288
+ return { aborted: null, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
289
+ degraded: stillDeadLegs.length > 0 || stillDeadWaves.length > 0 };
260
290
  }
261
291
 
262
292
  // runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
@@ -73,8 +73,15 @@ function deriveSeatLoss({ runId, critic, degrades = [] } = {}) {
73
73
  return {
74
74
  ...base,
75
75
  criticSeated: base.criticSeated && !criticLeg,
76
+ // SL-2 handoff: a reconciliation note (run-retry-notes.js's
77
+ // missingLegStillDeadNote) carries data.status: null when the retry
78
+ // produced no leg for the seat at all — there is no status to name, so
79
+ // the old `ended '${status}'` template rendered the literal string
80
+ // "ended 'null'". A status-carrying record keeps the original text.
76
81
  reason: base.reason || (criticLeg
77
- ? (criticLeg.data.reason || `the critic leg ended '${criticLeg.data.status}' with no usable output`)
82
+ ? (criticLeg.data.reason || (criticLeg.data.status
83
+ ? `the critic leg ended '${criticLeg.data.status}' with no usable output`
84
+ : 'the critic leg produced no usable output'))
78
85
  : null),
79
86
  deadBenchSeats: [...base.deadBenchSeats,
80
87
  ...legs.filter(l => l.data.seat !== critic).map(l => l.data.seat)],