amicus 4.7.0 → 4.8.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 +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
|
@@ -27,12 +27,21 @@ function defaultNpmCacheDir(platform) {
|
|
|
27
27
|
return path.join(os.homedir(), '.npm');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort `npm root -g`. Never throws; returns null on any failure.
|
|
32
|
+
* ⚠️ Windows needs shell:true — npm is a .cmd shim, and Node 24's
|
|
33
|
+
* CVE-2024-27980 hardening rejects .cmd via execFileSync without a shell
|
|
34
|
+
* (bare `npm` → ENOENT, `npm.cmd` → EINVAL). Without this the global install
|
|
35
|
+
* was invisible to the whole scan, which also blinded engine-repair's donor
|
|
36
|
+
* search: `doctor --fix` reported "no healthy sibling install" while one sat
|
|
37
|
+
* at %AppData%\npm\node_modules.
|
|
38
|
+
*/
|
|
39
|
+
function resolveNpmRootG({ execFileSync, platform } = {}) {
|
|
40
|
+
const win = (platform || process.platform) === 'win32';
|
|
32
41
|
try {
|
|
33
|
-
const
|
|
34
|
-
const out =
|
|
35
|
-
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
const exec = execFileSync || require('child_process').execFileSync;
|
|
43
|
+
const out = exec(win ? 'npm root -g' : 'npm', win ? [] : ['root', '-g'], {
|
|
44
|
+
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], shell: win,
|
|
36
45
|
});
|
|
37
46
|
return String(out).trim() || null;
|
|
38
47
|
} catch (_e) {
|
|
@@ -45,13 +54,41 @@ function safe(fn, fallback) {
|
|
|
45
54
|
try { return fn(); } catch (_e) { return fallback; }
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the engine version from the roots already on the record. Reads
|
|
59
|
+
* opencode-ai's own package.json, which is a faithful proxy for the executed
|
|
60
|
+
* binary because opencode-ai exact-pins all 12 platform sub-packages.
|
|
61
|
+
* ⚠️ Do NOT read next to the binary: hasOpencodeBinary probes
|
|
62
|
+
* opencode-windows-<arch>/bin/opencode.exe on win32 but .bin/opencode on
|
|
63
|
+
* POSIX, and .bin/ has no package.json — a binary-adjacent rule would work on
|
|
64
|
+
* Windows only and silently return nothing on the two POSIX CI legs.
|
|
65
|
+
* Uses the real `fs` module directly (not a seam) because this is the
|
|
66
|
+
* PRODUCTION default — tests always inject `readEngineVersion` instead (the
|
|
67
|
+
* suite's fakeFs implements no readFileSync).
|
|
68
|
+
* @returns {string|undefined} undefined (never null) so toEqual fixtures survive
|
|
69
|
+
*/
|
|
70
|
+
function defaultReadEngineVersion({ roots }) {
|
|
71
|
+
for (const root of roots || []) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = require('fs').readFileSync(path.join(root, 'opencode-ai', 'package.json'), 'utf-8');
|
|
74
|
+
const v = JSON.parse(raw).version;
|
|
75
|
+
if (v) { return String(v); }
|
|
76
|
+
} catch (_e) { /* try the next root */ }
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve p's real path via fs.realpathSync, tolerating any throw. */
|
|
82
|
+
function realNorm(p, fs) {
|
|
83
|
+
return path.normalize(safe(() => fs.realpathSync(p), p));
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/** Drop installs whose pkgDir resolves to the same real path; keep the first. */
|
|
49
87
|
function dedupByRealpath(installs, fs) {
|
|
50
88
|
const seen = new Set();
|
|
51
89
|
const out = [];
|
|
52
90
|
for (const inst of installs) {
|
|
53
|
-
const
|
|
54
|
-
const key = path.normalize(real);
|
|
91
|
+
const key = realNorm(inst.pkgDir, fs);
|
|
55
92
|
if (seen.has(key)) { continue; }
|
|
56
93
|
seen.add(key);
|
|
57
94
|
out.push(inst);
|
|
@@ -59,6 +96,21 @@ function dedupByRealpath(installs, fs) {
|
|
|
59
96
|
return out;
|
|
60
97
|
}
|
|
61
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when pkgDir is the same real install as the npm-global amicus
|
|
101
|
+
* package (`<gRoot>/amicus`). Recovers "this record IS the global install"
|
|
102
|
+
* after dedupByRealpath has already dropped the separate `kind:'global'`
|
|
103
|
+
* record — see the docblock on scanEngineInstalls for why (#133 R-A finding
|
|
104
|
+
* 1: on the documented end-user invocation, `amicus doctor` run from the
|
|
105
|
+
* globally-installed copy, `runningPkgDir` IS `<npm root -g>/amicus`, and
|
|
106
|
+
* listAmicusInstalls pushes `running` first, so dedup keeps `running` and
|
|
107
|
+
* drops `global`).
|
|
108
|
+
*/
|
|
109
|
+
function isGlobalInstall({ pkgDir, fs, gRoot }) {
|
|
110
|
+
if (!gRoot) { return false; }
|
|
111
|
+
return realNorm(pkgDir, fs) === realNorm(path.join(gRoot, 'amicus'), fs);
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* The amicus installs that could serve the MCP, highest-priority first
|
|
64
116
|
* (running, global, then npx-cache copies). All I/O behind seams.
|
|
@@ -76,7 +128,7 @@ function listAmicusInstalls(deps = {}) {
|
|
|
76
128
|
const platform = deps.platform || process.platform;
|
|
77
129
|
const runningPkgDir = deps.runningPkgDir || path.join(__dirname, '..', '..');
|
|
78
130
|
const npmCacheDir = deps.npmCacheDir || defaultNpmCacheDir(platform);
|
|
79
|
-
const npmRootG = deps.npmRootG ||
|
|
131
|
+
const npmRootG = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
80
132
|
|
|
81
133
|
const raw = [{ kind: 'running', pkgDir: runningPkgDir }];
|
|
82
134
|
|
|
@@ -121,22 +173,53 @@ function classifyLaunch(config) {
|
|
|
121
173
|
* @param {object} [deps] - listAmicusInstalls seams, plus:
|
|
122
174
|
* @param {(d:{pkgDir:string}) => boolean} [deps.hasOpencodeBinary]
|
|
123
175
|
* @param {(d:{pkgDir:string}) => string[]} [deps.opencodeRoots]
|
|
176
|
+
* @param {(d:{pkgDir:string, roots:string[]}) => (string|undefined)} [deps.readEngineVersion]
|
|
124
177
|
* @param {() => (object|null)} [deps.readAmicusMcpConfig]
|
|
125
|
-
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots}>, mcpLaunch: string}}
|
|
178
|
+
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots,engineVersion,isGlobal?}>, mcpLaunch: string}}
|
|
126
179
|
*/
|
|
127
180
|
function scanEngineInstalls(deps = {}) {
|
|
181
|
+
const fs = deps.fs || require('fs');
|
|
182
|
+
const platform = deps.platform || process.platform;
|
|
128
183
|
const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
|
|
129
184
|
const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
|
|
185
|
+
const readEngineVersion = deps.readEngineVersion || defaultReadEngineVersion;
|
|
130
186
|
const readAmicusMcpConfig = deps.readAmicusMcpConfig
|
|
131
187
|
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
132
188
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
189
|
+
// Resolve `npm root -g` once and feed that SAME resolver into
|
|
190
|
+
// listAmicusInstalls (below), so the isGlobal recovery here reuses its
|
|
191
|
+
// result instead of spawning a second `npm root -g` process.
|
|
192
|
+
let gRootCache;
|
|
193
|
+
let gRootResolved = false;
|
|
194
|
+
const npmRootGRaw = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
195
|
+
const npmRootGOnce = () => {
|
|
196
|
+
if (!gRootResolved) { gRootCache = safe(() => npmRootGRaw(), null); gRootResolved = true; }
|
|
197
|
+
return gRootCache;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const rawInstalls = listAmicusInstalls({ ...deps, platform, npmRootG: npmRootGOnce });
|
|
201
|
+
const gRoot = npmRootGOnce(); // already resolved by listAmicusInstalls above; this just reads the cache
|
|
202
|
+
|
|
203
|
+
const installs = rawInstalls.map((i) => {
|
|
204
|
+
const roots = opencodeRoots({ pkgDir: i.pkgDir });
|
|
205
|
+
// #133 R-A finding 1: recovers "this IS the global install" for a
|
|
206
|
+
// record whose `kind:'global'` twin was dropped by dedupByRealpath
|
|
207
|
+
// (see isGlobalInstall's docblock). Deliberately NOT stamped inside
|
|
208
|
+
// listAmicusInstalls — its output is pinned exact by toEqual in
|
|
209
|
+
// tests/utils/engine-install-scan.test.js:57 and :89.
|
|
210
|
+
const isGlobal = i.kind !== 'global' && isGlobalInstall({ pkgDir: i.pkgDir, fs, gRoot });
|
|
211
|
+
return {
|
|
212
|
+
...i,
|
|
213
|
+
engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
|
|
214
|
+
roots,
|
|
215
|
+
engineVersion: safe(() => readEngineVersion({ pkgDir: i.pkgDir, roots }), undefined),
|
|
216
|
+
...(isGlobal ? { isGlobal: true } : {}),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
138
219
|
const mcpLaunch = classifyLaunch(safe(() => readAmicusMcpConfig(), null));
|
|
139
220
|
return { installs, mcpLaunch };
|
|
140
221
|
}
|
|
141
222
|
|
|
142
|
-
module.exports = {
|
|
223
|
+
module.exports = {
|
|
224
|
+
listAmicusInstalls, scanEngineInstalls, classifyLaunch, resolveNpmRootG,
|
|
225
|
+
};
|
|
@@ -23,11 +23,105 @@ function runningPkgDir() {
|
|
|
23
23
|
return path.join(__dirname, '..', '..');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Parse a semver-shaped string into a [major, minor, patch] triple.
|
|
28
|
+
* Prerelease versions (`1.18.15-beta.1`) return null — treated the same as
|
|
29
|
+
* unparseable, so they sort LAST rather than tying with (and, via a stable
|
|
30
|
+
* sort, sometimes beating) the release they're a prerelease of. Only the
|
|
31
|
+
* exact-release leading triple is a valid donor signal; amicus pins exact
|
|
32
|
+
* release versions, never prereleases.
|
|
33
|
+
* @returns {[number,number,number]|null} null for undefined/non-string/non-semver/prerelease
|
|
34
|
+
*/
|
|
35
|
+
function parseVersionTriple(v) {
|
|
36
|
+
if (typeof v !== 'string') { return null; }
|
|
37
|
+
const trimmed = v.trim();
|
|
38
|
+
if (/^\d+\.\d+\.\d+-/.test(trimmed)) { return null; }
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
|
|
40
|
+
if (!m) { return null; }
|
|
41
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Descending version comparator: newest first, unparseable/absent sorts last
|
|
46
|
+
* (never throws). Two unparseable values compare equal (0).
|
|
47
|
+
*/
|
|
48
|
+
function compareVersionsDesc(a, b) {
|
|
49
|
+
const ta = parseVersionTriple(a);
|
|
50
|
+
const tb = parseVersionTriple(b);
|
|
51
|
+
if (!ta && !tb) { return 0; }
|
|
52
|
+
if (!ta) { return 1; }
|
|
53
|
+
if (!tb) { return -1; }
|
|
54
|
+
for (let i = 0; i < 3; i += 1) {
|
|
55
|
+
if (ta[i] !== tb[i]) { return tb[i] - ta[i]; }
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* TWO TIERS, not one sort. An explicitly-global healthy donor — `kind:'global'`,
|
|
62
|
+
* or `isGlobal:true` when engine-install-scan.js's dedup collapsed the
|
|
63
|
+
* `global` record into `running` (#133 R-A finding 1) — wins OUTRIGHT,
|
|
64
|
+
* regardless of version. Only when no explicit-global donor exists does
|
|
65
|
+
* engineVersion rank the remaining candidates (newest first; ties/absent
|
|
66
|
+
* versions fall back to list order, running-first).
|
|
67
|
+
*
|
|
68
|
+
* Review round 2, finding 2: a single version-first sort (this function's
|
|
69
|
+
* first cut) inverted R-A's own goal three ways, each confirmed by a test in
|
|
70
|
+
* tests/utils/engine-repair.test.js:
|
|
71
|
+
* 1. running (dev tree) newer than global → donated the dev tree. R-A's
|
|
72
|
+
* stated goal is "--fix stops donating the dev engine"; the dev tree
|
|
73
|
+
* running ahead of the pin is the NORMAL direction mid-pin-bump, not a
|
|
74
|
+
* reason to trust it over the global install.
|
|
75
|
+
* 2. running-that-is-global older than a healthy npx sibling → donated the
|
|
76
|
+
* npx sibling — the exact outcome the ORIGINAL (pre-engineVersion)
|
|
77
|
+
* findDonor test says must never happen, reintroduced via the source
|
|
78
|
+
* class this file's own scanEngineInstalls docblock calls LEAST
|
|
79
|
+
* trustworthy (npx-cache copies: optional-dependency skips, AV
|
|
80
|
+
* quarantine on every re-resolve).
|
|
81
|
+
* 3. global's version unresolved, npx sibling versioned → donated the npx
|
|
82
|
+
* copy despite a `global` record existing at all.
|
|
83
|
+
* All three share one cause: ranking by version BEFORE asking "is there an
|
|
84
|
+
* explicit global donor at all". Tiering fixes it without losing what the
|
|
85
|
+
* version ranking was FOR — Task 3's kind-only rule left a residual hole
|
|
86
|
+
* (dev checkout, no npm-global install, broken npx destination, healthy npx
|
|
87
|
+
* sibling — no record has kind:'global' at all) where the old code fell
|
|
88
|
+
* through to `healthy[0]`, the running dev tree, and could donate a
|
|
89
|
+
* version-skewed dev engine over a newer healthy sibling. That hole is
|
|
90
|
+
* exactly the case where tier 1 finds nothing and tier 2's version ranking
|
|
91
|
+
* takes over.
|
|
92
|
+
*
|
|
93
|
+
* A `kind !== 'running'` proxy for tier 1 is wrong: listAmicusInstalls pushes
|
|
94
|
+
* `running` first and `global` second, and dedupByRealpath keeps the FIRST of
|
|
95
|
+
* any two entries that resolve to the same real path. So on an ordinary
|
|
96
|
+
* end-user machine — where the running process IS the global install — the
|
|
97
|
+
* `global` record never survives dedup; that copy is labeled `kind:
|
|
98
|
+
* 'running'` (carrying `isGlobal:true` instead, once scanEngineInstalls has
|
|
99
|
+
* run). A `kind !== 'running'` filter would then skip the good global engine
|
|
100
|
+
* and donate some other (possibly stale) healthy copy, importing the exact
|
|
101
|
+
* version skew this self-heal exists to prevent.
|
|
102
|
+
*
|
|
103
|
+
* Tier 1 (`kind==='global' || isGlobal`) is correct on both topologies: on a
|
|
104
|
+
* dev machine the dev tree and the global install are distinct real paths, so
|
|
105
|
+
* the `global` record survives dedup and wins over the dev tree. On an
|
|
106
|
+
* end-user machine there is no separate `global` record — the running process
|
|
107
|
+
* already IS it, flagged `isGlobal:true` — so tier 1 still finds it. Tier 2
|
|
108
|
+
* (list-order fallback via a stable sort) only applies when tier 1 finds
|
|
109
|
+
* nothing at all — a pure dev checkout or npx-only machine.
|
|
110
|
+
*/
|
|
27
111
|
function findDonor({ installs, destPkgDir, fs }) {
|
|
28
112
|
const norm = (p) => { try { return path.normalize(fs.realpathSync(p)); } catch { return path.normalize(p); } };
|
|
29
113
|
const destReal = norm(destPkgDir);
|
|
30
|
-
|
|
114
|
+
const healthy = installs.filter((i) => i.engineOk && norm(i.pkgDir) !== destReal);
|
|
115
|
+
if (healthy.length === 0) { return null; }
|
|
116
|
+
|
|
117
|
+
const explicitGlobal = healthy.find((i) => i.kind === 'global' || i.isGlobal);
|
|
118
|
+
if (explicitGlobal) { return explicitGlobal; }
|
|
119
|
+
|
|
120
|
+
// No explicit-global donor on this machine at all — rank the remaining
|
|
121
|
+
// healthy candidates by engineVersion (newest first; Array#sort is stable,
|
|
122
|
+
// so ties/absent versions preserve list order, i.e. running-first).
|
|
123
|
+
const sorted = [...healthy].sort((a, b) => compareVersionsDesc(a.engineVersion, b.engineVersion));
|
|
124
|
+
return sorted[0];
|
|
31
125
|
}
|
|
32
126
|
|
|
33
127
|
/** The donor root (nested or hoisted) that actually holds the engine binary. */
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const { envNumber } = require('./env-num');
|
|
22
22
|
|
|
23
|
-
const DEFAULT_NO_OUTPUT_BACKSTOP_MS =
|
|
23
|
+
const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 300000;
|
|
24
24
|
|
|
25
25
|
/** @param {object} [env] test seam; defaults to process.env */
|
|
26
26
|
function resolveNoOutputBackstopMs(env) {
|
|
@@ -88,6 +88,35 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
88
88
|
sweepSessionIndexTmp:
|
|
89
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Engine version skew (#133 R-A): a PRESENT engine can still be the WRONG
|
|
93
|
+
* one — the npx-cache copy `npx -y amicus@latest mcp` launches and the
|
|
94
|
+
* global install resolve independently and at different times, and two
|
|
95
|
+
* engine versions writing one shared opencode.db is what produced #133's
|
|
96
|
+
* SQLiteError. Unlike reinstallEngineAv (antivirus quarantine — a MISSING
|
|
97
|
+
* binary), this is a WRONG-VERSION binary, so the remedy has to touch both
|
|
98
|
+
* sides: reinstall the global copy (which re-resolves to the exact pinned
|
|
99
|
+
* opencode-ai/@opencode-ai/sdk version amicus pins as of 4.7.1), and clear
|
|
100
|
+
* the npx-cache tree so the npx copy re-resolves fresh next time it
|
|
101
|
+
* launches instead of replaying a stale cached one.
|
|
102
|
+
*
|
|
103
|
+
* Review round 2, finding 3: the first cut of this hint said
|
|
104
|
+
* `npm cache clean --force`, which does NOT touch the npx tree — verified
|
|
105
|
+
* against npm's own lib/commands/cache.js (npm 11.16.0): `cache clean`
|
|
106
|
+
* removes `flatOptions.cache` (the registry-metadata cache, `<cache
|
|
107
|
+
* dir>/_cacache`), while npx installs live at the SEPARATE
|
|
108
|
+
* `flatOptions.npxCache` (`<cache dir>/_npx`). That command would have left
|
|
109
|
+
* the skewed npx copy byte-for-byte in place. The correct command is npm's
|
|
110
|
+
* own npx-cache subcommand: `npm cache npx rm` with no keys and `--force`
|
|
111
|
+
* empties the whole npx cache (cache.js's `npxRm`: no keys + force →
|
|
112
|
+
* `fs.rm(npxCache, {recursive:true, force:true})`). Since `doctor --fix`
|
|
113
|
+
* has no dedicated skew branch (WARN only — see doctor-engine-check.js),
|
|
114
|
+
* this hint is the ONLY remedy the user gets — it must actually work.
|
|
115
|
+
*/
|
|
116
|
+
engineVersionSkew:
|
|
117
|
+
'npm install -g amicus && npm cache npx rm --force (reinstalls the global engine to the exact pinned opencode-ai version — amicus pins it exactly as of 4.7.1 — '
|
|
118
|
+
+ 'and empties the npx cache, the SEPARATE directory `npm cache clean` does not touch, so the npx-cache copy `npx -y amicus@latest mcp` launches re-resolves fresh to that same pinned version instead of replaying a stale one)',
|
|
119
|
+
|
|
91
120
|
/**
|
|
92
121
|
* Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
|
|
93
122
|
* same producer shape as sweepSessionIndexTmp above, one level down — a
|
|
@@ -101,6 +130,19 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
101
130
|
*/
|
|
102
131
|
sweepSessionMetadataTmp:
|
|
103
132
|
'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stale sessions-index.json entries (R16): session-index.js :: recordSession
|
|
136
|
+
* never removes a row, so a project that is deleted, renamed or moved
|
|
137
|
+
* leaves its taskId -> path entries behind forever, and every
|
|
138
|
+
* recordSession call pays for rewriting the WHOLE index — the per-start
|
|
139
|
+
* cost grows with total sessions ever, not live ones. `doctor --fix`
|
|
140
|
+
* removes only entries whose project path no longer exists on disk
|
|
141
|
+
* (liveness, never age — R16-2): a five-year-old entry for a project that
|
|
142
|
+
* still exists is left alone.
|
|
143
|
+
*/
|
|
144
|
+
pruneSessionIndex:
|
|
145
|
+
'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
|
|
104
146
|
});
|
|
105
147
|
|
|
106
148
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// src/utils/session-index-prune.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* R16: sessions-index.json stale-entry prune for `amicus doctor --fix`.
|
|
6
|
+
*
|
|
7
|
+
* session-index.js :: recordSession appends taskId -> canonicalProjectPath on
|
|
8
|
+
* every session start and nothing ever removes one. A project that is
|
|
9
|
+
* deleted, renamed or moved leaves its rows behind forever, and every
|
|
10
|
+
* recordSession call pays for rewriting the WHOLE index (full read -> parse
|
|
11
|
+
* -> mutate -> stringify -> atomic write), so the per-start cost grows with
|
|
12
|
+
* total sessions ever, not live ones (docs/superpowers/plans/
|
|
13
|
+
* 2026-08-22-v48-wave25-r16-index-prune.md §0.1). This module lists and
|
|
14
|
+
* removes the dead rows; src/cli-handlers-doctor.js composes the result into
|
|
15
|
+
* a check line. Structurally mirrors utils/session-index-tmp-sweep.js (R16-1:
|
|
16
|
+
* reuse that whole warn/fix/hint shape rather than inventing a new one).
|
|
17
|
+
*
|
|
18
|
+
* R16-2 (liveness, NEVER age): an entry is stale IFF its project path no
|
|
19
|
+
* longer resolves to a directory on disk. No TTL, no mtime sort — a
|
|
20
|
+
* five-year-old entry for a project that still exists is still a valid
|
|
21
|
+
* lookup target; a one-day-old entry for a deleted project is not. Unlike its
|
|
22
|
+
* tmp-file sibling (which age-gates in `evaluate` so a live writer's ms-lived
|
|
23
|
+
* tmp is never swept), there is no such grace window here by design.
|
|
24
|
+
*
|
|
25
|
+
* R16-3 (probe distinct projects, not entries): the index is
|
|
26
|
+
* taskId -> project and many task ids share one project (measured: `amicus
|
|
27
|
+
* list --all`'s enumerateAllProjects walks every distinct project — 21,145
|
|
28
|
+
* rows in 8,275ms before a manual prune to 187 entries, 132 rows in 53ms
|
|
29
|
+
* after). Probing per-entry would repeat the same statSync for every task id
|
|
30
|
+
* that shares a project; `listStaleSessionIndexEntries` dedupes to the
|
|
31
|
+
* distinct project set FIRST, statSyncs each ONCE, then marks entries whose
|
|
32
|
+
* project is in the dead set. Both counts are reported.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const HINTS = require('./remediation-hints');
|
|
38
|
+
|
|
39
|
+
// The zeroed shape for "checked, nothing stale". Also the base (via spread)
|
|
40
|
+
// for the distinguishable failure shape listStaleSessionIndexEntries returns
|
|
41
|
+
// on an internal error — see that function's catch, below.
|
|
42
|
+
const EMPTY_RESULT = Object.freeze({
|
|
43
|
+
staleTaskIds: [], entryCount: 0, distinctProjectCount: 0, staleProjectCount: 0,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether `project` still resolves to a directory.
|
|
48
|
+
*
|
|
49
|
+
* statSync (not lstatSync) is deliberate: unlike session-index-tmp-sweep.js's
|
|
50
|
+
* DESTRUCTIVE unlink (see that file's symlink-safety comment) or
|
|
51
|
+
* session-metadata-tmp-sweep.js's never-follow walk, nothing here ever
|
|
52
|
+
* deletes anything AT `project` — only the taskId's row in a wholly separate
|
|
53
|
+
* JSON file. A project reached through a symlink is a live lookup target
|
|
54
|
+
* exactly like any other; a DANGLING symlink should read as gone the same way
|
|
55
|
+
* a deleted real directory does, which is what following (statSync), not
|
|
56
|
+
* lstatSync, gives us.
|
|
57
|
+
*
|
|
58
|
+
* Judgment call (R16, left open by the plan on purpose): only ENOENT/ENOTDIR
|
|
59
|
+
* is treated as "confirmed gone". Everything else — EACCES, EPERM, a
|
|
60
|
+
* transient EIO, an unmounted network share timing out — means "cannot
|
|
61
|
+
* confirm", not "confirmed gone", so it is treated as LIVE. This mirrors the
|
|
62
|
+
* ENOENT-only split workspace/artifact-guard.js :: readRunArtifact already
|
|
63
|
+
* uses for the identical ambiguity (its RN-10 fix). Treating "cannot read" as
|
|
64
|
+
* "does not exist" would prune a LIVE entry on a permissions blip — both a
|
|
65
|
+
* liveness-check correctness bug (R16-2) and a silent loss of user state
|
|
66
|
+
* (plan Global Constraint 5).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} project
|
|
69
|
+
* @param {(p: string) => import('fs').Stats} statSync
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
function projectExists(project, statSync) {
|
|
73
|
+
try {
|
|
74
|
+
return statSync(project).isDirectory();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) { return false; }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List session-index entries whose project no longer exists.
|
|
83
|
+
*
|
|
84
|
+
* Never THROWS: the corrupt/missing-index case is handled exactly as
|
|
85
|
+
* session-index.js :: readIndex already handles it (corrupt/missing -> {}),
|
|
86
|
+
* so it never reaches the catch below. An unexpected internal failure still
|
|
87
|
+
* degrades rather than propagates into `doctor` — but (council R16 fix round
|
|
88
|
+
* A3) the degraded result now carries `error`, which an all-clear result
|
|
89
|
+
* never has, so "checked, 0 stale" and "could not check" can never collapse
|
|
90
|
+
* to the same value. See evaluateSessionIndexPrune for how that distinction
|
|
91
|
+
* is read.
|
|
92
|
+
*
|
|
93
|
+
* @param {{statSync?: (p: string) => import('fs').Stats,
|
|
94
|
+
* readIndex?: () => Record<string,string>}} [deps] - injectable for tests
|
|
95
|
+
* (e.g. to simulate EACCES without needing real OS permissions).
|
|
96
|
+
* @returns {{staleTaskIds: string[], entryCount: number,
|
|
97
|
+
* distinctProjectCount: number, staleProjectCount: number, error?: string}}
|
|
98
|
+
*/
|
|
99
|
+
function listStaleSessionIndexEntries(deps = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const statSync = deps.statSync || fs.statSync;
|
|
102
|
+
const readIndex = deps.readIndex || require('./session-index').readIndex;
|
|
103
|
+
|
|
104
|
+
const index = readIndex() || {};
|
|
105
|
+
const taskIds = Object.keys(index);
|
|
106
|
+
|
|
107
|
+
// R16-3: dedupe to the DISTINCT project set before ever touching the disk.
|
|
108
|
+
const projects = new Set();
|
|
109
|
+
for (const taskId of taskIds) {
|
|
110
|
+
const project = index[taskId];
|
|
111
|
+
if (typeof project === 'string' && project) { projects.add(project); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const deadProjects = new Set();
|
|
115
|
+
for (const project of projects) {
|
|
116
|
+
if (!projectExists(project, statSync)) { deadProjects.add(project); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const staleTaskIds = taskIds.filter((taskId) => {
|
|
120
|
+
const project = index[taskId];
|
|
121
|
+
// A non-string/empty project can never resolve to a lookup target
|
|
122
|
+
// either — dead weight from the same "nothing ever removes a row" gap,
|
|
123
|
+
// and this is the only place that can ever clean it up.
|
|
124
|
+
return !(typeof project === 'string' && project) || deadProjects.has(project);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
staleTaskIds,
|
|
129
|
+
entryCount: taskIds.length,
|
|
130
|
+
distinctProjectCount: projects.size,
|
|
131
|
+
staleProjectCount: deadProjects.size,
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Council R16 fix round (A3): landing here must not read the same as "0
|
|
135
|
+
// stale rows" — this project's stated north star is that a
|
|
136
|
+
// correct-but-SILENT degrade fails the bar as hard as a crash. The
|
|
137
|
+
// expected corrupt/missing-index case never reaches this catch (readIndex
|
|
138
|
+
// itself already guards to {}); what DOES land here is either a real bug
|
|
139
|
+
// or a throwing injected dep (deps.readIndex/deps.statSync are both real
|
|
140
|
+
// injection points). Tag the result so evaluateSessionIndexPrune can tell
|
|
141
|
+
// "checked, found nothing" apart from "could not check" — still never
|
|
142
|
+
// throws into doctor, just stops reporting a failure as an all-clear.
|
|
143
|
+
return { ...EMPTY_RESULT, error: (err && err.message) || String(err) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove the given taskIds from the index, atomically, through the same
|
|
149
|
+
* writeFileAtomic path session-index.js :: recordSession uses. Re-reads the
|
|
150
|
+
* index fresh (never reuses a list-time snapshot), deletes ONLY the given
|
|
151
|
+
* ids — never any other key a concurrent writer may have added to the
|
|
152
|
+
* freshly-read index (verified: tests/doctor-index-prune.test.js's "removes
|
|
153
|
+
* only the given ids" and the B1 concurrent-add case below) — and writes
|
|
154
|
+
* only when at least one given id is still actually present.
|
|
155
|
+
*
|
|
156
|
+
* `deps.readIndex` plus the `|| {}` guard below mirror
|
|
157
|
+
* listStaleSessionIndexEntries (council R16 fix round A1/A4): that was the
|
|
158
|
+
* one asymmetry between the two siblings — `list` was stub-testable for a
|
|
159
|
+
* corrupt/absent readIndex, `prune` was not testable at all without a real
|
|
160
|
+
* config dir, and an injected readIndex() returning null/undefined would
|
|
161
|
+
* TypeError on `Object.prototype.hasOwnProperty.call(null, ...)`. The REAL
|
|
162
|
+
* readIndex can never return null (session-index.js :: readIndex always
|
|
163
|
+
* yields a validated object or `{}`), so this never fires in production —
|
|
164
|
+
* it closes a tested gap, not a live bug. Still does NOT guard its own
|
|
165
|
+
* THROWS, matching session-index-tmp-sweep.js :: unlinkSessionIndexTmp,
|
|
166
|
+
* which also lets fs errors propagate to its caller; evaluateSessionIndexPrune
|
|
167
|
+
* (below) is that catcher.
|
|
168
|
+
*
|
|
169
|
+
* B1/D1 (council R16 fix round 2 — raised again by two more models;
|
|
170
|
+
* adjudicated, real, pre-existing, still NOT fixed here): the race is
|
|
171
|
+
* TWO-SIDED. `target` is computed before the read so only the in-memory
|
|
172
|
+
* filter below runs between read and write — the narrowest this window gets
|
|
173
|
+
* without real synchronization — but session-index.js :: recordSession
|
|
174
|
+
* performs the IDENTICAL unlocked read-modify-write. Locking only THIS side
|
|
175
|
+
* would be theater: closing the race properly requires locking
|
|
176
|
+
* recordSession too, exactly the hot-start-path cost ruling R16-1 rejected
|
|
177
|
+
* when it chose this doctor-check design over "prune on write". Consequence,
|
|
178
|
+
* unchanged: a session recorded by another process between this read and
|
|
179
|
+
* this write is lost, degrading `amicus read <id>` from another project
|
|
180
|
+
* into a not-found. `src/utils/session-lock.js` already provides atomic
|
|
181
|
+
* PID/staleness lock-file primitives (used today per-session-dir, not for
|
|
182
|
+
* this file) — the natural home for that future lock/CAS, noted so it is
|
|
183
|
+
* not re-derived, but NOT wired in here: its own change, beyond R16.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} staleTaskIds
|
|
186
|
+
* @param {{readIndex?: () => Record<string,string>}} [deps] - injectable for
|
|
187
|
+
* tests (matches listStaleSessionIndexEntries's shape/rationale above).
|
|
188
|
+
* @returns {number} count actually removed (may be less than
|
|
189
|
+
* staleTaskIds.length if an id was already gone by the time this ran).
|
|
190
|
+
*/
|
|
191
|
+
function pruneStaleSessionIndexEntries(staleTaskIds, deps = {}) {
|
|
192
|
+
// Array.isArray, not `|| []` (council R16 fix round 2, A2/d1): the old
|
|
193
|
+
// guard caught null/undefined but let any OTHER truthy value through — a
|
|
194
|
+
// string would iterate per character below, a plain object would throw.
|
|
195
|
+
const ids = Array.isArray(staleTaskIds) ? staleTaskIds : [];
|
|
196
|
+
if (ids.length === 0) { return 0; }
|
|
197
|
+
|
|
198
|
+
// Lazy requires here match session-index-tmp-sweep.js's established
|
|
199
|
+
// convention for these same deps (council R16 fix round 2, A4) — kept, not hoisted.
|
|
200
|
+
const { INDEX_FILENAME, readIndex: realReadIndex } = require('./session-index');
|
|
201
|
+
const readIndex = deps.readIndex || realReadIndex;
|
|
202
|
+
const { getConfigDir } = require('./config');
|
|
203
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
204
|
+
const target = path.join(getConfigDir(), INDEX_FILENAME);
|
|
205
|
+
|
|
206
|
+
const index = readIndex() || {};
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const taskId of ids) {
|
|
209
|
+
if (Object.prototype.hasOwnProperty.call(index, taskId)) {
|
|
210
|
+
delete index[taskId];
|
|
211
|
+
removed += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (removed === 0) { return 0; }
|
|
215
|
+
|
|
216
|
+
writeFileAtomic(target, JSON.stringify(index, null, 2), { mode: 0o600 });
|
|
217
|
+
return removed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Compose the doctor check line for the stale-entry prune. Pure decision
|
|
222
|
+
* logic (list/prune side effects come in via `d`); src/cli-handlers-doctor.js
|
|
223
|
+
* wraps this in guard() the same way it wires the tmp-sweep check beside it.
|
|
224
|
+
* @param {{listStaleSessionIndexEntries: () => {staleTaskIds:string[],
|
|
225
|
+
* entryCount:number, distinctProjectCount:number, staleProjectCount:number,
|
|
226
|
+
* error?:string},
|
|
227
|
+
* fix?: boolean,
|
|
228
|
+
* pruneStaleSessionIndexEntries: (ids: string[]) => number}} d
|
|
229
|
+
*/
|
|
230
|
+
function evaluateSessionIndexPrune(d) {
|
|
231
|
+
const id = 'sessions-index-prune'; const name = 'Session index stale entries';
|
|
232
|
+
const list = d.listStaleSessionIndexEntries();
|
|
233
|
+
|
|
234
|
+
// Council R16 fix round (A3): a listing failure gets its own status, never
|
|
235
|
+
// coalesced into the "0 stale" shape below. `status: 'error'` is the same
|
|
236
|
+
// vocabulary guard() uses for every other doctor check failure —
|
|
237
|
+
// doctor-degrade.js turns it into a 'doctor-check-failed' record; doctor
|
|
238
|
+
// still finishes and prints every other line (loud, not fatal).
|
|
239
|
+
if (!list || list.error) {
|
|
240
|
+
return {
|
|
241
|
+
id, name, status: 'error',
|
|
242
|
+
message: `could not determine stale session-index entries: ${(list && list.error) || 'unknown error'}`,
|
|
243
|
+
hint: null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const { staleTaskIds, entryCount, distinctProjectCount, staleProjectCount } = list;
|
|
247
|
+
const staleCount = staleTaskIds.length;
|
|
248
|
+
|
|
249
|
+
if (staleCount === 0) {
|
|
250
|
+
return {
|
|
251
|
+
id, name, status: 'ok',
|
|
252
|
+
message: `0 stale rows (${entryCount} entries, ${distinctProjectCount} distinct project(s) checked)`,
|
|
253
|
+
hint: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (!d.fix) {
|
|
257
|
+
return {
|
|
258
|
+
id, name, status: 'warn',
|
|
259
|
+
message: `${staleCount} stale row(s) of ${entryCount} — ${staleProjectCount} of ${distinctProjectCount} distinct project(s) gone — run with --fix`,
|
|
260
|
+
hint: HINTS.pruneSessionIndex,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Council R16 fix round (A2): capture WHY a write underperformed instead of
|
|
265
|
+
// reporting every cause as the same generic guess — a thrown exception
|
|
266
|
+
// (disk full, EACCES, a bug) is a different fact from some ids having
|
|
267
|
+
// already been removed by a racing prune, and swallowing the exception's
|
|
268
|
+
// own message was hiding that difference. Status stays 'warn' either way
|
|
269
|
+
// (never crashes doctor — see the throwing-prune test) but the message no
|
|
270
|
+
// longer lies about which one happened.
|
|
271
|
+
let pruned = 0;
|
|
272
|
+
let writeError = null;
|
|
273
|
+
try { pruned = d.pruneStaleSessionIndexEntries(staleTaskIds) || 0; }
|
|
274
|
+
catch (e) { writeError = (e && e.message) || 'unknown error'; }
|
|
275
|
+
|
|
276
|
+
if (pruned === staleCount && !writeError) {
|
|
277
|
+
return {
|
|
278
|
+
id, name, status: 'ok', message: `pruned ${pruned} stale row(s)`, hint: null,
|
|
279
|
+
fixed: true,
|
|
280
|
+
fixDetail: `pruned ${pruned} stale session-index row(s) (${staleProjectCount} deleted project(s))`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// Council R16 fix round 2 (A3): name the OBSERVATION (fewer removed than
|
|
284
|
+
// listed), not an inferred cause — the count can differ for reasons other
|
|
285
|
+
// than "the index changed" underneath us.
|
|
286
|
+
const remaining = staleCount - pruned;
|
|
287
|
+
const reason = writeError ? `write failed: ${writeError}` : 'fewer entries removed than expected';
|
|
288
|
+
return {
|
|
289
|
+
id, name, status: 'warn',
|
|
290
|
+
message: `pruned ${pruned}, ${remaining} remaining (${reason})`,
|
|
291
|
+
hint: HINTS.pruneSessionIndex,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
listStaleSessionIndexEntries, pruneStaleSessionIndexEntries, evaluateSessionIndexPrune,
|
|
297
|
+
};
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
|
|
14
14
|
*
|
|
15
15
|
* Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
|
|
16
|
-
*
|
|
16
|
+
* v4.6.3 D8): this walks
|
|
17
17
|
* `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
|
|
18
18
|
* `subagents/<id>/` children — rather than consulting sessions-index.json.
|
|
19
19
|
* `amicus doctor` is a per-project surface; the index is best-effort and can
|