amicus 4.6.3 → 4.7.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.
Files changed (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. package/src/utils/validators.js +16 -0
@@ -40,4 +40,30 @@ function requireValidTaskId(args, useJson, commandLabel, usage) {
40
40
  return taskId;
41
41
  }
42
42
 
43
- module.exports = { requireNoUiForJson, requireValidTaskId };
43
+ /**
44
+ * `pack save --version <semver>` can never work, and used to fail SILENTLY:
45
+ * `version` is a global BOOLEAN_FLAG (src/cli.js), so parseArgs sets
46
+ * `args.version = true` and drops the semver into positionals, then
47
+ * bin/amicus.js prints the version banner BEFORE command dispatch — so
48
+ * `handlePack` never runs and no pack is written, at exit 0. `--version=2.0.0`
49
+ * fails identically (the inline value is discarded at the isBooleanFlag branch,
50
+ * ahead of the --key=value branch). The pack's own version is `--pack-version`.
51
+ *
52
+ * Returns the failure rather than exiting, so bin/amicus.js keeps one exit site
53
+ * and this stays unit-testable (bin/amicus.js is a script, not a module).
54
+ *
55
+ * @param {object} args - parsed CLI args
56
+ * @returns {{code: string, message: string, hint: string}|null} null when there is no conflict
57
+ */
58
+ function packSaveVersionConflict(args) {
59
+ if (!args || !args.version) { return null; }
60
+ const argv = Array.isArray(args._) ? args._ : [];
61
+ if (argv[0] !== 'pack' || argv[1] !== 'save') { return null; }
62
+ return {
63
+ code: ERROR_CODES.BAD_ARGS,
64
+ message: "Error: --version is amicus's own global flag, not the saved pack's version",
65
+ hint: 'Use --pack-version <semver> to set the version of the pack being saved.',
66
+ };
67
+ }
68
+
69
+ module.exports = { requireNoUiForJson, requireValidTaskId, packSaveVersionConflict };
@@ -419,6 +419,21 @@ function getCouncilWithSource(name, catalog = []) {
419
419
  * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
420
420
  * `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
421
421
  * callers keep working); `droppedMembers` additively pairs each with WHY.
422
+ *
423
+ * Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
424
+ * (that is the real key — BACKLOG.md's description of this shape had drifted to
425
+ * `{ref, reason}`). The two `reason` literals are produced below, in the
426
+ * alias-miss and catalog-miss branches of `classifyCouncilMembers`; they are
427
+ * free text, not a coded enum, and today NO consumer branches on the string. They
428
+ * are not display-only: `council/presets-cli.js` (`amicus council show`,
429
+ * `:149-150`) renders `${member} (${reason})`, `council/run.js` (`:80-87`) carries
430
+ * it verbatim into a degrade-note payload, and `reason` is also persisted into
431
+ * `run.json` (`run-state.js`, `mcp-council-run.js`) — not merely shown to a human.
432
+ * `cli-council-run-bench.js` (`:74`) only type-checks that `reason` is a string
433
+ * when round-tripping `--dropped-members` across the MCP→CLI spawn boundary. The
434
+ * tripwire: if a THIRD reason string is ever added here, stop and re-decide
435
+ * whether `reason` should become a coded enum instead of free text — this note
436
+ * marks that decision point, it does not make it.
422
437
  */
423
438
  function classifyCouncilMembers(members, catalog = []) {
424
439
  const aliases = getEffectiveAliases();
@@ -48,6 +48,38 @@ function evaluateEngineInstalls(d) {
48
48
 
49
49
  const broken = npxCopies.filter((i) => !i.engineOk);
50
50
  if (broken.length === 0) {
51
+ // Version skew (#133): a PRESENT engine can still be the wrong one. The
52
+ // npx copies and the global install resolve independently and at different
53
+ // times, and two versions writing one shared opencode.db is what produced
54
+ // #133's SQLiteError. Compare npx against global ONLY — a genuine source
55
+ // checkout's engine legitimately differs, so including it would fire red
56
+ // on every developer machine and in CI (E-1c). Unresolved versions never
57
+ // signal skew; absence of evidence is not evidence.
58
+ // WARN, never ERROR: doctor --fix has no skew branch, so an error would be
59
+ // unfixable, and this file already downgrades to warn at the ambiguous-npx
60
+ // branch below whenever the copy npx will select is ambiguous.
61
+ //
62
+ // Review round 2, finding 1: `kind === 'global'` alone is NOT the whole
63
+ // baseline. On the documented end-user invocation — `amicus doctor` run
64
+ // from the globally-installed copy — the running process IS the global
65
+ // install, so engine-install-scan.js's own dedup drops the separate
66
+ // `global` record and only `kind:'running'` survives, carrying
67
+ // `isGlobal: true` instead (see scanEngineInstalls). Without the
68
+ // `|| i.isGlobal` clause, the skew check was structurally unable to fire
69
+ // for exactly the users who filed #133. A TRUE source checkout (running,
70
+ // no isGlobal) is still excluded, as intended.
71
+ const globalV = (installs.find((i) => i.kind === 'global' || i.isGlobal) || {}).engineVersion;
72
+ const skewed = globalV
73
+ ? npxCopies.filter((i) => i.engineVersion && i.engineVersion !== globalV)
74
+ : [];
75
+ if (skewed.length > 0) {
76
+ const detail = skewed.map((i) => `${i.pkgDir} has ${i.engineVersion}`).join('; ');
77
+ return {
78
+ id, name, status: 'warn',
79
+ message: `engine version skew — global install has ${globalV}; ${detail}`,
80
+ hint: HINTS.engineVersionSkew,
81
+ };
82
+ }
51
83
  return {
52
84
  id, name, status: 'ok',
53
85
  message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
@@ -27,12 +27,21 @@ function defaultNpmCacheDir(platform) {
27
27
  return path.join(os.homedir(), '.npm');
28
28
  }
29
29
 
30
- /** Best-effort `npm root -g`. Never throws; returns null on any failure. */
31
- function defaultNpmRootG() {
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 { execFileSync } = require('child_process');
34
- const out = execFileSync('npm', ['root', '-g'], {
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 real = safe(() => fs.realpathSync(inst.pkgDir), inst.pkgDir);
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 || defaultNpmRootG;
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
- const installs = listAmicusInstalls(deps).map((i) => ({
134
- ...i,
135
- engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
136
- roots: opencodeRoots({ pkgDir: i.pkgDir }),
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 = { listAmicusInstalls, scanEngineInstalls, classifyLaunch };
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
- /** First healthy install whose real path differs from the destination. */
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
- return installs.find((i) => i.engineOk && norm(i.pkgDir) !== destReal) || null;
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. */
@@ -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
@@ -91,6 +91,7 @@ function buildWaveResultFromSession(project, waveId) {
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
93
  ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
94
+ ...(meta.tag ? { tag: meta.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
94
95
  createdAt: meta.createdAt || null,
95
96
  completedAt: meta.completedAt || null,
96
97
  });
@@ -47,6 +47,7 @@ function durationBetween(createdAt, completedAt) {
47
47
  * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
48
  * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
49
  * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
50
+ * `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
50
51
  */
51
52
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
52
53
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -71,6 +72,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
71
72
  opencodeSessionId: metadata.opencodeSessionId || null,
72
73
  usage: usage !== null ? usage : (metadata.usage || null),
73
74
  ...(metadata.pack ? { pack: metadata.pack } : {}),
75
+ ...(metadata.tag ? { tag: metadata.tag } : {}),
74
76
  };
75
77
  }
76
78
 
@@ -127,9 +129,11 @@ function waveExitCode(waveStatus) {
127
129
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
130
  * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
131
  * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
132
+ * @param {string|null} [opts.tag] - v4.7 F8 (D13): additive — present only when the wave was
133
+ * launched via --tag (absent, never null, otherwise).
130
134
  * @returns {object} wave document
131
135
  */
132
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
136
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null, tag = null }) {
133
137
  const { sumWaveUsage } = require('./pricing');
134
138
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
135
139
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -157,6 +161,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
157
161
  usage: sumWaveUsage(legs),
158
162
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
163
  ...(pack ? { pack } : {}),
164
+ ...(tag ? { tag } : {}),
160
165
  };
161
166
  }
162
167
 
@@ -33,9 +33,21 @@ function listSessionIndexTmpFiles() {
33
33
  return entries
34
34
  .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
35
35
  .map((name) => {
36
- let mtimeMs = null;
37
- try { mtimeMs = fs.statSync(path.join(dir, name)).mtimeMs; } catch { /* raced away skip below */ }
38
- return { name, mtimeMs };
36
+ let st = null;
37
+ // statSync (not lstatSync) is a DECIDED policy, not an oversight (owner
38
+ // ruling, v4.7 PR7 — see BACKLOG.md "session-index-tmp-sweep.js follows
39
+ // symlinks... where its sibling deliberately does not"). This sweep's
40
+ // targets are index tmp files directly in the flat config dir, never
41
+ // traversed through a directory an attacker could redirect — unlike
42
+ // session-metadata-tmp-sweep.js's walk through per-session directories
43
+ // (see that file's "Symlink safety" paragraph for why THAT sweep must
44
+ // never follow). Following here means a symlink named like an orphaned
45
+ // tmp file is still swept (unlink removes only the link); the one real
46
+ // consequence is AGE_THRESHOLD_MS reading the TARGET's mtime, so a fresh
47
+ // link to an old file is swept with no grace window. Filed, not fixed —
48
+ // see the BACKLOG entry above for the full inclusion/unfiled-delta split.
49
+ try { st = fs.statSync(path.join(dir, name)); } catch { /* raced away */ }
50
+ return { name, mtimeMs: st && st.isFile() ? st.mtimeMs : null };
39
51
  })
40
52
  .filter((f) => f.mtimeMs !== null);
41
53
  }
@@ -52,6 +64,9 @@ function unlinkSessionIndexTmp(name) {
52
64
  * this in guard() the same way it wires the mcp-legacy check's inspect/migrate.
53
65
  * @param {{listSessionIndexTmpFiles: () => Array<{name:string, mtimeMs:number}>,
54
66
  * fix?: boolean, now: () => number, unlinkSessionIndexTmp: (name: string) => void}} d
67
+ * Its four `message` strings are byte-identical to
68
+ * session-metadata-tmp-sweep.js's evaluateSessionMetadataTmpSweep by design
69
+ * (only `id`/`name`/`fixDetail` differ) — reword both or neither.
55
70
  */
56
71
  function evaluateSessionIndexTmpSweep(d) {
57
72
  const id = 'sessions-index-tmp'; const name = 'Session index tmp files';
@@ -94,4 +94,5 @@ module.exports = {
94
94
  INDEX_FILENAME,
95
95
  recordSession,
96
96
  lookupSessionProject,
97
+ readIndex,
97
98
  };
@@ -29,6 +29,14 @@
29
29
  * symlinked taskId or subagents directory could otherwise be traversed and
30
30
  * have files unlinked through the link, effectively outside the sessions
31
31
  * root; lstat closes that off at zero cost.
32
+ *
33
+ * Consequence of the SR-3 isFile() gate (listTmpIn, below): a SYMLINK whose
34
+ * basename matches the tmp pattern is now excluded from the list entirely —
35
+ * neither swept nor reported. Before SR-3 it was swept (unlink removes the
36
+ * link, never the target — a safe success). Deliberate: this module's
37
+ * never-follow policy applies to the entries it unlinks too. Note the sibling
38
+ * session-index-tmp-sweep.js diverges here — it uses statSync, so a
39
+ * symlink-to-a-file with the matching name IS still swept there.
32
40
  */
33
41
 
34
42
  const fs = require('fs');
@@ -38,7 +46,14 @@ const HINTS = require('./remediation-hints');
38
46
  /** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
39
47
  const AGE_THRESHOLD_MS = 60 * 1000;
40
48
 
41
- /** The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions. */
49
+ /**
50
+ * The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions.
51
+ * Reads process.cwd() directly, NOT doctor's injected getCwd
52
+ * (cli-handlers-doctor.js's realDeps().getCwd) — the
53
+ * listSessionMetadataTmpFiles/unlinkSessionMetadataTmp deps are wired
54
+ * argument-free in that same realDeps(), so that seam does not reach here.
55
+ * Thread cwd through those deps if a `doctor --cwd <dir>` mode ever lands.
56
+ */
42
57
  function sessionsRoot() {
43
58
  const { SESSIONS_DIR } = require('../session-manager');
44
59
  return path.join(process.cwd(), '.claude', SESSIONS_DIR);
@@ -56,9 +71,9 @@ function listTmpIn(dir, root) {
56
71
  return entries
57
72
  .filter(isMetadataTmp)
58
73
  .map((basename) => {
59
- let mtimeMs = null;
60
- try { mtimeMs = fs.lstatSync(path.join(dir, basename)).mtimeMs; } catch { /* raced away — skip below */ }
61
- return { name: path.relative(root, path.join(dir, basename)), mtimeMs };
74
+ let st = null;
75
+ try { st = fs.lstatSync(path.join(dir, basename)); } catch { /* raced away */ }
76
+ return { name: path.relative(root, path.join(dir, basename)), mtimeMs: st && st.isFile() ? st.mtimeMs : null };
62
77
  })
63
78
  .filter((f) => f.mtimeMs !== null);
64
79
  }
@@ -107,6 +122,11 @@ function unlinkSessionMetadataTmp(name) {
107
122
  * wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
108
123
  * @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
109
124
  * fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
125
+ * The four `message` strings below are byte-identical to the index sibling's
126
+ * (session-index-tmp-sweep.js's evaluateSessionIndexTmpSweep, same four
127
+ * ok/warn returns) on purpose — `id`/`name` and the `fixDetail` wording are
128
+ * the only disambiguators between the two rows. Reword one side and the
129
+ * pairing silently breaks: reword both, or neither.
110
130
  */
111
131
  function evaluateSessionMetadataTmpSweep(d) {
112
132
  const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
@@ -55,13 +55,17 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
55
55
  * @param {string} [opts.councilName] council name (additive attribution)
56
56
  * @param {string} [opts.project] project directory (additive attribution)
57
57
  * @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
58
+ * @param {string} [opts.tag] user-chosen tag (v4.7 F8 D16, additive attribution) —
59
+ * nullable dim, same null-not-absent convention as councilRunId/project below;
60
+ * deliberately the OPPOSITE convention from createSessionMetadata's tag (D13,
61
+ * absent-not-null) — see the dim-list comment below.
58
62
  * @param {number} [opts.attempt] fallback attempt count (omitted if absent)
59
63
  * @param {string} [opts.substitutedFor] substituted model (omitted if absent)
60
64
  * @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
61
65
  * @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
62
66
  */
63
67
  function appendSpend({ taskId, waveId, model, mode, usage,
64
- op, status, councilRunId, councilName, project, gateway,
68
+ op, status, councilRunId, councilName, project, gateway, tag,
65
69
  attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
66
70
  if (!usage) { return; }
67
71
  try {
@@ -76,15 +80,18 @@ function appendSpend({ taskId, waveId, model, mode, usage,
76
80
  mode: mode || null,
77
81
  tokens: usage.tokens || null,
78
82
  cost: usage.cost || null,
79
- // v4.3 additive attribution (spec 7.1). Nullable dimensions default to
80
- // null (so a row is always groupable); linkage fields are OMITTED unless
81
- // present (they only exist on fallback/retry rows).
83
+ // v4.3 additive attribution (spec 7.1), extended v4.7 F8 D16 with `tag`.
84
+ // Nullable dimensions (op/status/councilRunId/councilName/project/
85
+ // gateway/tag) default to null (so a row is always groupable); linkage
86
+ // fields are OMITTED unless present (they only exist on fallback/retry
87
+ // rows).
82
88
  op: op || null,
83
89
  status: status || null,
84
90
  councilRunId: councilRunId || null,
85
91
  councilName: councilName || null,
86
92
  project: project || null,
87
93
  gateway: gateway || null,
94
+ tag: tag || null,
88
95
  };
89
96
  if (attempt !== undefined) { row.attempt = attempt; }
90
97
  if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
@@ -24,6 +24,20 @@ const { PROVIDER_KEY_MAP } = require('./provider-registry');
24
24
  /** Task ID format: alphanumeric, hyphens, underscores, 1-64 chars */
25
25
  const TASK_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
26
26
 
27
+ const TAG_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
28
+ /**
29
+ * v4.7 F8 (D13): tag validation REJECTS (unlike sanitizeCouncilName, which
30
+ * cleans) — a stored tag is a user-chosen search key, so silent truncation
31
+ * or charset-stripping would make `--search`/`--group-by tag` miss it.
32
+ * Non-string guards the valueless `--tag` parse (cli.js turns it into true).
33
+ */
34
+ function validateTag(value) {
35
+ if (typeof value !== 'string' || !TAG_PATTERN.test(value)) {
36
+ return { ok: false, error: 'Invalid --tag: 1-64 chars, letters/digits/_/- only' };
37
+ }
38
+ return { ok: true, tag: value };
39
+ }
40
+
27
41
  /**
28
42
  * Validate a task ID format (safe for use in file paths)
29
43
  * @param {string} taskId
@@ -249,6 +263,8 @@ module.exports = {
249
263
  MODEL_THINKING_SUPPORT,
250
264
  TASK_ID_PATTERN,
251
265
  validateTaskId,
266
+ TAG_PATTERN,
267
+ validateTag,
252
268
  safeSessionDir,
253
269
  validatePromptContent,
254
270
  validateCwdPath,