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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +6 -7
- package/docs/ROADMAP.md +33 -4
- package/docs/configuration.md +5 -3
- package/docs/usage.md +10 -3
- package/electron/workspace-ui/live-model.js +85 -12
- package/electron/workspace-ui/workspace-app.js +6 -1
- package/electron/workspace-ui/workspace-render.js +9 -3
- package/electron/workspace-ui/workspace-seats.js +20 -5
- package/electron/workspace-ui/workspace-verbs.js +1 -1
- package/package.json +1 -1
- package/src/cli-handlers-council.js +9 -0
- package/src/cli-handlers-doctor.js +9 -3
- package/src/council/presets-cli.js +6 -2
- package/src/council/run-chair.js +9 -6
- package/src/session-manager.js +6 -2
- package/src/utils/alias-audit.js +19 -0
- package/src/utils/curated-models.js +43 -7
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +8 -6
- package/src/utils/remediation-hints.js +14 -0
- package/src/utils/session-metadata-tmp-sweep.js +136 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.6.
|
|
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
|
[](https://www.npmjs.com/package/amicus)
|
|
14
14
|
[](./LICENSE)
|
|
15
|
-
[](https://nodejs.org)
|
|
16
16
|
[](./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):
|
|
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) ≥
|
|
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) ≥
|
|
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 ≥
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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 ≥
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
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) {
|
|
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
|
-
|
|
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) {
|
|
174
|
-
|
|
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,
|
|
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.
|
|
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:
|
|
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 =
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
112
|
-
return
|
|
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 >=
|
|
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
|
-
|
|
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
|
|
package/src/council/run-chair.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
}
|
package/src/session-manager.js
CHANGED
|
@@ -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),
|
|
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),
|
|
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,
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
227
|
-
* models --check` warn about the product's own
|
|
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,
|
|
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)
|
|
80
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
+
};
|