amicus 4.6.2 → 4.7.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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
package/docs/usage.md CHANGED
@@ -9,7 +9,7 @@ The `am` alias is interchangeable with `amicus` everywhere.
9
9
  amicus start --model <model> --prompt "<task>"
10
10
  amicus start --model <model> --prompt-file briefing.md --no-ui --json
11
11
  amicus fanout --models "gemini,deepseek,gpt" --prompt "Review this" --json
12
- amicus list [--status <filter>] [--all] [--json]
12
+ amicus list [--status <filter>] [--all] [--search <q>] [--limit <n>] [--json]
13
13
  amicus resume <task_id> [--no-ui --json]
14
14
  amicus continue <task_id> --prompt "Next step..." [--no-ui --json]
15
15
  amicus read <task_id> [--conversation|--metadata|--json]
@@ -96,6 +96,7 @@ amicus start --model deepseek --prompt "Generate tests" --no-ui --timeout 30
96
96
  | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`). | |
97
97
  | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. | |
98
98
  | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. | |
99
+ | `--tag <t>` | Label this session for `list`/`--search`/`spend --group-by tag` (1-64 chars, `[A-Za-z0-9_-]`; invalid values are rejected, never silently cleaned). | *(none)* |
99
100
 
100
101
  > Agents: **Chat** auto-approves reads and asks before writes/bash (interactive default); **Build** has full tool access (headless default); **Plan** is read-only analysis. `--agent Chat` is interactive-only and incompatible with `--no-ui`.
101
102
 
@@ -126,6 +127,7 @@ amicus fanout --council free --prompt "Review this design" --json
126
127
  | `--wave-id <id>` | Set the wave ID explicitly; leg IDs become `<wave-id>-1` … `<wave-id>-N`. |
127
128
  | `--session-id <id\|"current">` | Session ID to pull shared context from (default `current`). Same semantics as on `start`. |
128
129
  | `--json` | Emit the wave document on stdout. |
130
+ | `--quiet` | Suppress per-leg progress ticks and the final wave-result summary (preflight errors are suppressed too). |
129
131
  | `--max-cost <$>` | Refuse the wave if the estimated total exceeds `$` (soft ceiling). |
130
132
  | `--no-cost-gate` | Disable the budget gate (per-$/Mtok threshold + ceiling) for this run. |
131
133
  | `--no-validate-model` | Skip catalog validation. |
@@ -134,6 +136,7 @@ amicus fanout --council free --prompt "Review this design" --json
134
136
  | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`), shared by every leg. |
135
137
  | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. |
136
138
  | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
139
+ | `--tag <t>` | Label this wave (and every leg's spend row) for `list`/`--search`/`spend --group-by tag` (1-64 chars, `[A-Za-z0-9_-]`; rejected, not cleaned). Mutually exclusive with `--retry-failed` (`BAD_ARGS`). |
137
140
 
138
141
  **Shared per-leg knobs.** Every leg in the wave also accepts the same per-leg options as `start`:
139
142
  `--agent`, `--thinking`, `--timeout`, `--summary-length`, `--no-context`, `--context-*`, `--mcp*`,
@@ -187,7 +190,7 @@ amicus council run --prompt-file briefing.md --models gemini,glm --chair deepsee
187
190
  | `--chair <model>` | Verdict synthesizer. Default `deepseek`; must **not** be a bench seat (pre-flight error). |
188
191
  | `--critic <model>` | Optional adversarial seat; must **be** a bench seat. Mutually exclusive with `--lenses`. |
189
192
  | `--lenses <s1,s2,...>` | Expert lenses, one per seat (count must equal seat count); forces `--no-ledger` semantics. |
190
- | `--out-dir <dir>` | Run directory. Default `./council-<runId>/`. |
193
+ | `--out-dir <dir>` | Run directory. Default `./council-<runId>/`. Must resolve inside the project directory — a path that escapes it is rejected with `BAD_ARGS`. |
191
194
  | `--json` | Emit the council-run document on stdout (error envelope + documented exit codes on failure). |
192
195
  | `--max-cost <$>` | **Whole-run** ceiling on **known** spend, checked before each paid stage launch. A leg whose cost cannot be determined does not count against it and never halts the run; when the total is inexact and a ceiling is set, the run exits `2`. |
193
196
  | `--timeout <min>` | **Per-leg** timeout (fanout semantics); bound the aggregate with your CI job timeout. |
@@ -199,6 +202,7 @@ amicus council run --prompt-file briefing.md --models gemini,glm --chair deepsee
199
202
  | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`). |
200
203
  | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. |
201
204
  | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
205
+ | `--tag <t>` | Label this run for `list`/`--search`/`spend --group-by tag` (1-64 chars, `[A-Za-z0-9_-]`; rejected, not cleaned). Every stage's sub-waves (Stage-1, critic/lens solos, Stage-2, chair, debate) carry the same tag on their wave metadata. |
202
206
 
203
207
  **Exit codes:** `0` full run · `2` degraded but reportable (fewer than 2 judges, chair failure —
204
208
  `overallVerdict: null` — a cost ceiling hit after the tally, or a `--max-cost` ceiling set over a
@@ -254,7 +258,7 @@ amicus template show review # Print a template's raw text
254
258
  amicus start --model gemini --template review --artifact plan.md --var focus=performance --no-ui --json
255
259
  ```
256
260
 
257
- **Known variables** (`src/template/render.js`): `{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}` (`YYYY-MM-DD`), `{{project}}`, `{{var.<key>}}` (from repeatable `--var key=value`). There is no `{{input}}` in v4.5 — that chaining variable, and the `critique`/`refine` built-ins that need it, arrive with v4.6's composable waves.
261
+ **Known variables** (`src/template/render.js`): `{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}` (`YYYY-MM-DD`), `{{project}}`, `{{var.<key>}}` (from repeatable `--var key=value`). There is no `{{input}}` in v4.5 — that chaining variable, and the `critique`/`refine` built-ins that need it, arrive with composable waves (`--input-from`) in a future release.
258
262
 
259
263
  **Strict by design — a typo fails loudly instead of silently dropping text:**
260
264
 
@@ -302,6 +306,8 @@ Then invoke it with `--pack <name|path>` on `start` / `fanout` / `council run`
302
306
 
303
307
  Every kind may also carry `description`, `version` (semver, default `1.0.0`), and `briefing.template` (a template **reference**, not rendered text — a pack never captures briefing prose).
304
308
 
309
+ The `version` field is set with **`--pack-version <semver>`**, not `--version` — the latter is amicus's own global "print the version" flag, which is intercepted before command dispatch and so can never carry a pack's version. `pack save --version` is rejected with `BAD_ARGS` naming the right spelling.
310
+
305
311
  **Precedence: flag > pack > config default > built-in default.** A pack only fills in values you didn't type explicitly on the command line — anything you do pass always wins, and the pack is recorded on the run either way (see below), so a hand-tuned invocation of a saved pack is never ambiguous about what actually ran.
306
312
 
307
313
  **`--from-run <id>`** builds a pack from an existing council run, fanout wave, or solo session instead of flags — resolution order is council pointer → wave `metadata.json` → solo `metadata.json`. It captures the bench/model, chair/critic/lenses, and the run options that were actually used; **briefing text is never captured**, only a template *reference* when the source run recorded one.
@@ -356,7 +362,7 @@ pack. Confirmed against the run's own `run.json` for this exact invocation (irre
356
362
  {
357
363
  "bench": ["gemini", "deepseek", "gpt"],
358
364
  "chair": "opus",
359
- "pack": { "name": "review-bench", "hash": "da084ba56162", "source": "dir" },
365
+ "pack": { "name": "review-bench", "version": "1.0.0", "hash": "da084ba56162", "source": "dir" },
360
366
  "options": { "timeout": 20, "maxCost": 2, "gateway": "auto", "outDir": "..." }
361
367
  }
362
368
  ```
@@ -372,16 +378,23 @@ pack is still recorded on the run either way.
372
378
  Amicus does **not** ship a frozen table of model names. Aliases and validation resolve against a **live catalog** fetched from provider APIs and cached at `~/.config/amicus/model-catalog.json` (24-hour TTL; the fetch works without an API key).
373
379
 
374
380
  ```bash
375
- amicus models # List the catalog
376
- amicus models --search gemini # Filter by substring over id and name
377
- amicus models --refresh # Force-refresh from provider APIs
378
- amicus models --check # Audit your aliases against the catalog
379
- amicus models --check --live # + probe every stored alias with a real leg (spends)
381
+ amicus models # List the catalog
382
+ amicus models --search gemini # Filter by substring over id and name
383
+ amicus models --refresh # Force-refresh from provider APIs
384
+ amicus models --check # Audit your aliases against the catalog
385
+ amicus models --check --strict # + exit non-zero on curated per-gateway drift too
386
+ amicus models --check --live # + probe every stored alias with a real leg (spends)
380
387
  ```
381
388
 
382
389
  `amicus models --check` exits with the **number of stale aliases** (capped at 100) and prints same-vendor replacement suggestions for each, so it drops cleanly into CI.
383
390
 
384
- **Drifted aliases.** `--check` (and the `doctor` aliases row) also flags **`DRIFTED:`** stored aliases — a stored alias whose target is still catalog-listed but no longer matches any route its family currently resolves to (the v4.6.1 `gemini` release-gate class, where `doctor` stayed green while the model behind it had moved on). Each drift line prints the exact `amicus setup --add-alias <alias>=<current>` refresh command. Drift is informational only — unlike stale aliases, it never changes the exit code.
391
+ **Gateway-only routes.** A curated alias whose direct form is *derived* from its OpenRouter route (rather than
392
+ authored) is not reported STALE when that direct form is missing from the vendor's
393
+ direct namespace while the OpenRouter route still serves — a gateway-only route with
394
+ no direct sibling is a routing choice, not staleness. Deliberately gateway-only
395
+ entries (e.g. `gpt-pro`) are annotated as such and are never offered a retarget.
396
+
397
+ **Drifted aliases.** `--check` (and the `doctor` aliases row) also flags **`DRIFTED:`** stored aliases — a stored alias whose target is still catalog-listed but no longer matches any route its family currently resolves to (the v4.6.1 `gemini` release-gate class, where `doctor` stayed green while the model behind it had moved on). Each drift line prints the exact `amicus setup --add-alias <alias>=<current>` refresh command. Drift is informational only by default and does not change the exit code — pass `--strict` alongside `--check` to make curated per-gateway drift (stale or divergent direct/OpenRouter forms) exit non-zero too.
385
398
 
386
399
  **Live probe (`--check --live`).** Presence in the catalog is not proof of service — a stored alias can point at a model id the catalog still lists but the provider has quietly stopped serving (the v4.6.1 `gemini` incident). `--check` alone can't see that; `--live` can, by actually asking. Scope is **stored aliases only** (`amicus setup --add-alias`) — curated defaults follow the catalog by construction and have no "was it actually served" question for a live probe to answer. **This spends real money — one tiny leg per stored alias** — every probed alias gets one ordinary engine leg on a single quiet fan-out wave, with a real session dir and a real spend-ledger row, exactly as if you'd run it yourself.
387
400
 
@@ -418,7 +431,9 @@ Each stored alias resolves to one of three outcomes:
418
431
  amicus list # Current project
419
432
  amicus list --status running # Filter: running, complete, error, timed-out,
420
433
  # aborted, crashed, idle-timeout
421
- amicus list --all # All projects
434
+ amicus list --all # All projects (cross-project, via the session index)
435
+ amicus list --search foo # Substring match: id, tag, briefing material (case-insensitive)
436
+ amicus list --limit 20 # Only the 20 newest rows (0 = unlimited); pairs well with --all
422
437
  amicus list --json # Machine-readable
423
438
 
424
439
  amicus read <id> # Fold summary (default)
@@ -444,6 +459,43 @@ amicus setup --api-keys # Open just the API-key window
444
459
  amicus setup --add-alias fast=google/gemini-2.5-flash # Add/override one alias (bare canonical)
445
460
  ```
446
461
 
462
+ **`amicus list` flags.** The CLI and the MCP `amicus_list` tool share one enumeration
463
+ (`src/sidecar/read.js`'s `enumerateSessions`) for ordinary start/fanout rows, so those rows carry
464
+ the same core fields everywhere: `id`, `model`, `status`, `mode` (`interactive`/`headless`),
465
+ `type` (`run` by default), `parentWave` (`null` unless the row is a fan-out leg), `legCount`
466
+ (`null` unless the row is a wave), and `tag` — the one field that's omitted, not `null`, when the
467
+ session has none. The human-readable CLI table adds a `TAG` column. Council-run rows are an
468
+ **MCP-only** row class: the CLI's own directory scan skips their `council-<runId>.json` pointer
469
+ files (a pointer's filename fails the session-ID pattern every other row's directory name must
470
+ match), so `amicus list` never shows a council run — only the MCP `amicus_list` tool merges them
471
+ in, each carrying `type: 'council-run'`, a fixed `mode: 'headless'`, and its own 80-char sanitized
472
+ `briefing` preview plus a `stage` field naming whichever stage is currently running. The MCP tool
473
+ also re-sanitizes every other row's `briefing` to that same 80-char cap and, for any row still
474
+ `status: 'running'`, adds live-progress fields (`phase`, `messageCount`, `lastActivityAt`,
475
+ `latestPreview`) — enrichments the CLI table doesn't apply, since it prints the raw 30-char slice
476
+ in-line instead. `--all` (CLI only) enumerates every project the global, advisory sessions-index
477
+ knows about, deduped by canonical project identity, and stamps each row's `project` field (the
478
+ CLI table adds a trailing `PROJECT` column whenever `--all` is set); a stale index entry pointing
479
+ at a missing or unreadable project is skipped rather than surfaced as an error. `--search <q>`
480
+ (both surfaces) is a case-insensitive substring filter over `id`, `tag`, and briefing material: a
481
+ fan-out wave row reads its full `briefing.md` off disk (falling back to the row's 200-char excerpt
482
+ if that file isn't readable), and a leg row (one spawned by a wave) matches on `id`/`tag` only —
483
+ its briefing is the parent wave's, and matching it there would surface the same wave once per leg
484
+ it spawned. On the MCP tool specifically, a council-run row's search material is `briefing.md`
485
+ written at MCP launch time, or falls back to the portion of `briefing-stage1.md` after
486
+ `--- MATERIAL / BRIEFING ---` (CLI-launched runs only ever have the latter file) — this clause is
487
+ MCP-only, since the CLI never lists a council row to search in the first place. A bare `--search`
488
+ with no value is a usage error on the CLI. Tag itself is set at launch with `--tag <t>` on
489
+ `start`/`fanout`/`council run` (see those sections above), and is also a dimension for
490
+ `amicus spend --group-by tag`.
491
+
492
+ **Known limitation: a tag is not inherited.** `--tag` only ever gets written at launch. `amicus
493
+ continue` and `amicus resume` write their spend rows with no tag, and a `fanout --retry-failed`
494
+ wave does not carry the original wave's tag either (`--tag` combined with `--retry-failed` is
495
+ rejected as a usage error). Since the spend ledger writes `tag: null` whenever none was set, those
496
+ rows group under `(unattributed)` in `amicus spend --group-by tag` rather than under the tag their
497
+ lineage started with.
498
+
447
499
  **`amicus status <id>` output.** Human-readable:
448
500
 
449
501
  ```
@@ -462,7 +514,7 @@ $ amicus status demo123 --json
462
514
  "taskId": "demo123",
463
515
  "status": "complete",
464
516
  "elapsed": "5m 0s",
465
- "version": "4.6.2",
517
+ "version": "4.7.0",
466
518
  "model": "google/gemini-2.5-flash",
467
519
  "phase": "terminal"
468
520
  }
@@ -514,7 +566,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
514
566
 
515
567
  | Check | What it verifies | Can fail as |
516
568
  |---|---|---|
517
- | `node` | Node.js ≥ 18 | error |
569
+ | `node` | Node.js ≥ 22.12 | error |
518
570
  | `config-dir` | The resolved config directory | *(always ok)* |
519
571
  | `keys` | At least one cloud-vendor key configured | error |
520
572
  | `default-model` | Your default model alias resolves | error |
@@ -528,13 +580,14 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
528
580
  | `mcp` | Amicus is registered as an MCP server in Claude Code | warn |
529
581
  | `mcp-legacy` | No duplicate legacy `sidecar` MCP entry survives alongside `amicus` | warn |
530
582
  | `sessions-index-tmp` | No orphaned `sessions-index.json.*.tmp` files | warn |
583
+ | `session-metadata-tmp` | No orphaned per-session `.metadata.json.*.tmp` files (the B09 class) | warn |
531
584
  | `openrouter-credit` | Remaining OpenRouter credit (skipped — reports `ok` — when no OpenRouter key is set) | warn |
532
585
  | `local-providers` **(v4.2)** | Every provider in `config.providers` is reachable | warn |
533
586
  | `project-root` | Your cwd looks like a real project, not an app/install dir | warn |
534
587
 
535
588
  **`local-providers`** probes every configured local provider (2s timeout each) the same way `amicus provider test` does, and reports per-id reachability in one line, e.g. `ollama: 3 models @ http://127.0.0.1:11434/v1; my-vllm: unreachable @ http://127.0.0.1:8000/v1`. No providers configured at all is a plain `ok` ("none configured") — this check can never fail your doctor run outright, only warn: a napping `ollama serve` isn't treated as broken setup.
536
589
 
537
- `--fix` self-heals four of the checks above in place: reprovisions Electron, copies the OpenCode engine into a broken npx-cache install, removes a duplicate legacy MCP entry, and sweeps orphaned session-index tmp files (only ones older than 60s). It does **not** start a local server for you — `local-providers` stays a warning until you start the server yourself.
590
+ `--fix` self-heals five of the checks above in place: reprovisions Electron, copies the OpenCode engine into a broken npx-cache install, removes a duplicate legacy MCP entry, sweeps orphaned session-index tmp files, and sweeps orphaned per-session metadata tmp files (both tmp sweeps only ones older than 60s). It does **not** start a local server for you — `local-providers` stays a warning until you start the server yourself.
538
591
 
539
592
  Exit code is `1` if anything is `error`, else `0` (same rule drives `--json`'s `ok` field).
540
593
 
@@ -568,7 +621,7 @@ Reads `~/.config/amicus/spend-ledger.jsonl` (one row per completed run/leg) and
568
621
  | `--model <id-or-prefix>` | rows whose model id starts with the given string |
569
622
  | `--op <op>` | rows for one operation (`start`, `leg`, `continue`, `resume`, …) |
570
623
  | `--failed` | rows with an explicit non-`complete` status (see the caveat below) |
571
- | `--group-by <dim>` | bucket totals by `model` (default) \| `wave` \| `council` \| `project` \| `op` \| `day` |
624
+ | `--group-by <dim>` | bucket totals by `model` (default) \| `wave` \| `council` \| `project` \| `op` \| `day` \| `tag` (untagged rows group under `(unattributed)`) |
572
625
  | `--rows` | also emit the raw filtered rows (capped at 1000; `--json` sets `rowsTruncated: true` past the cap) |
573
626
 
574
627
  All filters compose, e.g. `amicus spend --project . --group-by model --since 7d`.
@@ -105,6 +105,9 @@
105
105
  <!-- ⚠️ D8 extraction (Task 1, v4.6.2 PR4): workspace-seats.js must load BEFORE
106
106
  workspace-panels.js, whose renderSeatsPanel delegates to it. -->
107
107
  <script src="./workspace-seats.js"></script>
108
+ <!-- ⚠️ v4.7 PR7 extraction: workspace-lazy.js must load BEFORE workspace-panels.js,
109
+ whose wireLazyPanels/proseLoader delegate to it. -->
110
+ <script src="./workspace-lazy.js"></script>
108
111
  <!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
109
112
  Load order matters: workspace-app.js boots last and owns `state`. -->
110
113
  <script src="./workspace-panels.js"></script>
@@ -37,9 +37,17 @@
37
37
  return (state.visible && state.focused) ? 1500 : 5000;
38
38
  }
39
39
 
40
+ /** True when `status` is a terminal run status. The single consumption
41
+ * point for TERMINAL_STATUSES membership (v4.6.3 PR2 dedup) — the array
42
+ * itself stays exported and byte-identical to src/workspace/run-detail.js
43
+ * (drift-pinned). */
44
+ function isTerminal(status) {
45
+ return TERMINAL_STATUSES.indexOf(status) !== -1;
46
+ }
47
+
40
48
  /** Blind default per spec resolved Q2: ON live, OFF terminal. */
41
49
  function defaultBlind(status) {
42
- return TERMINAL_STATUSES.indexOf(status) === -1;
50
+ return !isTerminal(status);
43
51
  }
44
52
 
45
53
  function dash(v) {
@@ -86,9 +94,27 @@
86
94
  ];
87
95
  }
88
96
 
97
+ // v4.7 D6/E1: three row-per-launch producer roles added alongside the
98
+ // existing chair-attempt (run-chair.js), repair (run-stages.js/
99
+ // run-stage2.js/run-chair.js) and superseded (run-stages.js + debate.js)
100
+ // rows — launch-accounting extras, not seats, and unlike rebuttal/revote
101
+ // (F37, kept rendering on purpose below) they have no seats-panel meaning
102
+ // of their own. There is no pre-existing allowlist in this function (E1) —
103
+ // this is a plain exclusion added on top of the untouched id/shape logic.
104
+ // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
105
+ // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
106
+ // function via bracket lookup instead of `undefined` — silently mis-excluding (or
107
+ // mis-including) that seat. A null-prototype object has no inherited keys to collide with.
108
+ var SEATS_PANEL_EXCLUDED_ROLES = Object.create(null);
109
+ SEATS_PANEL_EXCLUDED_ROLES['chair-attempt'] = true;
110
+ SEATS_PANEL_EXCLUDED_ROLES.repair = true;
111
+ SEATS_PANEL_EXCLUDED_ROLES.superseded = true;
112
+
89
113
  /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
90
114
  function seatsFromRunStats(costRows) {
91
- return (costRows || []).map(function (r) {
115
+ return (costRows || []).filter(function (r) {
116
+ return !SEATS_PANEL_EXCLUDED_ROLES[r.role];
117
+ }).map(function (r) {
92
118
  return {
93
119
  // ⚠️ DE-ROT (F37): composite id — a v4.1 `--debate` run emits extra runStats rows for
94
120
  // the SAME bench alias (role 'rebuttal'/'revote', src/council/debate.js:88-96). With no
@@ -122,28 +148,79 @@
122
148
  * loss, an out-of-range index, or a zero-model unit) carry neither, so they
123
149
  * correctly fall back to the plain phrasing.
124
150
  *
125
- * D6 filter (zero usable legs ONLY, "no ghost when a retry succeeded"): a
126
- * candidate already present in `liveSeats` (it has a cost row — SL-2 healed
127
- * it) is dropped. This is the one thing standing between a recovered seat
128
- * and a duplicate/ghost row same failure family as the F37 debate-role
129
- * collision and the RN-11 keyed-row lessons just above (seatsFromRunStats,
130
- * seatCells): an identity that is not carefully matched silently
131
- * duplicates or overwrites instead of failing loud.
151
+ * D6 filter (zero usable legs ONLY, "no ghost when a live row already
152
+ * exists"): a candidate already present in `liveSeats` is dropped. Two
153
+ * different things can put it there, and both are reasons to suppress:
154
+ * an SL-2 retry actually healed it (a real recovered review), OR
155
+ * (owner-ruled, v4.7 CA-4 dead-seat convergence) the seat never recovered
156
+ * at all but the row-per-launch machinery still gives its dead leg an
157
+ * honest primary ERROR row (every billed leg gets a row now, including
158
+ * failures — run-stages.js/run-assemble.js) — so "it has a cost row" no
159
+ * longer implies "it healed". Either way that live row IS the seat's
160
+ * record; rendering a second "did not review" ghost row beside it would
161
+ * be a duplicate, not new information, so suppressing it here is the
162
+ * ACCEPTED terminal-path behavior: exactly one row per seat, whatever its
163
+ * status. This is the one thing standing between a recovered (or
164
+ * honestly-erred) seat and a duplicate/ghost row — same failure family as
165
+ * the F37 debate-role collision and the RN-11 keyed-row lessons just above
166
+ * (seatsFromRunStats, seatCells): an identity that is not carefully
167
+ * matched silently duplicates or overwrites instead of failing loud.
168
+ *
169
+ * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
170
+ * regardless of what the LIVE row's role was — so a model that died as
171
+ * critic but whose chair-fallback walk happened to land on that same alias
172
+ * (and succeeded, producing a live `role: 'chair'` cost row) silently
173
+ * erased the dead-critic row it was never a replacement for (spec §5, the
174
+ * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
175
+ * with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
176
+ * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
177
+ * suppress at all; a `'critic'` candidate is cleared only by a live
178
+ * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
179
+ * row landing on the same model. Hidden dependency: the recovered-critic
180
+ * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
181
+ * critic branch (src/council/run-stages.js), which only fires when lenses
182
+ * are absent — safe today only because --critic and --lenses are mutually
183
+ * exclusive (src/cli-handlers-council-run.js's `critic && lenses` check); if
184
+ * that exclusion ever loosens, a healed critic on a lens run would carry
185
+ * role 'seat' and this suppression would render a ghost dead row for it.
186
+ *
187
+ * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
188
+ * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
189
+ * (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
190
+ * existed on either doc for these runs. Consumed after the critic backstop,
191
+ * candidates get `role: null` (deadBenchSeats carries no critic/bench
192
+ * distinction beyond what `criticRequested` already covers above) and flow
193
+ * through the same `seen`-keyed dedup and role-aware suppression as every
194
+ * other candidate — deriveSeatLoss does not dedup its own array, so `seen`
195
+ * is what keeps a repeated alias (or one also named by a real degrade
196
+ * record) from rendering twice.
132
197
  *
133
198
  * @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
134
- * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent)
199
+ * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent)
200
+ * `criticRequested`/`criticSeated` back the critic candidate above,
201
+ * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
135
202
  * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
136
203
  * (or any seat list keyed the same way — the live seat map)
137
- * @returns {Array<{model: string, statusText: string}>}
204
+ * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
205
+ * when no critic was requested) — degrade records carry no role field, so
206
+ * this is the ONLY way a degrade-sourced candidate is identified as critic
207
+ * @returns {Array<{model: string, statusText: string, role: ?string}>}
138
208
  */
139
- function deadSeats(degrades, seatLoss, liveSeats) {
140
- var seen = {};
209
+ function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
210
+ var critic = runMeta && runMeta.critic ? runMeta.critic : null;
211
+ // ⚠️ Object.create(null) throughout this family (also workspace-render.js's
212
+ // `existing`/`seen` and workspace-app.js's `labelByModel`): a model literally
213
+ // named `toString` is truthy off a bare object, so it was dropped here and —
214
+ // worse — crashed workspace-render.js:212 reading `.children` off an inherited
215
+ // function, killing the seats repaint and every tick after it.
216
+ var seen = Object.create(null);
141
217
  var order = [];
142
- function add(model, retried) {
218
+ function add(model, retried, role) {
143
219
  if (!model || seen[model]) { return; }
144
220
  seen[model] = true;
145
221
  order.push({
146
222
  model: model,
223
+ role: role || null,
147
224
  statusText: retried ? 'did not review — retried once' : 'did not review',
148
225
  });
149
226
  }
@@ -152,16 +229,41 @@
152
229
  if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
153
230
  var data = d.data || {};
154
231
  var retried = !!(data.retryWaveId || data.firstFailure);
232
+ // Critic identification mirrors deriveSeatLoss (verdict.js): alias
233
+ // equality with run.critic — degrade records carry no role field.
155
234
  if (d.channel === 'dead-leg') {
156
- add(data.seat, retried);
235
+ add(data.seat, retried, critic && data.seat === critic ? 'critic' : null);
157
236
  } else {
158
- (data.models || []).forEach(function (m) { add(m, retried); });
237
+ (data.models || []).forEach(function (m) {
238
+ add(m, retried, critic && m === critic ? 'critic' : null);
239
+ });
159
240
  }
160
241
  });
161
242
  if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
162
- add(seatLoss.criticRequested, false);
243
+ add(seatLoss.criticRequested, false, 'critic');
163
244
  }
164
- var live = {};
245
+ if (seatLoss) {
246
+ // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
247
+ // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
248
+ // absorbs repeats and degrade-sourced duplicates.
249
+ (seatLoss.deadBenchSeats || []).forEach(function (m) { add(m, false, null); });
250
+ }
251
+ // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
252
+ // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
253
+ // a dead-critic candidate is cleared only by a live CRITIC leg. A null
254
+ // role is NOT reviewing: counting it would suppress silently, the exact
255
+ // class the announcement invariant forbids.
256
+ // Role 'claude' is deliberately absent: it is emitted only by claudeRunStatsRow
257
+ // (src/council/run-assemble.js:129-132) for a seat that never launches a leg, and
258
+ // preflightClaudeReview (run-assemble.js:86-102) rejects 'claude' as chair/critic/
259
+ // bench — so no 'claude' leg can die. If that reservation ever loosens, this
260
+ // allowlist is the single place to extend.
261
+ function isReviewing(role) {
262
+ return role === 'seat' || role === 'critic' ||
263
+ (typeof role === 'string' && role.indexOf('lens:') === 0);
264
+ }
265
+ var reviewing = Object.create(null);
266
+ var byRole = Object.create(null);
165
267
  // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
166
268
  // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
167
269
  // executable id, not the alias a degrade record names; `modelInput` carries the alias.
@@ -170,14 +272,23 @@
170
272
  // so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
171
273
  // errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
172
274
  // are already alias-only, so `|| s.model` leaves that path unchanged.
173
- (liveSeats || []).forEach(function (s) { live[s.modelInput || s.model] = true; });
174
- return order.filter(function (s) { return !live[s.model]; });
275
+ (liveSeats || []).forEach(function (s) {
276
+ if (!isReviewing(s.role)) { return; }
277
+ var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
278
+ reviewing[alias] = true;
279
+ byRole[alias + '|' + s.role] = true;
280
+ });
281
+ return order.filter(function (s) {
282
+ if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
283
+ return !reviewing[s.model];
284
+ });
175
285
  }
176
286
 
177
287
  // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
178
288
  var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
179
289
  deadSeats: deadSeats,
180
- defaultBlind: defaultBlind, dash: dash, TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
290
+ defaultBlind: defaultBlind, isTerminal: isTerminal, dash: dash,
291
+ TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
181
292
  if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
182
293
  if (typeof window !== 'undefined') { window.AmicusLive = api; }
183
294
  })();
@@ -26,6 +26,9 @@
26
26
  debate: null, // ⚠️ DE-ROT (F38): parsed debate.json, fetched once per run-open; stays
27
27
  // null on a non-debate run, an aborted/skipped debate, or a parse failure —
28
28
  // drillIntoJudge's judge-*.md fallback covers all three.
29
+ debateFetch: null, // T18-m1: test seam — the fire-and-forget debate.json fetch promise
30
+ // (null when no fetch was issued for the open run). Nothing renders
31
+ // from this; tests may `await state.debateFetch` to sequence past it.
29
32
  blind: false,
30
33
  // Task 19 (RN-5) + fix-wave (RN-5 amendment): the (run id, status) pair renderDetail() last
31
34
  // computed state.blind's default for. Together they gate the recompute (in renderDetail(),
@@ -34,7 +37,7 @@
34
37
  // live loop's running -> terminal refresh, or the abort-confirm re-read) still auto-reveals.
35
38
  detailRunId: null,
36
39
  detailRunStatus: null,
37
- labelByModel: {},
40
+ labelByModel: Object.create(null),
38
41
  listTimer: null,
39
42
  liveTimer: null, // LIVE (Task 15)
40
43
  liveEpoch: 0, // LIVE (Task 15): bumped by stopLiveLoop() to invalidate in-flight ticks
@@ -66,7 +69,19 @@
66
69
  function openRun(runId) {
67
70
  state.runId = runId;
68
71
  state.debate = null;
72
+ // Fix-wave item 2: reset synchronously alongside state.debate so the contract comment
73
+ // above ("null when no fetch was issued for the open run") holds in EVERY window —
74
+ // including between this openRun(runId) call and its get-run reply, where the old
75
+ // in-.then reset left state.debateFetch pointing at the PREVIOUS run's promise. The F09
76
+ // stale-reply guard (below) early-returns before ever touching debateFetch again, so
77
+ // moving the reset here changes no other behavior.
78
+ state.debateFetch = null;
69
79
  return invoke('workspace:get-run', runId).then(function (detail) {
80
+ // F09 guard (v4.6.3 PR2): a reply for a run the user has since
81
+ // navigated away from must never overwrite the run now open. Guard on
82
+ // runId movement ONLY — a same-run re-open (the live loop's terminal
83
+ // refresh) must still apply its fresher reply.
84
+ if (state.runId !== runId) { return; }
70
85
  state.detail = detail;
71
86
  // ⚠️ DE-ROT (F38): debate.json is the re-vote index the matrix drill-in needs — fetched
72
87
  // once per run-open (never per render), fire-and-forget. An aborted or cost-ceiling
@@ -78,8 +93,9 @@
78
93
  // navigated away from must never overwrite the run now open. Capture `runId` and check
79
94
  // it's still `state.runId` before writing; a rejection (dead channel, closed window) is
80
95
  // caught too, so it never surfaces as an unhandled rejection in the renderer.
96
+ // (state.debateFetch reset moved synchronously above, beside state.debate — fix-wave item 2.)
81
97
  if (detail && detail.run && detail.run.debate) {
82
- invoke('workspace:read-artifact', runId, 'debate.json').then(function (res) {
98
+ state.debateFetch = invoke('workspace:read-artifact', runId, 'debate.json').then(function (res) {
83
99
  if (state.runId !== runId) { return; }
84
100
  try { state.debate = JSON.parse(res.text); } catch (err) { state.debate = null; }
85
101
  }).catch(function () {
@@ -128,7 +144,7 @@
128
144
  state.detailRunStatus = d.run.status;
129
145
  }
130
146
  $('blind-toggle').checked = state.blind;
131
- state.labelByModel = {};
147
+ state.labelByModel = Object.create(null);
132
148
  d.derived.names.forEach(function (p) { state.labelByModel[p.model] = p.label; });
133
149
 
134
150
  $('run-title').textContent = d.runId;
@@ -146,7 +162,7 @@
146
162
  P.renderVerdictPanel();
147
163
  R.renderCost($('cost-body'), d.derived.cost, state.blind, labelOf);
148
164
  P.wireLazyPanels();
149
- var isTerminal = window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1;
165
+ var isTerminal = window.AmicusLive.isTerminal(d.run.status);
150
166
  $('abort-btn').hidden = isTerminal;
151
167
  V.startLiveLoop();
152
168
  }