amicus 4.2.1 → 4.3.0
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 +14 -1
- package/README.md +7 -4
- package/bin/amicus.js +5 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +33 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +24 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/src/cli-handlers-council-run.js +61 -5
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +62 -27
- package/src/cli-handlers-watch.js +89 -0
- package/src/cli.js +58 -1
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +14 -1
- package/src/council/run-stages.js +13 -0
- package/src/council/run.js +32 -4
- package/src/headless.js +9 -1
- package/src/mcp-council-awareness.js +46 -1
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +38 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +149 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +58 -26
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +50 -30
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/spend-query.js +104 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/spend-ledger.js +24 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
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,7 +3,20 @@
|
|
|
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
|
-
## [
|
|
6
|
+
## [4.3.0] - 2026-07-24
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Observability data layer.** Three file surfaces every consumer polls, no push/IPC/`fs.watch` anywhere: the existing durable snapshots (`metadata.json`/`progress.json`/`wave.json`/council `run.json`, all additively extended), a new append-only `events.jsonl` milestone stream per wave dir / council-run dir, and the composed live doc (the `amicus_status` rollup, stamped `view:'live'` with per-leg read-time `usage`).
|
|
11
|
+
- **`amicus watch <id>`** — live-render any fan-out wave, council run, or session from any terminal, reading only the data layer above (no attach): an in-place refresh table on a TTY, milestone lines (`--plain` / non-TTY), or NDJSON (`--json`). `--interval` controls the poll rate (default 2s, floor 0.5s); exit code maps the run's terminal state (`complete`→0, `partial`→2, else 1). `--ui` registers the flag for the v4.4 Council Workspace GUI (rejects `--json`) — the GUI itself is not shipped in this release.
|
|
12
|
+
- **`--follow` on `fanout` and `council run`** — stream a run's own milestone events to stderr as they happen; stdout's `--json`/human contracts stay byte-identical. On `council run`, `--follow` covers the run's own lifecycle and each stage's boundaries, not the per-leg events inside a stage's internal fan-out sub-wave.
|
|
13
|
+
- **`--on-complete` hook.** CLI: runs a user-authored shell command once a wave/council run reaches a terminal state, with the payload carried via 8 environment variables (`AMICUS_TASK_ID`, `AMICUS_TYPE`, `AMICUS_STATUS`, `AMICUS_EXIT_CODE`, `AMICUS_RESULT_FILE`, `AMICUS_EVENTS_FILE`, `AMICUS_COST`, `AMICUS_PROJECT`) — ids/paths only, never model-generated text; exit-isolated from the run (a non-zero exit or a 60s timeout is a warning only). MCP: only `onComplete: "mcp-notify"` is accepted, a best-effort advisory notification — `exec` is never exposed over MCP.
|
|
14
|
+
- **Failed-leg resilience.** `fanout --retry-failed <waveId>` relaunches only a wave's terminal, non-complete legs as a new linked wave (byte-identical retry from each leg's saved context; `--models` filters which legs retry; the original `wave.json` is never touched). `--fallback` / `--no-fallback` opt into per-leg cheaper-model substitution, off by default, triggered only by a classified rate-limit/overload failure (never timeout or auth) and always recorded loudly (a `leg-fallback` event, an `attempts[]` array, a `fallback` block on the final doc).
|
|
15
|
+
- **Spend visibility & attribution.** `continue`/`resume`/council rows are now recorded in the spend ledger, not just `start`/`fanout` legs, and every row carries attribution (`op`/`status`/`waveId`/`councilRunId`/`councilName`/`project`/`gateway`, plus fallback/retry linkage). `amicus spend` grows a full query surface — `--wave`/`--council`/`--project`/`--model`/`--op`/`--failed`/`--group-by <model|wave|council|project|op|day>`/`--rows` — plus a `wasted` rollup (both `--failed` and `wasted` deliberately exclude rows with no recorded status at all, so a pre-v4.3 ledger row is never counted as a failure that was never actually recorded). A new read-only `amicus_spend` MCP tool (16th tool) mirrors the same flags for MCP-only hosts.
|
|
16
|
+
|
|
17
|
+
### Notes
|
|
18
|
+
|
|
19
|
+
- All additive: no schema-breaking changes to v4.0 artifacts. `SPEND_LEDGER_SCHEMA_VERSION` stays `1`; both JSONL ledgers (`spend-ledger.jsonl`, `council-ledger.jsonl`) remain internal, non-envelope files, not published docs. A wave dir / council-run dir now also contains `events.jsonl`.
|
|
7
20
|
|
|
8
21
|
## [4.2.1] - 2026-07-23
|
|
9
22
|
|
package/README.md
CHANGED
|
@@ -45,12 +45,13 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
|
|
|
45
45
|
|
|
46
46
|
## What is Amicus
|
|
47
47
|
|
|
48
|
-
One install delivers
|
|
48
|
+
One install delivers five things that work together:
|
|
49
49
|
|
|
50
50
|
- **The `second-opinion` LLM Council skill.** Structured multi-model review: independent reviews → anonymized peer cross-review → a non-Claude chair verdict → tiered accept/deny decisions. This is the hero.
|
|
51
51
|
- **The `sidecar` chat skill.** Ad-hoc fork/work/fold — spin up one other model in a real window (or headless), work alongside it, fold the summary back.
|
|
52
52
|
- **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
53
|
- **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
|
+
- **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.
|
|
54
55
|
|
|
55
56
|
Claude is the orchestrator. The council and chat skills run *on top of* the engine; you talk to Claude, and Claude drives Amicus.
|
|
56
57
|
|
|
@@ -313,9 +314,10 @@ amicus update
|
|
|
313
314
|
| `amicus continue` | Start a new session building on a previous one. |
|
|
314
315
|
| `amicus read` | Output a session's summary / conversation / metadata. |
|
|
315
316
|
| `amicus status <id>` | One-shot status for a session or fan-out wave (human or `--json`; `--wave <id>` alternative spelling). |
|
|
317
|
+
| `amicus watch <id>` | Live-render a fan-out wave, council run, or session from any terminal until it finishes — in-place table on a TTY, `--plain` milestone lines, or `--json` NDJSON; exit code maps the terminal state. |
|
|
316
318
|
| `amicus models` | List, search, refresh the catalog, or audit aliases. |
|
|
317
319
|
| `amicus doctor` | Diagnose your setup — keys, default model, catalog, aliases, OpenCode binary, Electron, skills, MCP registration, OpenRouter credit (`--json`; `--fix` self-heals what it can). |
|
|
318
|
-
| `amicus spend` | Cross-run cost rollup from the spend ledger — total + per-model spend, tokens, and source mix, most-expensive first (`--since 7d` windows it; `--json` for a versioned doc; shows remaining OpenRouter credit when a key is configured). |
|
|
320
|
+
| `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). |
|
|
319
321
|
| `amicus key` | Manage API keys non-interactively: `amicus key <provider> <key>` saves after live validation; `--remove`; bare `amicus key` lists providers. |
|
|
320
322
|
| `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). |
|
|
321
323
|
| `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. |
|
|
@@ -348,7 +350,7 @@ $ amicus status demo123 --json
|
|
|
348
350
|
"taskId": "demo123",
|
|
349
351
|
"status": "complete",
|
|
350
352
|
"elapsed": "5m 0s",
|
|
351
|
-
"version": "4.
|
|
353
|
+
"version": "4.3.0",
|
|
352
354
|
"model": "google/gemini-2.5-flash",
|
|
353
355
|
"phase": "terminal"
|
|
354
356
|
}
|
|
@@ -382,7 +384,7 @@ Full details, the API-key/prefix table, and the migration notice are in **[docs/
|
|
|
382
384
|
|
|
383
385
|
## MCP integration
|
|
384
386
|
|
|
385
|
-
The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes
|
|
387
|
+
The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes sixteen tools:
|
|
386
388
|
|
|
387
389
|
| Tool | What it does |
|
|
388
390
|
|------|--------------|
|
|
@@ -401,6 +403,7 @@ The MCP server is auto-registered on install (Claude Code and Claude Desktop / C
|
|
|
401
403
|
| `amicus_council_stats` | Reviewer-reliability stats from past council runs. |
|
|
402
404
|
| `amicus_verdict` | Build the final council verdict from a tally + decisions; writes `<outDir>/report.html` when `render:true` and `outDir` are given. |
|
|
403
405
|
| `amicus_council_run` | Run the full headless council engine (Stage-1 reviews → anonymized cross-review → tally → chair verdict); returns `{ runId, runDir }`, async. |
|
|
406
|
+
| `amicus_spend` | Read-only cross-run cost rollup from the spend ledger (mirrors the CLI `amicus spend` flags). |
|
|
404
407
|
|
|
405
408
|
The async pattern is **start → status → read** — `amicus_start`/`amicus_fanout` return immediately, then you poll `amicus_status` and call `amicus_read`; `amicus_wait` collapses that poll loop into one blocking call.
|
|
406
409
|
|
package/bin/amicus.js
CHANGED
|
@@ -127,6 +127,11 @@ async function main() {
|
|
|
127
127
|
exitCode = await handleSpend(args);
|
|
128
128
|
break;
|
|
129
129
|
}
|
|
130
|
+
case 'watch': {
|
|
131
|
+
const { handleWatch } = require('../src/cli-handlers-watch');
|
|
132
|
+
exitCode = await handleWatch(args);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
130
135
|
case 'provider': {
|
|
131
136
|
const { handleProvider } = require('../src/cli-handlers-provider');
|
|
132
137
|
exitCode = await handleProvider(args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
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": [
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/BourbonDog/amicus/schemas/council-run-live.schema.json",
|
|
4
|
+
"title": "Amicus composed live council-run doc (amicus_status/buildCouncilStatusPayload, view:'live')",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["taskId", "type", "runId", "runDir", "status", "currentStage", "stages", "legsTotal", "legsComplete", "elapsed", "exitCode", "version"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"type": { "const": "council-run" },
|
|
9
|
+
"view": { "const": "live" },
|
|
10
|
+
"runId": { "type": "string" },
|
|
11
|
+
"runDir": { "type": "string" },
|
|
12
|
+
"status": { "type": "string" },
|
|
13
|
+
"currentStage": { "type": ["string", "null"] },
|
|
14
|
+
"stages": {
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["name", "status"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": { "type": "string" },
|
|
21
|
+
"status": { "type": "string" },
|
|
22
|
+
"waveId": { "type": ["string", "null"] }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"legsTotal": { "type": ["number", "null"] },
|
|
27
|
+
"legsComplete": { "type": ["number", "null"] },
|
|
28
|
+
"elapsed": { "type": "string" },
|
|
29
|
+
"exitCode": { "type": ["number", "null"] },
|
|
30
|
+
"usage": { "type": "object" }
|
|
31
|
+
},
|
|
32
|
+
"additionalProperties": true
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/BourbonDog/amicus/schemas/event.schema.json",
|
|
4
|
+
"title": "Amicus observability event (events.jsonl line)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "type", "event", "ts", "id"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": { "const": 1 },
|
|
9
|
+
"type": { "const": "event" },
|
|
10
|
+
"event": { "enum": ["wave-started", "leg-started", "leg-fallback", "leg-terminal", "wave-terminal", "retry-started", "run-started", "stage-started", "stage-terminal", "run-terminal"] },
|
|
11
|
+
"ts": { "type": "string", "format": "date-time" },
|
|
12
|
+
"id": { "type": "string" }
|
|
13
|
+
},
|
|
14
|
+
"additionalProperties": true
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/BourbonDog/amicus/schemas/progress.schema.json",
|
|
4
|
+
"title": "Amicus leg/solo progress snapshot (progress.json)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "type", "stage", "updatedAt"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": { "const": 1 },
|
|
9
|
+
"type": { "const": "progress" },
|
|
10
|
+
"stage": { "type": "string" },
|
|
11
|
+
"stageLabel": { "type": "string" },
|
|
12
|
+
"updatedAt": { "type": "string", "format": "date-time" },
|
|
13
|
+
"messagesReceived": { "type": "number" },
|
|
14
|
+
"latestTool": { "type": "string" },
|
|
15
|
+
"usage": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"tokens": { "type": "object" },
|
|
19
|
+
"costReported": { "type": "number" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": true
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/BourbonDog/amicus/schemas/run-live.schema.json",
|
|
4
|
+
"title": "Amicus composed live single-session doc (amicus_status, view:'live')",
|
|
5
|
+
"description": "amicus_status's single-session response always stamps type:'run' (stampEnvelope); documented here as a const rather than the brief's 'may be absent' guess to match real output.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["taskId", "type", "status"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"type": { "const": "run" },
|
|
10
|
+
"view": { "const": "live" },
|
|
11
|
+
"status": { "type": "string" },
|
|
12
|
+
"usage": { "type": "object" }
|
|
13
|
+
},
|
|
14
|
+
"additionalProperties": true
|
|
15
|
+
}
|
|
@@ -11,6 +11,31 @@
|
|
|
11
11
|
"windowDays": { "type": ["number", "null"] },
|
|
12
12
|
"total": { "type": "object" },
|
|
13
13
|
"byModel": { "type": "array", "items": { "type": "object" } },
|
|
14
|
-
"credit": { "type": ["object", "null"] }
|
|
14
|
+
"credit": { "type": ["object", "null"] },
|
|
15
|
+
"filters": { "type": "object" },
|
|
16
|
+
"groupBy": { "enum": ["model", "wave", "council", "project", "op", "day"] },
|
|
17
|
+
"groups": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"required": ["key", "amount", "runs"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"key": { "type": "string" },
|
|
24
|
+
"amount": { "type": "number" },
|
|
25
|
+
"runs": { "type": "number" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"wasted": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["amount", "runs", "byStatus"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"amount": { "type": "number" },
|
|
34
|
+
"runs": { "type": "number" },
|
|
35
|
+
"byStatus": { "type": "object" }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"rows": { "type": "array" },
|
|
39
|
+
"rowsTruncated": { "type": "boolean" }
|
|
15
40
|
}
|
|
16
41
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/BourbonDog/amicus/schemas/wave-live.schema.json",
|
|
4
|
+
"title": "Amicus composed live wave doc (amicus_status, view:'live')",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["taskId", "type", "status", "legs"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"type": { "const": "wave" },
|
|
9
|
+
"view": { "const": "live" },
|
|
10
|
+
"status": { "type": "string" },
|
|
11
|
+
"legs": { "type": "array", "items": { "type": "object", "properties": { "usage": { "type": "object" } } } },
|
|
12
|
+
"usage": { "type": "object" }
|
|
13
|
+
},
|
|
14
|
+
"additionalProperties": true
|
|
15
|
+
}
|
|
@@ -20,7 +20,30 @@ function parseList(value) {
|
|
|
20
20
|
return String(value).split(',').map(s => s.trim()).filter(Boolean);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize the internal `--council-name` passthrough before it can reach the
|
|
25
|
+
* spend ledger's `councilName` column (v4.3 Task 4 review fix, spec §7.3:
|
|
26
|
+
* spend docs hold only ids/numbers/paths "by construction"). That value is
|
|
27
|
+
* user-supplied (via mcp-council-run.js, ultimately an MCP caller's `input`),
|
|
28
|
+
* unbounded, and unvalidated — unlike a real `--council <preset>`, which is
|
|
29
|
+
* catalog-validated upstream. Strips control/non-printable characters, trims,
|
|
30
|
+
* and caps length so a hostile/malformed passthrough can't land raw in a
|
|
31
|
+
* `--group-by council` rollup. Precedence is untouched by this: it's applied
|
|
32
|
+
* only to the passthrough branch, never to the catalog-validated preset name.
|
|
33
|
+
* @param {string} name @returns {string|null} sanitized name, or null if empty after cleanup
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeCouncilName(name) {
|
|
36
|
+
// eslint-disable-next-line no-control-regex -- deliberately stripping C0/DEL control chars
|
|
37
|
+
const cleaned = String(name).replace(/[\x00-\x1F\x7F]/g, '').trim().slice(0, 64);
|
|
38
|
+
return cleaned || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve bench models from --models XOR --council (mirrors handleFanout).
|
|
43
|
+
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed --council
|
|
44
|
+
* name when that branch was taken, else null — threaded into runCouncil's
|
|
45
|
+
* `councilName` option so council ledger rows can be attributed to a preset.
|
|
46
|
+
*/
|
|
24
47
|
function resolveBench(args, useJson) {
|
|
25
48
|
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
26
49
|
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
@@ -40,16 +63,17 @@ function resolveBench(args, useJson) {
|
|
|
40
63
|
const { resolveCouncilMembers } = require('./utils/config');
|
|
41
64
|
const { readCache } = require('./utils/model-catalog');
|
|
42
65
|
const catalog = (readCache() || {}).models || [];
|
|
43
|
-
const
|
|
66
|
+
const presetName = args.council.trim();
|
|
67
|
+
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
44
68
|
if (expanded.error) {
|
|
45
69
|
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }) };
|
|
46
70
|
}
|
|
47
71
|
if (expanded.dropped && expanded.dropped.length && !useJson) {
|
|
48
72
|
process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
|
|
49
73
|
}
|
|
50
|
-
return { bench: expanded.models };
|
|
74
|
+
return { bench: expanded.models, presetName };
|
|
51
75
|
}
|
|
52
|
-
return { bench: parseList(args.models) };
|
|
76
|
+
return { bench: parseList(args.models), presetName: null };
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
function renderRunHuman(run) {
|
|
@@ -85,6 +109,19 @@ async function handleCouncilRun(args) {
|
|
|
85
109
|
const benchRes = resolveBench(args, useJson);
|
|
86
110
|
if (benchRes.fail !== undefined) { return benchRes.fail; }
|
|
87
111
|
const bench = benchRes.bench;
|
|
112
|
+
// v4.3 Task 3 (spec §7.1): the preset name, when this run came from a real
|
|
113
|
+
// --council <preset>. `--council-name` is an internal, undocumented passthrough
|
|
114
|
+
// set by mcp-council-run.js — the MCP handler always expands a preset to
|
|
115
|
+
// `--models` before spawning (so `--council`/`--models` stay mutually exclusive
|
|
116
|
+
// on this CLI surface), which would otherwise strand the preset name with no
|
|
117
|
+
// way to reach this process. Never fabricated: --models with neither flag
|
|
118
|
+
// stays null, matching spec §7.1 ("preset name … else null"). The
|
|
119
|
+
// passthrough branch is sanitized (see sanitizeCouncilName docblock) — the
|
|
120
|
+
// preset-name branch is catalog-validated upstream and never touched here,
|
|
121
|
+
// so precedence (a real --council preset always outranks the passthrough)
|
|
122
|
+
// is unchanged.
|
|
123
|
+
const councilName = benchRes.presetName
|
|
124
|
+
|| (typeof args['council-name'] === 'string' ? sanitizeCouncilName(args['council-name']) : null);
|
|
88
125
|
if (bench.length < 2) {
|
|
89
126
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
90
127
|
message: 'Error: a council needs at least 2 seats (fanout semantics)' });
|
|
@@ -139,8 +176,11 @@ async function handleCouncilRun(args) {
|
|
|
139
176
|
? path.resolve(project, String(args['out-dir']))
|
|
140
177
|
: path.resolve(project, `council-${runId}`);
|
|
141
178
|
|
|
142
|
-
const { resolveGatewayMode } = require('./utils/config');
|
|
179
|
+
const { resolveGatewayMode, loadConfig } = require('./utils/config');
|
|
180
|
+
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
181
|
+
const { readCache } = require('./utils/model-catalog');
|
|
143
182
|
const { runCouncil } = require('./council/run');
|
|
183
|
+
const cfg = loadConfig() || {};
|
|
144
184
|
const { exitCode, run } = await runCouncil({
|
|
145
185
|
briefing: promptRes.prompt, models: bench, chair, critic, lenses,
|
|
146
186
|
project, runId, runDir,
|
|
@@ -148,6 +188,7 @@ async function handleCouncilRun(args) {
|
|
|
148
188
|
gateway: resolveGatewayMode(args.gateway),
|
|
149
189
|
noValidateModel: !!args['no-validate-model'],
|
|
150
190
|
date: new Date().toISOString().slice(0, 10),
|
|
191
|
+
councilName,
|
|
151
192
|
// v4.1 §4.5b/§4.5d. `--claude-review` is resolved here but VALIDATED by the
|
|
152
193
|
// engine's preflightClaudeReview (run-assemble.js): the reserved-seat and
|
|
153
194
|
// 'claude may not chair' guards live there on purpose so MCP, the GitHub
|
|
@@ -157,6 +198,21 @@ async function handleCouncilRun(args) {
|
|
|
157
198
|
debate: !!args.debate,
|
|
158
199
|
claudeReviewFile: args['claude-review'] ? path.resolve(args['claude-review']) : null,
|
|
159
200
|
noCostGate: !!args['no-cost-gate'],
|
|
201
|
+
// v4.3 Task 13: --follow's json-vs-human mode mirrors the same --json this
|
|
202
|
+
// handler already resolved for the final run doc, so `--json --follow`
|
|
203
|
+
// NDJSON on stderr and the `--json` final doc on stdout agree.
|
|
204
|
+
follow: !!args.follow,
|
|
205
|
+
json: useJson,
|
|
206
|
+
onComplete: args['on-complete'],
|
|
207
|
+
// v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution for STAGE
|
|
208
|
+
// legs only (run-stages.js threads it through; the chair is excluded —
|
|
209
|
+
// see run-chair.js). --fallback forces on, --no-fallback forces off;
|
|
210
|
+
// unset defers to config `fallbacks.enabled`.
|
|
211
|
+
fallback: resolveFallbackConfig({
|
|
212
|
+
flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
|
|
213
|
+
config: cfg,
|
|
214
|
+
}),
|
|
215
|
+
catalog: (readCache() || {}).models || [],
|
|
160
216
|
});
|
|
161
217
|
|
|
162
218
|
if (useJson) {
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -116,6 +116,21 @@ async function handleStart(args) {
|
|
|
116
116
|
async function handleFanout(args) {
|
|
117
117
|
const useJson = !!args.json;
|
|
118
118
|
|
|
119
|
+
// --retry-failed <waveId> (v4.3 Task 19, spec 6.1): a completely different
|
|
120
|
+
// path from the --prompt/--models launch below (no briefing, no required
|
|
121
|
+
// --models — the original wave's failed legs supply their own saved
|
|
122
|
+
// context) — dispatch BEFORE any of that validation runs. --models here is
|
|
123
|
+
// optional and, when present, filters which failed legs get retried.
|
|
124
|
+
if (args['retry-failed']) {
|
|
125
|
+
const { retryFailedWave } = require('./sidecar/fanout-retry');
|
|
126
|
+
const { parseModelsList } = require('./sidecar/fanout-validate');
|
|
127
|
+
const { exitCode, errorDoc } = await retryFailedWave(String(args['retry-failed']), args.cwd || process.cwd(), {
|
|
128
|
+
models: parseModelsList(args.models), json: useJson,
|
|
129
|
+
});
|
|
130
|
+
if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
|
|
131
|
+
return exitCode;
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
// FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
|
|
120
135
|
// --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
|
|
121
136
|
// value silently fell through to resolveGatewayMode's pass-through instead
|
|
@@ -178,6 +193,8 @@ async function handleFanout(args) {
|
|
|
178
193
|
// Direct require — the src/index.js public re-export is added later (Task 13)
|
|
179
194
|
const { runFanout } = require('./sidecar/fanout');
|
|
180
195
|
const { loadConfig, resolveGatewayMode } = require('./utils/config');
|
|
196
|
+
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
197
|
+
const { readCache } = require('./utils/model-catalog');
|
|
181
198
|
const cfg = loadConfig() || {};
|
|
182
199
|
const { exitCode } = await runFanout({
|
|
183
200
|
models: args.models,
|
|
@@ -211,6 +228,15 @@ async function handleFanout(args) {
|
|
|
211
228
|
maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
|
|
212
229
|
noCostGate: !!args['no-cost-gate'],
|
|
213
230
|
maxCostPerMtok: cfg.maxCostPerMtok,
|
|
231
|
+
follow: !!args.follow,
|
|
232
|
+
onComplete: args['on-complete'],
|
|
233
|
+
// v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution. --fallback
|
|
234
|
+
// forces on, --no-fallback forces off; unset defers to config `fallbacks.enabled`.
|
|
235
|
+
fallback: resolveFallbackConfig({
|
|
236
|
+
flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
|
|
237
|
+
config: cfg,
|
|
238
|
+
}),
|
|
239
|
+
catalog: (readCache() || {}).models || [],
|
|
214
240
|
});
|
|
215
241
|
return exitCode;
|
|
216
242
|
}
|
|
@@ -14,11 +14,20 @@
|
|
|
14
14
|
* not a forked/independent counter) rather than adding to a full file. If
|
|
15
15
|
* result-schema.js is ever split/slimmed, buildSpendDoc is the one to fold
|
|
16
16
|
* back in alongside buildCatalogDoc/buildDoctorDoc.
|
|
17
|
+
*
|
|
18
|
+
* filterRows/groupRows/computeWasted (spec §7.3/§6.3 query layer) live in the
|
|
19
|
+
* sibling ./spend-query.js instead of here: adding them inline pushed this
|
|
20
|
+
* file to 306 lines, over the 300-line size gate. They're re-exported below
|
|
21
|
+
* so existing/brief-specified imports of them from THIS module still resolve.
|
|
22
|
+
* GROUP_DIMS/ROWS_CAP (Task 5) live there too, as the single source shared
|
|
23
|
+
* with the MCP `amicus_spend` tool (src/mcp-tools.js, src/mcp-spend.js) —
|
|
24
|
+
* re-exported below for the same reason.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
const { readSpendRows } = require('./utils/spend-ledger');
|
|
20
28
|
const { formatCost } = require('./utils/pricing');
|
|
21
29
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
30
|
+
const { filterRows, groupRows, computeWasted, emptyTokens, addTokens, GROUP_DIMS, ROWS_CAP } = require('./spend-query');
|
|
22
31
|
|
|
23
32
|
const CREDIT_CHECK_TIMEOUT_MS = 5000;
|
|
24
33
|
|
|
@@ -29,15 +38,6 @@ function parseSinceDays(since) {
|
|
|
29
38
|
return m ? parseInt(m[1], 10) : null;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
function emptyTokens() {
|
|
33
|
-
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function addTokens(into, tokens) {
|
|
37
|
-
if (!tokens) { return; }
|
|
38
|
-
for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
41
|
/**
|
|
42
42
|
* Aggregate ledger rows into a total + per-model rollup, most-expensive-first.
|
|
43
43
|
* A row with a null cost.amount contributes 0 to totals but is still counted
|
|
@@ -75,11 +75,16 @@ function aggregateSpend(rows) {
|
|
|
75
75
|
* Build the `--json` spend document. schemaVersion reuses result-schema's
|
|
76
76
|
* SCHEMA_VERSION (not a forked counter) — see module docblock for why this
|
|
77
77
|
* builder lives here instead of alongside buildCatalogDoc/buildDoctorDoc.
|
|
78
|
-
*
|
|
78
|
+
* New fields (filters/groupBy/groups/wasted/rows/rowsTruncated, spec §7.3)
|
|
79
|
+
* are all additive and only appear when the caller passes them — byte-compat
|
|
80
|
+
* for callers/tests that only ever passed {total, byModel, windowDays, credit}.
|
|
81
|
+
* @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null,
|
|
82
|
+
* filters?:object, groupBy?:string, groups?:Array, wasted?:object,
|
|
83
|
+
* rows?:Array, rowsTruncated?:boolean}} opts
|
|
79
84
|
*/
|
|
80
|
-
function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
85
|
+
function buildSpendDoc({ total, byModel, windowDays, credit, filters, groupBy, groups, wasted, rows, rowsTruncated }) {
|
|
81
86
|
const { SCHEMA_VERSION } = require('./utils/result-schema');
|
|
82
|
-
|
|
87
|
+
const doc = {
|
|
83
88
|
schemaVersion: SCHEMA_VERSION,
|
|
84
89
|
type: 'spend',
|
|
85
90
|
windowDays: windowDays !== undefined ? windowDays : null,
|
|
@@ -87,6 +92,12 @@ function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
|
87
92
|
byModel,
|
|
88
93
|
credit: credit || null,
|
|
89
94
|
};
|
|
95
|
+
if (filters !== undefined) { doc.filters = filters; }
|
|
96
|
+
if (groupBy !== undefined) { doc.groupBy = groupBy; }
|
|
97
|
+
if (groups !== undefined) { doc.groups = groups; }
|
|
98
|
+
if (wasted !== undefined) { doc.wasted = wasted; }
|
|
99
|
+
if (rows !== undefined) { doc.rows = rows; doc.rowsTruncated = !!rowsTruncated; }
|
|
100
|
+
return doc;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
/** Real deps; tests override via the second handleSpend arg. */
|
|
@@ -128,7 +139,7 @@ async function fetchCreditFooter(deps) {
|
|
|
128
139
|
return res || null;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
function renderHuman({ total, byModel, windowDays, credit }) {
|
|
142
|
+
function renderHuman({ total, byModel, windowDays, credit, wasted }) {
|
|
132
143
|
if (total.runs === 0) { return 'No spend recorded yet.\n'; }
|
|
133
144
|
let out = windowDays ? `amicus spend (last ${windowDays}d)\n\n` : 'amicus spend (all time)\n\n';
|
|
134
145
|
out += 'model runs tokens(in/out) cost sources\n';
|
|
@@ -140,6 +151,9 @@ function renderHuman({ total, byModel, windowDays, credit }) {
|
|
|
140
151
|
`${formatCost({ amount: m.amount, source: dominantSource(m.sourceMix) }).padStart(9)} ${mix}\n`;
|
|
141
152
|
}
|
|
142
153
|
out += `\nTotal: ${formatCost({ amount: total.amount, source: dominantSource(total.sourceMix) })} across ${total.runs} run(s)\n`;
|
|
154
|
+
if (wasted && wasted.runs > 0) {
|
|
155
|
+
out += `Wasted (failed runs): ${formatCost({ amount: wasted.amount, source: 'mixed' })} across ${wasted.runs} rows — see amicus spend --failed\n`;
|
|
156
|
+
}
|
|
143
157
|
if (credit && typeof credit.limitRemaining === 'number') {
|
|
144
158
|
out += `OpenRouter credit remaining: $${credit.limitRemaining}\n`;
|
|
145
159
|
}
|
|
@@ -155,8 +169,11 @@ function dominantSource(mix) {
|
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
/**
|
|
158
|
-
* `amicus spend [--since 7d] [--
|
|
159
|
-
*
|
|
172
|
+
* `amicus spend [--since 7d] [--wave <id>] [--council <runId|name>] [--project <path|.>]
|
|
173
|
+
* [--model <id-or-prefix>] [--op <op>] [--failed] [--group-by <dim>] [--rows] [--json]`
|
|
174
|
+
* @param {{_:string[], json?:boolean, since?:string, wave?:string, council?:string,
|
|
175
|
+
* project?:string|boolean, model?:string, op?:string, failed?:boolean,
|
|
176
|
+
* 'group-by'?:string, rows?:boolean}} args
|
|
160
177
|
* @param {object} [depsOverride] test seam
|
|
161
178
|
* @returns {Promise<number>} exit code
|
|
162
179
|
*/
|
|
@@ -173,26 +190,44 @@ async function handleSpend(args, depsOverride = {}) {
|
|
|
173
190
|
}
|
|
174
191
|
}
|
|
175
192
|
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
193
|
+
const groupBy = args['group-by'] || 'model';
|
|
194
|
+
if (!GROUP_DIMS.includes(groupBy)) {
|
|
195
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `invalid --group-by '${groupBy}'`,
|
|
196
|
+
hint: `--group-by one of: ${GROUP_DIMS.join('|')}` });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rows = readSpendRows(deps.dir);
|
|
200
|
+
const filters = {
|
|
201
|
+
wave: args.wave, council: args.council, model: args.model,
|
|
202
|
+
op: args.op, failed: !!args.failed,
|
|
203
|
+
project: args.project === '.' || args.project === true ? process.cwd() : args.project,
|
|
204
|
+
};
|
|
205
|
+
const filtered = filterRows(rows, { ...filters, since: windowDays, now: windowDays !== null ? deps.now() : undefined });
|
|
206
|
+
|
|
207
|
+
const { total, byModel } = aggregateSpend(filtered);
|
|
208
|
+
const groups = groupRows(filtered, groupBy);
|
|
209
|
+
const wasted = computeWasted(filtered);
|
|
210
|
+
let rowsOut, rowsTruncated;
|
|
211
|
+
if (args.rows) {
|
|
212
|
+
rowsTruncated = filtered.length > ROWS_CAP;
|
|
213
|
+
rowsOut = filtered.slice(0, ROWS_CAP);
|
|
183
214
|
}
|
|
184
215
|
|
|
185
|
-
|
|
186
|
-
// Nothing recorded (or nothing in the --since window): skip the network
|
|
216
|
+
// Nothing recorded (or nothing survives the filters): skip the network
|
|
187
217
|
// credit probe entirely — there's no rollup to attach it to either way.
|
|
188
218
|
const credit = total.runs === 0 ? null : await fetchCreditFooter(deps).catch(() => null);
|
|
189
219
|
|
|
190
220
|
if (useJson) {
|
|
191
|
-
|
|
221
|
+
const doc = buildSpendDoc({ total, byModel, windowDays, credit,
|
|
222
|
+
filters, groupBy, groups, wasted, rows: rowsOut, rowsTruncated });
|
|
223
|
+
process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
|
|
192
224
|
return 0;
|
|
193
225
|
}
|
|
194
|
-
process.stdout.write(renderHuman({ total, byModel, windowDays, credit }));
|
|
226
|
+
process.stdout.write(renderHuman({ total, byModel, windowDays, credit, wasted }));
|
|
195
227
|
return 0;
|
|
196
228
|
}
|
|
197
229
|
|
|
198
|
-
module.exports = {
|
|
230
|
+
module.exports = {
|
|
231
|
+
handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays,
|
|
232
|
+
filterRows, groupRows, computeWasted, GROUP_DIMS, ROWS_CAP,
|
|
233
|
+
};
|