amicus 4.7.0 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.7.1] - 2026-08-09
9
+
10
+ ### Changed
11
+
12
+ - **`continue`, `resume`, and `--retry-failed` now inherit the parent session's/wave's tag,
13
+ instead of dropping it.** `amicus list` now shows a TAG for continuations (they no longer group
14
+ under `(unattributed)` for `--group-by tag`), and `--tag` is now **rejected** on `continue` and
15
+ `resume` — the tag can only come from the parent, it cannot be overridden. This deletes a
16
+ documented limitation, not a bug: `docs/usage.md` carried a paragraph headed "Known limitation: a
17
+ tag is not inherited", and v4.7.0's own release notes called the gap "future work, not
18
+ oversights." That limitation is now gone.
19
+ - **`opencode-ai` and `@opencode-ai/sdk` are pinned to exactly `1.18.15`** (previously `^1.2.20` /
20
+ `^1.1.36`). Dev and CI now run on the same engine version end users resolve, so this is the
21
+ first release whose test suite actually ran against the engine users get. Honest scope: the pin
22
+ makes the *resolved* engine a pure function of the amicus version — it does nothing for a user
23
+ whose installed amicus version hasn't moved, and it does not force any existing npx cache to
24
+ re-resolve (that mechanism was investigated for #133 and found not to exist).
25
+
26
+ ### Fixed
27
+
28
+ - **The no-output backstop message now reports what it observed, not what it guessed.** The old
29
+ three-line message asserted "likely a listed-but-not-serving model or a dead endpoint" with no
30
+ evidence behind it; it now names only what the deadline mechanism actually saw, and names
31
+ `AMICUS_NO_OUTPUT_BACKSTOP_MS` as the live governing window only when it is one, calling it out
32
+ as an overridden default otherwise.
33
+ - **The no-output backstop window now doubles on a Stage-1 retry, clamped to the leg timeout, so
34
+ retries can now heal a class of no-output failure that previously required a manual rerun.**
35
+ This is not a fix for the backstop class itself — only Stage-1 bench/critic/lens units retry;
36
+ judges, chair, chair-repair, and debate legs do not, and can still hit the backstop with no
37
+ automatic recovery.
38
+ - **`doctor` now reports engine version skew between installs instead of only grading on
39
+ presence.** A stale npx-cached copy of `opencode-ai` next to a newer global install now surfaces
40
+ as a WARN.
41
+ - **`npm root -g` now resolves on Windows** (it previously threw `ENOENT`/`EINVAL`, making the
42
+ global amicus install invisible to `doctor`). This also un-blinds `doctor --fix`'s donor
43
+ selection, which depends on seeing that global install to pick a healthy engine to copy from.
44
+ - **`npm test` no longer fails intermittently with `ENOENT: … src\__sizecheck_tmp__.js`.**
45
+ `tests/scripts/check-file-sizes.test.js` writes a real `src/__sizecheck_tmp__.js` and unlinks it a
46
+ few milliseconds later. It cannot move that fixture to a tmpdir: `checkAllTracked()` filters its
47
+ input through the anchored `src/**/*.js` include glob and only then resolves against
48
+ `process.cwd()`, so a tmpdir path is dropped before the read and the test would assert on an empty
49
+ violation list. Three suites walk `src/` in parallel jest workers, and a directory listing taken
50
+ while that file existed followed by a read issued after the unlink threw ENOENT — killing
51
+ `no-phantom-dependencies.test.js` during collection ("Test suite failed to run") on roughly 1 full
52
+ run in 2, with `cli-template-args.test.js` and `council/degrade-invariant.test.js` exposed
53
+ identically (both confirmed failing under a reproduction that widens the collision window). All
54
+ three now read through the new `tests/helpers/read-if-present.js`, which skips a file that vanished
55
+ mid-walk and still throws on any non-ENOENT failure, so an unreadable source file stays loud.
56
+ Pre-existing — reproduces at 0fe6128, unrelated to the graphify integration.
57
+
58
+ ### Internal
59
+
60
+ - **`finalizeSpendForReopen` extracted to `src/sidecar/reopen-spend.js`**, out of
61
+ `sidecar/continue.js`, to keep that file under the 300-line gate; `resume.js` was already
62
+ reaching across into `continue.js` for it, so the shared home is the honest one.
63
+ - **Deleted three unreachable helpers from `scripts/validate-docs.js`** (dead code with no call
64
+ site remaining after earlier `--check` work landed).
65
+ - **Three doc/marker-freshness gates, all jest-enforced — one genuinely new, two hardened.**
66
+ `tests/docs-command-coverage.test.js` and the anchor-link gate
67
+ (`tests/docs-council-toc-anchors.test.js`) both already existed and already ran in CI; this
68
+ branch hardens them (the former now derives its command list from `bin/amicus.js`'s switch
69
+ instead of hardcoding five entries, the latter generalizes from one file to sixteen). Only the
70
+ CLAUDE.md AUTO-marker/cross-link freshness gate in this changelog entry's own commit is genuinely
71
+ new — `generate-docs --check` had never run in CI before — so a stale `CLAUDE.md` can no longer
72
+ pass CI silently.
73
+
8
74
  ## [4.7.0] - 2026-08-08
9
75
 
10
76
  **"The count is the count."** Every number Amicus shows you is the number — what a council cost,
package/README.md CHANGED
@@ -450,7 +450,7 @@ $ amicus status demo123 --json
450
450
  "taskId": "demo123",
451
451
  "status": "complete",
452
452
  "elapsed": "5m 0s",
453
- "version": "4.7.0",
453
+ "version": "4.7.1",
454
454
  "model": "google/gemini-2.5-flash",
455
455
  "phase": "terminal"
456
456
  }
@@ -460,6 +460,6 @@ aliases, and council presets carry over untouched.
460
460
  | `eslint` ^8.0.0 | Code linting |
461
461
  | `lint-staged` ^16.3.2 | Run linters on staged files |
462
462
 
463
- `opencode-ai` (>=1.0.0) is the bundled LLM conversation engine it is installed automatically as a postinstall step and does not need a separate `npm install`.
463
+ The bundled **`opencode-ai`** engine (pinned to `1.18.15`, matched by `@opencode-ai/sdk`) installs automatically as a normal dependency you don't install it separately. Its own postinstall lays down the per-platform binaries.
464
464
 
465
465
  > **Legacy names.** Pre-rebrand `SIDECAR_*` environment variables were removed entirely in v2.0.0 — they are no longer read, with no warning. Rename to the `AMICUS_*` equivalents documented above. See [docs/SHIMS.md](./SHIMS.md) for the full removal record and rename table.
package/docs/council.md CHANGED
@@ -540,8 +540,8 @@ model to the reliability ledger. Thin CLI wrapper over `tally()` (`src/council/t
540
540
  ### Tally-input schema
541
541
 
542
542
  Claude assembles this file at the end of Stage 2 (the full assembly recipe is in
543
- [SKILL.md](../skills/second-opinion/SKILL.md#stage-2--cross-review), under "Stage-2 → tally
544
- assembly recipe"). It needs **all five top-level keys** — `tally()` throws
543
+ [SKILL.md](../skills/second-opinion/SKILL.md#the-engine-run--stages-13-plus-the-stage-5-artifacts),
544
+ under "Stage-2 → tally assembly recipe"). It needs **all five top-level keys** — `tally()` throws
545
545
  `Cannot read properties of undefined (reading 'map')` if `meta` or `findings` is missing:
546
546
 
547
547
  | Field | Type | Meaning |
package/docs/testing.md CHANGED
@@ -523,7 +523,7 @@ Complete mapping of test files to their targets and focus areas.
523
523
  | `evals/tests/result_writer.test.js` | Result output | Summary formatting, file writing |
524
524
  | `scripts/check-secrets.test.js` | Secret detection | Pattern matching, allowlist, multi-secret |
525
525
  | `scripts/check-file-sizes.test.js` | File size limits | Line counting, batch checking |
526
- | `scripts/validate-docs.test.js` | Doc drift detection | Section extraction, drift comparison, staged file check |
526
+ | `scripts/validate-docs.test.js` | Doc drift detection | Staged file check |
527
527
  | `helpers/cdp-client.test.js` | CDP helper | Mock HTTP+WebSocket CDP server, factory methods |
528
528
  | `electron-headless-mode.test.js` | Electron headless | Source-level verify `AMICUS_HEADLESS_TEST` guard |
529
529
  | `cli-headless-e2e.integration.test.js` | CLI E2E (real LLM) | `start --no-ui`, `list`, `read`, `read --metadata` |
@@ -233,13 +233,15 @@ For full headless configuration, see [docs/configuration.md](./configuration.md)
233
233
 
234
234
  ## Headless Leg Fails with `NO_OUTPUT_BACKSTOP`
235
235
 
236
- **Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP: model produced no output, reasoning, or tool calls in Ns`.
236
+ **Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in Ns — the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)`. You may instead see `...in Ns — a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default` — this covers two different cases with the same wording: a Stage-1 retry (its window is the resolved `AMICUS_NO_OUTPUT_BACKSTOP_MS` value **doubled**, so a 120 s default reads `240s` on the retry) and the `amicus models --check --live` probe (a fixed, non-tunable `30s` — see below). Both are "caller-set" in the sense that the specific window wasn't read live from the env var at that call, but only the retry case is actually governed by it.
237
237
 
238
- **Cause:** The model endpoint accepted the request but produced nothing — no output, reasoning, or tool calls — for the entire backstop window (120 s by default). This is the "accepted but not serving" class: usually a dead or misconfigured endpoint, or a catalog-listed model that's no longer actually being served upstream.
238
+ **Cause:** The mechanism only knows that the deadline passed with no substantive activity — no output, reasoning, or tool calls — for the backstop window (120 s by default for an ordinary leg). It does **not** know *why*. Do not read this as "the endpoint is dead" or "the model isn't being served" the message deliberately stops short of that claim, because at one of its two firing sites the backstop can win the race before the prompt send even resolves, so acceptance itself was never observed. Treat `NO_OUTPUT_BACKSTOP` as "silence past the deadline," not a diagnosis.
239
239
 
240
- **Confirm:** `amicus models --check` audits your configured aliases against the live catalog and flags drift/staleness. A model that still resolves locally but has quietly been retired upstream is exactly this failure mode, but a static audit only compares against the catalog's *listing* it can't tell you whether a still-listed model actually answers. For that, run `amicus models --check --live` on demand: it sends one tiny real request to every stored alias and reports `SERVED` / `SILENT` / `ERROR` per alias, where `SILENT` (`accepted-but-silent`) is this exact `NO_OUTPUT_BACKSTOP` failure, caught deliberately instead of by accident. It spends real money (one tiny leg per stored alias) see [docs/usage.md § `amicus models`](./usage.md#amicus-models--the-model-catalog) for cost, scope, and exit-code details.
240
+ **Lesson from #133:** an earlier version of this message *did* guess a cause ("likely a listed-but-not-serving model or a dead endpoint"), and that guess sent 30 minutes of debugging at model ids and API keys on a real incident. The actual cause was an OpenCode engine version skew between the npx-cached copy and the global install sitting the whole time in `~/.local/share/opencode/log/opencode.log`. **Before chasing model/endpoint theories, check that log for the session's real error.**
241
241
 
242
- **Fix:** Check the alias's target (`amicus models --search <term>` to find the current id, then re-point the alias) this is the common case. Raise `AMICUS_NO_OUTPUT_BACKSTOP_MS` only if a model legitimately needs more than 120 s to produce its first token; see [docs/configuration.md § Headless Poller Tuning](./configuration.md#headless-poller-tuning).
242
+ **Confirm:** `amicus models --check` — audits your configured aliases against the live catalog and flags drift/staleness. A model that still resolves locally but has quietly been retired upstream is one possible explanation, but a static audit only compares against the catalog's *listing* — it can't tell you whether a still-listed model actually answers. For that, run `amicus models --check --live` on demand: it sends one tiny real request to every stored alias and reports `SERVED` / `SILENT` / `ERROR` per alias, where `SILENT` (`accepted-but-silent`) is this exact `NO_OUTPUT_BACKSTOP` failure, caught deliberately instead of by accident. It spends real money (one tiny leg per stored alias) see [docs/usage.md § `amicus models`](./usage.md#amicus-models--the-model-catalog) for cost, scope, and exit-code details.
243
+
244
+ **Fix:** Check the `~/.local/share/opencode/log/opencode.log` for the session's real error first (see #133 above). Then check the alias's target (`amicus models --search <term>` to find the current id, then re-point the alias) — a stale alias is a common case, but not the only one. If the failing leg was a Stage-1 retry (the window read `240s`, or generally double your configured/default value), `AMICUS_NO_OUTPUT_BACKSTOP_MS` **is** the lever — raising it raises both the first attempt's window and the retry's doubled one, unless your `--timeout` is low enough that the doubled window is clamped to the leg timeout. On an ordinary (non-retry) leg, raise it only if a model legitimately needs more than 120 s to produce its first token. The live probe's fixed 30 s window is the one case the env var genuinely cannot touch. See [docs/configuration.md § Headless Poller Tuning](./configuration.md#headless-poller-tuning).
243
245
 
244
246
  ---
245
247
 
package/docs/usage.md CHANGED
@@ -487,14 +487,18 @@ written at MCP launch time, or falls back to the portion of `briefing-stage1.md`
487
487
  MCP-only, since the CLI never lists a council row to search in the first place. A bare `--search`
488
488
  with no value is a usage error on the CLI. Tag itself is set at launch with `--tag <t>` on
489
489
  `start`/`fanout`/`council run` (see those sections above), and is also a dimension for
490
- `amicus spend --group-by tag`.
491
-
492
- **Known limitation: a tag is not inherited.** `--tag` only ever gets written at launch. `amicus
493
- continue` and `amicus resume` write their spend rows with no tag, and a `fanout --retry-failed`
494
- wave does not carry the original wave's tag either (`--tag` combined with `--retry-failed` is
495
- rejected as a usage error). Since the spend ledger writes `tag: null` whenever none was set, those
496
- rows group under `(unattributed)` in `amicus spend --group-by tag` rather than under the tag their
497
- lineage started with.
490
+ `amicus spend --group-by tag`. `amicus continue` and `amicus resume` don't take a `--tag` of
491
+ their own — they automatically inherit the parent session's tag (see below).
492
+
493
+ **Tag inheritance.** `amicus continue` and `amicus resume` carry the parent session's tag forward
494
+ onto the new/reopened session's metadata and spend row, with no `--tag` flag needed — resume reuses
495
+ the parent's own session directory, and continue copies the tag onto the new session it creates, so
496
+ a multi-hop `continue` chain keeps the same tag at every depth. A `fanout --retry-failed` wave
497
+ likewise replays the original wave's tag automatically. Because the tag in each of these cases
498
+ comes from the session/wave being reopened rather than a fresh launch, `--tag` is rejected as a
499
+ usage error when combined with `continue`, `resume`, or `--retry-failed` — there is nothing new to
500
+ set. An untagged parent still leaves the key absent (not `null`) on the new metadata, and its spend
501
+ row still groups under `(unattributed)`, exactly as an untagged `start`/`fanout` would.
498
502
 
499
503
  **`amicus status <id>` output.** Human-readable:
500
504
 
@@ -514,7 +518,7 @@ $ amicus status demo123 --json
514
518
  "taskId": "demo123",
515
519
  "status": "complete",
516
520
  "elapsed": "5m 0s",
517
- "version": "4.7.0",
521
+ "version": "4.7.1",
518
522
  "model": "google/gemini-2.5-flash",
519
523
  "phase": "terminal"
520
524
  }
@@ -574,7 +578,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
574
578
  | `aliases` | Your configured aliases still resolve against the catalog | warn |
575
579
  | `anthropic-base-url` | `ANTHROPIC_BASE_URL` isn't host-form (host-form 404s every direct-Anthropic leg unless normalized) | warn |
576
580
  | `opencode-bin` | The OpenCode engine binary is on `PATH` | error |
577
- | `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide) | warn (error only if there's exactly one npx-cache copy and it's broken) |
581
+ | `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide, and a version-skewed one — present but the wrong opencode-ai release vs. the global install, #133) | warn (error only if there's exactly one npx-cache copy and it's broken; also warns, never errors, on engine version skew between the npx copies and the global install) |
578
582
  | `electron` | Electron (the interactive GUI) is installed | warn — headless still works |
579
583
  | `skills` | Both skills exist under `~/.claude/skills/` | warn |
580
584
  | `mcp` | Amicus is registered as an MCP server in Claude Code | warn |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -68,6 +68,7 @@
68
68
  "generate-docs:check": "node scripts/generate-docs.js --check",
69
69
  "validate-docs": "node scripts/validate-docs.js --full",
70
70
  "setup-hooks": "node scripts/setup-hooks.js",
71
+ "graphify:index": "graphify extract . --backend claude-cli",
71
72
  "check:secrets": "node scripts/check-secrets.js --all",
72
73
  "check:sizes": "node scripts/check-file-sizes.js --all",
73
74
  "check:tarball": "node scripts/check-tarball-lifecycle.js"
@@ -75,10 +76,10 @@
75
76
  "dependencies": {
76
77
  "@electron/get": "^5.0.0",
77
78
  "@modelcontextprotocol/sdk": "^1.27.0",
78
- "@opencode-ai/sdk": "^1.1.36",
79
+ "@opencode-ai/sdk": "1.18.15",
79
80
  "dotenv": "^17.2.3",
80
81
  "extract-zip": "^2.0.1",
81
- "opencode-ai": "^1.2.20",
82
+ "opencode-ai": "1.18.15",
82
83
  "tiktoken": "^1.0.0",
83
84
  "update-notifier": "^7.3.1",
84
85
  "zod": "^3.0.0"
@@ -110,9 +110,12 @@ async function provisionElectron(deps = {}) {
110
110
  * path.
111
111
  *
112
112
  * Hook setup still needs to happen for devs, so we run setup-hooks.js here.
113
- * It is a no-op for consumers: setup-hooks.js exits 0 outside a git checkout
114
- * (the published tarball / github: export has no .git), so this changes
115
- * nothing for end users while keeping `npm install` wiring hooks for devs.
113
+ * It is a no-op for consumers, but NOT merely because the published tarball
114
+ * has no .git: git resolves a repository by walking UP, so during a consumer's
115
+ * `npm install amicus` (cwd = node_modules/amicus) it finds the CONSUMER'S
116
+ * .git. setup-hooks.js is what makes this safe — it configures hooks only when
117
+ * git's toplevel IS the amicus package root, so a dependency install can never
118
+ * repoint someone else's core.hooksPath. See its docstring for the full story.
116
119
  *
117
120
  * Best-effort and self-contained: never throws (a hook-setup failure must
118
121
  * never roll back the install), so it is safe to call before the optional
@@ -21,20 +21,66 @@
21
21
  *
22
22
  * npm run setup-hooks # or: node scripts/setup-hooks.js
23
23
  *
24
+ * ONLY EVER CONFIGURES THE AMICUS CHECKOUT ITSELF.
25
+ * ------------------------------------------------
26
+ * "Are we in a git checkout?" is NOT a sufficient guard, because git resolves
27
+ * a repository by walking UP the directory tree. When a consumer runs
28
+ * `npm install amicus` inside their own repo, npm runs this postinstall with
29
+ * cwd = <consumer>/node_modules/amicus — where `git rev-parse` finds the
30
+ * CONSUMER'S .git and happily reports success. Writing core.hooksPath there
31
+ * pointed their repository at a .husky directory that does not exist (it is
32
+ * not in package.json "files", so it never ships), which silently disabled
33
+ * every hook in their .git/hooks — no error, no warning, `git commit` just
34
+ * stops running them. The damage also outlived a failed install: npm rolls
35
+ * back node_modules, but not writes to someone else's .git/config.
36
+ *
37
+ * So the guard asks `git rev-parse --show-prefix` from the package root. That
38
+ * prints the path of the current directory RELATIVE to the top of the work
39
+ * tree, so it is empty exactly when the package root IS the repository root,
40
+ * and non-empty ("node_modules/amicus/") when amicus merely sits inside
41
+ * someone else's checkout. A dependency install, a vendored copy and a global
42
+ * install all land below their enclosing repo's root; the dev checkout — and
43
+ * every linked worktree of it, whose root is the worktree — comes back empty.
44
+ *
45
+ * DO NOT "simplify" this into comparing `--show-toplevel` against the package
46
+ * root as strings. That was the first attempt and it broke on Windows CI: the
47
+ * runners' %TEMP% is an 8.3 short path (C:\Users\RUNNER~1\...), Node's
48
+ * fs.realpathSync PRESERVES short components while git always reports the long
49
+ * form, so the two spellings of one directory never compared equal and the
50
+ * guard refused to configure a legitimate checkout. Case, separators, symlinks
51
+ * and 8.3 are four different ways for equal paths to spell differently; asking
52
+ * git to compute the relationship sidesteps all of them at once.
53
+ *
54
+ * Every git call is anchored at the package root rather than process.cwd() for
55
+ * the same reason: what gets configured must depend on where amicus lives, not
56
+ * on where the script happened to be invoked from.
57
+ *
24
58
  * Safe to run anywhere: exits 0 outside a git checkout (npm tarball
25
59
  * installs, exported archives) and never fails the install.
26
60
  */
27
61
 
28
62
  const { execFileSync } = require('node:child_process');
63
+ const path = require('node:path');
64
+
65
+ /** This package's root — the parent of the scripts/ directory holding this file. */
66
+ const PKG_ROOT = path.resolve(__dirname, '..');
29
67
 
30
68
  function git(...args) {
31
- return execFileSync('git', args, { encoding: 'utf-8' }).trim();
69
+ return execFileSync('git', args, { cwd: PKG_ROOT, encoding: 'utf-8' }).trim();
32
70
  }
33
71
 
72
+ let prefix;
34
73
  try {
35
- git('rev-parse', '--git-dir');
74
+ prefix = git('rev-parse', '--show-prefix');
36
75
  } catch {
37
- process.exit(0); // not a git checkout — nothing to configure
76
+ process.exit(0); // not a git checkout (or a bare repo) — nothing to configure
77
+ }
78
+
79
+ if (prefix !== '') {
80
+ // amicus is nested inside someone else's repository — a dependency install,
81
+ // a vendored copy, a global install under a tracked directory. Their hooks
82
+ // are none of our business. Silent: this is the normal consumer path.
83
+ process.exit(0);
38
84
  }
39
85
 
40
86
  try {
@@ -380,7 +380,9 @@ amicus list --json # Output as JSON
380
380
  - `--cwd <path>`: Project directory (default: current directory)
381
381
 
382
382
  Rows show the `--tag <t>` label set at launch time (`start`/`fanout`/`council run` —
383
- not a `list` flag itself); untagged sessions show blank.
383
+ not a `list` flag itself), or inherited automatically from the parent session on
384
+ `continue`/`resume` (neither accepts its own `--tag` — the tag comes from the
385
+ parent, so there's nothing new to set); untagged sessions show blank.
384
386
 
385
387
  ### Resume a Sidecar
386
388
 
@@ -21,6 +21,17 @@ const { GATEWAY_MODES } = require('./utils/model-descriptor');
21
21
  */
22
22
  async function handleResume(args) {
23
23
  const useJson = !!args.json;
24
+ // v4.7.1 Task 7 (R-D): --tag parses today only because getKnownFlags()
25
+ // unions every usage block's flags (fanout's/start's/council run's), and is
26
+ // then read by nobody here — resume reuses the parent session's own dir, so
27
+ // its tag is already inherited (E-5a) with nothing left to set. Mirrors
28
+ // handleFanout's --tag/--retry-failed rejection shape (cli-handlers-fanout.js:27-29).
29
+ if (args.tag !== undefined) {
30
+ process.exit(failJson(useJson, {
31
+ code: ERROR_CODES.BAD_ARGS,
32
+ message: 'Error: --tag is not supported on resume — the tag is inherited from the parent session',
33
+ }));
34
+ }
24
35
  const taskId = requireValidTaskId(args, useJson, 'resume', 'Usage: amicus resume <task_id>');
25
36
  requireNoUiForJson(args, useJson);
26
37
 
@@ -51,6 +62,15 @@ async function handleResume(args) {
51
62
  */
52
63
  async function handleContinue(args) {
53
64
  const useJson = !!args.json;
65
+ // v4.7.1 Task 7 (R-D): same rationale as handleResume above — a continuation
66
+ // inherits the parent session's tag onto its own metadata.json (createContinueSessionMetadata),
67
+ // so a user-supplied --tag here would silently be ignored rather than applied.
68
+ if (args.tag !== undefined) {
69
+ process.exit(failJson(useJson, {
70
+ code: ERROR_CODES.BAD_ARGS,
71
+ message: 'Error: --tag is not supported on continue — the tag is inherited from the parent session',
72
+ }));
73
+ }
54
74
  const taskId = requireValidTaskId(args, useJson, 'continue', 'Usage: amicus continue <task_id> --prompt "..."');
55
75
 
56
76
  // BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
@@ -64,7 +64,7 @@ function createLaunchers(deps = {}) {
64
64
  * @param {{models: string[], prompt: string, project: string, waveId: string,
65
65
  * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
66
66
  * councilRunId?: string, councilName?: string, tag?: string, fallback?: object,
67
- * catalog?: Array}} opts
67
+ * catalog?: Array, noOutputBackstopMs?: number}} opts
68
68
  * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
69
69
  * ids forwarded verbatim into the runFanout call so it can stamp them onto
70
70
  * every leg. tag (v4.7 F8 D16) rides the same forward — every call site
@@ -72,6 +72,9 @@ function createLaunchers(deps = {}) {
72
72
  * fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
73
73
  * additive/opt-in — omitted by callers that must never substitute (the
74
74
  * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
75
+ * noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
76
+ * Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
77
+ * to escalate the window on a Stage-1 retry.
75
78
  * @returns {Promise<{wave: object|null, exitCode: number}>}
76
79
  */
77
80
  async function launchWave(opts) {
@@ -118,6 +121,14 @@ function createLaunchers(deps = {}) {
118
121
  // undefined when no --tag, so stampLegAttribution's `if (options.tag)`
119
122
  // guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
120
123
  tag: opts.tag,
124
+ // Task 5 (#129): spread-guarded on Number.isFinite, NOT on truthiness —
125
+ // an explicit 0 is this knob's documented disable hatch
126
+ // (no-output-backstop.js:13-15) and a truthiness guard would silently
127
+ // drop it. Guarding at all — rather than a plain
128
+ // `noOutputBackstopMs: opts.noOutputBackstopMs` — keeps the transport
129
+ // call key-identical for run-stage1-launch / run-stage2 / run-chair /
130
+ // run-debate, none of which set it.
131
+ ...(Number.isFinite(opts.noOutputBackstopMs) ? { noOutputBackstopMs: opts.noOutputBackstopMs } : {}),
121
132
  // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
122
133
  // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
123
134
  // fallback path only activates when `fallback.enabled` is true.
@@ -17,6 +17,7 @@
17
17
  const briefings = require('./briefings');
18
18
  const { materializeReviews, isAbortExit } = require('./run-launch');
19
19
  const runState = require('./run-state');
20
+ const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
20
21
  const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
21
22
  = require('./run-retry-notes');
22
23
 
@@ -143,6 +144,16 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
143
144
  const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
144
145
  stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
145
146
  stillDeadRetryLegs: [] };
147
+ // Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
148
+ // latency failure is structurally unhealable. Double the window, clamped to
149
+ // the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
150
+ // silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
151
+ // the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
152
+ const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
153
+ const escalatedBackstopMs = Math.min(
154
+ 2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
155
+ legTimeoutMs,
156
+ );
146
157
 
147
158
  for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
148
159
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
@@ -171,7 +182,8 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
171
182
  councilRunId: o.runId, councilName: o.councilName,
172
183
  tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
173
184
  fallback: o.fallback, catalog: o.catalog,
174
- waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit) };
185
+ waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit),
186
+ noOutputBackstopMs: escalatedBackstopMs };
175
187
  // Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
176
188
  // even down to its last surviving seat — critic/lens are always solos).
177
189
  // A model-count proxy (`models.length === 1`) is wrong for a bench unit
package/src/headless.js CHANGED
@@ -154,6 +154,58 @@ function withTimeout(promise, ms, label) {
154
154
  ]);
155
155
  }
156
156
 
157
+ /**
158
+ * Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
159
+ * ONLY what the mechanism observed — a deadline passed with no substantive
160
+ * activity (output/reasoning/tool calls) — never a cause. At the pre-send
161
+ * firing site (runHeadless, ~:506-518) the backstop can win the race against
162
+ * sendPromptAsync before the send ever resolves, so "the endpoint accepted
163
+ * the request" is not even something that site observed. The previous text
164
+ * asserted "likely a listed-but-not-serving model or a dead endpoint" — a
165
+ * canned guess with no evidence gate — which sent 30 minutes of #133's
166
+ * debugging at model ids and API keys while the real cause (an opencode
167
+ * engine version skew) sat in ~/.local/share/opencode/log/opencode.log the
168
+ * whole time.
169
+ *
170
+ * `fromEnv` distinguishes two ways `ms` was decided, NOT whether
171
+ * AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
172
+ * - fromEnv=true: `ms` IS the live env-resolved value (or its documented
173
+ * default) — the message says so directly, "(0 disables)" included,
174
+ * because raising the env var changes exactly this window.
175
+ * - fromEnv=false: `ms` arrived as a direct, caller-set numeric option.
176
+ * Task 6 review (Important finding): this is NOT synonymous with "the
177
+ * env var doesn't apply" — src/council/run-retry.js:154 computes a
178
+ * Stage-1 retry's escalated window as
179
+ * `2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs :
180
+ * resolveNoOutputBackstopMs())` and forwards that as a direct
181
+ * `noOutputBackstopMs` (line 186) — so a 240s retry-fired backstop is
182
+ * "caller-set" by this predicate while still being *derived from* the
183
+ * env default doubled. Only src/sidecar/models-probe.js:79's hardcoded,
184
+ * non-tunable 30s (PROBE_WINDOW_MS; docs/usage.md:406 promises it's "not
185
+ * tunable") is truly independent of the env var. Because a real
186
+ * `fromEnv` flag distinguishing those two cases would have to ride the
187
+ * same value through src/sidecar/fanout.js, which is line-locked at
188
+ * EXACTLY 300/300 this release, the caller-set branch instead names the
189
+ * var as something this window *overrides* rather than either claiming
190
+ * it governs (false on the probe) or omitting it (false/unhelpful on the
191
+ * retry) — true on both, and still points a user at the remedy.
192
+ *
193
+ * Kept module-scope and pure (not a closure over runHeadless locals) so it
194
+ * can be asserted on directly in tests without driving the poll loop; the
195
+ * `noOutputBackstopReason` closure inside runHeadless just forwards to this
196
+ * with the per-run `noOutputBackstopMs`/`backstopFromEnv` values, so the two
197
+ * firing sites there stay identical to what's tested here.
198
+ * @param {{ms: number, fromEnv: boolean}} args
199
+ * @returns {string}
200
+ */
201
+ function formatNoOutputBackstopReason({ ms, fromEnv }) {
202
+ return 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
203
+ + `${Math.round(ms / 1000)}s — `
204
+ + (fromEnv
205
+ ? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
206
+ : 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
207
+ }
208
+
157
209
  /**
158
210
  * Wait for the OpenCode server to be ready using SDK health check
159
211
  */
@@ -473,16 +525,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
473
525
  // deadline `nowMs >= deadline` can never satisfy — the backstop would
474
526
  // silently never fire. Finite zero (the documented explicit-disable
475
527
  // value) still takes the direct branch: Number.isFinite(0) === true.
476
- const noOutputBackstopMs = Number.isFinite(options.noOutputBackstopMs)
477
- ? options.noOutputBackstopMs : resolveNoOutputBackstopMs(options._env);
528
+ // Capture the source-of-truth predicate ONCE and reuse it (do not call
529
+ // Number.isFinite a second time below) — `backstopFromEnv` is just its
530
+ // complement: options.noOutputBackstopMs is a direct, caller-set value
531
+ // exactly when Number.isFinite is true, so the window came from
532
+ // resolveNoOutputBackstopMs's env-resolution seam exactly when it's false.
533
+ // Reused both to pick noOutputBackstopMs and to decide whether the reason
534
+ // string below may name the env var, so the two can never drift apart.
535
+ const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
536
+ const noOutputBackstopMs = backstopFromEnv
537
+ ? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
478
538
  const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
479
539
  let backstopFired = false;
480
540
  // Single source for the reason string so the pre-send firing site below and
481
541
  // the per-poll firing site further down (still ticking the SAME instance)
482
- // can never drift apart.
483
- const noOutputBackstopReason = () => 'NO_OUTPUT_BACKSTOP: model produced no '
484
- + `output, reasoning, or tool calls in ${Math.round(noOutputBackstopMs / 1000)}s `
485
- + '— likely a listed-but-not-serving model or a dead endpoint';
542
+ // can never drift apart. Forwards to the module-scope, pure
543
+ // formatNoOutputBackstopReason (below/exported) so tests can assert on the
544
+ // string shape directly without driving the whole poll loop.
545
+ const noOutputBackstopReason = () => formatNoOutputBackstopReason({
546
+ ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
547
+ });
486
548
 
487
549
  // Send prompt asynchronously (returns immediately, we poll for results) —
488
550
  // bounded by the backstop: an endpoint that accepts but never answers must
@@ -1413,6 +1475,7 @@ module.exports = {
1413
1475
  extractSummary,
1414
1476
  findTrailingFoldMarker,
1415
1477
  formatFoldOutput,
1478
+ formatNoOutputBackstopReason,
1416
1479
  DEFAULT_TIMEOUT,
1417
1480
  FOLD_MARKER,
1418
1481
  COMPLETE_MARKER,
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
88
88
 
89
89
  /** Create session metadata for continuation */
90
90
  function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
91
- const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
91
+ const { model, briefing, headless, agent, gateway, resolutionVersion, tag } = options;
92
92
 
93
93
  const sessionDir = SessionPaths.sessionDir(project, taskId);
94
94
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -102,7 +102,11 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
102
102
  agent: agent || (headless ? 'build' : 'chat'),
103
103
  status: 'running',
104
104
  createdAt: new Date().toISOString(),
105
- continuesFrom: oldTaskId
105
+ continuesFrom: oldTaskId,
106
+ // v4.7.1 Task 7 (D13): absent-not-null, same idiom as start-metadata.js:50
107
+ // — a continuation inherits the parent's tag so a continue chain never
108
+ // scatters into `(unattributed)`.
109
+ ...(tag ? { tag } : {}),
106
110
  };
107
111
  // #61 Task 5.2 (best-effort provenance): only present when THIS continue
108
112
  // call freshly routed an explicit --model through the gateway router — the
@@ -116,26 +120,6 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
116
120
  return sessionDir;
117
121
  }
118
122
 
119
- /**
120
- * Resolve a reopened session's usage, write it onto metadata, and append one
121
- * attributed ledger row. Mirrors start.js's finalize (the only sites that
122
- * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
123
- * @returns {{usage: object|null}}
124
- */
125
- function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
126
- const { resolveUsage } = require('../utils/pricing');
127
- const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
128
- if (usage) {
129
- metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
130
- try {
131
- const { appendSpend } = require('../utils/spend-ledger');
132
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
133
- appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
134
- } catch { /* best-effort */ }
135
- }
136
- return { usage };
137
- }
138
-
139
123
  /**
140
124
  * Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
141
125
  * @returns {Promise<number>} process exit code
@@ -196,6 +180,7 @@ async function continueSidecar(options) {
196
180
 
197
181
  const sessionDir = createContinueSessionMetadata(newTaskId, project, {
198
182
  model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
183
+ tag: oldMetadata.tag, // v4.7.1 Task 7: inherit the parent's tag (absent if the parent had none).
199
184
  }, oldTaskId);
200
185
 
201
186
  // Lock the NEW continuation session dir too — not just the previous one — so a
@@ -270,6 +255,7 @@ async function continueSidecar(options) {
270
255
  // ledger row (status: statusFromResult, matching start.js — not terminal.status).
271
256
  {
272
257
  const { statusFromResult } = require('../utils/result-schema');
258
+ const { finalizeSpendForReopen } = require('./reopen-spend');
273
259
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
274
260
  const { usage } = finalizeSpendForReopen({
275
261
  taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
@@ -292,6 +278,5 @@ module.exports = {
292
278
  loadPreviousSession,
293
279
  buildContinuationContext,
294
280
  createContinueSessionMetadata,
295
- finalizeSpendForReopen,
296
281
  continueSidecar
297
282
  };
@@ -73,7 +73,10 @@ function buildRetryPlan(origWaveId, project, { models } = {}) {
73
73
  } catch { /* legacy leg — fall back to briefing.md at launch time */ }
74
74
  eligible.push({ legId, model, systemPrompt, userMessage, hadSavedContext });
75
75
  }
76
- return { eligible };
76
+ // waveMeta has been read since :51 but was discarded; retryFailedWave needs
77
+ // the tag BEFORE it builds fanoutOpts, and its own origMeta re-read at :176
78
+ // runs after runFanoutImpl at :153 — too late to influence the launch.
79
+ return { eligible, tag: waveMeta.tag };
77
80
  }
78
81
 
79
82
  /**
@@ -121,7 +124,7 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
121
124
  // --json caller's stdout stays machine-parseable either way.
122
125
  const noopDoc = {
123
126
  ...buildWaveResult({ waveId: origWaveId, legs: [], status: 'complete' }),
124
- retryOf: origWaveId, effective: [], note: 'no failed legs',
127
+ retryOf: origWaveId, effective: [], note: 'no failed legs', tag: plan.tag,
125
128
  };
126
129
  process.stdout.write(JSON.stringify(noopDoc, null, 2) + '\n');
127
130
  } else {
@@ -148,7 +151,16 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
148
151
  // expects (parseModelsList/validateFanoutModels) — an array silently fails
149
152
  // every leg pre-flight (BAD_ARGS), matching run-launch.js:41's precedent.
150
153
  // Strip our own injection key so it is never forwarded.
151
- const fanoutOpts = { ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId };
154
+ const fanoutOpts = {
155
+ ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId,
156
+ // v4.7.1 Task 8: inherit the ORIGINAL wave's tag — sourced from disk
157
+ // (plan.tag, off waveMeta) never from opts.tag/args.tag. This is placed
158
+ // AFTER the `...opts` spread so it wins even if a caller's opts somehow
159
+ // carried a tag (the CLI itself already rejects --tag + --retry-failed
160
+ // upstream in cli-handlers-fanout.js). Absent-not-null idiom, matching
161
+ // fanout.js:152's `...(options.pack ? {...} : {})`.
162
+ ...(plan.tag ? { tag: plan.tag } : {}),
163
+ };
152
164
  delete fanoutOpts.runFanout;
153
165
  const { wave, exitCode } = await runFanoutImpl(fanoutOpts);
154
166
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module sidecar/reopen-spend
3
+ * Spend finalization for a REOPENED session (continue/resume). Split out of
4
+ * sidecar/continue.js to keep that file under the 300-line gate; resume.js was
5
+ * already reaching across for it, so the shared home is the honest one.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ /**
11
+ * Resolve a reopened session's usage, write it onto metadata, and append one
12
+ * attributed ledger row. Mirrors start.js's finalize (the only sites that
13
+ * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
14
+ * @returns {{usage: object|null}}
15
+ */
16
+ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
17
+ const { resolveUsage } = require('../utils/pricing');
18
+ const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
19
+ if (usage) {
20
+ metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
+ try {
22
+ const { appendSpend } = require('../utils/spend-ledger');
23
+ const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
24
+ // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
+ // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
+ appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
27
+ } catch { /* best-effort */ }
28
+ }
29
+ return { usage };
30
+ }
31
+
32
+ module.exports = { finalizeSpendForReopen };
@@ -251,7 +251,7 @@ async function resumeSidecar(options) {
251
251
  // v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
252
252
  // a ledger row (status: statusFromResult, matching start.js — not terminal.status).
253
253
  {
254
- const { finalizeSpendForReopen } = require('./continue');
254
+ const { finalizeSpendForReopen } = require('./reopen-spend');
255
255
  const { statusFromResult } = require('../utils/result-schema');
256
256
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
257
257
  const { usage } = finalizeSpendForReopen({
@@ -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