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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +7 -4
  4. package/bin/amicus.js +5 -0
  5. package/package.json +1 -1
  6. package/schemas/council-run-live.schema.json +33 -0
  7. package/schemas/event.schema.json +15 -0
  8. package/schemas/progress.schema.json +24 -0
  9. package/schemas/run-live.schema.json +15 -0
  10. package/schemas/spend.schema.json +26 -1
  11. package/schemas/wave-live.schema.json +15 -0
  12. package/src/cli-handlers-council-run.js +61 -5
  13. package/src/cli-handlers-run.js +26 -0
  14. package/src/cli-handlers-spend.js +62 -27
  15. package/src/cli-handlers-watch.js +89 -0
  16. package/src/cli.js +58 -1
  17. package/src/council/run-chair.js +10 -2
  18. package/src/council/run-debate.js +5 -1
  19. package/src/council/run-launch.js +14 -1
  20. package/src/council/run-stages.js +13 -0
  21. package/src/council/run.js +32 -4
  22. package/src/headless.js +9 -1
  23. package/src/mcp-council-awareness.js +46 -1
  24. package/src/mcp-council-run.js +28 -4
  25. package/src/mcp-notify.js +54 -0
  26. package/src/mcp-server.js +51 -1
  27. package/src/mcp-spend.js +125 -0
  28. package/src/mcp-tools.js +39 -0
  29. package/src/mcp-wait.js +28 -2
  30. package/src/observe/events.js +156 -0
  31. package/src/observe/follow.js +26 -0
  32. package/src/observe/live-doc.js +38 -0
  33. package/src/observe/on-complete.js +117 -0
  34. package/src/observe/watch-render.js +149 -0
  35. package/src/sidecar/continue.js +32 -0
  36. package/src/sidecar/fallback-chains.js +65 -0
  37. package/src/sidecar/fanout-leg-fallback.js +189 -0
  38. package/src/sidecar/fanout-leg.js +58 -26
  39. package/src/sidecar/fanout-retry.js +208 -0
  40. package/src/sidecar/fanout-validate.js +42 -4
  41. package/src/sidecar/fanout.js +50 -30
  42. package/src/sidecar/progress.js +5 -0
  43. package/src/sidecar/resume.js +12 -0
  44. package/src/sidecar/start.js +13 -1
  45. package/src/spend-query.js +104 -0
  46. package/src/utils/error-classify.js +31 -0
  47. package/src/utils/model-tiers.js +1 -1
  48. package/src/utils/spend-ledger.js +24 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.2.1",
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
- ## [Unreleased]
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 four things that work together:
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.2.1",
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 fifteen tools:
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.2.1",
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
- /** Resolve bench models from --models XOR --council (mirrors handleFanout). */
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 expanded = resolveCouncilMembers(args.council.trim(), catalog);
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) {
@@ -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
- * @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null}} opts
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
- return {
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] [--json]`
159
- * @param {{_:string[], json?:boolean, since?:string}} args
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
- let rows = readSpendRows(deps.dir);
177
- if (windowDays !== null) {
178
- const cutoff = deps.now() - windowDays * 86400000;
179
- rows = rows.filter(r => {
180
- const t = Date.parse(r.ts);
181
- return Number.isFinite(t) && t >= cutoff;
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
- const { total, byModel } = aggregateSpend(rows);
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
- process.stdout.write(JSON.stringify(buildSpendDoc({ total, byModel, windowDays, credit }), null, 2) + '\n');
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 = { handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays };
230
+ module.exports = {
231
+ handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays,
232
+ filterRows, groupRows, computeWasted, GROUP_DIMS, ROWS_CAP,
233
+ };