amicus 4.5.1 → 4.5.2

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.5.1",
3
+ "version": "4.5.2",
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
@@ -3,6 +3,53 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.5.2] - 2026-07-31
7
+
8
+ ### Fixed
9
+
10
+ - **OpenCode server-start timeout is no longer pinned to the SDK's 5000 ms, and a start timeout
11
+ is now retried.** `@opencode-ai/sdk` defaults `createOpencodeServer`'s start timeout to 5 s and
12
+ lets the caller override it; amicus never passed one, so every start on every platform ran on
13
+ that default — undocumented, untunable, and invisible to `amicus doctor`. Worse, the existing
14
+ start retry (`retryOnLockRace`) classified only `database is locked` / `SQLITE_BUSY`, so a start
15
+ timeout fell straight through with **zero** retries, past machinery already wired in at every
16
+ call site. On a Windows box with the project on a OneDrive-synced volume and Defender active, a
17
+ cold OpenCode/SQLite start blew the window: the council's shared server failed to acquire, the
18
+ run degraded to exactly the per-wave configuration `src/council/run-server.js` exists to
19
+ eliminate, and the whole Stage-1 bench died with `COUNCIL_QUORUM: Only 0 Stage-1 review(s)
20
+ survived`. Three of the reporter's runs were lost this way. A start timeout is now classified as
21
+ transient (`isTimeoutClassStartFailure`) and retried on the same bounded 250/500/1000/2000 ms
22
+ schedule, the timeout is threaded through `buildServerOptions` and both upstream start sites,
23
+ and the default is raised to **30 s on Windows / 15 s elsewhere** — a slow start costs latency,
24
+ a failed start costs a review seat.
25
+ - **The Electron self-heal was dead code in every published install.** `src/sidecar/unzip.js`
26
+ did a bare, unguarded `require('extract-zip')` for a package that was never declared in
27
+ `dependencies` or `optionalDependencies`. It resolved in the dev tree only because `puppeteer`
28
+ (a devDependency) pulls it transitively — `npm ls extract-zip --omit=dev` returned empty — so on
29
+ a real `npm i -g amicus` `robustExtract` threw `MODULE_NOT_FOUND` before Strategy 1. That made
30
+ the native-unzip fallback below it unreachable, the bounded idle/max timers from the
31
+ extract-zip-node24 work inert, and `amicus doctor --fix` dead-end at `self-heal incomplete` —
32
+ while routing users toward antivirus allow-listing for what was actually a missing module.
33
+ `extract-zip` is now a declared production dependency, the `require` degrades into the native
34
+ strategies instead of throwing out of the function, and a new `no-phantom-dependencies` test
35
+ fails on any undeclared runtime require anywhere in `src/`, `bin/` or `electron/`.
36
+ - **A lost critic is now recorded on `verdict.json`.** The critic is a solo wave with one leg, and
37
+ unlike a dead bench wave (which trips the quorum gate and fails the run loudly) a dead critic is
38
+ survivable — so a run could reach a full verdict, tally and chair synthesis that had never seen
39
+ the adversarial seat, with the only record being `deadWaves` in `run.json`. Field run `dfb6a692`
40
+ did exactly that. `verdict.json` now carries an optional `seatLoss` block
41
+ (`criticRequested`/`criticSeated`/`reason`/`deadBenchSeats`) whenever `--critic` was requested,
42
+ so a reader of the verdict can see the critic never ran. Additive; `schemaVersion` stays `2`.
43
+
44
+ ### Added
45
+
46
+ - **`AMICUS_SERVER_START_TIMEOUT_MS`** — override the server-start window (see
47
+ [docs/configuration.md](docs/configuration.md#server-startup)). Values ≤ 0 are ignored rather
48
+ than honored, since a zero start timeout fails every start instantly.
49
+ - **Successful server starts are logged at debug level** with both `startMs` and the `timeoutMs`
50
+ ceiling in force, so headroom on a slow box is measurable rather than inferred — the question
51
+ the field report could not answer.
52
+
6
53
  ## [4.5.1] - 2026-07-30
7
54
 
8
55
  ### Added
package/README.md CHANGED
@@ -15,6 +15,8 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
15
15
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-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
+ **[Quick start ↓](#quick-start)** · [Commands](#commands) · [Documentation](#documentation) · [Troubleshooting](#troubleshooting)
19
+
18
20
  > **Supported clients:** Claude Code CLI, Claude Desktop, and Claude Cowork are fully tested and supported. Claude Code web is experimental.
19
21
 
20
22
  </div>
@@ -24,10 +26,10 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
24
26
  ## Table of Contents
25
27
 
26
28
  - [What is Amicus](#what-is-amicus)
29
+ - [The Council](#the-council)
27
30
  - [Ways to run the council](#ways-to-run-the-council)
28
31
  - [Quick start](#quick-start)
29
32
  - [Requirements & Dependencies](#requirements--dependencies)
30
- - [The Council](#the-council)
31
33
  - [The parallel window](#the-parallel-window)
32
34
  - [Commands](#commands)
33
35
  - [Models](#models)
@@ -52,7 +54,7 @@ One install delivers six things that work together:
52
54
  - **The `amicus` CLI (with an `am` alias) and an MCP server.** The engine underneath both skills: launches sessions, shares context, runs parallel waves, and exposes the same surface to Claude as MCP tools.
53
55
  - **A self-updating model catalog.** Aliases and validation resolve against a live catalog fetched from provider APIs (cached locally), so model names stay current without a hard-coded table.
54
56
  - **Observability.** `amicus watch <id>` renders any live or finished run (fan-out or council) from any terminal; `--follow` streams milestones as they happen; `--on-complete` fires a hook when a run lands; `--retry-failed` plus opt-in cheaper-model fallbacks recover dead legs without relaunching the whole wave; `amicus spend` answers "what did this cost, and where" with per-run attribution.
55
- - **Council Workspace.** `amicus watch <runId> --ui`: a window that shows a council *thinking* — live seats, the anonymized judge packet, the adjudication matrix, dissent drill-in, chair verdict, and cost-by-seat — for both live and historical runs. It also **auto-opens** on an MCP-invoked council run from Claude Code (local), so you no longer have to remember the flag (see [The Council](#the-council)).
57
+ - **Council Workspace.** `amicus watch <runId> --ui`: a window that shows a council *thinking* — live seats, the anonymized judge packet, the adjudication matrix, dissent drill-in, chair verdict, and cost-by-seat — for both live and historical runs. It also **auto-opens** on an MCP-invoked council run from Claude Code (local), so you no longer have to remember the flag (see [docs/council.md](./docs/council.md#council-workspace-gui)).
56
58
 
57
59
  Claude is the orchestrator. The council and chat skills run *on top of* the engine; you talk to Claude, and Claude drives Amicus.
58
60
 
@@ -60,15 +62,103 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
60
62
 
61
63
  ---
62
64
 
65
+ ## The Council
66
+
67
+ > Trigger it by saying *"council review this"* to Claude, or, on the plugin channel, run **`/amicus:council`** directly.
68
+
69
+ **Why multi-model.** Any single model — including the one running your session — has consistent blind spots. Route the *same* material through models from *different* families and the disagreements surface: missed issues, overstated confidence, claims one model alone would have waved through. The council is the structured version of that idea.
70
+
71
+ **The flow, in five beats:**
72
+
73
+ 1. **Independent reviews.** Each council model reviews the artifact on its own (one parallel wave), producing a structured findings list — claim, severity (`blocker | major | minor | nit`), location, rationale.
74
+ 2. **Anonymized cross-review.** Claude relabels every review (Review A, B, C…) and sends the identical bundle to every model. Each model ranks the reviews and adjudicates every finding (`agree | dispute | neutral`) — *unknowingly judging its own*, so self-bias washes out. This yields a **street-cred** ranking and sorts findings into **Disputed / Confirmed / Contested / Singleton** tiers.
75
+ 3. **Chair verdict.** A designated **non-Claude** chair receives the de-anonymized picture — all reviews, rankings, and adjudications — and synthesizes an independent verdict. Claude presents it verbatim; Claude does not synthesize.
76
+ 4. **Tiered decisions.** Confirmed findings get one bulk accept/deny; Contested and Singleton findings are decided one at a time (accept / deny / modify).
77
+ 5. **Outputs applied.** Accepted findings are written into a reviewed copy of the source; the full run is captured in the run folder.
78
+
79
+ ```mermaid
80
+ flowchart LR
81
+ A["Artifact"] --> B["Independent<br/>reviews"]
82
+ B --> C["Anonymized<br/>cross-review"]
83
+ C --> D["Chair verdict<br/>(non-Claude)"]
84
+ D --> E["Tiered<br/>accept / deny"]
85
+ E --> F["Reviewed copy<br/>+ run folder"]
86
+ ```
87
+
88
+ **What a run produces** (in `output/<stem>-council/`):
89
+
90
+ - `review-<model>.md` × N — each model's independent review.
91
+ - `crossreview-matrix.md` — the adjudication grid plus the de-anonymized street-cred table.
92
+ - `verdict.md` — the chair's synthesis.
93
+ - `report.md` — synthesis + the full decision log + a per-call run-stats table.
94
+ - `report.html` — the deterministic renderer output (adjudication matrix, street-cred table,
95
+ findings-by-tier, cost — no chair prose). This is the default artifact handed to the user.
96
+ - For an **editable source**, the accepted edits land in `<stem>-reviewed.<ext>` next to the original.
97
+
98
+ **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.
99
+
100
+ - **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.
101
+ - **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.
102
+ - **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.
103
+ - **Chair verdict scale** — the chair closes with 3–5 hard questions and one parseable line: `VERDICT: Ship it | Fix these first | Fundamental rethink`.
104
+ - **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.
105
+
106
+ 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).
107
+
108
+ **Cost is disclosed up front.** Before any model launches, you see the run shape — including any enabled optional elements — for example:
109
+
110
+ > This run uses 3 council models across 2 fanout waves + 1 chair call, with critic seat + debate mode ON (~7 base runs + up to 6 rebuttal calls).
111
+
112
+ Then the council waits for your confirmation.
113
+
114
+ The skill lives at **[`skills/second-opinion/SKILL.md`](./skills/second-opinion/SKILL.md)**; the design spec behind it is **[`skills/second-opinion/COUNCIL-DESIGN.md`](./skills/second-opinion/COUNCIL-DESIGN.md)**. For what `amicus council tally|verdict|report|stats` actually take as input and produce — field-by-field schemas, verdict.json's provenance, and a full worked example run against the real CLI — see **[docs/council.md](./docs/council.md)**.
115
+
116
+ ---
117
+
63
118
  ## Ways to run the council
64
119
 
65
120
  The council is the hero — start with the everyday way, and reach for the more powerful ways when you need them:
66
121
 
67
- - **Just ask, in Claude Code.** Hand Claude a plan, diff, design, or manuscript and say *"council review this."* The `second-opinion` skill runs the whole ritual in your session — several models review independently → anonymized cross-review → a non-Claude chair verdict → tiered accept/deny edits — with no setup beyond your API keys. This is how most people use it. → [The Council](#the-council)
68
- - **Headless, in CI, with no Claude runtime.** `amicus council run --prompt-file plan.md --council free` runs that same pipeline in one command — reviews → cross-review → tally → chair verdict — writing `verdict.json` and `report.html`. It needs no Claude session, so it drops straight into CI. → [Headless council](./docs/council.md#amicus-council-run)
122
+ - **Just ask, in Claude Code.** Hand Claude a plan, diff, design, or manuscript and say *"council review this."* The `second-opinion` skill runs the whole ritual above in your session, with no setup beyond your API keys. This is how most people use it. → [Quick start](#quick-start)
123
+ - **Headless, in CI, with no Claude runtime.** `amicus council run --prompt-file plan.md --council free` runs that same pipeline in one command — reviews → cross-review → tally → chair verdict — writing `verdict.json` and `report.html`. It needs no Claude session, so it drops straight into CI. → [Headless council (CI)](#headless-council-ci)
69
124
  - **With a debate round.** Add `--debate` and every Contested or Disputed finding goes back to its raiser to **defend, amend, or withdraw** while the disputing judges re-vote — exactly one rebuttal round, then the final tally. → [The Council](#the-council)
70
125
  - **On free, local, private models — at $0.** Point the council (and sidecars) at an OpenAI-compatible server already running on your machine — Ollama, LM Studio, or vLLM — with `amicus provider add`. No API key, no per-token bill, nothing leaves your machine, and it works offline. → [`amicus provider`](./docs/usage.md#amicus-provider)
71
126
 
127
+ ### Headless council (CI)
128
+
129
+ The same pipeline runs with no Claude runtime at all: `amicus council run --prompt-file briefing.md --models gemini,glm --chair deepseek --json` executes the review waves, the anonymized cross-review, the tally, and the chair verdict in one command, and writes the full run directory (`verdict.json` with the chair's parsed `overallVerdict`, `report.html`, every review and judge output). That is what powers the repo's own **Council Review GitHub Action v2** — on PRs labeled `council-review` it posts an adjudicated verdict as a check run plus a sticky comment, uploads the run directory as an evidence artifact, and can optionally gate merges via its `fail_on` input (default: report-only). Reference: [docs/council.md](./docs/council.md#amicus-council-run).
130
+
131
+ ### Free council (zero-cost)
132
+
133
+ Want the cross-examination without the model spend? `amicus setup` offers a **Free OpenRouter council** mode — readline wizard option 2, and the Electron **Models** step. It detects the free `:free` models live from the catalog, lets you multi-pick (Enter takes a vendor-diverse default), and saves them as `councils.free` — a first-class `councils` config primitive seeded under collision-safe `free-*` aliases. Your `config.default` is left untouched, and all you need is an `OPENROUTER_API_KEY`.
134
+
135
+ Run it anywhere a council runs:
136
+
137
+ ```bash
138
+ amicus fanout --council free --prompt "Review this design"
139
+ ```
140
+
141
+ The `amicus_fanout` MCP tool takes the same `council` parameter, and the `second-opinion` skill reads `councils.free` automatically. A member that gets delisted is dropped with a warning — the council still runs as long as ≥2 survive. Free models are **rate-limited and quality-variable**, and some return 404 unless you enable data-sharing at [openrouter.ai/settings/privacy](https://openrouter.ai/settings/privacy).
142
+
143
+ ### Council presets
144
+
145
+ Save your own named member lists with `amicus council save <name> --models a,b,c` (≥2 resolvable aliases or `provider/model` IDs), then run them with `--council <name>` anywhere a council runs. `amicus council list` shows saved presets plus three built-in benches that work with no setup at all — `free` (the same zero-cost dynamic pick described above, used when you haven't seeded `councils.free`), `budget` (cheap workhorses, one per vendor family), and `frontier` (premium flagships, one per vendor family). `amicus council show <name>` resolves any of them (saved or built-in) and reports which members are currently usable. A saved council always shadows a built-in of the same name — exactly how the wizard's `councils.free` seeding already worked.
146
+
147
+ ### Policy packs (v4.5)
148
+
149
+ A council preset only saves the bench. A **pack** saves the whole run — bench, chair, critic/lenses, cost/timeout options, and a briefing template — as one named, shareable JSON file:
150
+
151
+ ```bash
152
+ amicus pack save review-bench --kind council --bench gemini,deepseek,gpt --chair opus --timeout 20 --max-cost 2
153
+ amicus council run --pack review-bench --prompt-file plan.md --json
154
+ ```
155
+
156
+ Any flag you also type on that second line overrides just that value — a pack only fills in what you didn't say explicitly, and it's recorded on the run either way. Packs work the same way on `fanout`/`start` and on the `amicus_fanout`/`amicus_start`/`amicus_council_run` MCP tools. `amicus pack list`/`show`/`rm` manage them, and `--from-run <id>` builds one from a run you already liked instead of typing flags at all. Full reference: [docs/usage.md § Policy packs](./docs/usage.md#policy-packs).
157
+
158
+ ### Briefing templates (v4.5)
159
+
160
+ `--template <name> --artifact <file>` (plus repeatable `--var k=v`) renders a `{{prompt}}`/`{{artifact}}`-style Markdown template before it's sent, on `start`/`fanout`/`council run` alike — templates live in `~/.config/amicus/templates/`, and a pack's `briefing.template` is how one reaches an MCP-invoked run (MCP has no template param of its own). `amicus template list|show` manage them; v4.5 ships one built-in, `review`. Full reference: [docs/usage.md § Briefing templates](./docs/usage.md#briefing-templates).
161
+
72
162
  ---
73
163
 
74
164
  ## Quick start
@@ -152,6 +242,14 @@ amicus start --model gemini --prompt "Fact-check the auth approach Claude just p
152
242
 
153
243
  A window opens alongside your editor with Gemini ready, pre-loaded with your conversation. Work with it, then **Fold** the summary back.
154
244
 
245
+ ### Updating
246
+
247
+ Amicus checks the npm registry at most once every 24 hours (cached background check). When an update exists, the CLI prints a notice and the Electron toolbar shows a one-click **Update** banner. Or run it yourself:
248
+
249
+ ```bash
250
+ amicus update
251
+ ```
252
+
155
253
  ### Install from GitHub
156
254
 
157
255
  The npm package is the primary path. To install straight from the repo instead — the postinstall runs **identically** (same MCP registration, same two skills) — you just need `git` on your `PATH`:
@@ -204,82 +302,6 @@ Everything you need before your first run, and what's optional.
204
302
 
205
303
  ---
206
304
 
207
- ## The Council
208
-
209
- > Trigger it by saying *"council review this"* to Claude, or, on the plugin channel, run **`/amicus:council`** directly.
210
-
211
- **Why multi-model.** Any single model — including the one running your session — has consistent blind spots. Route the *same* material through models from *different* families and the disagreements surface: missed issues, overstated confidence, claims one model alone would have waved through. The council is the structured version of that idea.
212
-
213
- **The flow, in five beats:**
214
-
215
- 1. **Independent reviews.** Each council model reviews the artifact on its own (one parallel wave), producing a structured findings list — claim, severity (`blocker | major | minor | nit`), location, rationale.
216
- 2. **Anonymized cross-review.** Claude relabels every review (Review A, B, C…) and sends the identical bundle to every model. Each model ranks the reviews and adjudicates every finding (`agree | dispute | neutral`) — *unknowingly judging its own*, so self-bias washes out. This yields a **street-cred** ranking and sorts findings into **Disputed / Confirmed / Contested / Singleton** tiers.
217
- 3. **Chair verdict.** A designated **non-Claude** chair receives the de-anonymized picture — all reviews, rankings, and adjudications — and synthesizes an independent verdict. Claude presents it verbatim; Claude does not synthesize.
218
- 4. **Tiered decisions.** Confirmed findings get one bulk accept/deny; Contested and Singleton findings are decided one at a time (accept / deny / modify).
219
- 5. **Outputs applied.** Accepted findings are written into a reviewed copy of the source; the full run is captured in the run folder.
220
-
221
- **What a run produces** (in `output/<stem>-council/`):
222
-
223
- - `review-<model>.md` × N — each model's independent review.
224
- - `crossreview-matrix.md` — the adjudication grid plus the de-anonymized street-cred table.
225
- - `verdict.md` — the chair's synthesis.
226
- - `report.md` — synthesis + the full decision log + a per-call run-stats table.
227
- - `report.html` — the deterministic renderer output (adjudication matrix, street-cred table,
228
- findings-by-tier, cost — no chair prose). This is the default artifact handed to the user.
229
- - For an **editable source**, the accepted edits land in `<stem>-reviewed.<ext>` next to the original.
230
-
231
- **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.
232
-
233
- - **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.
234
- - **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.
235
- - **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.
236
- - **Chair verdict scale** — the chair closes with 3–5 hard questions and one parseable line: `VERDICT: Ship it | Fix these first | Fundamental rethink`.
237
- - **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.
238
-
239
- 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).
240
-
241
- **Cost is disclosed up front.** Before any model launches, you see the run shape — including any enabled optional elements — for example:
242
-
243
- > This run uses 3 council models across 2 fanout waves + 1 chair call, with critic seat + debate mode ON (~7 base runs + up to 6 rebuttal calls).
244
-
245
- Then the council waits for your confirmation.
246
-
247
- The skill lives at **[`skills/second-opinion/SKILL.md`](./skills/second-opinion/SKILL.md)**; the design spec behind it is **[`skills/second-opinion/COUNCIL-DESIGN.md`](./skills/second-opinion/COUNCIL-DESIGN.md)**. For what `amicus council tally|verdict|report|stats` actually take as input and produce — field-by-field schemas, verdict.json's provenance, and a full worked example run against the real CLI — see **[docs/council.md](./docs/council.md)**.
248
-
249
- **Headless council (CI).** The same pipeline runs with no Claude runtime at all: `amicus council
250
- run --prompt-file briefing.md --models gemini,glm --chair deepseek --json` executes the review
251
- waves, the anonymized cross-review, the tally, and the chair verdict in one command, and writes
252
- the full run directory (`verdict.json` with the chair's parsed `overallVerdict`, `report.html`,
253
- every review and judge output). That is what powers the repo's own **Council Review GitHub Action
254
- v2** — on PRs labeled `council-review` it posts an adjudicated verdict as a check run plus a
255
- sticky comment, uploads the run directory as an evidence artifact, and can optionally gate merges
256
- via its `fail_on` input (default: report-only). Reference: [docs/council.md](./docs/council.md#amicus-council-run).
257
-
258
- **Free council (zero-cost).** Want the cross-examination without the model spend? `amicus setup` offers a **Free OpenRouter council** mode — readline wizard option 2, and the Electron **Models** step. It detects the free `:free` models live from the catalog, lets you multi-pick (Enter takes a vendor-diverse default), and saves them as `councils.free` — a first-class `councils` config primitive seeded under collision-safe `free-*` aliases. Your `config.default` is left untouched, and all you need is an `OPENROUTER_API_KEY`.
259
-
260
- Run it anywhere a council runs:
261
-
262
- ```bash
263
- amicus fanout --council free --prompt "Review this design"
264
- ```
265
-
266
- The `amicus_fanout` MCP tool takes the same `council` parameter, and the `second-opinion` skill reads `councils.free` automatically. A member that gets delisted is dropped with a warning — the council still runs as long as ≥2 survive. Free models are **rate-limited and quality-variable**, and some return 404 unless you enable data-sharing at [openrouter.ai/settings/privacy](https://openrouter.ai/settings/privacy).
267
-
268
- **Council presets.** Save your own named member lists with `amicus council save <name> --models a,b,c` (≥2 resolvable aliases or `provider/model` IDs), then run them with `--council <name>` anywhere a council runs. `amicus council list` shows saved presets plus three built-in benches that work with no setup at all — `free` (the same zero-cost dynamic pick described above, used when you haven't seeded `councils.free`), `budget` (cheap workhorses, one per vendor family), and `frontier` (premium flagships, one per vendor family). `amicus council show <name>` resolves any of them (saved or built-in) and reports which members are currently usable. A saved council always shadows a built-in of the same name — exactly how the wizard's `councils.free` seeding already worked.
269
-
270
- **Policy packs (v4.5).** A council preset only saves the bench. A **pack** saves the whole run — bench, chair, critic/lenses, cost/timeout options, and a briefing template — as one named, shareable JSON file:
271
-
272
- ```bash
273
- amicus pack save review-bench --kind council --bench gemini,deepseek,gpt --chair opus --timeout 20 --max-cost 2
274
- amicus council run --pack review-bench --prompt-file plan.md --json
275
- ```
276
-
277
- Any flag you also type on that second line overrides just that value — a pack only fills in what you didn't say explicitly, and it's recorded on the run either way. Packs work the same way on `fanout`/`start` and on the `amicus_fanout`/`amicus_start`/`amicus_council_run` MCP tools. `amicus pack list`/`show`/`rm` manage them, and `--from-run <id>` builds one from a run you already liked instead of typing flags at all. Full reference: [docs/usage.md § Policy packs](./docs/usage.md#policy-packs).
278
-
279
- **Briefing templates (v4.5).** `--template <name> --artifact <file>` (plus repeatable `--var k=v`) renders a `{{prompt}}`/`{{artifact}}`-style Markdown template before it's sent, on `start`/`fanout`/`council run` alike — templates live in `~/.config/amicus/templates/`, and a pack's `briefing.template` is how one reaches an MCP-invoked run (MCP has no template param of its own). `amicus template list|show` manage them; v4.5 ships one built-in, `review`. Full reference: [docs/usage.md § Briefing templates](./docs/usage.md#briefing-templates).
280
-
281
- ---
282
-
283
305
  ## The parallel window
284
306
 
285
307
  When you don't need a full council — just one other model's take — fork a conversation. Amicus extracts your current Claude Code context, opens a session pre-loaded with it, you **work** alongside it, and you **fold** a structured summary back into Claude's context when you're done.
@@ -305,12 +327,6 @@ When you don't need a full council — just one other model's take — fork a co
305
327
 
306
328
  **Safety.** Amicus warns on **file conflicts** (a file changed externally while the session ran) and on **context drift** (the shared context may be stale relative to your current session), so a fold never silently overwrites newer work.
307
329
 
308
- **Auto-update.** Amicus checks the npm registry at most once every 24 hours (cached background check). When an update exists, the CLI prints a notice and the Electron toolbar shows a one-click **Update** banner. Or run it yourself:
309
-
310
- ```bash
311
- amicus update
312
- ```
313
-
314
330
  ![The parallel-window architecture: fork, work, fold](./docs/architecture.png)
315
331
 
316
332
  ---
@@ -332,7 +348,7 @@ amicus update
332
348
  | `amicus spend` | Cross-run cost rollup from the spend ledger, with per-run attribution — total + per-model spend, tokens, and source mix, most-expensive first (`--wave`/`--council`/`--project`/`--model`/`--op`/`--failed` filter it, `--group-by` buckets it, `--since 7d` windows it; `--json` for a versioned doc; shows remaining OpenRouter credit when a key is configured). |
333
349
  | `amicus key` | Manage API keys non-interactively: `amicus key <provider> <key>` saves after live validation; `--remove`; bare `amicus key` lists providers. |
334
350
  | `amicus provider` | Add/list/test/remove local, OpenAI-compatible providers (LM Studio, Ollama, vLLM) — configured with `--preset` or `--url`, at **$0** marginal cost (`--json` on every subcommand). |
335
- | `amicus council` | Council math: `tally <input.json>` (deterministic tiers + ledger append), `stats` (reviewer reliability), `report <verdict.json> [--md\|--html]`, `validate <file>` (findings-block check, exit 0/2/1), `verdict <tally.json> [--decisions <d.json>] [-o <out.json>]` (build + write verdict.json). Presets: `save <name> --models a,b,c`, `list [--json]`, `show <name> [--json]` — see [The Council](#the-council) for the built-in `free`/`budget`/`frontier` benches. |
351
+ | `amicus council` | Council math: `tally <input.json>` (deterministic tiers + ledger append), `stats` (reviewer reliability), `report <verdict.json> [--md\|--html]`, `validate <file>` (findings-block check, exit 0/2/1), `verdict <tally.json> [--decisions <d.json>] [-o <out.json>]` (build + write verdict.json). Presets: `save <name> --models a,b,c`, `list [--json]`, `show <name> [--json]` — see [Council presets](#council-presets) for the built-in `free`/`budget`/`frontier` benches. |
336
352
  | `amicus council run` | The headless council engine: Stage-1 reviews → anonymized cross-review → deterministic tally → non-Claude chair verdict, in one command with no Claude runtime. Add `--debate` for a Stage-2.5 rebuttal round (raisers defend/amend/withdraw, disputing judges re-vote) and `--claude-review <file>` to enter Claude's own review as judged review N+1. Writes a run directory with `verdict.json` (including `overallVerdict`) and `report.html` — see [docs/council.md](./docs/council.md#amicus-council-run). |
337
353
  | `amicus pack` | Save a full run configuration — bench, chair/critic/lenses, options, briefing template — and invoke it by name: `save <name> --kind council\|fanout\|solo [flags]` (or `--from-run <id>`), `list`, `show <name>`, `rm <name>`. `--pack <name>` on `start`/`fanout`/`council run` loads one; explicit flags always override it. See [docs/usage.md § Policy packs](./docs/usage.md#policy-packs). |
338
354
  | `amicus template` | `list`/`show <name>` a briefing template. `--template <name> [--artifact <file>] [--var k=v]` on `start`/`fanout`/`council run` renders one before the briefing is sent. See [docs/usage.md § Briefing templates](./docs/usage.md#briefing-templates). |
@@ -364,7 +380,7 @@ $ amicus status demo123 --json
364
380
  "taskId": "demo123",
365
381
  "status": "complete",
366
382
  "elapsed": "5m 0s",
367
- "version": "4.5.1",
383
+ "version": "4.5.2",
368
384
  "model": "google/gemini-2.5-flash",
369
385
  "phase": "terminal"
370
386
  }
@@ -474,7 +490,7 @@ Run `amicus doctor` first — it checks keys, catalog, OpenCode binary, Electron
474
490
  | `npm install -g amicus` fails with `EEXIST: … claude-sidecar` | The old upstream `claude-sidecar` package is still installed globally; npm won't overwrite another package's bin shims | `npm uninstall -g claude-sidecar`, then `npm install -g amicus`. Your keys and past sessions are not lost, but v2.0.0 no longer reads the old paths automatically — see [docs/SHIMS.md](./docs/SHIMS.md) for the one-time migration steps (rename `~/.config/sidecar/` and any `.claude/sidecar_sessions/` dirs). |
475
491
  | Install fails partway, or `amicus doctor` reports the OpenCode binary "not found" | A **transient** error during the OpenCode engine's own postinstall (a spawn `ENOENT`, or an antivirus file-lock while it lays down its 11 per-platform binaries) can roll back the whole atomic install — retrying usually succeeds | Just re-run `npm install -g amicus`. If it still fails, clear the cache first: `npm cache clean --force && npm install -g amicus`. |
476
492
  | `401` / auth error | No usable key for the model's vendor — bare `provider/model` ids fall back to `OPENROUTER_API_KEY` automatically, so this means neither the direct key nor an OpenRouter key is configured (or `--gateway direct`/`openrouter` forced a gateway whose key is missing) | Run `amicus setup`, or `amicus key <provider> <key>` to add the missing key; see [Routing](#routing). |
477
- | `402` / "Payment Required" on first council review / `start` / `fanout` call | Your OpenRouter key is real but has no credit. Key save (`amicus key openrouter <key>` or the setup wizard's key step) only checks that the key **authenticates** — it doesn't check balance, so a zero-credit key saves cleanly and only fails later, on the first real model call. (The `amicus council` subcommand itself is deterministic math and never calls a model.) | Add credit at [openrouter.ai/credits](https://openrouter.ai/credits), **or** switch to a zero-cost council: `amicus setup` → option 2 (Free OpenRouter council) builds one from live `:free`-suffixed models and saves it as `councils.free` — then run `amicus fanout --council free …`. See "Free council (zero-cost)" under [The Council](#the-council) above. |
493
+ | `402` / "Payment Required" on first council review / `start` / `fanout` call | Your OpenRouter key is real but has no credit. Key save (`amicus key openrouter <key>` or the setup wizard's key step) only checks that the key **authenticates** — it doesn't check balance, so a zero-credit key saves cleanly and only fails later, on the first real model call. (The `amicus council` subcommand itself is deterministic math and never calls a model.) | Add credit at [openrouter.ai/credits](https://openrouter.ai/credits), **or** switch to a zero-cost council: `amicus setup` → option 2 (Free OpenRouter council) builds one from live `:free`-suffixed models and saves it as `councils.free` — then run `amicus fanout --council free …`. See [Free council (zero-cost)](#free-council-zero-cost) above. |
478
494
  | Every direct `anthropic/…` model (`haiku`, `sonnet`, `opus`, `claude`) errors `Not Found` in ~2 s at zero tokens, but the same model works via `openrouter/anthropic/…` | An inherited `ANTHROPIC_BASE_URL` missing its `/v1` path segment. The engine appends only `/messages`, so requests hit `https://api.anthropic.com/messages` → HTTP 404 with an empty body → the bare status text. A shell spawned by Claude Code sets the `/v1`-less form for you. The model id, alias, and key are all fine. | `export ANTHROPIC_BASE_URL=https://api.anthropic.com/v1`, or unset it entirely, or pass `--gateway openrouter`. In a council a dead seat **degrades the run instead of failing it** — smoke-test each seat with one throwaway `amicus start` before paying for a council. See [docs/troubleshooting.md](./docs/troubleshooting.md#every-direct-anthropic-model-fails-with-not-found). |
479
495
  | `Model 'X' is unverified against the direct catalog; attempting anyway` for a model that plainly exists | Not a claim the model is wrong — amicus **couldn't check**. That vendor's direct catalog fetch failed (usually a stale or truncated stored key), leaving its namespace empty, and an empty namespace never blocks a launch. The engine may still run the model from its own credential store, so a working model warns forever. | `amicus models --refresh` and watch for a provider that stays empty; re-save the good key with `amicus key <provider> <apikey>`. See [docs/troubleshooting.md](./docs/troubleshooting.md#model-x-is-unverified-against-the-direct-catalog-attempting-anyway). |
480
496
  | Session not found | No session matches the given ID | Run `amicus list`, or omit `--session-id` to use the most recent. |
@@ -137,6 +137,22 @@ Legacy `SIDECAR_IDLE_TIMEOUT*` names were removed in v2.0.0 — rename to the `A
137
137
 
138
138
  Set `AMICUS_IDLE_TIMEOUT=0` to disable self-termination entirely.
139
139
 
140
+ ### Server startup
141
+
142
+ | Variable | Purpose | Default |
143
+ |----------|---------|---------|
144
+ | `AMICUS_SERVER_START_TIMEOUT_MS` | How long to wait for OpenCode to report it is listening before treating the start as failed. | `30000` on Windows, `15000` elsewhere |
145
+
146
+ A start that exceeds this window is treated as **transient** and retried on the same bounded schedule as an OpenCode database lock race (5 attempts, 250/500/1000/2000 ms), because retrying costs nothing but the backoff while a failed start costs a whole review seat.
147
+
148
+ Raise it if you see `Timeout waiting for server to start` on a slow box — a project directory on a sync-backed volume (OneDrive, Dropbox) with an antivirus scanner attached can push a cold OpenCode/SQLite start well past the default. Values of `0` or below are ignored rather than honored, since a zero start timeout fails every start instantly.
149
+
150
+ To see how much headroom you actually have, run with `LOG_LEVEL=debug` and look for the `OpenCode server started` line — it reports both `startMs` (what the start took) and `timeoutMs` (the ceiling it ran against):
151
+
152
+ ```json
153
+ {"level":"debug","msg":"OpenCode server started","startMs":561,"timeoutMs":30000}
154
+ ```
155
+
140
156
  ### Shared server
141
157
 
142
158
  The shared-server mode (`AMICUS_SHARED_SERVER=1`, which is the default) lets multiple Amicus sessions reuse a single OpenCode Go binary process rather than spawning one per invocation, eliminating cold-start latency on the second and subsequent calls. Disable it with `AMICUS_SHARED_SERVER=0` if you need per-process isolation or are diagnosing a crash loop.
package/docs/usage.md CHANGED
@@ -443,7 +443,7 @@ $ amicus status demo123 --json
443
443
  "taskId": "demo123",
444
444
  "status": "complete",
445
445
  "elapsed": "5m 0s",
446
- "version": "4.5.1",
446
+ "version": "4.5.2",
447
447
  "model": "google/gemini-2.5-flash",
448
448
  "phase": "terminal"
449
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
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": [
@@ -77,6 +77,7 @@
77
77
  "@modelcontextprotocol/sdk": "^1.27.0",
78
78
  "@opencode-ai/sdk": "^1.1.36",
79
79
  "dotenv": "^17.2.3",
80
+ "extract-zip": "^2.0.1",
80
81
  "opencode-ai": "^1.2.20",
81
82
  "tiktoken": "^1.0.0",
82
83
  "update-notifier": "^7.3.1",
@@ -93,7 +94,7 @@
93
94
  "jest": "^29.0.0",
94
95
  "lint-staged": "^16.3.2",
95
96
  "puppeteer": "^24.36.0",
96
- "sharp": "^0.33.5",
97
+ "sharp": "^0.35.3",
97
98
  "ws": "^8.19.0"
98
99
  },
99
100
  "engines": {
@@ -4,53 +4,207 @@
4
4
  "title": "amicus council-verdict document",
5
5
  "description": "Verdict record (`council verdict --json`, amicus_verdict, verdict.json). overallVerdict is the chair's parsed VERDICT line — null in every Stage-4 manual path, populated by the headless engine.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "type", "runId", "council", "overallVerdict", "findings", "streetCred", "runStats", "tierCounts"],
7
+ "required": [
8
+ "schemaVersion",
9
+ "type",
10
+ "runId",
11
+ "council",
12
+ "overallVerdict",
13
+ "findings",
14
+ "streetCred",
15
+ "runStats",
16
+ "tierCounts"
17
+ ],
8
18
  "properties": {
9
- "schemaVersion": { "const": 2 },
10
- "type": { "const": "council-verdict" },
11
- "runId": { "type": "string" },
12
- "runType": { "type": ["string", "null"] },
13
- "date": { "type": ["string", "null"] },
14
- "chair": { "type": ["string", "null"] },
15
- "council": { "type": "array", "items": { "type": "string" } },
16
- "claudeInCouncil": { "type": "boolean" },
19
+ "schemaVersion": {
20
+ "const": 2
21
+ },
22
+ "type": {
23
+ "const": "council-verdict"
24
+ },
25
+ "runId": {
26
+ "type": "string"
27
+ },
28
+ "runType": {
29
+ "type": [
30
+ "string",
31
+ "null"
32
+ ]
33
+ },
34
+ "date": {
35
+ "type": [
36
+ "string",
37
+ "null"
38
+ ]
39
+ },
40
+ "chair": {
41
+ "type": [
42
+ "string",
43
+ "null"
44
+ ]
45
+ },
46
+ "council": {
47
+ "type": "array",
48
+ "items": {
49
+ "type": "string"
50
+ }
51
+ },
52
+ "claudeInCouncil": {
53
+ "type": "boolean"
54
+ },
17
55
  "overallVerdict": {
18
56
  "oneOf": [
19
- { "enum": ["Ship it", "Fix these first", "Fundamental rethink"] },
20
- { "type": "null" }
57
+ {
58
+ "enum": [
59
+ "Ship it",
60
+ "Fix these first",
61
+ "Fundamental rethink"
62
+ ]
63
+ },
64
+ {
65
+ "type": "null"
66
+ }
21
67
  ]
22
68
  },
23
69
  "findings": {
24
70
  "type": "array",
25
71
  "items": {
26
72
  "type": "object",
27
- "required": ["id", "tier", "decision", "applied"],
73
+ "required": [
74
+ "id",
75
+ "tier",
76
+ "decision",
77
+ "applied"
78
+ ],
28
79
  "properties": {
29
- "id": { "type": "string" },
30
- "raiser": { "type": ["string", "null"] },
31
- "severity": { "type": ["string", "null"] },
32
- "tier": { "enum": ["Confirmed", "Contested", "Singleton", "Disputed"] },
33
- "basis": { "type": "object" },
34
- "confidence": { "enum": ["thin", "solid"] },
35
- "tierOverride": { "type": ["object", "null"] },
36
- "duplicateOf": { "type": ["string", "null"] },
37
- "adjudications": { "type": "array" },
38
- "decision": { "type": ["string", "null"] },
39
- "applied": { "type": "boolean" },
80
+ "id": {
81
+ "type": "string"
82
+ },
83
+ "raiser": {
84
+ "type": [
85
+ "string",
86
+ "null"
87
+ ]
88
+ },
89
+ "severity": {
90
+ "type": [
91
+ "string",
92
+ "null"
93
+ ]
94
+ },
95
+ "tier": {
96
+ "enum": [
97
+ "Confirmed",
98
+ "Contested",
99
+ "Singleton",
100
+ "Disputed"
101
+ ]
102
+ },
103
+ "basis": {
104
+ "type": "object"
105
+ },
106
+ "confidence": {
107
+ "enum": [
108
+ "thin",
109
+ "solid"
110
+ ]
111
+ },
112
+ "tierOverride": {
113
+ "type": [
114
+ "object",
115
+ "null"
116
+ ]
117
+ },
118
+ "duplicateOf": {
119
+ "type": [
120
+ "string",
121
+ "null"
122
+ ]
123
+ },
124
+ "adjudications": {
125
+ "type": "array"
126
+ },
127
+ "decision": {
128
+ "type": [
129
+ "string",
130
+ "null"
131
+ ]
132
+ },
133
+ "applied": {
134
+ "type": "boolean"
135
+ },
40
136
  "debate": {
41
137
  "type": "object",
42
138
  "properties": {
43
- "action": { "enum": ["defended", "amended", "withdrawn", "no-response"] },
44
- "previousTier": { "type": ["string", "null"] }
139
+ "action": {
140
+ "enum": [
141
+ "defended",
142
+ "amended",
143
+ "withdrawn",
144
+ "no-response"
145
+ ]
146
+ },
147
+ "previousTier": {
148
+ "type": [
149
+ "string",
150
+ "null"
151
+ ]
152
+ }
45
153
  },
46
- "required": ["action"],
154
+ "required": [
155
+ "action"
156
+ ],
47
157
  "additionalProperties": false
48
158
  }
49
159
  }
50
160
  }
51
161
  },
52
- "streetCred": { "type": "array", "items": { "type": "object" } },
53
- "runStats": { "type": "array", "items": { "type": "object" } },
54
- "tierCounts": { "type": "object" }
162
+ "streetCred": {
163
+ "type": "array",
164
+ "items": {
165
+ "type": "object"
166
+ }
167
+ },
168
+ "runStats": {
169
+ "type": "array",
170
+ "items": {
171
+ "type": "object"
172
+ }
173
+ },
174
+ "tierCounts": {
175
+ "type": "object"
176
+ },
177
+ "seatLoss": {
178
+ "type": "object",
179
+ "description": "Present only when --critic was requested. Records whether the adversarial seat actually reviewed: a dead critic wave is survivable (the quorum gate guards only the bench), so a run can otherwise reach a full verdict with the critic silently absent.",
180
+ "properties": {
181
+ "criticRequested": {
182
+ "type": "string",
183
+ "description": "The model asked for as critic."
184
+ },
185
+ "criticSeated": {
186
+ "type": "boolean",
187
+ "description": "False when the critic wave died before producing legs."
188
+ },
189
+ "reason": {
190
+ "type": [
191
+ "string",
192
+ "null"
193
+ ],
194
+ "description": "Why the critic wave died, when it did."
195
+ },
196
+ "deadBenchSeats": {
197
+ "type": "array",
198
+ "items": {
199
+ "type": "string"
200
+ },
201
+ "description": "Bench models lost to dead waves, excluding the critic."
202
+ }
203
+ },
204
+ "required": [
205
+ "criticRequested",
206
+ "criticSeated"
207
+ ]
208
+ }
55
209
  }
56
210
  }
@@ -19,7 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { writeFileAtomic } = require('../utils/atomic-write');
22
- const { buildVerdict, writeVerdictAtomic } = require('./verdict');
22
+ const { buildVerdict, summarizeSeatLoss, writeVerdictAtomic } = require('./verdict');
23
23
  const { buildReport } = require('./report');
24
24
  const { validateFindings } = require('./findings');
25
25
  const { toGlobalFindings } = require('./anonymize');
@@ -178,8 +178,12 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
178
178
  * buildVerdict's own signature.
179
179
  * @returns {object} the verdict written to disk
180
180
  */
181
- function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
182
- const verdict = buildVerdict(record, []);
181
+ function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves }) {
182
+ // v4.5.2 — computed here rather than in run.js so verdict assembly stays in
183
+ // one place; see summarizeSeatLoss in ./verdict for why a lost critic has to
184
+ // reach the verdict at all.
185
+ const seatLoss = summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
186
+ const verdict = buildVerdict(record, [], { seatLoss });
183
187
  verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
184
188
  writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
185
189
  const html = buildReport({ verdict }, { format: 'html' });
@@ -284,7 +284,8 @@ async function runCouncil(options, deps = {}) {
284
284
  runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
285
285
  emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
286
286
  emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
287
- asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
287
+ asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
288
+ critic: o.critic, deadWaves });
288
289
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
289
290
  emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
290
291
  emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
@@ -16,6 +16,44 @@ const VERDICT_SCHEMA_VERSION = 2;
16
16
  * @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
17
17
  * parsed chair `VERDICT:` line; omitted/undefined → null.
18
18
  */
19
+ /**
20
+ * Describe which requested seats actually reviewed, for the verdict's own face.
21
+ *
22
+ * ⚠️ ADDED v4.5.2 from a field report. The critic is a SOLO wave with one leg,
23
+ * so losing it loses 100% of the adversarial role — and unlike a dead bench wave
24
+ * (which trips the quorum gate and fails the run loudly) a dead critic is
25
+ * survivable, so the run continues to a full verdict, tally and chair synthesis
26
+ * that never saw the critic's findings. Run `dfb6a692` did exactly that and the
27
+ * only record was `deadWaves` in run.json, a file nobody opens when the verdict
28
+ * reads clean. A user who typed `--critic` asked for adversarial review; a
29
+ * verdict produced without it must say so where the verdict is read.
30
+ *
31
+ * Returns null when no critic was requested — there is nothing to report, and an
32
+ * always-present block would train readers to ignore it.
33
+ *
34
+ * @param {{runId: string, critic: ?string,
35
+ * deadWaves: Array<{waveId: string, models: string[], reason: string}>}} o
36
+ * @returns {?{criticRequested: string, criticSeated: boolean, reason: ?string,
37
+ * deadBenchSeats: string[]}}
38
+ */
39
+ function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
40
+ if (!critic) { return null; }
41
+ // Match on EITHER carrier. The `-c1` suffix is the convention run-stages.js
42
+ // uses, but a wave that names the critic model is the critic wave whatever it
43
+ // is called — and relying on the id alone would silently under-report if that
44
+ // convention ever changes.
45
+ const isCriticWave = w =>
46
+ w.waveId === `${runId}-c1` || (w.models || []).includes(critic);
47
+ const dead = deadWaves.find(isCriticWave) || null;
48
+ return {
49
+ criticRequested: critic,
50
+ criticSeated: !dead,
51
+ reason: dead ? dead.reason : null,
52
+ deadBenchSeats: deadWaves.filter(w => !isCriticWave(w))
53
+ .flatMap(w => w.models || []),
54
+ };
55
+ }
56
+
19
57
  function buildVerdict(record, decisions = [], opts = {}) {
20
58
  const byId = new Map(decisions.map(d => [d.id, d]));
21
59
  return {
@@ -46,6 +84,9 @@ function buildVerdict(record, decisions = [], opts = {}) {
46
84
  streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
47
85
  runStats: record.runStats,
48
86
  tierCounts: record.tierCounts,
87
+ // Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
88
+ // was requested, so its absence never has to be interpreted.
89
+ ...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
49
90
  };
50
91
  }
51
92
 
@@ -93,4 +134,6 @@ function writeVerdictAtomic(filePath, verdict) {
93
134
  fs.renameSync(tmp, filePath);
94
135
  }
95
136
 
96
- module.exports = { buildVerdict, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
137
+ module.exports = {
138
+ buildVerdict, summarizeSeatLoss, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
139
+ };
package/src/headless.js CHANGED
@@ -272,6 +272,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
272
272
  if (options.mcp) {
273
273
  serverOptions.mcp = options.mcp;
274
274
  }
275
+ // v4.5.2: explicit per-call override only — see the note at the matching
276
+ // hop in src/sidecar/session-utils.js. The default and the env knob both
277
+ // resolve downstream in buildServerOptions.
278
+ if (options.timeout !== undefined) {
279
+ serverOptions.timeout = options.timeout;
280
+ }
275
281
  // v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
276
282
  // startServer directly rather than going through startOpenCodeServer, so
277
283
  // the lock-class retry added for the concurrent-start race never covered
@@ -402,11 +402,67 @@ async function getSessionStatus(client, sessionId, directory) {
402
402
  return result.data || {};
403
403
  }
404
404
 
405
+ /**
406
+ * How long to wait for OpenCode to announce it is listening, per platform.
407
+ *
408
+ * ⚠️ ADDED v4.5.2 from a field report. `@opencode-ai/sdk` defaults this to
409
+ * 5000ms (`dist/server.js:4-8`) and lets the caller override it; amicus never
410
+ * passed one, so every start on every platform ran on the SDK's 5s — untunable
411
+ * and invisible to `amicus doctor`. A reporter's Windows box (project on a
412
+ * OneDrive-synced volume, Defender active) blew through it on a cold
413
+ * OpenCode/SQLite open: the council's shared server failed to acquire, the run
414
+ * degraded to the per-wave configuration `src/council/run-server.js` exists to
415
+ * eliminate, and the whole Stage-1 bench died (`COUNCIL_QUORUM: Only 0 …`).
416
+ *
417
+ * The asymmetry decides the number: a slow start costs LATENCY, a failed start
418
+ * costs a REVIEW SEAT. win32 gets the widest window because that is where
419
+ * sync-backed volumes and always-on AV filter drivers are the norm.
420
+ *
421
+ * This is a ceiling, not a sleep — a healthy start still resolves in well under
422
+ * a second and pays none of it.
423
+ */
424
+ const SERVER_START_TIMEOUT_MS = Object.freeze({ win32: 30000, default: 15000 });
425
+
426
+ /**
427
+ * Resolve the start timeout: explicit option → env override → platform default.
428
+ *
429
+ * `0` and negatives are REJECTED rather than honored. For most amicus knobs `0`
430
+ * is a documented disable switch (see src/utils/env-num.js), but a 0ms start
431
+ * timeout disables nothing — it fails every start instantly. That is an own-goal
432
+ * an operator can only reach by accident, so it falls back to the default.
433
+ *
434
+ * @param {object} [options] - Server options ({timeout} respected if positive)
435
+ * @param {object} [env] - Environment (test seam; defaults to process.env)
436
+ * @param {string} [platform] - Platform (test seam; defaults to process.platform)
437
+ * @returns {number} milliseconds
438
+ */
439
+ function resolveServerStartTimeoutMs(options = {}, env, platform) {
440
+ const plat = platform || process.platform;
441
+ const dflt = SERVER_START_TIMEOUT_MS[plat] || SERVER_START_TIMEOUT_MS.default;
442
+ const positive = (v) => {
443
+ const n = Number(v);
444
+ return Number.isFinite(n) && n > 0 ? n : null;
445
+ };
446
+ if (options.timeout !== undefined) {
447
+ const explicit = positive(options.timeout);
448
+ if (explicit) { return explicit; }
449
+ }
450
+ const raw = (env || process.env).AMICUS_SERVER_START_TIMEOUT_MS;
451
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
452
+ const fromEnv = positive(raw);
453
+ if (fromEnv) { return fromEnv; }
454
+ }
455
+ return dflt;
456
+ }
457
+
405
458
  /**
406
459
  * Build the server options object for createOpencodeServer.
407
460
  * Extracted for testability (no SDK dependency).
408
461
  *
409
462
  * @param {object} [options] - Server options
463
+ * @param {number} [options.timeout] - Start timeout in ms. Omit to use
464
+ * AMICUS_SERVER_START_TIMEOUT_MS or the platform default; NEVER omitted from
465
+ * the object handed to the SDK, so the SDK's own 5000ms default is unreachable.
410
466
  * @param {number} [options.port] - Port to run on
411
467
  * @param {string} [options.hostname='127.0.0.1'] - Hostname to bind to
412
468
  * @param {AbortSignal} [options.signal] - Abort signal to stop server
@@ -544,6 +600,9 @@ function buildServerOptions(options = {}) {
544
600
 
545
601
  const serverOptions = {
546
602
  hostname: options.hostname || '127.0.0.1',
603
+ // ALWAYS set — unlike port/signal below, an omitted timeout is not a
604
+ // harmless "let the SDK decide", it is the 5000ms that cost a bench.
605
+ timeout: resolveServerStartTimeoutMs(options),
547
606
  };
548
607
 
549
608
  // Only include port/signal when explicitly set — passing undefined
@@ -660,11 +719,27 @@ async function startServer(options = {}) {
660
719
  }
661
720
  }
662
721
 
663
- const createOpencodeServer = await getCreateOpencodeServer();
722
+ // `_createOpencodeServer` is a test seam, matching `_hasOpencodeBinary` /
723
+ // `_ensureEngine` / `_opencodeRoots` above: the SDK arrives through a dynamic
724
+ // `import()`, which `jest.mock` cannot intercept under CommonJS, so the start
725
+ // path is otherwise unreachable from a unit test.
726
+ const createOpencodeServer = options._createOpencodeServer
727
+ || await getCreateOpencodeServer();
664
728
  const serverOptions = buildServerOptions(options);
665
729
 
730
+ // Measure the healthy path. The v4.5.2 timeout had to be sized from the
731
+ // asymmetry of the failure (a slow start costs latency, a failed one costs a
732
+ // review seat) because nothing recorded how long a GOOD start takes — so the
733
+ // margin against the ceiling was unmeasurable on exactly the slow boxes that
734
+ // needed it. Now it is one debug line, not an inference.
735
+ const startedAt = Date.now();
666
736
  const sdkServer = await createOpencodeServer(serverOptions);
667
- const client = await createClient(sdkServer.url);
737
+ const { logger } = require('./utils/logger');
738
+ logger.debug('OpenCode server started', {
739
+ startMs: Date.now() - startedAt,
740
+ timeoutMs: serverOptions.timeout,
741
+ });
742
+ const client = await (options._createClient || createClient)(sdkServer.url);
668
743
 
669
744
  // Capture the Go server PID once so close() can force-kill it cross-platform
670
745
  // (F3 #15). Prefer a PID the SDK exposes; fall back to the port listener.
@@ -822,6 +897,8 @@ module.exports = {
822
897
  abortSession,
823
898
  checkHealth,
824
899
  buildServerOptions,
900
+ resolveServerStartTimeoutMs,
901
+ SERVER_START_TIMEOUT_MS,
825
902
  buildServerHandle,
826
903
  startServer,
827
904
  loadMcpConfig,
@@ -253,6 +253,10 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
253
253
  if (options.models) { serverOptions.models = options.models; }
254
254
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
255
255
  if (options.agentName) { serverOptions.agentName = options.agentName; }
256
+ // Explicit per-call override only. Unset is the normal case and is correct:
257
+ // buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
258
+ // default downstream, so forwarding `undefined` here would change nothing.
259
+ if (options.timeout !== undefined) { serverOptions.timeout = options.timeout; }
256
260
 
257
261
  // v4.4.1 Task 0.5: a LOCK-CLASS start failure is retried (5 attempts,
258
262
  // 250/500/1000/2000ms — widened from 3/750ms by Step 10.5, see server-setup).
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
173
173
  deps = {},
174
174
  } = opts;
175
175
  const fs = deps.fs || fsDefault;
176
- const extractZip = deps.extractZip || require('extract-zip');
176
+ // GUARDED (v4.5.2). This `require` used to be bare, and `extract-zip` was
177
+ // never declared in dependencies — it resolved in the dev tree only because
178
+ // `puppeteer` (a devDependency) pulls it transitively, so a published install
179
+ // threw MODULE_NOT_FOUND here and took the WHOLE function with it: the native
180
+ // fallback below, the bounded idle/max timers, and `doctor --fix` all became
181
+ // unreachable. The dependency is now declared, so this should never fire —
182
+ // but Strategy 1 being unavailable is precisely what the native strategies
183
+ // exist for, so it must degrade into them rather than out of the function.
184
+ let extractZip = deps.extractZip;
185
+ if (!extractZip) {
186
+ try {
187
+ extractZip = require('extract-zip');
188
+ } catch (e) {
189
+ extractZip = () => { throw new Error(`extract-zip unavailable: ${e.message}`); };
190
+ }
191
+ }
177
192
  const spawn = deps.spawn || spawnSync;
178
193
  const setTimer = deps.setTimeout || setTimeout;
179
194
  const clearTimer = deps.clearTimeout || clearTimeout;
@@ -89,6 +89,28 @@ function ensurePortAvailable(port = DEFAULT_PORT) {
89
89
  */
90
90
  const LOCK_CLASS_START_FAILURE = /database is locked|database table is locked|SQLITE_BUSY/i;
91
91
 
92
+ /**
93
+ * A start failure that is a TIMEOUT, not a deterministic error.
94
+ *
95
+ * `@opencode-ai/sdk` rejects with `Timeout waiting for server to start after
96
+ * ${timeout}ms` when OpenCode has not printed its listening line inside the
97
+ * caller-supplied window (SDK default: 5000ms — see AMICUS_SERVER_START_TIMEOUT_MS
98
+ * in src/opencode-client.js for why amicus no longer accepts that default).
99
+ *
100
+ * ⚠️ ADDED v4.5.2 from a field report. A start timeout is TRANSIENT — a cold
101
+ * SQLite open on a sync-backed volume with an AV scanner attached simply takes
102
+ * longer than the window — and is therefore *more* retryable than a lock race,
103
+ * since retrying costs nothing but the backoff. Before this, it matched no
104
+ * alternative in LOCK_CLASS_START_FAILURE and so fell straight through
105
+ * `retryOnLockRace` with ZERO retries. A reporter's council degraded to per-wave
106
+ * servers on this error and then lost its entire Stage-1 bench
107
+ * (`COUNCIL_QUORUM: Only 0 Stage-1 review(s) survived`).
108
+ *
109
+ * Deliberately anchored to "…server to start". A REQUEST timeout, an ETIMEDOUT
110
+ * connect, and a generic "timeout" are NOT this class and must not sleep here.
111
+ */
112
+ const TIMEOUT_CLASS_START_FAILURE = /Timeout waiting for server to start/i;
113
+
92
114
  /**
93
115
  * Backoff between start attempts; 5 attempts total, ≤3.75s of added latency.
94
116
  *
@@ -109,12 +131,48 @@ const LOCK_RETRY_DELAYS_MS = [250, 500, 1000, 2000];
109
131
  * @returns {boolean} true only for a lock-class (retryable) start failure
110
132
  */
111
133
  function isLockClassStartFailure(error) {
134
+ return matchesStartFailure(error, LOCK_CLASS_START_FAILURE);
135
+ }
136
+
137
+ /**
138
+ * @param {Error|null} error
139
+ * @returns {boolean} true only for a timeout-class (retryable) start failure
140
+ */
141
+ function isTimeoutClassStartFailure(error) {
142
+ return matchesStartFailure(error, TIMEOUT_CLASS_START_FAILURE);
143
+ }
144
+
145
+ /**
146
+ * The union the retry actually applies to: lock-class OR timeout-class.
147
+ *
148
+ * Kept separate from the two predicates so each class keeps its own narrow,
149
+ * accurate meaning — `isLockClassStartFailure` still answers "was this a lock
150
+ * race?" and nothing else, so its docblock does not quietly become a lie.
151
+ *
152
+ * @param {Error|null} error
153
+ * @returns {boolean} true for any transient (retryable) start failure
154
+ */
155
+ function isRetryableStartFailure(error) {
156
+ return isLockClassStartFailure(error) || isTimeoutClassStartFailure(error);
157
+ }
158
+
159
+ /**
160
+ * Test `pattern` against every carrier an error might arrive on.
161
+ *
162
+ * The real failure arrives as a message with the server's own stdout inlined
163
+ * ("Server exited with code 1 / Server output: … database is locked"), and
164
+ * amicus prefixes it again at the fanout boundary (`Failed to start server:
165
+ * …`), so check the usual carriers too — a wrapped/spawn-shaped error still
166
+ * has to match.
167
+ *
168
+ * @param {Error|null} error
169
+ * @param {RegExp} pattern
170
+ * @returns {boolean}
171
+ */
172
+ function matchesStartFailure(error, pattern) {
112
173
  if (!error) { return false; }
113
- // The real failure arrives as a message with the server's own stdout inlined
114
- // ("Server exited with code 1 / Server output: … database is locked"), but
115
- // check the usual carriers too so a wrapped/spawn-shaped error still matches.
116
174
  const carriers = [error.message, error.stderr, error.stdout, error.cause && error.cause.message];
117
- return carriers.some(c => typeof c === 'string' && LOCK_CLASS_START_FAILURE.test(c));
175
+ return carriers.some(c => typeof c === 'string' && pattern.test(c));
118
176
  }
119
177
 
120
178
  /**
@@ -141,9 +199,16 @@ async function retryOnLockRace(attempt, opts = {}) {
141
199
  try {
142
200
  return await attempt(i);
143
201
  } catch (error) {
144
- if (i >= delays.length || !isLockClassStartFailure(error)) { throw error; }
145
- logger.warn('OpenCode server start lost a lock race — retrying', {
146
- attempt: i + 1, of: delays.length + 1, delayMs: delays[i], error: error.message,
202
+ if (i >= delays.length || !isRetryableStartFailure(error)) { throw error; }
203
+ logger.warn('OpenCode server start failed transiently — retrying', {
204
+ attempt: i + 1,
205
+ of: delays.length + 1,
206
+ delayMs: delays[i],
207
+ // Name WHICH transient class fired: a run that retried on `timeout`
208
+ // wants a bigger AMICUS_SERVER_START_TIMEOUT_MS, one that retried on
209
+ // `lock` wants less concurrency. Same retry, different operator action.
210
+ failureClass: isLockClassStartFailure(error) ? 'lock' : 'timeout',
211
+ error: error.message,
147
212
  });
148
213
  await new Promise(resolve => setTimeout(resolve, delays[i]));
149
214
  }
@@ -158,5 +223,7 @@ module.exports = {
158
223
  killPortProcess,
159
224
  ensurePortAvailable,
160
225
  isLockClassStartFailure,
226
+ isTimeoutClassStartFailure,
227
+ isRetryableStartFailure,
161
228
  retryOnLockRace
162
229
  };