amicus 4.6.2 → 4.6.3

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.6.2",
3
+ "version": "4.6.3",
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,61 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.6.3] - 2026-08-05
9
+
10
+ ### Added
11
+
12
+ - **`fable` now carries an authored direct-Anthropic route** (`anthropic/claude-fable-5`,
13
+ verified live: Anthropic's `/v1/models` lists it and a direct leg serves). With an
14
+ Anthropic key present, `fable` routes direct-first like the other Anthropic aliases;
15
+ the `ANTHROPIC_MODELS` floor gains a matching row so keyless installs validate it.
16
+ - **`doctor` gains a `session-metadata-tmp` check; `--fix` sweeps the orphans.** A kill
17
+ between an atomic write's tmp-file and rename leaves `.metadata.json.*.tmp` orphans in
18
+ per-session directories (the B09 class — ~30 write sites). Plain `doctor` reports them;
19
+ `--fix` removes orphans older than 60 s from the current project's sessions root and
20
+ announces the heal in the one voice (`Recovered: …`).
21
+ - **`council save` announces when it shadows a built-in bench.** Saving a council named
22
+ `free`/`budget`/`frontier` previously printed no notice at all (the overwrite marker only
23
+ tracked user-config names); the save now reports `shadowsBuiltin` (`--json`) and prints
24
+ the shadow notice.
25
+
26
+ ### Fixed
27
+
28
+ - **`models --check` no longer false-flags deliberate gateway-only routes.** A curated
29
+ alias whose direct form is derived (not authored) from its OpenRouter route is no
30
+ longer reported STALE — flat row, `GATEWAY STALE` row, candidates, and the
31
+ `fix: --add-alias` retarget suggestion all suppressed together — when the OpenRouter
32
+ route still serves. Deliberately gateway-only entries (`gpt-pro`) are annotated in
33
+ the curated data and never audited for a direct sibling. Kills the v4.6.2
34
+ release-gate false positive whose suggested "fix" was a silent tier downgrade.
35
+ - **The Workspace seats panel's dead-seat rows are now role-aware and old-run
36
+ resilient.** A model that died as critic but succeeded as chair no longer has
37
+ its dead row hidden by the chair's cost row (only a live reviewing leg —
38
+ seat/critic/lens — suppresses, and a dead critic only by a live critic leg);
39
+ the dead critic's row names its role. Pre-v4.6 runs render their losses too:
40
+ `verdict.seatLoss.deadBenchSeats` feeds rows, and `verdict.json`'s
41
+ `degrades[]` backstops a `run.json` that lost its checkpoint. A stale
42
+ `get-run` reply from a run you navigated away from can no longer repaint the
43
+ run now open.
44
+ - **A valueless `-o`/`--out` on `council verdict` now errors** (`BAD_ARGS`, flag named,
45
+ exit 1) instead of crashing mid-write (renameSync `TypeError`) and orphaning a
46
+ `true.tmp-<pid>` temp file. Behavior change, per the v4.6.3 R1 ruling.
47
+ - **README corrections from the v4.6.3 accuracy review**: the Node.js floor is
48
+ 22.12 (required since v3.0 — the README, install scripts, landing page, and
49
+ `amicus doctor`'s node check still said 18), and the optional-council-elements
50
+ list now matches the shipped skill (four opt-ins; the chair's verdict scale
51
+ has been standard, not opt-in). The doctor's `node` row now errors below
52
+ 22.12 (it previously passed anything ≥ 18).
53
+
54
+ ### CI
55
+
56
+ - **The MCP-Registry skip-check now verifies the version it trusts.** The
57
+ release workflow's idempotency pre-check previously skipped registry publish
58
+ on a bare HTTP 200; it now also requires the response body to both name the
59
+ exact version and report it as `active`, so preview-API schema churn — or a
60
+ stale/deprecated registry entry — can no longer produce a false skip. Every
61
+ new failure mode still routes toward publishing.
62
+
8
63
  ## [4.6.2] - 2026-08-05
9
64
 
10
65
  ### Added
@@ -91,6 +146,17 @@ All notable changes to Amicus are documented here. Format follows
91
146
  with replacement suggestions by `models --check` / `doctor`. This was the pin that made
92
147
  `models --check` exit 1 — the v4.6.2 release-gate risk.
93
148
 
149
+ ### CI
150
+
151
+ - **The macOS/node-24 unit leg now runs jest with `--workerIdleMemoryLimit=1GB`.** That leg —
152
+ and only that leg — intermittently lost a worker to a native SIGSEGV ("A jest worker process
153
+ was terminated by another process"), which fails whichever suite occupied the worker with
154
+ zero assertion failures and a green rerun. Three confirmed hits (2026-07-31
155
+ run-cost-unknown, 2026-08-04 PR #100 update-notice, 2026-08-05 PR #105 — 6466 passed, the
156
+ dead worker alone failed 1 suite) tripped the standing third-occurrence rule. The limit
157
+ makes jest recycle an idle worker before the leak reaches segfault territory; the flag is
158
+ injected via a matrix `include`, so the other five legs still run a bare `npm test`.
159
+
94
160
  ## [4.6.1] - 2026-08-03
95
161
 
96
162
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
12
12
 
13
13
  [![npm version](https://img.shields.io/npm/v/amicus?color=D97757&labelColor=1A1C29)](https://www.npmjs.com/package/amicus)
14
14
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue?labelColor=1A1C29)](./LICENSE)
15
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen?labelColor=1A1C29)](https://nodejs.org)
15
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen?labelColor=1A1C29)](https://nodejs.org)
16
16
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?labelColor=1A1C29)](./CONTRIBUTING.md)
17
17
 
18
18
  **[Quick start ↓](#quick-start)** · [Commands](#commands) · [Documentation](#documentation) · [Troubleshooting](#troubleshooting)
@@ -99,12 +99,11 @@ flowchart LR
99
99
  findings-by-tier, cost — no chair prose). This is the default artifact handed to the user.
100
100
  - For an **editable source**, the accepted edits land in `<stem>-reviewed.<ext>` next to the original.
101
101
 
102
- **Optional council elements** (v2.2.0, all default off): five opt-in behaviors, offered once as a menu at launch — nothing turns on unless you name it, and the confirmation lists exactly what's on.
102
+ **Optional council elements** (v2.2.0, all default off): four opt-in behaviors, offered once as a menu at launch — nothing turns on unless you name it, and the confirmation lists exactly what's on. **Chair verdict scale** *(standard since v2.2.0's follow-ups — no longer opt-in)*: the chair always closes with 3–5 hard questions and one parseable `VERDICT: Ship it | Fix these first | Fundamental rethink` line.
103
103
 
104
104
  - **Critic seat** — one reviewer swaps to a four-pass adversarial brief (adversarial pass, edge-case hunt, consistency check, executability test). Its findings enter the same anonymized bundle as everyone else's, so the bench disciplines the critic: manufactured negativity lands Disputed and dies in the tally.
105
105
  - **Expert lenses** — each reviewer takes a distinct expert perspective; you pick the panel domain (business, technical, customer, financial, or custom). Lens runs never feed the reliability ledger, and the report discloses the weakened cross-review anonymity.
106
106
  - **Debate mode** — after cross-review, every Contested or Disputed finding goes back to its raiser to **defend, amend, or withdraw**, and the disputing judges re-vote. Exactly one rebuttal round, then the final tally.
107
- - **Chair verdict scale** — the chair closes with 3–5 hard questions and one parseable line: `VERDICT: Ship it | Fix these first | Fundamental rethink`.
108
107
  - **Claude in the council** — Claude adds its own fresh review to the bundle so the bench ranks and adjudicates it. Claude is *judged* but never votes or chairs, so the verdict stays independent.
109
108
 
110
109
  The critic and lens methodologies are adapted from the `/critic` and `/debate` agents in [John Renaldi's product-kit](https://github.com/jrenaldi79/plugin-marketplace) (MIT); the briefing boilerplate lives in [`skills/second-opinion/SEAT-BRIEFS.md`](./skills/second-opinion/SEAT-BRIEFS.md).
@@ -192,7 +191,7 @@ Every path delivers the MCP server and both skills. They differ in what else you
192
191
 
193
192
  #### With npm — recommended
194
193
 
195
- The canonical path, and the one that gets you the full interactive experience (needs [Node.js](https://nodejs.org) ≥ 18):
194
+ The canonical path, and the one that gets you the full interactive experience (needs [Node.js](https://nodejs.org) ≥ 22.12):
196
195
 
197
196
  ```bash
198
197
  npm install -g amicus
@@ -202,7 +201,7 @@ This is the path to pick unless you specifically want the plugin's slash command
202
201
 
203
202
  #### With the install script
204
203
 
205
- Same result as npm, one command — macOS, Linux, or Windows (needs [Node.js](https://nodejs.org) ≥ 18):
204
+ Same result as npm, one command — macOS, Linux, or Windows (needs [Node.js](https://nodejs.org) ≥ 22.12):
206
205
 
207
206
  ```bash
208
207
  # macOS / Linux
@@ -333,7 +332,7 @@ Everything you need before your first run, and what's optional.
333
332
 
334
333
  **Runtime**
335
334
 
336
- - **Node.js ≥ 18** — `node --version` to check. This is the only hard runtime prerequisite.
335
+ - **Node.js ≥ 22.12** — `node --version` to check. This is the only hard runtime prerequisite.
337
336
  - **An active Claude Code or Cowork session** — Amicus is orchestrated by Claude; it is not a standalone chatbot.
338
337
 
339
338
  **Install path & the git toolchain**
@@ -441,7 +440,7 @@ $ amicus status demo123 --json
441
440
  "taskId": "demo123",
442
441
  "status": "complete",
443
442
  "elapsed": "5m 0s",
444
- "version": "4.6.2",
443
+ "version": "4.6.3",
445
444
  "model": "google/gemini-2.5-flash",
446
445
  "phase": "terminal"
447
446
  }
package/docs/ROADMAP.md CHANGED
@@ -7,10 +7,10 @@ major jump, gated on funding/cofounder. The observability arc is split so the **
7
7
  first (v4.3)** and the **Electron "Council Workspace" (v4.4)** rides on top of it. `--dry-run` cost
8
8
  preview dropped to the backlog.
9
9
 
10
- Amicus is at **v4.6.1** (tagged 2026-08-03). Each 4.x rev below leads with the benefit, not the
10
+ Amicus is at **v4.6.2** (tagged 2026-08-05). Each 4.x rev below leads with the benefit, not the
11
11
  plumbing.
12
12
 
13
- **Status:** v4.0 through **v4.6.1** have **shipped** — everything down to the v4.7 heading is a
13
+ **Status:** v4.0 through **v4.6.2** have **shipped** — everything down to the v4.7 heading is a
14
14
  record of what landed, not a plan. **v4.7 (composition + tagging + GUI ergonomics) is the next
15
15
  planned rev** — that scope carried the number v4.6 here until the degrade-announcement-invariant
16
16
  milestone took the v4.6.0 release (2026-08-02); renumbered, not descoped. v5.0 remains
@@ -140,6 +140,35 @@ more chance": the once-only Stage-1 retry with `Recovered:` heals, ruled heal-fi
140
140
  the **MCP update notice**, the `rebuildElectron` hint deletion, and the **fold-back-corrected
141
141
  MODEL-NOTES seed** (PR #93). Ninth consecutive first-attempt publish.
142
142
 
143
+ ### v4.6.1 / v4.6.2 — the field-report five *(patch train)* — ✅ SHIPPED v4.6.2, 2026-08-05
144
+ Field-report-driven hardening in five sequential PRs plus one ruling follow-up: the
145
+ `ANTHROPIC_BASE_URL` diagnosis pair (doctor row + host-form normalization) and stored-alias
146
+ drift warning (#95); doctor-suite hermeticity (#96); the no-output backstop —
147
+ `AMICUS_NO_OUTPUT_BACKSTOP_MS`, legs that produce nothing fail fast with a real reason (#99);
148
+ `models --check --live` — one quiet paid wave proves stored aliases actually SERVE (#100);
149
+ Workspace dead-seat rows — an announced-dead seat renders on the seats panel, live mid-poll
150
+ after the owner's ruling, blind-masked (#102, #103); chair-attempt records — the fallback walk
151
+ is diagnosable from `run.json` (`chairAttempts[]`, #105). The v4.6 announcement invariant now
152
+ reaches the surface users watch and the artifact they keep.
153
+
154
+ ### v4.6.3 — the post-train sweep *(patch)* — ✅ SHIPPED v4.6.3, 2026-08-05
155
+ A four-PR correction patch: the models audit stops crying wolf, the seats panel stops being
156
+ fooled, and a handful of proven small defects land with tests.
157
+ - **Audit routing-choice + fable direct route** — `models --check` stops flagging deliberate
158
+ gateway-only routes like `gpt-pro` STALE (no more harmful downgrade suggestion); fable gains an
159
+ authored `anthropic/claude-fable-5` route and routes direct-first with an Anthropic key — #107
160
+ - **Role-aware, old-run-resilient dead-seat rows + openRun guard** — a model dead as critic but
161
+ alive as chair now renders its dead row; pre-v4.6 runs render dead rows from
162
+ `verdict.degrades`/`seatLoss.deadBenchSeats`; the third F09-class stale-reply hole
163
+ (`openRun`'s `get-run` reply) closes — #108
164
+ - **Valueless `-o` + save-shadow notice + metadata tmp sweep** — a trailing bare `-o`/`--out`
165
+ now errors instead of orphaning a tmp file; `council save` announces when it shadows a
166
+ built-in bench; `doctor --fix` sweeps orphaned `metadata.json` tmp files — #109
167
+ - **Registry body assert + Node-floor truth sweep + `makeBaseDeps()`** — the MCP-Registry
168
+ skip-check verifies the version *and* status it trusts, fail-toward-publish on every other
169
+ path; the README/install scripts/doctor all agree on the real Node ≥22.12 floor; eleven
170
+ duplicated doctor test fixtures consolidate into one factory — #110
171
+
143
172
  ## v4.7 — "Compose your councils" *(renumbered from v4.6, 2026-08-02 — spec + fresh plan at kickoff per the anti-rot rule)*
144
173
  **Benefit:** councils chain — generate → critique → refine with no manual copy-paste — and history
145
174
  becomes navigable.
@@ -148,8 +177,8 @@ becomes navigable.
148
177
  - **Session/wave tagging + `--search` + grouped history** (F8) *(S–M)*
149
178
  - **GUI power ergonomics** (F10: focus-follows fold hotkey, distinguishable window titles, tiling
150
179
  presets) *(S each)*
151
- - Deferred-item candidates per the v4.5 design doc's §8: RN-2, TST-1/TST-2, REL-2, CA-4, LC-5,
152
- remainder of TST-7
180
+ - Deferred-item candidates per the v4.5 design doc's §8: RN-2, TST-1/TST-2, REL-2, CA-4
181
+ (repair-solos half — the failed-chair half shipped v4.6.2), remainder of TST-7
153
182
  - **README + docs update** *(S)*
154
183
  > The 2026-07-19 combined spec (`2026-07-19-v4.5-policy-packs-composition-design.md`) holds the
155
184
  > approved chaining/tagging/F10 design detail and is the primary input to the v4.7 brainstorm; it
@@ -247,9 +247,11 @@ Everything lives under `~/.config/amicus/` (`getConfigDir()` in `src/utils/confi
247
247
  metadata) use an atomic write: a temp file named `.<target>.<pid>.<random>.tmp` is written
248
248
  alongside the target, then renamed into place. A process killed between the write and the rename
249
249
  leaves an orphaned `.tmp` file behind forever — harmless, but it accumulates. `amicus doctor --fix`
250
- sweeps orphaned `sessions-index.json.*.tmp` files (only ones older than 60 seconds, so a live
251
- writer's in-flight tmp file is never touched); `amicus doctor` (without `--fix`) just reports the
252
- count.
250
+ sweeps orphaned `sessions-index.json.*.tmp` files and, per-session, orphaned `.metadata.json.*.tmp`
251
+ files (the B09 class, ~30 write sites both `<taskId>/` and `<taskId>/subagents/<id>/` levels
252
+ under the current project's `.claude/amicus_sessions/`); both sweeps only remove files older than
253
+ 60 seconds, so a live writer's in-flight tmp file is never touched. `amicus doctor` (without
254
+ `--fix`) just reports the counts.
253
255
 
254
256
  ### Session storage
255
257
 
package/docs/usage.md CHANGED
@@ -381,6 +381,12 @@ amicus models --check --live # + probe every stored alias with a real leg (spen
381
381
 
382
382
  `amicus models --check` exits with the **number of stale aliases** (capped at 100) and prints same-vendor replacement suggestions for each, so it drops cleanly into CI.
383
383
 
384
+ **Gateway-only routes.** A curated alias whose direct form is *derived* from its OpenRouter route (rather than
385
+ authored) is not reported STALE when that direct form is missing from the vendor's
386
+ direct namespace while the OpenRouter route still serves — a gateway-only route with
387
+ no direct sibling is a routing choice, not staleness. Deliberately gateway-only
388
+ entries (e.g. `gpt-pro`) are annotated as such and are never offered a retarget.
389
+
384
390
  **Drifted aliases.** `--check` (and the `doctor` aliases row) also flags **`DRIFTED:`** stored aliases — a stored alias whose target is still catalog-listed but no longer matches any route its family currently resolves to (the v4.6.1 `gemini` release-gate class, where `doctor` stayed green while the model behind it had moved on). Each drift line prints the exact `amicus setup --add-alias <alias>=<current>` refresh command. Drift is informational only — unlike stale aliases, it never changes the exit code.
385
391
 
386
392
  **Live probe (`--check --live`).** Presence in the catalog is not proof of service — a stored alias can point at a model id the catalog still lists but the provider has quietly stopped serving (the v4.6.1 `gemini` incident). `--check` alone can't see that; `--live` can, by actually asking. Scope is **stored aliases only** (`amicus setup --add-alias`) — curated defaults follow the catalog by construction and have no "was it actually served" question for a live probe to answer. **This spends real money — one tiny leg per stored alias** — every probed alias gets one ordinary engine leg on a single quiet fan-out wave, with a real session dir and a real spend-ledger row, exactly as if you'd run it yourself.
@@ -462,7 +468,7 @@ $ amicus status demo123 --json
462
468
  "taskId": "demo123",
463
469
  "status": "complete",
464
470
  "elapsed": "5m 0s",
465
- "version": "4.6.2",
471
+ "version": "4.6.3",
466
472
  "model": "google/gemini-2.5-flash",
467
473
  "phase": "terminal"
468
474
  }
@@ -514,7 +520,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
514
520
 
515
521
  | Check | What it verifies | Can fail as |
516
522
  |---|---|---|
517
- | `node` | Node.js ≥ 18 | error |
523
+ | `node` | Node.js ≥ 22.12 | error |
518
524
  | `config-dir` | The resolved config directory | *(always ok)* |
519
525
  | `keys` | At least one cloud-vendor key configured | error |
520
526
  | `default-model` | Your default model alias resolves | error |
@@ -528,13 +534,14 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
528
534
  | `mcp` | Amicus is registered as an MCP server in Claude Code | warn |
529
535
  | `mcp-legacy` | No duplicate legacy `sidecar` MCP entry survives alongside `amicus` | warn |
530
536
  | `sessions-index-tmp` | No orphaned `sessions-index.json.*.tmp` files | warn |
537
+ | `session-metadata-tmp` | No orphaned per-session `.metadata.json.*.tmp` files (the B09 class) | warn |
531
538
  | `openrouter-credit` | Remaining OpenRouter credit (skipped — reports `ok` — when no OpenRouter key is set) | warn |
532
539
  | `local-providers` **(v4.2)** | Every provider in `config.providers` is reachable | warn |
533
540
  | `project-root` | Your cwd looks like a real project, not an app/install dir | warn |
534
541
 
535
542
  **`local-providers`** probes every configured local provider (2s timeout each) the same way `amicus provider test` does, and reports per-id reachability in one line, e.g. `ollama: 3 models @ http://127.0.0.1:11434/v1; my-vllm: unreachable @ http://127.0.0.1:8000/v1`. No providers configured at all is a plain `ok` ("none configured") — this check can never fail your doctor run outright, only warn: a napping `ollama serve` isn't treated as broken setup.
536
543
 
537
- `--fix` self-heals four of the checks above in place: reprovisions Electron, copies the OpenCode engine into a broken npx-cache install, removes a duplicate legacy MCP entry, and sweeps orphaned session-index tmp files (only ones older than 60s). It does **not** start a local server for you — `local-providers` stays a warning until you start the server yourself.
544
+ `--fix` self-heals five of the checks above in place: reprovisions Electron, copies the OpenCode engine into a broken npx-cache install, removes a duplicate legacy MCP entry, sweeps orphaned session-index tmp files, and sweeps orphaned per-session metadata tmp files (both tmp sweeps only ones older than 60s). It does **not** start a local server for you — `local-providers` stays a warning until you start the server yourself.
538
545
 
539
546
  Exit code is `1` if anything is `error`, else `0` (same rule drives `--json`'s `ok` field).
540
547
 
@@ -37,9 +37,17 @@
37
37
  return (state.visible && state.focused) ? 1500 : 5000;
38
38
  }
39
39
 
40
+ /** True when `status` is a terminal run status. The single consumption
41
+ * point for TERMINAL_STATUSES membership (v4.6.3 PR2 dedup) — the array
42
+ * itself stays exported and byte-identical to src/workspace/run-detail.js
43
+ * (drift-pinned). */
44
+ function isTerminal(status) {
45
+ return TERMINAL_STATUSES.indexOf(status) !== -1;
46
+ }
47
+
40
48
  /** Blind default per spec resolved Q2: ON live, OFF terminal. */
41
49
  function defaultBlind(status) {
42
- return TERMINAL_STATUSES.indexOf(status) === -1;
50
+ return !isTerminal(status);
43
51
  }
44
52
 
45
53
  function dash(v) {
@@ -130,20 +138,56 @@
130
138
  * seatCells): an identity that is not carefully matched silently
131
139
  * duplicates or overwrites instead of failing loud.
132
140
  *
141
+ * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
142
+ * regardless of what the LIVE row's role was — so a model that died as
143
+ * critic but whose chair-fallback walk happened to land on that same alias
144
+ * (and succeeded, producing a live `role: 'chair'` cost row) silently
145
+ * erased the dead-critic row it was never a replacement for (spec §5, the
146
+ * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
147
+ * with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
148
+ * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
149
+ * suppress at all; a `'critic'` candidate is cleared only by a live
150
+ * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
151
+ * row landing on the same model. Hidden dependency: the recovered-critic
152
+ * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
153
+ * critic branch (src/council/run-stages.js), which only fires when lenses
154
+ * are absent — safe today only because --critic and --lenses are mutually
155
+ * exclusive (cli-handlers-council-run.js:196); if that exclusion ever
156
+ * loosens, a healed critic on a lens run would carry role 'seat' and this
157
+ * suppression would render a ghost dead row for it.
158
+ *
159
+ * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
160
+ * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
161
+ * (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
162
+ * existed on either doc for these runs. Consumed after the critic backstop,
163
+ * candidates get `role: null` (deadBenchSeats carries no critic/bench
164
+ * distinction beyond what `criticRequested` already covers above) and flow
165
+ * through the same `seen`-keyed dedup and role-aware suppression as every
166
+ * other candidate — deriveSeatLoss does not dedup its own array, so `seen`
167
+ * is what keeps a repeated alias (or one also named by a real degrade
168
+ * record) from rendering twice.
169
+ *
133
170
  * @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
134
- * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent)
171
+ * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent)
172
+ * `criticRequested`/`criticSeated` back the critic candidate above,
173
+ * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
135
174
  * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
136
175
  * (or any seat list keyed the same way — the live seat map)
137
- * @returns {Array<{model: string, statusText: string}>}
176
+ * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
177
+ * when no critic was requested) — degrade records carry no role field, so
178
+ * this is the ONLY way a degrade-sourced candidate is identified as critic
179
+ * @returns {Array<{model: string, statusText: string, role: ?string}>}
138
180
  */
139
- function deadSeats(degrades, seatLoss, liveSeats) {
181
+ function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
182
+ var critic = runMeta && runMeta.critic ? runMeta.critic : null;
140
183
  var seen = {};
141
184
  var order = [];
142
- function add(model, retried) {
185
+ function add(model, retried, role) {
143
186
  if (!model || seen[model]) { return; }
144
187
  seen[model] = true;
145
188
  order.push({
146
189
  model: model,
190
+ role: role || null,
147
191
  statusText: retried ? 'did not review — retried once' : 'did not review',
148
192
  });
149
193
  }
@@ -152,16 +196,36 @@
152
196
  if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
153
197
  var data = d.data || {};
154
198
  var retried = !!(data.retryWaveId || data.firstFailure);
199
+ // Critic identification mirrors deriveSeatLoss (verdict.js): alias
200
+ // equality with run.critic — degrade records carry no role field.
155
201
  if (d.channel === 'dead-leg') {
156
- add(data.seat, retried);
202
+ add(data.seat, retried, critic && data.seat === critic ? 'critic' : null);
157
203
  } else {
158
- (data.models || []).forEach(function (m) { add(m, retried); });
204
+ (data.models || []).forEach(function (m) {
205
+ add(m, retried, critic && m === critic ? 'critic' : null);
206
+ });
159
207
  }
160
208
  });
161
209
  if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
162
- add(seatLoss.criticRequested, false);
210
+ add(seatLoss.criticRequested, false, 'critic');
163
211
  }
164
- var live = {};
212
+ if (seatLoss) {
213
+ // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
214
+ // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
215
+ // absorbs repeats and degrade-sourced duplicates.
216
+ (seatLoss.deadBenchSeats || []).forEach(function (m) { add(m, false, null); });
217
+ }
218
+ // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
219
+ // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
220
+ // a dead-critic candidate is cleared only by a live CRITIC leg. A null
221
+ // role is NOT reviewing: counting it would suppress silently, the exact
222
+ // class the announcement invariant forbids.
223
+ function isReviewing(role) {
224
+ return role === 'seat' || role === 'critic' ||
225
+ (typeof role === 'string' && role.indexOf('lens:') === 0);
226
+ }
227
+ var reviewing = {};
228
+ var byRole = {};
165
229
  // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
166
230
  // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
167
231
  // executable id, not the alias a degrade record names; `modelInput` carries the alias.
@@ -170,14 +234,23 @@
170
234
  // so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
171
235
  // errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
172
236
  // are already alias-only, so `|| s.model` leaves that path unchanged.
173
- (liveSeats || []).forEach(function (s) { live[s.modelInput || s.model] = true; });
174
- return order.filter(function (s) { return !live[s.model]; });
237
+ (liveSeats || []).forEach(function (s) {
238
+ if (!isReviewing(s.role)) { return; }
239
+ var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
240
+ reviewing[alias] = true;
241
+ byRole[alias + '|' + s.role] = true;
242
+ });
243
+ return order.filter(function (s) {
244
+ if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
245
+ return !reviewing[s.model];
246
+ });
175
247
  }
176
248
 
177
249
  // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
178
250
  var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
179
251
  deadSeats: deadSeats,
180
- defaultBlind: defaultBlind, dash: dash, TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
252
+ defaultBlind: defaultBlind, isTerminal: isTerminal, dash: dash,
253
+ TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
181
254
  if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
182
255
  if (typeof window !== 'undefined') { window.AmicusLive = api; }
183
256
  })();
@@ -67,6 +67,11 @@
67
67
  state.runId = runId;
68
68
  state.debate = null;
69
69
  return invoke('workspace:get-run', runId).then(function (detail) {
70
+ // F09 guard (v4.6.3 PR2): a reply for a run the user has since
71
+ // navigated away from must never overwrite the run now open. Guard on
72
+ // runId movement ONLY — a same-run re-open (the live loop's terminal
73
+ // refresh) must still apply its fresher reply.
74
+ if (state.runId !== runId) { return; }
70
75
  state.detail = detail;
71
76
  // ⚠️ DE-ROT (F38): debate.json is the re-vote index the matrix drill-in needs — fetched
72
77
  // once per run-open (never per render), fire-and-forget. An aborted or cost-ceiling
@@ -146,7 +151,7 @@
146
151
  P.renderVerdictPanel();
147
152
  R.renderCost($('cost-body'), d.derived.cost, state.blind, labelOf);
148
153
  P.wireLazyPanels();
149
- var isTerminal = window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1;
154
+ var isTerminal = window.AmicusLive.isTerminal(d.run.status);
150
155
  $('abort-btn').hidden = isTerminal;
151
156
  V.startLiveLoop();
152
157
  }
@@ -36,6 +36,12 @@
36
36
  return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
37
37
  }
38
38
 
39
+ /** td className for seat-table cell index i — one source for the three
40
+ * call sites (renderSeats create + update, renderDeadSeatRows). */
41
+ function seatCellClass(i) {
42
+ return i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '');
43
+ }
44
+
39
45
  /** Dual-name display flip (blind mode). pair = {model, label}. */
40
46
  function display(pair, blind) {
41
47
  if (!pair) { return '—'; }
@@ -197,7 +203,7 @@
197
203
  var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
198
204
  if (!row) {
199
205
  row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
200
- return el('td', { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
206
+ return el('td', { className: seatCellClass(i) }, [c]);
201
207
  }));
202
208
  tbody.appendChild(row);
203
209
  return;
@@ -205,7 +211,7 @@
205
211
  cells.forEach(function (c, i) {
206
212
  var td = row.children[i];
207
213
  if (td && td.textContent !== c) { td.textContent = c; }
208
- if (td) { td.className = i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : ''); }
214
+ if (td) { td.className = seatCellClass(i); }
209
215
  });
210
216
  });
211
217
  // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
@@ -282,6 +288,6 @@
282
288
  el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
283
289
  renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
284
290
  renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
285
- renderProseSections: renderProseSections,
291
+ renderProseSections: renderProseSections, seatCellClass: seatCellClass,
286
292
  };
287
293
  })();
@@ -48,13 +48,20 @@
48
48
  var tbody = A.$('seats-body');
49
49
  window.AmicusRender.renderSeats(tbody, seats, A.state.blind, A.labelOf);
50
50
  var seatLoss = d.verdict && d.verdict.seatLoss;
51
- var dead = window.AmicusLive.deadSeats(d.run.degrades, seatLoss, seats);
51
+ var runMeta = { critic: (d.run && d.run.critic) || null };
52
+ // Source-selection (v4.6.3 PR2, spec D4): run-degrade.js swallows checkpoint failures, so
53
+ // verdict.json can carry degrade records run.json's own checkpoint lost — fall back to it
54
+ // ONLY when run.degrades is empty/absent. A fallback, never a union: both docs can carry
55
+ // records for the SAME run, and the persisted run.json copy is authoritative when present.
56
+ var deg = (d.run && d.run.degrades && d.run.degrades.length) ? d.run.degrades
57
+ : ((d.verdict && d.verdict.degrades) || []);
58
+ var dead = window.AmicusLive.deadSeats(deg, seatLoss, seats, runMeta);
52
59
  renderDeadSeatRows(tbody, dead, A.state.blind, A.labelOf);
53
60
  }
54
61
 
55
62
  /**
56
63
  * Paints the dead-seat rows appended after live rows. Deliberately NOT
57
- * folded into workspace-render.js's renderSeats (287/300 — must not grow)
64
+ * folded into workspace-render.js's renderSeats (293/300 — must not grow)
58
65
  * and NOT run through its keyed diff: dead rows carry no per-tick-changing
59
66
  * field, so a full rebuild every call is correct and cheap, and renderSeats
60
67
  * just above already self-cleans any PRIOR dead row as an unrecognized
@@ -74,7 +81,7 @@
74
81
  function renderDeadSeatRows(tbody, dead, blindOn, labelOf) {
75
82
  (dead || []).forEach(function (seat) {
76
83
  var cells = window.AmicusLive.seatCells(
77
- { model: seat.model, status: seat.statusText, stalled: false }, blindOn, labelOf);
84
+ { model: seat.model, role: seat.role, status: seat.statusText, stalled: false }, blindOn, labelOf);
78
85
  // Fix wave 2 (smoke-caught, GUI smoke on real degraded run 12c96b6b): dead seats never
79
86
  // produce a review, so state.labelByModel (built from the run's names derivation — models
80
87
  // that DID review) never carries them; seatCells' own `blindOn && label ? label : alias`
@@ -88,7 +95,7 @@
88
95
  { className: 'seat-dead', dataset: { key: 'dead:' + seat.model } },
89
96
  cells.map(function (c, i) {
90
97
  return window.AmicusRender.el('td',
91
- { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
98
+ { className: window.AmicusRender.seatCellClass(i) }, [c]);
92
99
  }));
93
100
  tbody.appendChild(row);
94
101
  });
@@ -105,7 +112,15 @@
105
112
  var A = window.AmicusApp;
106
113
  var d = A.state.detail;
107
114
  var seatLoss = d && d.verdict ? d.verdict.seatLoss : null;
108
- var dead = window.AmicusLive.deadSeats(live.degrades, seatLoss, live.seats || []);
115
+ var runMeta = { critic: (d && d.run && d.run.critic) || null };
116
+ // Source-selection (v4.6.3 PR2, spec D4), live-path twin of renderSeatsPanel's fallback
117
+ // above: the tick's own live.degrades wins when non-empty; state.detail.verdict.degrades is
118
+ // usually absent mid-run (verdict.json doesn't exist until the run finishes) — fine, this
119
+ // branch only matters for the rare same-run reopen where a prior terminal fetch already
120
+ // populated state.detail.verdict.
121
+ var deg = (live.degrades && live.degrades.length) ? live.degrades
122
+ : ((d && d.verdict && d.verdict.degrades) || []);
123
+ var dead = window.AmicusLive.deadSeats(deg, seatLoss, live.seats || [], runMeta);
109
124
  renderDeadSeatRows(A.$('seats-body'), dead, A.state.blind, A.labelOf);
110
125
  }
111
126
 
@@ -66,7 +66,7 @@
66
66
  var A = window.AmicusApp;
67
67
  stopLiveLoop();
68
68
  var d = A.state.detail;
69
- if (!d || !d.run || window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1) { return; }
69
+ if (!d || !d.run || window.AmicusLive.isTerminal(d.run.status)) { return; }
70
70
  var epoch = A.state.liveEpoch; // F42: stopLiveLoop() above just bumped it; this chain owns it
71
71
  var tick = function () {
72
72
  // F42: pin the id PER TICK and SEND the pinned id — matching the reply against
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.6.2",
3
+ "version": "4.6.3",
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": [
@@ -162,6 +162,15 @@ function runVerdict(args, useJson) {
162
162
  hint: 'pass a valid decisions.json array or omit --decisions' });
163
163
  }
164
164
  }
165
+ // R1 (v4.6.3): parseArgs records a valueless trailing -o/--out as boolean
166
+ // true (and --out= as ''). The boolean crashes writeVerdictAtomic mid-write
167
+ // (renameSync TypeError on a non-string path) leaving an orphaned
168
+ // true.tmp-<pid>; the empty string silently falls through to the default
169
+ // path. Name the flag and refuse both — the unknown-flag precedent.
170
+ if (args.out !== undefined && (typeof args.out !== 'string' || args.out === '')) {
171
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: '-o/--out requires a value',
172
+ hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
173
+ }
165
174
  const outPath = args.out || './verdict.json';
166
175
  let verdict;
167
176
  try {
@@ -80,10 +80,14 @@ function realDeps() {
80
80
  now: () => Date.now(),
81
81
  listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
82
82
  unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
83
+ listSessionMetadataTmpFiles: () => metaSweep.listSessionMetadataTmpFiles(), // D8
84
+ unlinkSessionMetadataTmp: (n) => metaSweep.unlinkSessionMetadataTmp(n),
83
85
  };
84
86
  }
85
87
  // B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
86
88
  const tmpSweep = require('./utils/session-index-tmp-sweep');
89
+ // D8: per-session metadata.json sibling sweep — utils/session-metadata-tmp-sweep.js.
90
+ const metaSweep = require('./utils/session-metadata-tmp-sweep');
87
91
 
88
92
  /** Run one guarded check; a thrown fn becomes an error line. */
89
93
  function guard(id, name, fn) {
@@ -108,10 +112,10 @@ async function runDoctorChecks(depsOverride = {}) {
108
112
  const checks = [];
109
113
 
110
114
  checks.push(guard('node', 'Node.js', () => {
111
- const major = parseInt(String(d.nodeVersion).replace(/^v/, '').split('.')[0], 10);
112
- return major >= 18
115
+ const [maj, min] = String(d.nodeVersion).replace(/^v/, '').split('.').map(n => parseInt(n, 10));
116
+ return (maj > 22 || (maj === 22 && min >= 12))
113
117
  ? { id: 'node', name: 'Node.js', status: 'ok', message: d.nodeVersion, hint: null }
114
- : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=18)`, hint: 'Install Node 18 or newer from https://nodejs.org' };
118
+ : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=22.12)`, hint: 'Install Node 22.12 or newer from https://nodejs.org' };
115
119
  }));
116
120
 
117
121
  checks.push(guard('config-dir', 'Config directory', () => (
@@ -201,6 +205,8 @@ async function runDoctorChecks(depsOverride = {}) {
201
205
 
202
206
  checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
203
207
 
208
+ checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
209
+
204
210
  // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
205
211
  checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
206
212
  const values = d.readApiKeyValues() || {};
@@ -45,18 +45,22 @@ function runSave(name, modelsArg, useJson) {
45
45
  hint: 'each member must be a known alias (see `amicus models`) or a provider/model id containing "/"' });
46
46
  }
47
47
  const overwritten = !!getCouncil(name);
48
+ const shadowsBuiltin = listBuiltinCouncilNames().includes(name);
48
49
  const cfg = loadConfig() || {};
49
50
  if (!cfg.councils) { cfg.councils = {}; }
50
51
  cfg.councils[name] = members;
51
52
  saveConfig(cfg);
52
- const doc = { ok: true, name, models: members, overwritten };
53
+ const doc = { ok: true, name, models: members, overwritten, shadowsBuiltin };
53
54
  process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderSave(doc));
54
55
  return 0;
55
56
  }
56
57
 
57
58
  function renderSave(doc) {
58
59
  const notice = doc.overwritten ? ' (overwritten)' : '';
59
- return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` +
60
+ const shadow = doc.shadowsBuiltin
61
+ ? ` note: '${doc.name}' now shadows the built-in bench of the same name — 'amicus council list' shows both\n`
62
+ : '';
63
+ return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` + shadow +
60
64
  " for full run configuration — chair, options, templates — see 'amicus pack'\n";
61
65
  }
62
66
 
@@ -143,12 +143,14 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
143
143
  emitStageStarted(o.runDir, o.runId, 'chair', null, o.follow);
144
144
  // Fallback chain (spec §4): retry same chair once → promote best
145
145
  // non-bench model from the ledger → give up (no Claude fallback headless).
146
- let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
147
- recordAttempt(attempt, `${o.runId}-ch1`, o.chair);
146
+ const waveId1 = `${o.runId}-ch1`;
147
+ let attempt = await attemptChair(o.chair, waveId1);
148
+ recordAttempt(attempt, waveId1, o.chair);
148
149
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
149
150
  if (!attempt.leg && !overBudget()) {
150
- attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
151
- recordAttempt(attempt, `${o.runId}-ch2`, o.chair);
151
+ const waveId2 = `${o.runId}-ch2`;
152
+ attempt = await attemptChair(o.chair, waveId2);
153
+ recordAttempt(attempt, waveId2, o.chair);
152
154
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
153
155
  }
154
156
  if (attempt.leg) { actualChair = o.chair; }
@@ -157,8 +159,9 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
157
159
  try { statsRows = statsFn(); } catch { /* no ledger yet */ }
158
160
  const fallback = pickFallbackChair(statsRows, o.models, o.chair);
159
161
  if (fallback) {
160
- attempt = await attemptChair(fallback, `${o.runId}-ch3`);
161
- recordAttempt(attempt, `${o.runId}-ch3`, fallback);
162
+ const waveId3 = `${o.runId}-ch3`;
163
+ attempt = await attemptChair(fallback, waveId3);
164
+ recordAttempt(attempt, waveId3, fallback);
162
165
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
163
166
  if (attempt.leg) { actualChair = fallback; }
164
167
  }
@@ -22,6 +22,9 @@ const SESSION_STATUS = {
22
22
  /** Canonical session dir name — new sessions are written here. */
23
23
  const SESSIONS_DIR = 'amicus_sessions';
24
24
 
25
+ /** Subagent sessions nest one level under their parent taskId dir. */
26
+ const SUBAGENTS_DIR = 'subagents';
27
+
25
28
  /**
26
29
  * Get the canonical session directory path for a task (used for WRITES).
27
30
  * Spec Reference: §8.1 Session directory structure
@@ -256,7 +259,7 @@ function saveSummary(projectDir, taskId, summary) {
256
259
  * // Returns: '/path/to/project/.claude/amicus_sessions/abc123/subagents/subagent-xyz'
257
260
  */
258
261
  function getSubagentDir(projectDir, parentTaskId, subagentId) {
259
- return path.join(getSessionDir(projectDir, parentTaskId), 'subagents', subagentId);
262
+ return path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR, subagentId);
260
263
  }
261
264
 
262
265
  /**
@@ -355,7 +358,7 @@ function getSubagentSession(projectDir, parentTaskId, subagentId) {
355
358
  * @returns {object[]} Array of sub-agent metadata
356
359
  */
357
360
  function listSubagents(projectDir, parentTaskId, filter = {}) {
358
- const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), 'subagents');
361
+ const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR);
359
362
 
360
363
  if (!fs.existsSync(subagentsDir)) {
361
364
  return [];
@@ -412,6 +415,7 @@ module.exports = {
412
415
  getSessionDir,
413
416
  resolveExistingSessionDir,
414
417
  SESSIONS_DIR,
418
+ SUBAGENTS_DIR,
415
419
  SESSION_STATUS,
416
420
  // Sub-agent functions
417
421
  getSubagentDir,
@@ -62,6 +62,12 @@ function idsByProvider(catalog) {
62
62
  * at least one live resolution from any other source. This ensures the
63
63
  * suggested `--add-alias` fix actually clears the warning, and prevents
64
64
  * permanently unclearable noise when the default openrouter route is live.
65
+ *
66
+ * A stale 'defaults' entry is additionally suppressed when it is the alias's
67
+ * DERIVED direct-form pin (per curated-models.directFormProvenance()) and the
68
+ * alias is either covered live from another source or declares gatewayOnly
69
+ * (v4.6.3 PR1, spec D2) — an AUTHORED defaults pin and user-config rows are
70
+ * never suppressed this way.
65
71
  * @param {Array<{alias,model,source}>} sources
66
72
  * @param {Array<{id:string}>} catalog
67
73
  */
@@ -75,11 +81,24 @@ function findStaleAliases(sources, catalog) {
75
81
  const covered = new Set(
76
82
  sources.filter(({ model }) => isLive(model) === true).map(({ alias }) => alias)
77
83
  );
84
+ // v4.6.3 PR1 (spec D2). Lazy-required so suites that doMock curated-models
85
+ // for other cases keep working; a stub without the accessor simply gets no
86
+ // suppression (fail-open toward reporting).
87
+ const cm = require('./curated-models');
88
+ const provenance = typeof cm.directFormProvenance === 'function' ? (cm.directFormProvenance() || {}) : {};
78
89
  return sources.filter(({ alias, model, source }) => {
79
90
  const ids = byProvider.get(model.split('/')[0]);
80
91
  if (!ids) { return false; } // provider unverifiable
81
92
  if (ids.has(model)) { return false; } // live
82
93
  if (source.startsWith('curated-route') && covered.has(alias)) { return false; }
94
+ // A 'defaults' pin that is the alias's DERIVED direct form: its absence
95
+ // from the direct namespace is a routing fact, not staleness, while the
96
+ // alias has live coverage (or declares gatewayOnly). The fix: suggestion
97
+ // this row would otherwise print is a retarget nobody should run — the
98
+ // 2026-08-05 release-gate false positive (v4.6.3 spec §3).
99
+ const prov = provenance[alias];
100
+ if (source === 'defaults' && prov && prov.directForm === 'derived' &&
101
+ (prov.gatewayOnly || covered.has(alias))) { return false; }
83
102
  return true;
84
103
  });
85
104
  }
@@ -68,7 +68,9 @@ const FAMILIES = [
68
68
  ];
69
69
 
70
70
  /**
71
- * Alias-only entries (no wizard quick pick); openrouter route only.
71
+ * Alias-only entries (no wizard quick pick). Every entry authors an
72
+ * openrouter route; entries whose vendor's direct API genuinely serves the
73
+ * model also author a direct route (claude/sonnet/haiku/fable).
72
74
  * Refreshed against the live catalog 2026-08-04.
73
75
  */
74
76
  const CARDLESS = [
@@ -77,7 +79,12 @@ const CARDLESS = [
77
79
  // gpt-5.5-pro ($30/$180 — still served, but expected to sunset with the
78
80
  // 5.5 line). `gpt-pro` tracks SOL while the `gpt` family tracks terra —
79
81
  // see the tier-semantics comment on the `gpt` family above.
80
- { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
82
+ // gatewayOnly (owner ruling 2026-08-05, recorded in the v4.6.3 spec): the
83
+ // openrouter-only route is a deliberate routing choice — OpenAI's direct
84
+ // namespace does not serve gpt-5.6-sol-pro, so the DERIVED direct form
85
+ // must never be audited as stale and no direct pairing may be suggested.
86
+ { alias: 'gpt-pro', gatewayOnly: true,
87
+ routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
81
88
  // codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
82
89
  { alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
83
90
  { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
@@ -86,7 +93,11 @@ const CARDLESS = [
86
93
  anthropic: 'anthropic/claude-sonnet-5' } },
87
94
  { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5',
88
95
  anthropic: 'anthropic/claude-haiku-4-5-20251001' } },
89
- { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5' } },
96
+ // fable: direct route authored 2026-08-05 (owner ruling R2, v4.6.3 spec §3).
97
+ // Anthropic's /v1/models lists claude-fable-5 AND the direct route serves
98
+ // (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
99
+ { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
100
+ anthropic: 'anthropic/claude-fable-5' } },
90
101
  { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
91
102
  { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
92
103
  { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
@@ -160,7 +171,8 @@ function listCuratedRoutes() {
160
171
  * Vendors whose direct-API ids differ from OpenRouter's (dot vs. dash
161
172
  * versioning, distinct model names, etc.). NEVER derive a direct form for
162
173
  * these — derivation would emit the wrong (dot) id, or invent a direct id
163
- * for a model that is OpenRouter-only today (e.g. fable).
174
+ * for a model the direct API does not serve (fable was that case until its
175
+ * direct route was verified and authored, 2026-08-05).
164
176
  * Frozen so consumers can only read it (`.has()`) — a frozen Set still
165
177
  * supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
166
178
  */
@@ -200,6 +212,28 @@ function gatewayRoutesFor(vendorPath, obj) {
200
212
  return routes;
201
213
  }
202
214
 
215
+ /**
216
+ * Per-alias provenance of the `direct` form in toGatewayRoutes(), for the
217
+ * auditors (alias-audit.js / gateway-route-audit.js): an AUTHORED direct
218
+ * form absent from its namespace is stale; a DERIVED one is a computed
219
+ * convenience whose absence is a routing fact, not staleness, while the
220
+ * authoring openrouter route is live. `gatewayOnly` mirrors an entry's
221
+ * explicit routing-choice annotation (owner-ruled): suppress derived-form
222
+ * findings unconditionally and never suggest a direct pairing.
223
+ * @returns {Object<string, {directForm: 'authored'|'derived'|'none', gatewayOnly: boolean}>}
224
+ */
225
+ function directFormProvenance() {
226
+ const out = {};
227
+ const entryProv = (vendorPath, obj, gatewayOnly) => {
228
+ const direct = directFormFor(vendorPath, obj);
229
+ const directForm = !direct ? 'none' : (obj[vendorPath] ? 'authored' : 'derived');
230
+ return { directForm, gatewayOnly: gatewayOnly === true };
231
+ };
232
+ for (const f of FAMILIES) { out[f.alias] = entryProv(f.vendorPath, f.fallback, f.gatewayOnly); }
233
+ for (const e of CARDLESS) { out[e.alias] = entryProv(vendorOf(e.routes.openrouter), e.routes, e.gatewayOnly); }
234
+ return out;
235
+ }
236
+
203
237
  /**
204
238
  * @returns {Object<string,{direct?: string, openrouter: string}>} alias →
205
239
  * per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
@@ -223,8 +257,9 @@ function toGatewayRoutes() {
223
257
  * disagree. It previously string-stripped `openrouter/` itself, which emitted
224
258
  * OpenRouter's dot ids for divergent vendors (`anthropic/claude-opus-4.8` —
225
259
  * the direct API only serves the dash form) and invented a bare direct id for
226
- * OpenRouter-only models (`fable`). Both made `amicus doctor` and `amicus
227
- * models --check` warn about the product's own shipped defaults.
260
+ * then-OpenRouter-only models (`fable`, direct-authored 2026-08-05). Both made
261
+ * `amicus doctor` and `amicus models --check` warn about the product's own
262
+ * shipped defaults.
228
263
  */
229
264
  function toDefaultAliases() {
230
265
  const out = {};
@@ -235,5 +270,6 @@ function toDefaultAliases() {
235
270
  }
236
271
 
237
272
  module.exports = {
238
- getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
273
+ getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
274
+ directFormProvenance, DIVERGENT_VENDORS
239
275
  };
@@ -30,7 +30,7 @@
30
30
 
31
31
  'use strict';
32
32
 
33
- const { toGatewayRoutes } = require('./curated-models');
33
+ const { toGatewayRoutes, directFormProvenance } = require('./curated-models');
34
34
  const { classifyModel } = require('./model-classification');
35
35
  const { pairAcrossGateways } = require('./gateway-route-catalog');
36
36
  const { isDirectProvider } = require('./provider-registry');
@@ -70,19 +70,32 @@ function isAuthoritative(catalogInfo, id) {
70
70
  */
71
71
  function auditGatewayRoutes(catalogInfo) {
72
72
  const routes = toGatewayRoutes();
73
+ const provenance = directFormProvenance();
73
74
  const findings = [];
74
75
 
75
76
  for (const [alias, forms] of Object.entries(routes)) {
77
+ const prov = provenance[alias] || { directForm: 'none', gatewayOnly: false };
76
78
  for (const gateway of ['direct', 'openrouter']) {
77
79
  const id = forms[gateway];
78
80
  if (!id) { continue; }
79
- if (classifyModel(id, gateway, catalogInfo) === 'invalid') {
80
- findings.push({ alias, gateway, kind: 'stale', model: id });
81
+ if (classifyModel(id, gateway, catalogInfo) !== 'invalid') { continue; }
82
+ // v4.6.3 PR1 (spec D2): a DERIVED direct form is a computed convenience,
83
+ // not an authored claim. Its absence from the direct namespace is a
84
+ // routing fact — not staleness — while the authoring openrouter route
85
+ // is live, or when the entry declares gatewayOnly (an owner-ruled
86
+ // routing choice). An AUTHORED direct form absent from its namespace
87
+ // reports exactly as before.
88
+ if (gateway === 'direct' && prov.directForm === 'derived' &&
89
+ (prov.gatewayOnly ||
90
+ classifyModel(forms.openrouter, 'openrouter', catalogInfo) === 'valid')) {
91
+ continue;
81
92
  }
93
+ findings.push({ alias, gateway, kind: 'stale', model: id });
82
94
  }
83
95
 
84
96
  const vendor = vendorOf(forms.openrouter);
85
97
  if (!vendor || !isDirectProvider(vendor)) { continue; } // gateway-only vendor: no direct route ever possible
98
+ if (prov.gatewayOnly) { continue; } // declared routing choice: never suggest a direct pairing
86
99
  const token = bareSegment(forms.openrouter, vendor);
87
100
  if (!token) { continue; }
88
101
  const paired = pairAcrossGateways(vendor, token, catalogInfo); // Task-5 contract: bare segment only
@@ -8,17 +8,19 @@
8
8
  const https = require('https');
9
9
 
10
10
  /**
11
- * Hardcoded Anthropic models (no public listing endpoint). This is the
12
- * DIRECT-API floor only Fable is OpenRouter-only (see curated-models.js
13
- * DIVERGENT_VENDORS / CARDLESS 'fable' entry, which has no `anthropic` route)
14
- * and must never appear here: classifyModel() returns 'valid' on a floor HIT
15
- * before it ever checks `authoritative`, so listing an OR-only model here
16
- * would mislabel a direct-API request for it as valid.
11
+ * Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
12
+ * failed live fetch) gets. Every id here must be one the direct API
13
+ * GENUINELY serves classifyModel() returns 'valid' on a floor HIT before
14
+ * it ever checks `authoritative`, so a speculative row would mislabel a
15
+ * dead direct-API request as valid. (fable joined 2026-08-05 after live
16
+ * verification /v1/models lists claude-fable-5 and a direct smoke leg
17
+ * served; v4.6.3 spec §3.)
17
18
  */
18
19
  const ANTHROPIC_MODELS = [
19
20
  { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
20
21
  { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
21
22
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
23
+ { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', contextLength: null, pricing: null },
22
24
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
23
25
  // Dated snapshot: the id Anthropic's /v1/models actually lists, and the
24
26
  // `haiku` direct route curated-models.js authors. Without it the floor
@@ -87,6 +87,20 @@ const REMEDIATION_HINTS = Object.freeze({
87
87
  */
88
88
  sweepSessionIndexTmp:
89
89
  'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
90
+
91
+ /**
92
+ * Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
93
+ * same producer shape as sweepSessionIndexTmp above, one level down — a
94
+ * kill between an atomic write's tmp-write and rename leaves a stray temp
95
+ * file in a session directory forever. `doctor --fix` sweeps files older
96
+ * than 60s (never a live writer's ms-lived tmp).
97
+ *
98
+ * Voice ruling (Christian, 2026-08-03, sweepSessionIndexTmp above): applies
99
+ * verbatim here — the cause is definitional, not a guess, so this hint also
100
+ * keeps its confident voice rather than the unverified-cause voice.
101
+ */
102
+ sweepSessionMetadataTmp:
103
+ 'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
90
104
  });
91
105
 
92
106
  module.exports = REMEDIATION_HINTS;
@@ -0,0 +1,136 @@
1
+ // src/utils/session-metadata-tmp-sweep.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * v4.6.3 PR3 Task 3 (D8): orphaned per-session metadata.json.*.tmp sweep for
6
+ * `amicus doctor --fix`.
7
+ *
8
+ * A kill between writeFileAtomic's tmp-write and rename (any of the ~30
9
+ * metadata.json write sites — session-manager.js, sidecar/session-finalize.js,
10
+ * etc.) leaves a stray `.metadata.json.<pid>.<hex>.tmp` file in a session
11
+ * directory forever (the B09 orphan class). This module lists and removes
12
+ * them; src/cli-handlers-doctor.js composes the result into a check line.
13
+ * Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
14
+ *
15
+ * Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
16
+ * docs/superpowers/plans/2026-08-05-v463-pr3-cli-doctor-odds.md): this walks
17
+ * `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
18
+ * `subagents/<id>/` children — rather than consulting sessions-index.json.
19
+ * `amicus doctor` is a per-project surface; the index is best-effort and can
20
+ * point at OTHER projects (issue #40's cross-project fallback exists exactly
21
+ * because the index can be stale). Trusting it here to decide which
22
+ * directories a --fix sweep may unlink from risks touching an unrelated
23
+ * project on stale data — the wrong failure direction for a destructive
24
+ * operation. Walking the cwd-scoped tree directly can only ever find/remove
25
+ * files under the project doctor is already running against.
26
+ *
27
+ * Symlink safety (Task 3 review carry, v4.6.3 PR3): every stat in this walk
28
+ * is lstatSync, never statSync — this module never follows symlinks. A
29
+ * symlinked taskId or subagents directory could otherwise be traversed and
30
+ * have files unlinked through the link, effectively outside the sessions
31
+ * root; lstat closes that off at zero cost.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const HINTS = require('./remediation-hints');
37
+
38
+ /** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
39
+ const AGE_THRESHOLD_MS = 60 * 1000;
40
+
41
+ /** The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions. */
42
+ function sessionsRoot() {
43
+ const { SESSIONS_DIR } = require('../session-manager');
44
+ return path.join(process.cwd(), '.claude', SESSIONS_DIR);
45
+ }
46
+
47
+ /** True when `basename` is an orphaned metadata tmp file (not e.g. progress.json.*.tmp). */
48
+ function isMetadataTmp(basename) {
49
+ return basename.startsWith('.metadata.json.') && basename.endsWith('.tmp');
50
+ }
51
+
52
+ /** List metadata tmp files directly inside `dir`, named relative to `root`. */
53
+ function listTmpIn(dir, root) {
54
+ let entries;
55
+ try { entries = fs.readdirSync(dir); } catch { return []; }
56
+ return entries
57
+ .filter(isMetadataTmp)
58
+ .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 };
62
+ })
63
+ .filter((f) => f.mtimeMs !== null);
64
+ }
65
+
66
+ /**
67
+ * List orphaned per-session metadata.json.*.tmp files under the cwd-scoped
68
+ * sessions root, covering both `<taskId>/` and `<taskId>/subagents/<id>/`.
69
+ * @returns {Array<{name: string, mtimeMs: number}>}
70
+ */
71
+ function listSessionMetadataTmpFiles() {
72
+ const { SUBAGENTS_DIR } = require('../session-manager');
73
+ const root = sessionsRoot();
74
+ let taskIds;
75
+ try { taskIds = fs.readdirSync(root); } catch { return []; }
76
+ const found = [];
77
+ for (const taskId of taskIds) {
78
+ const taskDir = path.join(root, taskId);
79
+ let stat;
80
+ try { stat = fs.lstatSync(taskDir); } catch { continue; }
81
+ if (!stat.isDirectory()) { continue; }
82
+ found.push(...listTmpIn(taskDir, root));
83
+
84
+ const subagentsDir = path.join(taskDir, SUBAGENTS_DIR);
85
+ let subIds;
86
+ try { subIds = fs.readdirSync(subagentsDir); } catch { continue; }
87
+ for (const subId of subIds) {
88
+ const subDir = path.join(subagentsDir, subId);
89
+ let subStat;
90
+ try { subStat = fs.lstatSync(subDir); } catch { continue; }
91
+ if (!subStat.isDirectory()) { continue; }
92
+ found.push(...listTmpIn(subDir, root));
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ /** Delete one orphaned tmp file by name (relative to the sessions root; never an absolute/caller path). */
99
+ function unlinkSessionMetadataTmp(name) {
100
+ const root = sessionsRoot();
101
+ fs.unlinkSync(path.join(root, name));
102
+ }
103
+
104
+ /**
105
+ * Compose the doctor check line for the metadata tmp-orphan sweep. Pure
106
+ * decision logic (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js
107
+ * wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
108
+ * @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
109
+ * fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
110
+ */
111
+ function evaluateSessionMetadataTmpSweep(d) {
112
+ const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
113
+ const files = d.listSessionMetadataTmpFiles() || [];
114
+ if (files.length === 0) {
115
+ return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
116
+ }
117
+ if (!d.fix) {
118
+ return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionMetadataTmp };
119
+ }
120
+ const nowMs = d.now();
121
+ const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
122
+ let swept = 0;
123
+ for (const f of sweepable) {
124
+ try { d.unlinkSessionMetadataTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
125
+ }
126
+ const remaining = files.length - swept;
127
+ if (remaining === 0) {
128
+ const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-metadata tmp file(s)` } : {};
129
+ return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
130
+ }
131
+ return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionMetadataTmp };
132
+ }
133
+
134
+ module.exports = {
135
+ AGE_THRESHOLD_MS, listSessionMetadataTmpFiles, unlinkSessionMetadataTmp, evaluateSessionMetadataTmpSweep,
136
+ };