amicus 4.6.3 → 4.7.1

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 (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  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/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. 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,11 +378,12 @@ 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.
@@ -387,7 +394,7 @@ direct namespace while the OpenRouter route still serves — a gateway-only rout
387
394
  no direct sibling is a routing choice, not staleness. Deliberately gateway-only
388
395
  entries (e.g. `gpt-pro`) are annotated as such and are never offered a retarget.
389
396
 
390
- **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.
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.
391
398
 
392
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.
393
400
 
@@ -424,7 +431,9 @@ Each stored alias resolves to one of three outcomes:
424
431
  amicus list # Current project
425
432
  amicus list --status running # Filter: running, complete, error, timed-out,
426
433
  # aborted, crashed, idle-timeout
427
- 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
428
437
  amicus list --json # Machine-readable
429
438
 
430
439
  amicus read <id> # Fold summary (default)
@@ -450,6 +459,47 @@ amicus setup --api-keys # Open just the API-key window
450
459
  amicus setup --add-alias fast=google/gemini-2.5-flash # Add/override one alias (bare canonical)
451
460
  ```
452
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`. `amicus continue` and `amicus resume` don't take a `--tag` of
491
+ their own — they automatically inherit the parent session's tag (see below).
492
+
493
+ **Tag inheritance.** `amicus continue` and `amicus resume` carry the parent session's tag forward
494
+ onto the new/reopened session's metadata and spend row, with no `--tag` flag needed — resume reuses
495
+ the parent's own session directory, and continue copies the tag onto the new session it creates, so
496
+ a multi-hop `continue` chain keeps the same tag at every depth. A `fanout --retry-failed` wave
497
+ likewise replays the original wave's tag automatically. Because the tag in each of these cases
498
+ comes from the session/wave being reopened rather than a fresh launch, `--tag` is rejected as a
499
+ usage error when combined with `continue`, `resume`, or `--retry-failed` — there is nothing new to
500
+ set. An untagged parent still leaves the key absent (not `null`) on the new metadata, and its spend
501
+ row still groups under `(unattributed)`, exactly as an untagged `start`/`fanout` would.
502
+
453
503
  **`amicus status <id>` output.** Human-readable:
454
504
 
455
505
  ```
@@ -468,7 +518,7 @@ $ amicus status demo123 --json
468
518
  "taskId": "demo123",
469
519
  "status": "complete",
470
520
  "elapsed": "5m 0s",
471
- "version": "4.6.3",
521
+ "version": "4.7.1",
472
522
  "model": "google/gemini-2.5-flash",
473
523
  "phase": "terminal"
474
524
  }
@@ -528,7 +578,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
528
578
  | `aliases` | Your configured aliases still resolve against the catalog | warn |
529
579
  | `anthropic-base-url` | `ANTHROPIC_BASE_URL` isn't host-form (host-form 404s every direct-Anthropic leg unless normalized) | warn |
530
580
  | `opencode-bin` | The OpenCode engine binary is on `PATH` | error |
531
- | `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide) | warn (error only if there's exactly one npx-cache copy and it's broken) |
581
+ | `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide, and a version-skewed one — present but the wrong opencode-ai release vs. the global install, #133) | warn (error only if there's exactly one npx-cache copy and it's broken; also warns, never errors, on engine version skew between the npx copies and the global install) |
532
582
  | `electron` | Electron (the interactive GUI) is installed | warn — headless still works |
533
583
  | `skills` | Both skills exist under `~/.claude/skills/` | warn |
534
584
  | `mcp` | Amicus is registered as an MCP server in Claude Code | warn |
@@ -575,7 +625,7 @@ Reads `~/.config/amicus/spend-ledger.jsonl` (one row per completed run/leg) and
575
625
  | `--model <id-or-prefix>` | rows whose model id starts with the given string |
576
626
  | `--op <op>` | rows for one operation (`start`, `leg`, `continue`, `resume`, …) |
577
627
  | `--failed` | rows with an explicit non-`complete` status (see the caveat below) |
578
- | `--group-by <dim>` | bucket totals by `model` (default) \| `wave` \| `council` \| `project` \| `op` \| `day` |
628
+ | `--group-by <dim>` | bucket totals by `model` (default) \| `wave` \| `council` \| `project` \| `op` \| `day` \| `tag` (untagged rows group under `(unattributed)`) |
579
629
  | `--rows` | also emit the raw filtered rows (capped at 1000; `--json` sets `rowsTruncated: true` past the cap) |
580
630
 
581
631
  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>
@@ -94,9 +94,27 @@
94
94
  ];
95
95
  }
96
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
+
97
113
  /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
98
114
  function seatsFromRunStats(costRows) {
99
- return (costRows || []).map(function (r) {
115
+ return (costRows || []).filter(function (r) {
116
+ return !SEATS_PANEL_EXCLUDED_ROLES[r.role];
117
+ }).map(function (r) {
100
118
  return {
101
119
  // ⚠️ DE-ROT (F37): composite id — a v4.1 `--debate` run emits extra runStats rows for
102
120
  // the SAME bench alias (role 'rebuttal'/'revote', src/council/debate.js:88-96). With no
@@ -130,13 +148,23 @@
130
148
  * loss, an out-of-range index, or a zero-model unit) carry neither, so they
131
149
  * correctly fall back to the plain phrasing.
132
150
  *
133
- * D6 filter (zero usable legs ONLY, "no ghost when a retry succeeded"): a
134
- * candidate already present in `liveSeats` (it has a cost row — SL-2 healed
135
- * it) is dropped. This is the one thing standing between a recovered seat
136
- * and a duplicate/ghost row same failure family as the F37 debate-role
137
- * collision and the RN-11 keyed-row lessons just above (seatsFromRunStats,
138
- * seatCells): an identity that is not carefully matched silently
139
- * 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.
140
168
  *
141
169
  * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
142
170
  * regardless of what the LIVE row's role was — so a model that died as
@@ -152,9 +180,9 @@
152
180
  * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
153
181
  * critic branch (src/council/run-stages.js), which only fires when lenses
154
182
  * are absent — safe today only because --critic and --lenses are mutually
155
- * exclusive (cli-handlers-council-run.js:196); if that exclusion ever
156
- * loosens, a healed critic on a lens run would carry role 'seat' and this
157
- * suppression would render a ghost dead row for it.
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.
158
186
  *
159
187
  * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
160
188
  * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
@@ -180,7 +208,12 @@
180
208
  */
181
209
  function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
182
210
  var critic = runMeta && runMeta.critic ? runMeta.critic : null;
183
- var seen = {};
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);
184
217
  var order = [];
185
218
  function add(model, retried, role) {
186
219
  if (!model || seen[model]) { return; }
@@ -220,12 +253,17 @@
220
253
  // a dead-critic candidate is cleared only by a live CRITIC leg. A null
221
254
  // role is NOT reviewing: counting it would suppress silently, the exact
222
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.
223
261
  function isReviewing(role) {
224
262
  return role === 'seat' || role === 'critic' ||
225
263
  (typeof role === 'string' && role.indexOf('lens:') === 0);
226
264
  }
227
- var reviewing = {};
228
- var byRole = {};
265
+ var reviewing = Object.create(null);
266
+ var byRole = Object.create(null);
229
267
  // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
230
268
  // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
231
269
  // executable id, not the alias a degrade record names; `modelInput` carries the alias.
@@ -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,6 +69,13 @@
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) {
70
80
  // F09 guard (v4.6.3 PR2): a reply for a run the user has since
71
81
  // navigated away from must never overwrite the run now open. Guard on
@@ -83,8 +93,9 @@
83
93
  // navigated away from must never overwrite the run now open. Capture `runId` and check
84
94
  // it's still `state.runId` before writing; a rejection (dead channel, closed window) is
85
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.)
86
97
  if (detail && detail.run && detail.run.debate) {
87
- invoke('workspace:read-artifact', runId, 'debate.json').then(function (res) {
98
+ state.debateFetch = invoke('workspace:read-artifact', runId, 'debate.json').then(function (res) {
88
99
  if (state.runId !== runId) { return; }
89
100
  try { state.debate = JSON.parse(res.text); } catch (err) { state.debate = null; }
90
101
  }).catch(function () {
@@ -133,7 +144,7 @@
133
144
  state.detailRunStatus = d.run.status;
134
145
  }
135
146
  $('blind-toggle').checked = state.blind;
136
- state.labelByModel = {};
147
+ state.labelByModel = Object.create(null);
137
148
  d.derived.names.forEach(function (p) { state.labelByModel[p.model] = p.label; });
138
149
 
139
150
  $('run-title').textContent = d.runId;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Council Workspace — lazy prose-panel loading (v4.4 §5.2). v4.7 PR7 extraction of the
3
+ * loading machinery out of workspace-panels.js, which was at 294/300 with the T19 stale-paint
4
+ * fixes still to land; the same treatment workspace-seats.js got in v4.6.2 PR4 (D8).
5
+ *
6
+ * Split line: this file owns WHEN and WHETHER an artifact read is issued and which reply is
7
+ * allowed to paint (the `loading` promise cache, the run/issue staleness fences).
8
+ * workspace-panels.js keeps NAME RESOLUTION (sanitizeName / resolveArtifactName — the RN-1
9
+ * disambiguation pair, pinned by tests/electron/workspace-ui-static.test.js) and the panel
10
+ * adapters. Cross-calls resolve `window.Amicus*` at CALL time, never at this file's load time
11
+ * — the house discipline for every renderer script.
12
+ *
13
+ * Loads AFTER workspace-seats.js and BEFORE workspace-panels.js (index.html), whose
14
+ * wireLazyPanels/proseLoader are thin delegates into this namespace.
15
+ */
16
+ (function () {
17
+ 'use strict';
18
+
19
+ // ---- lazy prose panels (spec §5.2: load on first open; NOT cached across a same-run rewire
20
+ // — a blind flip or the live loop's terminal refresh both drop it, T19-m1) -----------------
21
+ // ⚠️ DE-ROT (F09): a NEW toggle listener stacking on every renderDetail() call is the bug this
22
+ // shape exists to avoid — see wireLazyPanels()/proseLoader() below. Register the three
23
+ // listeners ONCE at boot (workspace-app.js's boot block calls proseLoader per panel id) and
24
+ // dispatch through this module-level `loaders` map, which renderDetail (via wireLazyPanels)
25
+ // overwrites per run.
26
+ //
27
+ // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
28
+ // settled (the old code guessed with setTimeout(render, 300), which could fire before an
29
+ // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
30
+ // is idempotent per panel id and returns its in-flight promise; the promise cache
31
+ // (`loading`) and the per-run spec (`loaders`) are both keyed by panel id. `loading` is
32
+ // dropped by wireLazyPanels() on BOTH a run CHANGE and a same-run rewire (T19-m1, Task 3) —
33
+ // the run-change drop is what stops F09's stale-run artifact requests; the same-run drop is
34
+ // what T19-m1's `issue` token (below) fences against repainting a superseded same-run wave.
35
+ var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
36
+ var loading = {}; // panelId -> Promise (dropped on every run CHANGE or same-run rewire)
37
+ // Task 19 (RN-5): the run wireLazyPanels() last reset panels/loading for — gates the reset
38
+ // below to run CHANGES only. A same-run call (renderDetail() runs this on every blind toggle
39
+ // too, and on the live loop's terminal refresh) instead drops the cached load for EVERY
40
+ // tracked panel — open or closed (T19-m1, Task 3) — and reissues a fetch for whichever panel
41
+ // is currently open (Fix 1).
42
+ var lastWiredRunId = null;
43
+ // ⚠️ T19-m1 (v4.7 PR7): the only staleness fence used to be the runId captured at issue time,
44
+ // which cannot distinguish two requests issued for the SAME run — a blind flip, a manifest that
45
+ // grew, or any same-run rewire issues a second load while the first is still in flight, and
46
+ // whichever settles LAST won the paint. Monotonic per-panel issue number: the completion handler
47
+ // paints only if it is still the newest issue. Keys are the three fixed panel-id literals (never
48
+ // a model name), so a bare object is safe here — unlike the model-keyed maps in live-model.js.
49
+ var issue = {}; // panelId -> monotonically increasing issue number
50
+
51
+ function loadPanel(panelId, bodyId, files) {
52
+ var A = window.AmicusApp;
53
+ if (loading[panelId]) { return loading[panelId]; }
54
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): this is the third
55
+ // instance of the F09 class of bug (a stale async response overwriting shared DOM after
56
+ // the user has navigated away) — already fixed once for the toggle-listener stack (F09
57
+ // itself) and once for the fire-and-forget debate.json fetch in workspace-app.js (guards
58
+ // with `if (state.runId !== runId) return;`). wireLazyPanels() clearing `loading[panelId]`
59
+ // on every run switch permits a NEW request to be issued, but never fenced the PRIOR
60
+ // request's eventual resolution — open reviews-panel on run A, switch to run B (which
61
+ // issues its own request), and A's response — however late — used to overwrite whatever
62
+ // B had just rendered. Capture the runId this request was issued for, and guard as the
63
+ // FIRST statement of the completion handler, exactly like the debate.json fix.
64
+ var runId = A.state.runId;
65
+ // ⚠️ T19-m1 (v4.7 PR7): captured the same way as `runId` above — read once, at issue time —
66
+ // so the completion guard below compares against the value in force when THIS request was
67
+ // issued, not whatever `issue[panelId]` has become by the time it resolves.
68
+ var token = (issue[panelId] = (issue[panelId] || 0) + 1);
69
+ var pending = Promise.all(files().map(function (f) {
70
+ return A.invoke('workspace:read-artifact', runId, f.name).then(function (res) {
71
+ return { name: f.name, title: f.title, text: res.text || '', truncated: res.truncated, error: res.error };
72
+ });
73
+ })).then(function (sections) {
74
+ if (A.state.runId !== runId || issue[panelId] !== token) { return; } // stale: superseded
75
+ window.AmicusRender.renderProseSections(A.$(bodyId), sections.map(function (s) {
76
+ return s.error ? { name: s.name, title: s.title, error: s.name + ' — ' + s.error } : s;
77
+ }));
78
+ A.$(panelId).dataset.loaded = '1'; // display/debug marker only — `loading` is the real gate
79
+ }, function (err) {
80
+ // ⚠️ T19-m2 (v4.7 PR7). Two-argument .then(onFulfilled, onRejected) — NOT a trailing
81
+ // .catch. workspace-verbs.js:76-84 already ruled on this exact construct: with a trailing
82
+ // .catch a THROW inside onFulfilled is routed here too, so a painter bug would be absorbed
83
+ // into a silent blank panel that ALSO evicts its own cache and therefore retries forever.
84
+ // With the two-argument form this handler only ever sees a genuinely rejected invoke().
85
+ // The `=== pending` self-check is load-bearing: without it a late rejection can evict a
86
+ // NEWER in-flight promise from `loading[panelId]`. That newer promise still resolves and
87
+ // paints fine on its own — it is not stranded — but the eviction leaves no cache entry
88
+ // behind for it, so a toggle that lands before it settles finds `loading[panelId]` empty
89
+ // and fires a spurious duplicate fetch. And the log is not optional — a silent
90
+ // eviction is the correct-but-silent degrade the product principle rejects.
91
+ if (loading[panelId] === pending) { delete loading[panelId]; }
92
+ console.error('workspace lazy panel: read-artifact failed for ' + panelId, err);
93
+ });
94
+ loading[panelId] = pending;
95
+ return pending;
96
+ }
97
+
98
+ /** Registered ONCE at boot (per panel id); reads the current run's spec off `loaders`. */
99
+ function proseLoader(panelId) {
100
+ var A = window.AmicusApp;
101
+ var panel = A.$(panelId);
102
+ panel.addEventListener('toggle', function () {
103
+ if (!panel.open) { return; }
104
+ var spec = loaders[panelId];
105
+ if (spec) { loadPanel(panelId, spec.bodyId, spec.files); }
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Rewrites the per-run spec map on every call. On a run CHANGE (tracked via the module-level
111
+ * `lastWiredRunId`, above), resets panel open/loaded state and drops the previous run's
112
+ * cached load promises — exactly what F09's stale-run protection needs. On a SAME-run call
113
+ * (Task 19, RN-5: renderDetail() calls this on every blind toggle too, and the live loop's
114
+ * terminal refresh) any panel the user already has open is instead refreshed in place — see
115
+ * Fix 1 below — never left showing stale-blind content, never collapsed. Registers no
116
+ * listeners itself.
117
+ */
118
+ function wireLazyPanels() {
119
+ var A = window.AmicusApp;
120
+ // ⚠️ Fix-wave (Fix 4): keyed off `A.state.detail.runId`, not `A.state.runId` — the latter is
121
+ // set synchronously at the top of openRun(), before its workspace:get-run reply lands, so an
122
+ // out-of-order reply could make the two diverge. workspace-app.js's own run-change gate
123
+ // (renderDetail(), above `d.runId`) reads off the SAME `state.detail.runId`, so the two
124
+ // provably agree on whether this is a run change.
125
+ var sameRun = A.state.detail.runId === lastWiredRunId;
126
+ if (!sameRun) {
127
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
128
+ var p = A.$(id);
129
+ p.dataset.loaded = '0';
130
+ p.open = false;
131
+ delete loading[id];
132
+ });
133
+ lastWiredRunId = A.state.detail.runId;
134
+ }
135
+ var bench = A.state.detail.run.bench || [];
136
+ var debated = !!A.state.detail.run.debate;
137
+ // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
138
+ // artifact is NOT translated into a friendly "not written yet" note anywhere in this
139
+ // read path — it lands in the panel verbatim, absolute host path and all. `run.debate` is
140
+ // seeded on run.json's FIRST write, so it's truthy on every --debate run, including ones
141
+ // where the re-vote wave never actually ran (no contested findings, cost ceiling, abort) —
142
+ // requesting revote-<model>.md speculatively in that (near-certain) case means one ugly
143
+ // error row per bench model for a condition that isn't an error at all. run-detail.js
144
+ // already computes a presence manifest (state.detail.artifacts) for exactly these
145
+ // allowlisted names via fs.statSync — filter on it instead of requesting known-absent
146
+ // files. Applies to review-/judge- too (the same latent gap, just plan-mandated rather
147
+ // than new).
148
+ var artifacts = A.state.detail.artifacts || {};
149
+ function present(name) { return !!(artifacts[name] && artifacts[name].present); }
150
+ // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
151
+ // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
152
+ // re-vote title below already used — so the next blind-mode ruling lands in one place instead
153
+ // of being re-applied by hand in every file that happens to render an identity.
154
+ loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
155
+ return bench.map(function (m) {
156
+ var label = A.state.labelByModel[m];
157
+ return { name: window.AmicusPanels.resolveArtifactName(m, 'review'), title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
158
+ }).filter(function (f) { return present(f.name); });
159
+ } };
160
+ loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
161
+ // ⚠️ v4.4.1 RN-4: the presence filter is NOT optional here either. Without it, a run whose
162
+ // Stage 2 never ran (a one-seat bench, an abort before the cross-review, a cost ceiling)
163
+ // requested a file the manifest already knows is absent and rendered readRunArtifact's raw
164
+ // error string in the panel — "absolute host path and all", per this file's own round-2
165
+ // note above `present()`. reviews-panel and judges-panel have always filtered; this was the
166
+ // odd one out.
167
+ return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }]
168
+ .filter(function (f) { return present(f.name); });
169
+ } };
170
+ loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
171
+ var files = bench.map(function (m) {
172
+ var label = A.state.labelByModel[m];
173
+ return { name: window.AmicusPanels.resolveArtifactName(m, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
174
+ });
175
+ if (debated) {
176
+ // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
177
+ // prose lives in revote-<model>.md (not judge-<model>.md). Included per bench model
178
+ // like judge-*.md above, but — per the presence filter — only when the manifest
179
+ // confirms the file actually exists (see the code-review note above `present()`).
180
+ // ⚠️ CODE REVIEW (round 2, finding 3): this title is new code (unlike the review-/
181
+ // judge- titles above, which mirror the brief verbatim), so it goes through
182
+ // AmicusRender.display() — the single blind-flip definition — rather than adding a
183
+ // fourth hand-rolled copy of the same ternary.
184
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this name used to be recomputed via a
185
+ // bare sanitizeName(m) call, ignoring the disambiguation map entirely — for a colliding
186
+ // pair BOTH models resolved to the same bare revote-<sanitized>.md name, reintroducing
187
+ // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
188
+ // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
189
+ // legacy fallback keeps older detail payloads (no artifactsByModel map) correct too.
190
+ files = files.concat(bench.map(function (m) {
191
+ var label = A.state.labelByModel[m];
192
+ return { name: window.AmicusPanels.resolveArtifactName(m, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
193
+ }));
194
+ }
195
+ return files.filter(function (f) { return present(f.name); });
196
+ } };
197
+ // ⚠️ Fix-wave (Fix 1, RN-9): a same-run call (the blind toggle, or the live loop's
198
+ // running -> terminal refresh) must re-render any panel the user already has open, or it
199
+ // keeps showing content painted under the PREVIOUS blind state. renderProseSections()
200
+ // (workspace-render.js) clears its container before repainting, so this replaces sections
201
+ // in place rather than appending duplicates. Drop the cached promise first so loadPanel()
202
+ // actually re-fetches instead of returning its already-settled one.
203
+ if (sameRun) {
204
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
205
+ var p = A.$(id);
206
+ // ⚠️ T19-m1 (v4.7 PR7): the cache drop used to be INSIDE the `p.open` guard, so a panel
207
+ // the user had collapsed kept its settled promise across a blind flip — reopening it
208
+ // returned that promise and repainted the previous blind state with no new fetch (recon
209
+ // path A). Dropping unconditionally closes path A — but by itself it only CONVERTS path D
210
+ // (collapse mid-flight) into a race: it drops the cache entry even while that panel's
211
+ // fetch is still outstanding, so a reopen before it settles issues a SECOND concurrent
212
+ // fetch, and the orphaned first one has no fence but `runId` (unchanged for a same-run
213
+ // rewire). `issue` (declared above) and loadPanel()'s completion guard are what actually
214
+ // close D. Unconditional drop costs a re-read of that panel's artifacts on the next open;
215
+ // renderDetail fires on run open, blind toggle, and the live loop's terminal refresh only
216
+ // (the tick calls applyLive, not renderDetail), so this is not a per-poll storm.
217
+ delete loading[id];
218
+ if (p.open) { loadPanel(id, loaders[id].bodyId, loaders[id].files); }
219
+ });
220
+ }
221
+ }
222
+
223
+ // `loaders` stays module-private; drillIntoJudge (still in workspace-panels.js) needs to ask
224
+ // whether this run has a spec for a panel, and nothing else needs the map itself.
225
+ function panelSpec(panelId) { return loaders[panelId] || null; }
226
+
227
+ window.AmicusLazy = {
228
+ loadPanel: loadPanel,
229
+ proseLoader: proseLoader,
230
+ wireLazyPanels: wireLazyPanels,
231
+ panelSpec: panelSpec,
232
+ };
233
+ })();
@@ -76,7 +76,18 @@
76
76
  'aria-label': verdictTitle(cell),
77
77
  }, [cell.sym + (cell.isRaiser ? '*' : '')]);
78
78
  if (cell.verdict === 'dispute') {
79
- td.addEventListener('click', function () { onDrill(cell.judge, row.id); });
79
+ // ⚠️ T19-m2 (v4.7 PR7): drillIntoJudge's derived promise was discarded here, so a
80
+ // rejection — or a throw inside its own post-load body, which loadPanel's onRejected
81
+ // does NOT cover — escaped unhandled. `Promise.resolve(onDrill(...))` would NOT fix the
82
+ // synchronous half: onDrill(...) is evaluated BEFORE Promise.resolve sees it, so the
83
+ // throw escapes anyway (measured). Calling it inside the .then callback moves both the
84
+ // sync and async failure modes onto the chain. A trailing .catch is correct HERE
85
+ // (unlike loadPanel — see workspace-verbs.js:76-84) because there is no separate
86
+ // onFulfilled whose throw it could absorb: the callback IS the whole operation.
87
+ td.addEventListener('click', function () {
88
+ Promise.resolve().then(function () { return onDrill(cell.judge, row.id); })
89
+ .catch(function (err) { console.error('workspace matrix: drill into judge failed', err); });
90
+ });
80
91
  }
81
92
  cells.push(td);
82
93
  });