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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -0
- package/README.md +105 -89
- package/docs/configuration.md +16 -0
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-verdict.schema.json +183 -29
- package/src/council/run-assemble.js +7 -3
- package/src/council/run.js +2 -1
- package/src/council/verdict.js +44 -1
- package/src/headless.js +6 -0
- package/src/opencode-client.js +79 -2
- package/src/sidecar/session-utils.js +4 -0
- package/src/sidecar/unzip.js +16 -1
- package/src/utils/server-setup.js +74 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.5.
|
|
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
|
[](https://nodejs.org)
|
|
16
16
|
[](./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 [
|
|
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
|
|
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](
|
|
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
|

|
|
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 [
|
|
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.
|
|
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
|
|
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. |
|
package/docs/configuration.md
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.5.
|
|
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.
|
|
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": [
|
|
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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
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
|
-
{
|
|
20
|
-
|
|
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": [
|
|
73
|
+
"required": [
|
|
74
|
+
"id",
|
|
75
|
+
"tier",
|
|
76
|
+
"decision",
|
|
77
|
+
"applied"
|
|
78
|
+
],
|
|
28
79
|
"properties": {
|
|
29
|
-
"id": {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
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": {
|
|
44
|
-
|
|
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": [
|
|
154
|
+
"required": [
|
|
155
|
+
"action"
|
|
156
|
+
],
|
|
47
157
|
"additionalProperties": false
|
|
48
158
|
}
|
|
49
159
|
}
|
|
50
160
|
}
|
|
51
161
|
},
|
|
52
|
-
"streetCred": {
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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' });
|
package/src/council/run.js
CHANGED
|
@@ -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);
|
package/src/council/verdict.js
CHANGED
|
@@ -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 = {
|
|
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
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
|
|
|
173
173
|
deps = {},
|
|
174
174
|
} = opts;
|
|
175
175
|
const fs = deps.fs || fsDefault;
|
|
176
|
-
|
|
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' &&
|
|
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 || !
|
|
145
|
-
logger.warn('OpenCode server start
|
|
146
|
-
attempt: i + 1,
|
|
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
|
};
|