amicus 4.6.0 → 4.6.1
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +40 -8
- package/docs/publishing.md +1 -1
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +182 -35
- package/src/council/run-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-stages.js +41 -11
- package/src/council/verdict.js +8 -1
- package/src/mcp-server.js +17 -2
- package/src/mcp-tools.js +5 -1
- package/src/utils/degrade.js +1 -0
- package/src/utils/remediation-hints.js +15 -11
- package/src/utils/update-notice.js +171 -0
|
@@ -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
|
-
|
|
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: `${
|
|
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
|
-
|
|
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:
|
|
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
|
package/src/council/verdict.js
CHANGED
|
@@ -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 ||
|
|
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)],
|
package/src/mcp-server.js
CHANGED
|
@@ -1459,6 +1459,21 @@ async function startMcpServer() {
|
|
|
1459
1459
|
{ capabilities: { roots: {} } }
|
|
1460
1460
|
);
|
|
1461
1461
|
|
|
1462
|
+
// MCP update notice (spec 2026-08-03): the MCP server replaces the CLI's
|
|
1463
|
+
// deliberately-skipped pre-command update check. Fire-and-forget — startup
|
|
1464
|
+
// is never delayed; when the async init resolves with an update known, one
|
|
1465
|
+
// stderr line lands in the client's MCP log. The per-result notice itself
|
|
1466
|
+
// is appended by maybeAppendUpdateNotice in the registration wrapper below.
|
|
1467
|
+
const { initUpdateCheck, getUpdateInfo } = require('./utils/updater');
|
|
1468
|
+
const { maybeAppendUpdateNotice } = require('./utils/update-notice');
|
|
1469
|
+
initUpdateCheck().then(() => {
|
|
1470
|
+
const updateInfo = getUpdateInfo();
|
|
1471
|
+
if (updateInfo && updateInfo.hasUpdate) {
|
|
1472
|
+
process.stderr.write(
|
|
1473
|
+
`[amicus] update available: v${updateInfo.current} -> v${updateInfo.latest}\n`);
|
|
1474
|
+
}
|
|
1475
|
+
}).catch(() => { /* advisory only */ });
|
|
1476
|
+
|
|
1462
1477
|
for (const tool of getTools()) {
|
|
1463
1478
|
const register = (name) => server.registerTool(
|
|
1464
1479
|
name,
|
|
@@ -1466,11 +1481,11 @@ async function startMcpServer() {
|
|
|
1466
1481
|
async (input) => {
|
|
1467
1482
|
try {
|
|
1468
1483
|
const project = await resolveProjectDir(input.project, server);
|
|
1469
|
-
return await handlers[tool.name](input, project, server);
|
|
1484
|
+
return maybeAppendUpdateNotice(await handlers[tool.name](input, project, server));
|
|
1470
1485
|
}
|
|
1471
1486
|
catch (err) {
|
|
1472
1487
|
logger.error(`MCP tool error: ${name}`, { error: err.message });
|
|
1473
|
-
return textResult(`Error: ${err.message}`, true);
|
|
1488
|
+
return maybeAppendUpdateNotice(textResult(`Error: ${err.message}`, true));
|
|
1474
1489
|
}
|
|
1475
1490
|
}
|
|
1476
1491
|
);
|
package/src/mcp-tools.js
CHANGED
|
@@ -574,9 +574,13 @@ function getGuideText() {
|
|
|
574
574
|
.map(([name, model]) => `| ${name} | ${model} |`)
|
|
575
575
|
.join('\n');
|
|
576
576
|
// #33: surface the running version (and a call-time staleness warning) so a
|
|
577
|
-
// post-upgrade agent session can tell it's running old code.
|
|
577
|
+
// post-upgrade agent session can tell it's running old code. Spec 2026-08-03
|
|
578
|
+
// adds the registry-side sibling: a newer release exists (not latched here —
|
|
579
|
+
// the guide is the on-demand surface).
|
|
578
580
|
const warn = versionWarning();
|
|
581
|
+
const updateLine = require('./utils/update-notice').guideUpdateLine();
|
|
579
582
|
const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
|
|
583
|
+
+ (updateLine ? `\n\n> ${updateLine}` : '')
|
|
580
584
|
+ (warn ? `\n\n> ⚠️ ${warn}` : '');
|
|
581
585
|
|
|
582
586
|
return `# Amicus Usage Guide
|
package/src/utils/degrade.js
CHANGED
|
@@ -16,6 +16,7 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
16
16
|
'dead-leg', 'dead-wave', 'budget-refusal', 'shared-server-unavailable',
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
|
+
'stage1-retry',
|
|
19
20
|
'internal',
|
|
20
21
|
// doctor channels
|
|
21
22
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -49,21 +49,19 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
49
49
|
/** Electron absent — reinstall to add the interactive GUI (headless still works). */
|
|
50
50
|
reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
* Electron present but broken (ABI mismatch / partial unpack). Delete the
|
|
54
|
-
* vendored copy and reinstall to force a clean rebuild.
|
|
55
|
-
*/
|
|
56
|
-
rebuildElectron:
|
|
57
|
-
'rm -rf node_modules/electron && npm install -g amicus (rebuild Electron after an ABI mismatch or partial unpack)',
|
|
58
|
-
|
|
59
52
|
/** Point the user at the single recovery hub. */
|
|
60
53
|
runDoctor: 'run: amicus doctor (diagnoses config, keys, engine & MCP, with copy-paste fixes)',
|
|
61
54
|
|
|
62
55
|
/**
|
|
63
|
-
* Self-heal the optional Electron GUI in place (#56).
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
56
|
+
* Self-heal the optional Electron GUI in place (#56). The convergence target
|
|
57
|
+
* for the "reinstall to fix Electron" hints — it provisions the binary from
|
|
58
|
+
* cache (or downloads on demand) WITHOUT a global reinstall, so it can't
|
|
59
|
+
* loop the way `npm install -g amicus` could when the rollback recurs.
|
|
60
|
+
* (`rebuildElectron`, the manual rm-rf-and-reinstall variant, was deleted
|
|
61
|
+
* 2026-08-03 by owner ruling: no live call site once this hint became the
|
|
62
|
+
* target, and its prose asserted unverified causes. A reintroduction must
|
|
63
|
+
* use the unverified-cause voice — absence-pinned in
|
|
64
|
+
* tests/remediation-hints.test.js.)
|
|
67
65
|
*/
|
|
68
66
|
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
69
67
|
|
|
@@ -80,6 +78,12 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
80
78
|
* atomic tmp-write and rename leaves a stray temp file in the config dir
|
|
81
79
|
* forever. `doctor --fix` sweeps files older than 60s (never a live writer's
|
|
82
80
|
* ms-lived tmp).
|
|
81
|
+
*
|
|
82
|
+
* Voice ruling (Christian, 2026-08-03): this hint keeps its confident cause.
|
|
83
|
+
* "Left by an interrupted write" is definitional, not a guess — the atomic
|
|
84
|
+
* write pattern admits no other producer, and the age gate excludes live
|
|
85
|
+
* writers — so the Plan 3 unverified-cause voice deliberately does NOT
|
|
86
|
+
* apply. Do not re-file it against that criterion.
|
|
83
87
|
*/
|
|
84
88
|
sweepSessionIndexTmp:
|
|
85
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/update-notice — "a newer amicus exists" for the MCP channel
|
|
3
|
+
*
|
|
4
|
+
* The MCP server is the one entry point that skips bin/amicus.js's update
|
|
5
|
+
* banner (deliberately — stdout is protocol). This module is the MCP-shaped
|
|
6
|
+
* replacement (spec docs/superpowers/specs/2026-08-03-mcp-update-notice-design.md):
|
|
7
|
+
* updater.js's cached check rendered as ONE appended text content block on the
|
|
8
|
+
* first successful tool result of the process (latched, D1), plus an always-on
|
|
9
|
+
* line in amicus_guide.
|
|
10
|
+
*
|
|
11
|
+
* Voice contract (v4.6 hint ruling): the version pair is verified fact; the
|
|
12
|
+
* upgrade instruction is stated as fact only when derived from a readable MCP
|
|
13
|
+
* registration config — fallbacks keep the "likely" hedge. Everything here is
|
|
14
|
+
* advisory: every export swallows its own failures rather than throwing into
|
|
15
|
+
* a tool result.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/** Upgrade wordings (spec §4). Config-derived rows are verified-voiced;
|
|
21
|
+
* NPX_CACHED_LINE keeps the hedge — the config read is best-effort. */
|
|
22
|
+
const GLOBAL_LINE = 'Run `npm install -g amicus`, then restart your MCP client.';
|
|
23
|
+
const NPX_LATEST_LINE = 'Restart your MCP client — it launches `amicus@latest` and will pick up the new version.';
|
|
24
|
+
const NPX_CACHED_LINE = 'Your MCP config likely launches a cached/pinned npx copy; '
|
|
25
|
+
+ 'point it at `npx -y amicus@latest mcp` (or clear the npx cache), then restart your MCP client.';
|
|
26
|
+
const GENERIC_LINE = 'Upgrade your amicus install, then restart your MCP client.';
|
|
27
|
+
|
|
28
|
+
const CHANGELOG_URL = 'https://github.com/BourbonDog/amicus/blob/main/CHANGELOG.md';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flavor of THIS install — the copy serving the current process. Pure path
|
|
32
|
+
* heuristic on the realpath of our own package.json (no `npm root -g` shellout
|
|
33
|
+
* on the tool-result path): a `_npx` segment is the npx cache; any other
|
|
34
|
+
* `node_modules` home is a global-style install; no `node_modules` at all is a
|
|
35
|
+
* dev clone or similar.
|
|
36
|
+
* @param {{fs?: object, pkgPath?: string}} [deps]
|
|
37
|
+
* @returns {'global'|'npx'|'other'}
|
|
38
|
+
*/
|
|
39
|
+
function classifySelfInstall(deps = {}) {
|
|
40
|
+
const fs = deps.fs || require('fs');
|
|
41
|
+
const pkgPath = deps.pkgPath || require('./version-info').PKG_PATH;
|
|
42
|
+
try {
|
|
43
|
+
// Split the raw realpath — NOT path.dirname first: dirname is platform-
|
|
44
|
+
// bound (posix dirname collapses a foreign backslash path to '.', the CI
|
|
45
|
+
// path-fixture failure class), and the basename 'package.json' can never
|
|
46
|
+
// collide with the segment names probed here.
|
|
47
|
+
const parts = fs.realpathSync(pkgPath).split(/[\\/]/);
|
|
48
|
+
if (parts.includes('_npx')) { return 'npx'; }
|
|
49
|
+
if (parts.includes('node_modules')) { return 'global'; }
|
|
50
|
+
return 'other';
|
|
51
|
+
} catch {
|
|
52
|
+
return 'other';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when some RAW config arg is the amicus package token pinned `@latest`.
|
|
58
|
+
* Raw on purpose: mcp-self-identity's normalizeToken strips `@version`
|
|
59
|
+
* suffixes, which is exactly the information this check needs.
|
|
60
|
+
* @param {{args?: unknown[]}|null|undefined} config
|
|
61
|
+
*/
|
|
62
|
+
function pinsAmicusLatest(config) {
|
|
63
|
+
const args = Array.isArray(config && config.args) ? config.args : [];
|
|
64
|
+
return args.some((a) => {
|
|
65
|
+
const t = String(a).toLowerCase().replace(/\\/g, '/');
|
|
66
|
+
const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
|
|
67
|
+
return base === 'amicus@latest';
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one correct upgrade move for this install (spec §4), chosen
|
|
73
|
+
* config-first (what a RESTART will launch), self-path fallback.
|
|
74
|
+
* Never throws; worst case is the generic line.
|
|
75
|
+
* @param {{readConfig?: Function, classifyLaunch?: Function, selfFlavor?: Function}} [deps]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function upgradeInstruction(deps = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const readConfig = deps.readConfig
|
|
81
|
+
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
82
|
+
const classifyLaunchFn = deps.classifyLaunch
|
|
83
|
+
|| require('./engine-install-scan').classifyLaunch;
|
|
84
|
+
const selfFlavor = deps.selfFlavor || (() => classifySelfInstall(deps));
|
|
85
|
+
|
|
86
|
+
let config = null;
|
|
87
|
+
try { config = readConfig(); } catch { config = null; }
|
|
88
|
+
|
|
89
|
+
const launch = classifyLaunchFn(config);
|
|
90
|
+
if (launch === 'npx') {
|
|
91
|
+
return pinsAmicusLatest(config) ? NPX_LATEST_LINE : NPX_CACHED_LINE;
|
|
92
|
+
}
|
|
93
|
+
if (launch === 'path') {
|
|
94
|
+
// A path registration launches (approximately) the running copy — let
|
|
95
|
+
// its flavor pick between the npm-global move and the generic one.
|
|
96
|
+
return selfFlavor() === 'global' ? GLOBAL_LINE : GENERIC_LINE;
|
|
97
|
+
}
|
|
98
|
+
// 'none' / 'unknown' — config unreadable or unrecognized: self-path fallback.
|
|
99
|
+
const flavor = selfFlavor();
|
|
100
|
+
if (flavor === 'global') { return GLOBAL_LINE; }
|
|
101
|
+
if (flavor === 'npx') { return NPX_CACHED_LINE; }
|
|
102
|
+
return GENERIC_LINE;
|
|
103
|
+
} catch {
|
|
104
|
+
return GENERIC_LINE;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The full notice text: verified version pair + instruction + changelog.
|
|
110
|
+
* @param {{current: string, latest: string}} info
|
|
111
|
+
* @param {string} [instruction] - resolved lazily when omitted
|
|
112
|
+
*/
|
|
113
|
+
function buildUpdateNotice(info, instruction) {
|
|
114
|
+
return `Update available: amicus v${info.current} → v${info.latest}. `
|
|
115
|
+
+ `${instruction || upgradeInstruction()} Changelog: ${CHANGELOG_URL}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Once-per-process latch (spec D1). Flips ONLY on an actual append. */
|
|
119
|
+
let _noticeShown = false;
|
|
120
|
+
|
|
121
|
+
/** Test seam: re-arm the latch. */
|
|
122
|
+
function _resetLatchForTests() { _noticeShown = false; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The seam the MCP registration wrapper routes EVERY result through: append
|
|
126
|
+
* the notice block to the first successful tool result of this process, then
|
|
127
|
+
* stay quiet. No-op on isError results, unknown update state, malformed
|
|
128
|
+
* results, or any internal failure — the original result always comes back.
|
|
129
|
+
* @param {{content?: Array, isError?: boolean}|null} result
|
|
130
|
+
* @param {{getUpdateInfo?: Function}} [deps]
|
|
131
|
+
*/
|
|
132
|
+
function maybeAppendUpdateNotice(result, deps = {}) {
|
|
133
|
+
try {
|
|
134
|
+
if (_noticeShown) { return result; }
|
|
135
|
+
if (!result || result.isError || !Array.isArray(result.content)) { return result; }
|
|
136
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
137
|
+
const info = getUpdateInfo();
|
|
138
|
+
if (!info || !info.hasUpdate) { return result; }
|
|
139
|
+
result.content.push({ type: 'text', text: buildUpdateNotice(info) });
|
|
140
|
+
_noticeShown = true;
|
|
141
|
+
return result;
|
|
142
|
+
} catch {
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The amicus_guide version-line suffix (NOT latched — the guide is the
|
|
149
|
+
* on-demand surface), or null when there is nothing to say.
|
|
150
|
+
* @param {{getUpdateInfo?: Function}} [deps] - plus upgradeInstruction seams
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function guideUpdateLine(deps = {}) {
|
|
154
|
+
try {
|
|
155
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
156
|
+
const info = getUpdateInfo();
|
|
157
|
+
if (!info || !info.hasUpdate) { return null; }
|
|
158
|
+
return `**Update available: v${info.latest}** — ${upgradeInstruction(deps)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
classifySelfInstall,
|
|
166
|
+
upgradeInstruction,
|
|
167
|
+
buildUpdateNotice,
|
|
168
|
+
maybeAppendUpdateNotice,
|
|
169
|
+
guideUpdateLine,
|
|
170
|
+
_resetLatchForTests,
|
|
171
|
+
};
|