amicus 4.5.3 → 4.6.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +97 -0
- package/README.md +41 -13
- package/commands/council.md +1 -1
- package/docs/DISTRIBUTION.md +38 -11
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-run.schema.json +20 -0
- package/schemas/council-verdict.schema.json +20 -0
- package/schemas/doctor.schema.json +23 -1
- package/src/cli-council-run-render.js +51 -0
- package/src/cli-handlers-council-run.js +45 -44
- package/src/cli-handlers-council.js +9 -3
- package/src/cli-handlers-doctor.js +16 -37
- package/src/cli-handlers-watch.js +1 -1
- package/src/cli.js +1 -1
- package/src/council/ledger.js +5 -1
- package/src/council/report-html.js +16 -1
- package/src/council/report.js +25 -1
- package/src/council/run-assemble.js +25 -7
- package/src/council/run-budget.js +14 -8
- package/src/council/run-chair.js +21 -4
- package/src/council/run-debate-stage.js +115 -0
- package/src/council/run-degrade.js +44 -0
- package/src/council/run-finalize.js +18 -3
- package/src/council/run-server.js +24 -7
- package/src/council/run-stage2.js +10 -2
- package/src/council/run-stages.js +23 -21
- package/src/council/run.js +39 -67
- package/src/council/verdict.js +74 -8
- package/src/mcp-council-bench.js +45 -0
- package/src/mcp-council-run.js +11 -28
- package/src/mcp-server.js +5 -1
- package/src/mcp-tools.js +8 -0
- package/src/utils/degrade.js +68 -0
- package/src/utils/doctor-degrade.js +51 -0
- package/src/utils/doctor-electron-mcp-check.js +64 -5
- package/src/utils/doctor-engine-check.js +14 -3
- package/src/utils/doctor-mcp-checks.js +10 -3
- package/src/utils/known-flags.js +2 -1
- package/src/utils/remediation-hints.js +5 -3
- package/src/utils/result-schema.js +6 -2
- package/src/utils/session-index-tmp-sweep.js +2 -1
- package/src/workspace/run-scan.js +5 -1
|
@@ -106,11 +106,19 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
106
106
|
}
|
|
107
107
|
if (!parsed.ok) {
|
|
108
108
|
judgeResults.push({ judge, ok: false, order: null, adjudications: null,
|
|
109
|
-
conformance: leg.status === 'complete' ? 'unstructured' : 'clean'
|
|
109
|
+
conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
|
|
110
|
+
// #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
|
|
111
|
+
// Stage-1's convention (reviews carry the original wave leg even when a
|
|
112
|
+
// repair ran — repairs are separately recorded via appendStageWave).
|
|
113
|
+
// A repair solo's leg is NOT preferred here: attributing it instead
|
|
114
|
+
// would leave every non-repaired (the common case) judge with a false
|
|
115
|
+
// `status: 'error'` row — worse than the missing row #83 complained about.
|
|
116
|
+
leg: leg || null });
|
|
110
117
|
continue;
|
|
111
118
|
}
|
|
112
119
|
const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
|
|
113
|
-
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance
|
|
120
|
+
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
|
|
121
|
+
leg: leg || null });
|
|
114
122
|
}
|
|
115
123
|
return { aborted: null, judgeResults };
|
|
116
124
|
}
|
|
@@ -107,25 +107,6 @@ async function launchStage1(ctx) {
|
|
|
107
107
|
return { aborted, legs, deadWaves };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/**
|
|
111
|
-
* Announce Stage-1 sub-waves that never produced a leg.
|
|
112
|
-
*
|
|
113
|
-
* POLICY (the standing "never fail closed on availability" ruling, applied the
|
|
114
|
-
* same way run-budget.js applies it to cost): the run CONTINUES with the bench
|
|
115
|
-
* that did launch. What it must never do is lose the seats SILENTLY — so every
|
|
116
|
-
* dead wave is announced on stderr, kept on run.json's stage entry (run.js) and
|
|
117
|
-
* degrades the run's exit code to 2.
|
|
118
|
-
* @param {Array<{waveId: string, models: string[], reason: string}>} deadWaves
|
|
119
|
-
* @param {(s: string) => void} [write] stderr seam
|
|
120
|
-
*/
|
|
121
|
-
function reportDeadStage1Waves(deadWaves, write = (s) => process.stderr.write(s)) {
|
|
122
|
-
for (const d of deadWaves) {
|
|
123
|
-
write(`Notice: Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs `
|
|
124
|
-
+ `— ${d.reason}. Those seats are NOT in this council. The run continues with the bench that `
|
|
125
|
-
+ 'did launch and will exit degraded (2).\n');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
110
|
/** Role of a seat by its input alias. */
|
|
130
111
|
function roleFor(o, alias) {
|
|
131
112
|
if (o.lenses) {
|
|
@@ -151,12 +132,33 @@ async function runStage1(ctx) {
|
|
|
151
132
|
const { o } = ctx;
|
|
152
133
|
const { aborted, legs, deadWaves } = await launchStage1(ctx);
|
|
153
134
|
if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
|
|
154
|
-
|
|
135
|
+
|
|
136
|
+
for (const d of deadWaves) {
|
|
137
|
+
ctx.degrade.note({
|
|
138
|
+
channel: 'dead-wave',
|
|
139
|
+
what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
|
|
140
|
+
why: d.reason,
|
|
141
|
+
effect: 'Those seats are NOT in this council. The run continues with the bench that did '
|
|
142
|
+
+ 'launch and will exit degraded (2)',
|
|
143
|
+
data: { waveId: d.waveId, models: d.models, reason: d.reason },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
155
146
|
|
|
156
147
|
const materialized = materializeReviews(o.runDir, legs);
|
|
157
148
|
const alive = new Set(materialized.map(m => m.leg));
|
|
158
149
|
const deadLegs = legs.filter(l => !alive.has(l));
|
|
159
150
|
|
|
151
|
+
for (const leg of deadLegs) {
|
|
152
|
+
ctx.degrade.note({
|
|
153
|
+
channel: 'dead-leg',
|
|
154
|
+
what: `seat ${leg.modelInput || leg.model} did not review`,
|
|
155
|
+
why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
|
|
156
|
+
effect: `${materialized.length} of ${legs.length} seats reviewed; `
|
|
157
|
+
+ 'the run continues with the bench that did and will exit degraded (2)',
|
|
158
|
+
data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
160
162
|
const reviews = [];
|
|
161
163
|
let repairSeq = 0;
|
|
162
164
|
for (const m of materialized) {
|
|
@@ -263,4 +265,4 @@ async function runStage1(ctx) {
|
|
|
263
265
|
// module that produces the exit codes — so the child no longer imports from its
|
|
264
266
|
// parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
|
|
265
267
|
// and run-debate.js, which have always taken it from here.
|
|
266
|
-
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor
|
|
268
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
|
package/src/council/run.js
CHANGED
|
@@ -26,7 +26,7 @@ const runState = require('./run-state');
|
|
|
26
26
|
const { createLaunchers } = require('./run-launch');
|
|
27
27
|
const { runStage1, runStage2 } = require('./run-stages'); // stage 2 lives in ./run-stage2 (300-line gate), re-exported there
|
|
28
28
|
const { runChair, pickFallbackChair } = require('./run-chair');
|
|
29
|
-
const
|
|
29
|
+
const { runDebateStage } = require('./run-debate-stage'); // debate orchestration lives there (300-line gate), extracted from here (v4.6 Plan 1 Task 1)
|
|
30
30
|
const { decorateRecord } = require('./debate');
|
|
31
31
|
const asm = require('./run-assemble');
|
|
32
32
|
const { createBudget } = require('./run-budget');
|
|
@@ -62,8 +62,9 @@ async function runCouncil(options, deps = {}) {
|
|
|
62
62
|
// across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
|
|
63
63
|
// why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
|
|
64
64
|
const degraded = { value: false };
|
|
65
|
+
const degrade = require('./run-degrade').createDegradeSink({ runDir: o.runDir, degraded });
|
|
65
66
|
const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
|
|
66
|
-
noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir,
|
|
67
|
+
noteBudgetRefusal, inexactUnderCeiling } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degrade });
|
|
67
68
|
// v4.4.1 Task 0.5: ONE OpenCode server for the whole run — ./run-server carries
|
|
68
69
|
// the why and the evidence that `_scratch` judge isolation survives it. Acquired
|
|
69
70
|
// below (a getter, because the launchers are built first); null = as before.
|
|
@@ -72,6 +73,20 @@ async function runCouncil(options, deps = {}) {
|
|
|
72
73
|
|| createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal, sharedServer: () => sharedServer });
|
|
73
74
|
|
|
74
75
|
runState.initCouncilRun(o); // run.json seed + sessions-dir pointer (run-state.js)
|
|
76
|
+
|
|
77
|
+
// dropped-members (spec §5, Plan 4): a seat the user's preset requested that
|
|
78
|
+
// never resolved is a lost seat — announced like every other loss. Fires
|
|
79
|
+
// once per member, before any launch (zero spend), for BOTH transports.
|
|
80
|
+
for (const dm of o.droppedMembers || []) {
|
|
81
|
+
degrade.note({
|
|
82
|
+
channel: 'dropped-members',
|
|
83
|
+
what: `seat ${dm.member} was not seated`,
|
|
84
|
+
why: dm.reason,
|
|
85
|
+
effect: 'the bench is smaller than the preset requested; the run will exit degraded (2)',
|
|
86
|
+
data: { member: dm.member, reason: dm.reason },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
emitRunStarted(o.runDir, o.runId, { bench: o.models, chair: o.chair }, o.follow);
|
|
76
91
|
|
|
77
92
|
let signalled = null;
|
|
@@ -93,15 +108,15 @@ async function runCouncil(options, deps = {}) {
|
|
|
93
108
|
const claimed = sharedServer;
|
|
94
109
|
sharedServer = null;
|
|
95
110
|
await require('./run-server').releaseRunServer(claimed);
|
|
96
|
-
const code = resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling });
|
|
111
|
+
const code = resolveTerminalExit({ signalled, exitCode, degraded, degrade, inexactUnderCeiling });
|
|
97
112
|
const run = await writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock });
|
|
98
113
|
return { exitCode: code, run };
|
|
99
114
|
};
|
|
100
115
|
|
|
101
116
|
// Injected launchers bring their own transport. Never throws — degrades to null.
|
|
102
|
-
if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer(o, deps); }
|
|
117
|
+
if (!deps.launchers) { sharedServer = await require('./run-server').acquireRunServer({ ...o, degrade }, deps); }
|
|
103
118
|
|
|
104
|
-
const ctx = { o, launchers, addWave, overBudget, scratchDir: path.join(o.runDir, '_scratch') };
|
|
119
|
+
const ctx = { o, launchers, addWave, overBudget, degrade, scratchDir: path.join(o.runDir, '_scratch') };
|
|
105
120
|
|
|
106
121
|
try {
|
|
107
122
|
// v4.1 §4.4: Claude-in-council is a FILE input — validated after initRun (so the
|
|
@@ -139,7 +154,6 @@ async function runCouncil(options, deps = {}) {
|
|
|
139
154
|
});
|
|
140
155
|
emitStageTerminal(o.runDir, o.runId, 'stage1', s1Status, o.lenses ? null : `${o.runId}-s1`, o.follow);
|
|
141
156
|
if (signalled || s1.aborted) { return finalize(s1.aborted || signalled); }
|
|
142
|
-
if (s1.degraded) { degraded.value = true; } // bench shrank → never a "full run"
|
|
143
157
|
if (s1.reviews.length < 2) {
|
|
144
158
|
return finalize(1, {
|
|
145
159
|
code: 'COUNCIL_QUORUM',
|
|
@@ -175,7 +189,15 @@ async function runCouncil(options, deps = {}) {
|
|
|
175
189
|
runState.updateStage(o.runDir, 'stage2', { status: 'complete', completedAt: now() });
|
|
176
190
|
emitStageTerminal(o.runDir, o.runId, 'stage2', 'complete', `${o.runId}-s2`, o.follow);
|
|
177
191
|
if (signalled || s2.aborted) { return finalize(s2.aborted || signalled); }
|
|
178
|
-
|
|
192
|
+
const usableJudges = s2.judgeResults.filter(j => j.ok).length;
|
|
193
|
+
if (usableJudges < 2) {
|
|
194
|
+
degrade.note({
|
|
195
|
+
channel: 'thin-cross-review',
|
|
196
|
+
what: `only ${usableJudges} of ${s2.judgeResults.length} judges returned a usable cross-review`,
|
|
197
|
+
why: 'the other judges produced no parseable Stage-2 block',
|
|
198
|
+
effect: 'findings were tiered on a thinner cross-review than the bench size implies; will exit degraded (2)',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
179
201
|
|
|
180
202
|
// Merge Stage-2 judging conformance into each seat's row (worst wins).
|
|
181
203
|
const byJudge = new Map(s2.judgeResults.map(j => [j.judge, j]));
|
|
@@ -193,64 +215,12 @@ async function runCouncil(options, deps = {}) {
|
|
|
193
215
|
const provisional = tally(provisionalInput);
|
|
194
216
|
|
|
195
217
|
// ---- Stage 2.5: debate (optional, spec §5.1) ----
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
// spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
|
|
203
|
-
// checkpoint — no ledger append, written before any debate leg launches.
|
|
204
|
-
fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
|
|
205
|
-
runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
|
|
206
|
-
emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
|
|
207
|
-
emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
|
|
208
|
-
const worthDebating = !runDebateMod.nothingToDebate(provisional);
|
|
209
|
-
if (worthDebating && !overBudget()) {
|
|
210
|
-
runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
|
|
211
|
-
emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
|
|
212
|
-
const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
|
|
213
|
-
// A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
|
|
214
|
-
// the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
|
|
215
|
-
if (dbg.aborted) {
|
|
216
|
-
runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
|
|
217
|
-
contested: dbg.contested, disputed: dbg.disputed } });
|
|
218
|
-
return finalize(dbg.aborted);
|
|
219
|
-
}
|
|
220
|
-
runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
|
|
221
|
-
emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
|
|
222
|
-
// run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
|
|
223
|
-
// knows whether the wave launched. Never advertise a `-rv` id here: a skipped
|
|
224
|
-
// re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
|
|
225
|
-
// Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
|
|
226
|
-
// defended/amended or the cost ceiling skipped it — 'complete' would report
|
|
227
|
-
// work that never happened.
|
|
228
|
-
runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
|
|
229
|
-
? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
|
|
230
|
-
// debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
|
|
231
|
-
// v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
|
|
232
|
-
emitStageTerminal(o.runDir, o.runId, 'debate-revote',
|
|
233
|
-
dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
|
|
234
|
-
({ debatedInput, debateFindings, debateSummary } = dbg);
|
|
235
|
-
debatedRecord = tally(debatedInput);
|
|
236
|
-
// Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
|
|
237
|
-
// list must be normalized to null here — otherwise the packet-assembly
|
|
238
|
-
// ternary below still calls buildDebateAddendum({outcomes: []}), which
|
|
239
|
-
// emits a bare "--- Debate round outcomes ---" heading with nothing
|
|
240
|
-
// under it (same defect class ee447b6 fixed on the report renderer).
|
|
241
|
-
debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
|
|
242
|
-
? dbg.addendumOutcomes : null;
|
|
243
|
-
// Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
|
|
244
|
-
// each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
|
|
245
|
-
if (dbg.degraded) { degraded.value = true; }
|
|
246
|
-
} else if (worthDebating) {
|
|
247
|
-
// Budget gone before the defense wave launched, but there WAS something to debate — the
|
|
248
|
-
// other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
|
|
249
|
-
debateSummary.outcome = 'skipped-cost-ceiling';
|
|
250
|
-
degraded.value = true;
|
|
251
|
-
}
|
|
252
|
-
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
253
|
-
}
|
|
218
|
+
const { debatedInput, debatedRecord, debateOutcomes, debateFindings, aborted: debateAborted } =
|
|
219
|
+
await runDebateStage(ctx, { provisional, provisionalInput, overBudget });
|
|
220
|
+
// Mirrors the `if (signalled || s1.aborted)` / `if (signalled || s2.aborted)` guards
|
|
221
|
+
// above: run-debate-stage.js can't reach this closure's `finalize`, so it hands the
|
|
222
|
+
// signal back here instead (see its docblock) and we finalize on its behalf.
|
|
223
|
+
if (debateAborted) { return finalize(debateAborted); }
|
|
254
224
|
|
|
255
225
|
const packet = asm.buildChairPacketFile({
|
|
256
226
|
runDir: o.runDir, reviews: s1.reviews, claudeReview, date: o.date,
|
|
@@ -258,7 +228,7 @@ async function runCouncil(options, deps = {}) {
|
|
|
258
228
|
});
|
|
259
229
|
|
|
260
230
|
const chairRes = await runChair(ctx, {
|
|
261
|
-
packet,
|
|
231
|
+
packet, degrade, statsFn, isSignalled: () => signalled,
|
|
262
232
|
});
|
|
263
233
|
if (chairRes.aborted !== null) { return finalize(chairRes.aborted); }
|
|
264
234
|
const { chairLeg, actualChair, chairText, chairConformance, overallVerdict } = chairRes;
|
|
@@ -284,8 +254,10 @@ async function runCouncil(options, deps = {}) {
|
|
|
284
254
|
runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
|
|
285
255
|
emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
|
|
286
256
|
emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
|
|
257
|
+
// Verdict assembly is the degrade cut-off: anything noted after this line
|
|
258
|
+
// reaches stderr + run.json but not verdict.json (spec §6 rule 1).
|
|
287
259
|
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
288
|
-
critic: o.critic, deadWaves });
|
|
260
|
+
critic: o.critic, deadWaves, degrades: degrade.all() });
|
|
289
261
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
290
262
|
emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
|
|
291
263
|
emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
|
package/src/council/verdict.js
CHANGED
|
@@ -9,13 +9,6 @@ const { parseChairVerdict } = require('./parse-stage2');
|
|
|
9
9
|
// engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
|
|
10
10
|
const VERDICT_SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
14
|
-
* @param {object} record tally() output
|
|
15
|
-
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
16
|
-
* @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
|
|
17
|
-
* parsed chair `VERDICT:` line; omitted/undefined → null.
|
|
18
|
-
*/
|
|
19
12
|
/**
|
|
20
13
|
* Describe which requested seats actually reviewed, for the verdict's own face.
|
|
21
14
|
*
|
|
@@ -54,6 +47,50 @@ function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
|
|
|
54
47
|
};
|
|
55
48
|
}
|
|
56
49
|
|
|
50
|
+
/**
|
|
51
|
+
* seatLoss, derived from the sink's records (v4.6 Plan 2, spec D3 — closes #84).
|
|
52
|
+
*
|
|
53
|
+
* WHY A DERIVATION: two fields reporting lost seats can disagree; deriving one
|
|
54
|
+
* from the other makes contradiction inexpressible. `summarizeSeatLoss` stays
|
|
55
|
+
* exactly as v4.5.2 shipped it (its tests pass unedited — that is the proof the
|
|
56
|
+
* shape survived); this function rebuilds its wave input from `dead-wave`
|
|
57
|
+
* records and then adds the losses waves can never show: `dead-leg` records —
|
|
58
|
+
* a solo critic wave that STARTED but whose one leg died is invisible to
|
|
59
|
+
* deadWaves, which is #84's second half.
|
|
60
|
+
*
|
|
61
|
+
* Reads ONLY record.data (Task 1's machine surface) — never the prose fields.
|
|
62
|
+
* @param {{runId: string, critic: ?string, degrades: Array<object>}} o
|
|
63
|
+
* @returns {?object} the summarizeSeatLoss shape, or null when no critic was requested
|
|
64
|
+
*/
|
|
65
|
+
function deriveSeatLoss({ runId, critic, degrades = [] } = {}) {
|
|
66
|
+
if (!critic) { return null; }
|
|
67
|
+
const real = degrades.filter(d => d.kind !== 'heal' && d.data);
|
|
68
|
+
const waves = real.filter(d => d.channel === 'dead-wave')
|
|
69
|
+
.map(d => ({ waveId: d.data.waveId, models: d.data.models || [], reason: d.data.reason }));
|
|
70
|
+
const base = summarizeSeatLoss({ runId, critic, deadWaves: waves });
|
|
71
|
+
const legs = real.filter(d => d.channel === 'dead-leg');
|
|
72
|
+
const criticLeg = legs.find(l => l.data.seat === critic) || null;
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
criticSeated: base.criticSeated && !criticLeg,
|
|
76
|
+
reason: base.reason || (criticLeg
|
|
77
|
+
? (criticLeg.data.reason || `the critic leg ended '${criticLeg.data.status}' with no usable output`)
|
|
78
|
+
: null),
|
|
79
|
+
deadBenchSeats: [...base.deadBenchSeats,
|
|
80
|
+
...legs.filter(l => l.data.seat !== critic).map(l => l.data.seat)],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
86
|
+
* @param {object} record tally() output
|
|
87
|
+
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
88
|
+
* @param {{overallVerdict?: (string|null), seatLoss?: object, degrades?: Array<object>}} [opts]
|
|
89
|
+
* `overallVerdict` is the engine hook (Plan B): the parsed chair `VERDICT:`
|
|
90
|
+
* line; omitted/undefined → null. `seatLoss` (v4.5.2) and `degrades` (v4.6
|
|
91
|
+
* Plan 2) are additive and OPTIONAL — each lands on the verdict only when
|
|
92
|
+
* truthy/non-empty, absent otherwise (never fabricated).
|
|
93
|
+
*/
|
|
57
94
|
function buildVerdict(record, decisions = [], opts = {}) {
|
|
58
95
|
const byId = new Map(decisions.map(d => [d.id, d]));
|
|
59
96
|
return {
|
|
@@ -87,6 +124,11 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
87
124
|
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
|
88
125
|
// was requested, so its absence never has to be interpreted.
|
|
89
126
|
...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
|
|
127
|
+
// v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
|
|
128
|
+
// OPTIONAL — present only when the run actually degraded, so a clean run's
|
|
129
|
+
// verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
|
|
130
|
+
// seatLoss precedent).
|
|
131
|
+
...(opts.degrades && opts.degrades.length ? { degrades: opts.degrades } : {}),
|
|
90
132
|
};
|
|
91
133
|
}
|
|
92
134
|
|
|
@@ -127,6 +169,29 @@ function readOverallVerdict(runDir, runId) {
|
|
|
127
169
|
return null;
|
|
128
170
|
}
|
|
129
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Recover the additive loss surfaces for a Stage-5 rebuild (#87, v4.6 Plan 4).
|
|
174
|
+
* Same contract as readOverallVerdict directly above: the run folder's own
|
|
175
|
+
* verdict.json is the only source, a foreign runId never leaks, and absence
|
|
176
|
+
* yields nulls — the rebuild preserves, never invents. tally.json carries
|
|
177
|
+
* neither field, which is why the pre-#87 rebuild silently destroyed both.
|
|
178
|
+
* @param {string} runDir
|
|
179
|
+
* @param {string} [runId]
|
|
180
|
+
* @returns {{seatLoss: (object|null), degrades: (Array<object>|null)}}
|
|
181
|
+
*/
|
|
182
|
+
function readPriorVerdictSurfaces(runDir, runId) {
|
|
183
|
+
try {
|
|
184
|
+
const prior = JSON.parse(fs.readFileSync(path.join(runDir, 'verdict.json'), 'utf-8'));
|
|
185
|
+
if (!runId || prior.runId === runId) {
|
|
186
|
+
return {
|
|
187
|
+
seatLoss: (prior.seatLoss && typeof prior.seatLoss === 'object') ? prior.seatLoss : null,
|
|
188
|
+
degrades: Array.isArray(prior.degrades) && prior.degrades.length ? prior.degrades : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
} catch { /* no prior verdict.json, or unreadable — nothing to preserve */ }
|
|
192
|
+
return { seatLoss: null, degrades: null };
|
|
193
|
+
}
|
|
194
|
+
|
|
130
195
|
/** Atomic write: tmp + rename (matches the repo's wave.json convention). */
|
|
131
196
|
function writeVerdictAtomic(filePath, verdict) {
|
|
132
197
|
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
@@ -135,5 +200,6 @@ function writeVerdictAtomic(filePath, verdict) {
|
|
|
135
200
|
}
|
|
136
201
|
|
|
137
202
|
module.exports = {
|
|
138
|
-
buildVerdict, summarizeSeatLoss,
|
|
203
|
+
buildVerdict, summarizeSeatLoss, deriveSeatLoss, readOverallVerdict, readPriorVerdictSurfaces,
|
|
204
|
+
writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
|
|
139
205
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/mcp-council-bench.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module mcp-council-bench
|
|
6
|
+
* Bench resolution for `amicus_council_run` (models XOR council preset). Split
|
|
7
|
+
* out of mcp-council-run.js (v4.6 Plan 4 Task 4b): that file sat at 298/300
|
|
8
|
+
* lines and the --dropped-members producer (the MCP→child transport-parity
|
|
9
|
+
* fix) needed the room. `resolveBenchInput` is self-contained — no dependency
|
|
10
|
+
* on the handler's validation/spawn-argv logic — so it moves verbatim to its
|
|
11
|
+
* own leaf; the old home (mcp-council-run.js) requires it back, so its one
|
|
12
|
+
* call site keeps working unchanged. Never part of mcp-council-run.js's
|
|
13
|
+
* module.exports (an internal helper, not a re-exported API), so no re-export
|
|
14
|
+
* shim is needed there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
19
|
+
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
20
|
+
* preset name when that branch was taken, else null — this handler always
|
|
21
|
+
* spawns the CLI child with an already-expanded `--models` list (never
|
|
22
|
+
* `--council`), so the preset name would otherwise be lost; the caller
|
|
23
|
+
* forwards it via the internal `--council-name` passthrough instead.
|
|
24
|
+
*/
|
|
25
|
+
function resolveBenchInput(input) {
|
|
26
|
+
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
27
|
+
const hasModels = inputModels.length > 0;
|
|
28
|
+
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
29
|
+
if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
|
|
30
|
+
if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
|
|
31
|
+
if (hasCouncil) {
|
|
32
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
33
|
+
const { readCache } = require('./utils/model-catalog');
|
|
34
|
+
const catalog = (readCache() || {}).models || [];
|
|
35
|
+
const presetName = input.council.trim();
|
|
36
|
+
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
37
|
+
if (expanded.error) { return { error: expanded.error }; }
|
|
38
|
+
// v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
|
|
39
|
+
// to --models) — the pre-seed below is the only place this is recorded.
|
|
40
|
+
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
41
|
+
}
|
|
42
|
+
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { resolveBenchInput };
|
package/src/mcp-council-run.js
CHANGED
|
@@ -17,6 +17,9 @@ const runState = require('./council/run-state');
|
|
|
17
17
|
const { fenceSidecarOutput } = require('./utils/untrusted-fence');
|
|
18
18
|
const { isPathInside } = require('./project-root-allowlist');
|
|
19
19
|
const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
|
|
20
|
+
// v4.6 Plan 4 Task 4b: resolveBenchInput moved to its own leaf (size gate) —
|
|
21
|
+
// see mcp-council-bench.js's module docblock for why.
|
|
22
|
+
const { resolveBenchInput } = require('./mcp-council-bench');
|
|
20
23
|
|
|
21
24
|
function textResult(text, isError) {
|
|
22
25
|
const result = { content: [{ type: 'text', text }] };
|
|
@@ -40,34 +43,6 @@ const COUNCIL_PACK_PARAM_MAP = {
|
|
|
40
43
|
template: 'template',
|
|
41
44
|
};
|
|
42
45
|
|
|
43
|
-
/**
|
|
44
|
-
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
45
|
-
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
46
|
-
* preset name when that branch was taken, else null — this handler always
|
|
47
|
-
* spawns the CLI child with an already-expanded `--models` list (never
|
|
48
|
-
* `--council`), so the preset name would otherwise be lost; the caller
|
|
49
|
-
* forwards it via the internal `--council-name` passthrough instead.
|
|
50
|
-
*/
|
|
51
|
-
function resolveBenchInput(input) {
|
|
52
|
-
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
53
|
-
const hasModels = inputModels.length > 0;
|
|
54
|
-
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
55
|
-
if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
|
|
56
|
-
if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
|
|
57
|
-
if (hasCouncil) {
|
|
58
|
-
const { resolveCouncilMembers } = require('./utils/config');
|
|
59
|
-
const { readCache } = require('./utils/model-catalog');
|
|
60
|
-
const catalog = (readCache() || {}).models || [];
|
|
61
|
-
const presetName = input.council.trim();
|
|
62
|
-
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
63
|
-
if (expanded.error) { return { error: expanded.error }; }
|
|
64
|
-
// v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
|
|
65
|
-
// to --models) — the pre-seed below is the only place this is recorded.
|
|
66
|
-
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
67
|
-
}
|
|
68
|
-
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
46
|
/**
|
|
72
47
|
* amicus_council_run: validate → prep run dir → spawn CLI child → return
|
|
73
48
|
* {runId, runDir} immediately (fenced).
|
|
@@ -205,6 +180,14 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
205
180
|
// itself is never spawned (it would collide with `--models`) — this internal,
|
|
206
181
|
// undocumented flag carries the preset NAME through for attribution only.
|
|
207
182
|
if (presetName) { args.push('--council-name', presetName); }
|
|
183
|
+
// v4.6 Plan 4 Task 4b: same precedent as --council-name above — an internal,
|
|
184
|
+
// undocumented passthrough. The child's own resolveBench has no literal
|
|
185
|
+
// --council to re-resolve from, so without this it always resolves
|
|
186
|
+
// droppedMembers: [] and the sink's dropped-members channel (Task 4) never
|
|
187
|
+
// fires — a CLI `--council <preset>` run exits 2 on a dropped member while
|
|
188
|
+
// an identical MCP run exits 0. Omitted when nothing dropped (and always
|
|
189
|
+
// for bare `models` input) — argv stays byte-identical to today otherwise.
|
|
190
|
+
if (droppedMembers.length) { args.push('--dropped-members', JSON.stringify(droppedMembers)); }
|
|
208
191
|
// v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
|
|
209
192
|
// reason outDir is — an MCP client may send a relative path, and the child's cwd
|
|
210
193
|
// is the run dir. Validation of the file itself stays in the spawned engine's
|
package/src/mcp-server.js
CHANGED
|
@@ -1388,8 +1388,12 @@ const handlers = {
|
|
|
1388
1388
|
// must be carried through or it is destroyed. Unlike the CLI there is no
|
|
1389
1389
|
// run-folder path to anchor on (`record` arrives inline), so it is an
|
|
1390
1390
|
// explicit input; omitted → null, never fabricated.
|
|
1391
|
+
// #87: seatLoss/degrades get the same additive-passthrough treatment —
|
|
1392
|
+
// present only when the caller supplies them, never fabricated here.
|
|
1391
1393
|
const verdict = buildVerdict(input.record, input.decisions || [],
|
|
1392
|
-
{ overallVerdict: input.overallVerdict
|
|
1394
|
+
{ overallVerdict: input.overallVerdict,
|
|
1395
|
+
...(input.seatLoss ? { seatLoss: input.seatLoss } : {}),
|
|
1396
|
+
...(Array.isArray(input.degrades) && input.degrades.length ? { degrades: input.degrades } : {}) });
|
|
1393
1397
|
if (!input.render) {
|
|
1394
1398
|
return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
|
|
1395
1399
|
}
|
package/src/mcp-tools.js
CHANGED
|
@@ -433,6 +433,14 @@ function getTools() {
|
|
|
433
433
|
'(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
|
|
434
434
|
'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
|
|
435
435
|
'chair was skipped; never author one yourself.'),
|
|
436
|
+
seatLoss: z.record(z.any()).nullable().optional().describe(
|
|
437
|
+
'The engine-written <runDir>/verdict.json seatLoss block (v4.5.2 — critic seating). ' +
|
|
438
|
+
'Additive passthrough — preserved onto the rebuilt verdict; omitted → absent, never ' +
|
|
439
|
+
'fabricated (#87).'),
|
|
440
|
+
degrades: z.array(z.record(z.any())).nullable().optional().describe(
|
|
441
|
+
'The engine-written <runDir>/verdict.json degrades[] (v4.6 Plan 2 — what was lost). ' +
|
|
442
|
+
'Additive passthrough — preserved onto the rebuilt verdict; omitted → absent, never ' +
|
|
443
|
+
'fabricated (#87).'),
|
|
436
444
|
render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
|
|
437
445
|
outDir: z.string().optional().describe('Dir to write report.html into when render:true — resolved against the project dir and rejected if it escapes it. Omit to write nothing.'),
|
|
438
446
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module utils/degrade
|
|
5
|
+
* The degrade/heal record: one shape, one vocabulary, shared by the council
|
|
6
|
+
* runtime and `doctor`. Pure — no I/O, no council knowledge.
|
|
7
|
+
*
|
|
8
|
+
* WHY validation lives here and THROWS: it is what makes the announcement
|
|
9
|
+
* contract real. A degrade that does not say what was lost, why, and what it
|
|
10
|
+
* cost cannot be constructed. Callers never see the throw — run-degrade.js's
|
|
11
|
+
* sink catches it and converts it into an `internal` degrade (spec §7).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
15
|
+
// council runtime channels
|
|
16
|
+
'dead-leg', 'dead-wave', 'budget-refusal', 'shared-server-unavailable',
|
|
17
|
+
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
|
+
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
|
+
'internal',
|
|
20
|
+
// doctor channels
|
|
21
|
+
'doctor-check-failed', 'doctor-fix',
|
|
22
|
+
]));
|
|
23
|
+
|
|
24
|
+
const KINDS = Object.freeze(new Set(['degrade', 'heal']));
|
|
25
|
+
const REQUIRED = ['what', 'why', 'effect'];
|
|
26
|
+
|
|
27
|
+
function makeDegrade(input = {}) {
|
|
28
|
+
const kind = input.kind === undefined ? 'degrade' : input.kind;
|
|
29
|
+
if (!KINDS.has(kind)) {
|
|
30
|
+
throw new Error(`degrade: unknown kind '${kind}' (expected 'degrade' or 'heal')`);
|
|
31
|
+
}
|
|
32
|
+
if (!DEGRADE_CHANNELS.has(input.channel)) {
|
|
33
|
+
throw new Error(`degrade: unknown channel '${input.channel}'`);
|
|
34
|
+
}
|
|
35
|
+
for (const f of REQUIRED) {
|
|
36
|
+
if (typeof input[f] !== 'string' || !input[f].trim()) {
|
|
37
|
+
throw new Error(`degrade: '${f}' is required and must be a non-blank string`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const record = {
|
|
41
|
+
kind, channel: input.channel,
|
|
42
|
+
what: input.what.trim(), why: input.why.trim(), effect: input.effect.trim(),
|
|
43
|
+
};
|
|
44
|
+
if (typeof input.remedy === 'string' && input.remedy.trim()) {
|
|
45
|
+
record.remedy = input.remedy.trim();
|
|
46
|
+
}
|
|
47
|
+
if (input.data !== undefined) {
|
|
48
|
+
if (typeof input.data !== 'object' || input.data === null || Array.isArray(input.data)) {
|
|
49
|
+
throw new Error("degrade: 'data' must be a plain object when provided");
|
|
50
|
+
}
|
|
51
|
+
record.data = Object.freeze({ ...input.data });
|
|
52
|
+
}
|
|
53
|
+
return Object.freeze(record);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The ONE voice for every channel. Kept here rather than at call sites so ten
|
|
58
|
+
* channels cannot drift into ten dialects.
|
|
59
|
+
* @param {object} record from makeDegrade
|
|
60
|
+
* @returns {string} one line, newline-terminated
|
|
61
|
+
*/
|
|
62
|
+
function formatDegrade(record) {
|
|
63
|
+
const lead = record.kind === 'heal' ? 'Recovered' : 'Notice';
|
|
64
|
+
const remedy = record.remedy ? ` Try: ${record.remedy}.` : '';
|
|
65
|
+
return `${lead}: ${record.what} — ${record.why}. ${record.effect}.${remedy}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { makeDegrade, formatDegrade, DEGRADE_CHANNELS };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module utils/doctor-degrade
|
|
5
|
+
* The doctor-side collector (spec §4): maps doctor's check rows onto the shared
|
|
6
|
+
* degrade/heal vocabulary. Pure — reads row STATUS and the structured `fixed`
|
|
7
|
+
* flag only, never message prose (the Plan 2 rule: prose is the human surface).
|
|
8
|
+
*
|
|
9
|
+
* Mapping (spec §6, with the recorded interpretation):
|
|
10
|
+
* status 'error' → kind 'degrade', channel 'doctor-check-failed'
|
|
11
|
+
* row.fixed === true → kind 'heal', channel 'doctor-fix'
|
|
12
|
+
* 'ok'/'warn' rows → no record — doctor's exit code ignores warns, and the
|
|
13
|
+
* §6 equivalence (exit derives from any degrade) must
|
|
14
|
+
* hold without changing exit behavior.
|
|
15
|
+
* A row can produce both (a partial self-heal that still fails).
|
|
16
|
+
* Never throws: a malformed row degrades honestly, mirroring the council sink.
|
|
17
|
+
*/
|
|
18
|
+
const { makeDegrade } = require('./degrade');
|
|
19
|
+
|
|
20
|
+
function collectDoctorDegrades(checks) {
|
|
21
|
+
const records = [];
|
|
22
|
+
for (const c of Array.isArray(checks) ? checks : []) {
|
|
23
|
+
if (!c) { continue; }
|
|
24
|
+
const name = c.name || c.id || 'unnamed';
|
|
25
|
+
if (c.status === 'error') {
|
|
26
|
+
records.push(makeDegrade({
|
|
27
|
+
channel: 'doctor-check-failed',
|
|
28
|
+
what: `the '${name}' check failed`,
|
|
29
|
+
why: (typeof c.message === 'string' && c.message.trim()) ? c.message : 'the check produced no message',
|
|
30
|
+
effect: 'amicus may not work correctly until this is fixed; doctor exits 1',
|
|
31
|
+
...(typeof c.hint === 'string' && c.hint.trim() ? { remedy: c.hint } : {}),
|
|
32
|
+
data: { checkId: c.id || null },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
if (c.fixed === true) {
|
|
36
|
+
records.push(makeDegrade({
|
|
37
|
+
kind: 'heal',
|
|
38
|
+
channel: 'doctor-fix',
|
|
39
|
+
what: `the '${name}' check was repaired in place`,
|
|
40
|
+
why: (typeof c.fixDetail === 'string' && c.fixDetail.trim())
|
|
41
|
+
? `doctor --fix ${c.fixDetail}`
|
|
42
|
+
: "doctor --fix applied the check's self-heal",
|
|
43
|
+
effect: 'no further action needed; the repair already ran',
|
|
44
|
+
data: { checkId: c.id || null },
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return records;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { collectDoctorDegrades };
|