amicus 4.4.1 → 4.5.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 (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +130 -0
  3. package/README.md +15 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/ROADMAP.md +36 -10
  6. package/docs/configuration.md +24 -0
  7. package/docs/council.md +59 -0
  8. package/docs/schemas.md +1 -0
  9. package/docs/usage.md +151 -1
  10. package/electron/workspace-ui/workspace-app.js +39 -17
  11. package/electron/workspace-ui/workspace-panels.js +76 -18
  12. package/electron/workspace-ui/workspace-render.js +10 -0
  13. package/package.json +1 -1
  14. package/schemas/council-run-live.schema.json +1 -1
  15. package/schemas/council-run.schema.json +14 -0
  16. package/schemas/error.schema.json +1 -1
  17. package/schemas/event.schema.json +1 -1
  18. package/schemas/pack.schema.json +30 -0
  19. package/schemas/progress.schema.json +1 -1
  20. package/schemas/run-live.schema.json +1 -1
  21. package/schemas/run.schema.json +2 -1
  22. package/schemas/wave-live.schema.json +1 -1
  23. package/schemas/wave.schema.json +2 -1
  24. package/skills/second-opinion/SKILL.md +5 -0
  25. package/src/cli-handlers-council-run.js +51 -8
  26. package/src/cli-handlers-pack.js +238 -0
  27. package/src/cli-handlers-run.js +36 -8
  28. package/src/cli-handlers-template.js +53 -0
  29. package/src/cli.js +64 -3
  30. package/src/council/findings.js +4 -41
  31. package/src/council/presets-cli.js +23 -11
  32. package/src/council/run-stages.js +12 -9
  33. package/src/council/run-state.js +17 -0
  34. package/src/council/run.js +1 -1
  35. package/src/headless.js +18 -14
  36. package/src/mcp-council-run.js +108 -4
  37. package/src/mcp-server.js +203 -7
  38. package/src/mcp-tools.js +15 -5
  39. package/src/pack/pack-cli.js +38 -0
  40. package/src/pack/pack-forward.js +96 -0
  41. package/src/pack/pack-resolve.js +297 -0
  42. package/src/pack/pack-store.js +130 -0
  43. package/src/pack/pack-validate.js +113 -0
  44. package/src/sidecar/fanout.js +21 -4
  45. package/src/sidecar/progress.js +34 -0
  46. package/src/sidecar/start.js +5 -4
  47. package/src/sidecar/workspace-auto-open.js +69 -0
  48. package/src/sidecar/workspace-window.js +46 -1
  49. package/src/template/apply.js +88 -0
  50. package/src/template/render.js +86 -0
  51. package/src/template/store.js +106 -0
  52. package/src/utils/config.js +65 -25
  53. package/src/utils/error-doc.js +5 -0
  54. package/src/utils/result-schema-rebuild.js +1 -0
  55. package/src/utils/result-schema.js +8 -2
  56. package/src/workspace/artifact-guard.js +44 -6
  57. package/src/workspace/run-detail.js +6 -0
package/docs/usage.md CHANGED
@@ -42,6 +42,13 @@ amicus council run --prompt-file <b.md> --models a,b,c --chair <m> [--json] # H
42
42
  amicus council save <name> --models a,b,c # Save a named council preset (>=2 resolvable members)
43
43
  amicus council list [--json] # List saved councils + built-ins (free/budget/frontier)
44
44
  amicus council show <name> [--json] # Resolve a council (saved or built-in) and show its members
45
+ amicus template list [--json] # List briefing templates (built-ins marked)
46
+ amicus template show <name|path> [--json] # Print a template's raw text
47
+ amicus pack save <name> --kind council|fanout|solo [flags] # Save a full run config (bench/options/template)
48
+ amicus pack save <name> --from-run <id> # ...or build one from an existing run/wave/session
49
+ amicus pack list [--json] # List saved packs
50
+ amicus pack show <name|path> [--json] # Print a pack + its validation report
51
+ amicus pack rm <name> [--json] # Remove a saved pack
45
52
  ```
46
53
 
47
54
  ---
@@ -85,6 +92,10 @@ amicus start --model deepseek --prompt "Generate tests" --no-ui --timeout 30
85
92
  | `--setup` | Force-open configuration before launching. Does **not** relax the `--prompt`/`--prompt-file` requirement — `start --setup` still fails fast with "Error: --prompt or --prompt-file is required" if neither is given. | |
86
93
  | `--no-validate-model` | Skip model-catalog validation before launch. | validation on |
87
94
  | `--gateway <mode>` | Routing override for this launch: `auto` (direct-first), `direct` (require a direct provider key), or `openrouter` (force OpenRouter). Overrides `routing.prefer` for one call. | `auto` |
95
+ | `--pack <name\|path>` | Load a saved [policy pack](#policy-packs) — its `model`/options/template fill in for anything you didn't type explicitly. | |
96
+ | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`). | |
97
+ | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. | |
98
+ | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. | |
88
99
 
89
100
  > 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`.
90
101
 
@@ -119,6 +130,10 @@ amicus fanout --council free --prompt "Review this design" --json
119
130
  | `--no-cost-gate` | Disable the budget gate (per-$/Mtok threshold + ceiling) for this run. |
120
131
  | `--no-validate-model` | Skip catalog validation. |
121
132
  | `--gateway <mode>` | Routing override applied to every leg: `auto` (direct-first), `direct`, or `openrouter`. |
133
+ | `--pack <name\|path>` | Load a saved [policy pack](#policy-packs) — its bench/options/template fill in for anything you didn't type explicitly. |
134
+ | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`), shared by every leg. |
135
+ | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. |
136
+ | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
122
137
 
123
138
  **Shared per-leg knobs.** Every leg in the wave also accepts the same per-leg options as `start`:
124
139
  `--agent`, `--thinking`, `--timeout`, `--summary-length`, `--no-context`, `--context-*`, `--mcp*`,
@@ -180,6 +195,10 @@ amicus council run --prompt-file briefing.md --models gemini,glm --chair deepsee
180
195
  | `--debate` | Adds a Stage-2.5 rebuttal round (provisional tally → defense → re-vote → final tally) between cross-review and the final tally. |
181
196
  | `--claude-review <file>` | Enters Claude's own review from a file as judged review N+1 — no leg is ever launched for it; `claude` is a reserved seat name and may not also appear in `--models`, `--chair`, or `--critic` (pre-flight error). |
182
197
  | `--no-cost-gate` | Disable the per-leg price gate for the whole run (repairs + chair). |
198
+ | `--pack <name\|path>` | Load a saved [policy pack](#policy-packs) — its bench/chair/critic/lenses/options/template fill in for anything you didn't type explicitly. |
199
+ | `--template <name\|path>` | Render a [briefing template](#briefing-templates) (`{{prompt}}`, `{{artifact}}`, `{{artifact_path}}`, `{{date}}`, `{{project}}`, `{{var.*}}`). |
200
+ | `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. |
201
+ | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
183
202
 
184
203
  **Exit codes:** `0` full run · `2` degraded but reportable (fewer than 2 judges, chair failure —
185
204
  `overallVerdict: null` — a cost ceiling hit after the tally, or a `--max-cost` ceiling set over a
@@ -192,6 +211,14 @@ Field-by-field run-directory contents, the degradation table, and `verdict.json`
192
211
  `overallVerdict` are documented in **[docs/council.md](./council.md#amicus-council-run)**. This is
193
212
  the command the repo's Council Review GitHub Action (v2) runs on labeled PRs.
194
213
 
214
+ **Auto-open the Council Workspace (v4.5).** When this run is launched through the `amicus_council_run`
215
+ **MCP tool** from Claude Code (local), the same Council Workspace window that `amicus watch <runId>
216
+ --ui` opens by hand also opens automatically, detached, right after the run starts — no extra call
217
+ needed to watch it live. The plain CLI invocation above is unaffected either way: there is no MCP client to
218
+ detect on that path. Full decision order (the `ui` MCP param, the `workspace.autoOpen` config key,
219
+ and the four guards) is in **[docs/council.md's Council Workspace
220
+ section](./council.md#council-workspace-gui)**.
221
+
195
222
  ---
196
223
 
197
224
  ## `amicus council save|list|show` — Council Presets
@@ -217,6 +244,129 @@ amicus council show budget [--json] # Works on built-ins
217
244
 
218
245
  ---
219
246
 
247
+ ## Briefing templates
248
+
249
+ Render a `{{variable}}` briefing before it's sent — for `start`, `fanout`, and `council run` alike.
250
+
251
+ ```bash
252
+ amicus template list [--json] # Built-ins marked [built-in]; a same-named user file shadows one
253
+ amicus template show review # Print a template's raw text
254
+ amicus start --model gemini --template review --artifact plan.md --var focus=performance --no-ui --json
255
+ ```
256
+
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.
258
+
259
+ **Strict by design — a typo fails loudly instead of silently dropping text:**
260
+
261
+ | Situation | Result |
262
+ |---|---|
263
+ | Template uses `{{var.foo}}`, no `--var foo=...` given | Error (`TEMPLATE_RENDER`) |
264
+ | `--var foo=...` given, template never uses `{{var.foo}}` | Notice (not an error) — printed to stderr |
265
+ | Template uses `{{prompt}}`, no `--prompt`/`--prompt-file` given | Error |
266
+ | `--prompt`/`--prompt-file` given, template has no `{{prompt}}` slot | Error — the text would be silently dropped |
267
+ | Template uses `{{artifact}}`/`{{artifact_path}}`, no `--artifact` given | Error |
268
+ | `--artifact` given, template has no `{{artifact}}`/`{{artifact_path}}` slot | Error |
269
+ | An unrecognized `{{name}}` appears anywhere in the template | Error — lists the known variables |
270
+
271
+ **Where templates live.** Markdown files in `~/.config/amicus/templates/<name>.md` — the name is the filename minus `.md`. A user file **shadows** a built-in of the same name (`amicus template list` marks it `[shadows built-in]`); there is no `template save`/`rm` — your editor is the manager. v4.5 ships one built-in, `review` (asks for a severity-tagged, artifact-grounded review ending in a one-paragraph verdict).
272
+
273
+ **`--artifact <file>`** reads a file (256 KB cap) into `{{artifact}}` (its content) and `{{artifact_path}}` (its resolved path) — pass the plan/diff/design you want reviewed as a file instead of pasting it into `--prompt`.
274
+
275
+ **MCP.** None of the three run tools (`amicus_start`, `amicus_fanout`, `amicus_council_run`) have a `template` param of their own — a [policy pack](#policy-packs)'s `briefing.template` is the only way a template reaches an MCP-invoked run, rendered against that call's own briefing text at the same single application point a typed `--template` would use.
276
+
277
+ ---
278
+
279
+ ## Policy packs
280
+
281
+ Save a full run configuration — bench, chair/critic/lenses, options, briefing template — and invoke it by name instead of re-typing every flag.
282
+
283
+ ```bash
284
+ amicus pack save <name> --kind council|fanout|solo [flags] # build from flags
285
+ amicus pack save <name> --from-run <id> # build from an existing run/wave/session
286
+ amicus pack list [--json]
287
+ amicus pack show <name|path> [--json]
288
+ amicus pack rm <name> [--json]
289
+ ```
290
+
291
+ Then invoke it with `--pack <name|path>` on `start` / `fanout` / `council run` — or the `pack` param on the `amicus_start` / `amicus_fanout` / `amicus_council_run` MCP tools.
292
+
293
+ **What a pack can hold, per `kind`:**
294
+
295
+ | Kind | Bench field | Kind-specific fields | Allowed `options.*` |
296
+ |---|---|---|---|
297
+ | `council` | `bench` (a saved council name, or an array of ≥2 members) | `chair`, `critic`, `lenses` | `timeout`, `maxCost`, `gateway`, `debate` |
298
+ | `fanout` | `bench` (a saved council name, or an array of ≥2 members) | — | `timeout`, `maxCost`, `gateway`, `agent`, `thinking`, `summaryLength`, `noContext`, `contextTurns`, `contextMaxTokens` |
299
+ | `solo` | `model` | — | `timeout`, `maxCost`, `gateway`, `agent`, `thinking`, `summaryLength`, `noUi`, `noContext`, `contextTurns`, `contextMaxTokens` |
300
+
301
+ `council` packs do **not** accept `agent`, `thinking`, or `summaryLength` — they were inert on every surface (no council code path, CLI or MCP, ever reads a pack-filled one; the engine hardcodes agent `Plan`/summaryLength `verbose`), so they were dropped before release rather than shipped as dead weight a pack author would reasonably expect to work. A `council` pack that still sets one fails `pack save` with `PACK_INVALID`, naming the key. They remain valid, and functional, on `fanout`/`solo` packs.
302
+
303
+ 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
+
305
+ **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
+
307
+ **`--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.
308
+
309
+ **Where packs live.** One JSON file per pack, `~/.config/amicus/packs/<name>.json`. Re-saving an unchanged pack is a no-op; saving a changed pack under an unchanged version string auto-bumps its patch version instead of silently overwriting history. Every pack also carries a content hash (sha256 of its canonical, sorted-key JSON form, first 12 hex chars) computed fresh on every read — a hand-edited pack whose `version` field you forgot to bump still gets a distinct hash on any run that used it.
310
+
311
+ **Recorded on every run, whether or not any value was actually overridden:** `pack: {name, version, hash, source}` lands on the resulting solo session `metadata.json`, wave `metadata.json`/`wave.json`, or council `run.json` — `source` is `"dir"` for a saved pack invoked by name, `"path"` for one loaded by file path.
312
+
313
+ **Error codes**, all through the standard `--json` error envelope:
314
+
315
+ | Situation | Code |
316
+ |---|---|
317
+ | `pack show <missing>` | `PACK_NOT_FOUND` |
318
+ | `pack rm <missing>` | `PACK_NOT_FOUND` |
319
+ | `pack save` fails validation | `PACK_INVALID` (hard-fail; non-fatal warnings still print to stderr) |
320
+ | `--pack <name>` at run time is the wrong `kind` (e.g. a `solo` pack passed to `council run`) | `PACK_KIND_MISMATCH` |
321
+ | `pack save --from-run <unknown id>` | `BAD_SESSION` |
322
+
323
+ **Over MCP, pack resolution happens entirely in-process** — `amicus_start`/`amicus_fanout`/`amicus_council_run` never spawn a child with `--pack`; the pack's values are merged onto that call's own input before validation, exactly as they would be for a typed param. Two knobs get special handling for CLI parity: a pack's `options.maxCost` and `briefing.template` have no schema param of their own on `amicus_start`/`amicus_fanout` (neither tool exposes either directly), but they still apply — forwarded to the spawned CLI child's argv as `--max-cost`/`--template` (`amicus_fanout` always spawns; `amicus_start`'s spawn-fallback path does the same), or, on `amicus_start`'s in-process shared-server path, applied via the same budget-gate/template-render code the CLI itself uses, before any session is created. (`amicus_council_run` already has real MCP params for both, so this forwarding never triggers there.) Any *other* pack knob with nowhere to land in a given tool's own MCP schema is never silently dropped either — it comes back as an explicit `Notice: pack '<name>' sets <key>, which <tool> does not support over MCP — ignored.` content block, naming the pack's own camelCase option key (e.g. `contextTurns`, never the CLI's `context-turns`). Concretely, `amicus_fanout` has no MCP destination for `options.contextTurns`/`options.contextMaxTokens` (both notice); `amicus_start` has real `contextTurns`/`contextMaxTokens` params, so no notice there. `council` packs cannot carry `agent`/`thinking`/`summaryLength` at all (see above), so there is nothing left to orphan on that surface.
324
+
325
+ ### Worked example — save, inspect, invoke
326
+
327
+ Run end to end against the real CLI (a scratch config dir, so paths below are shown in their normal
328
+ `~/.config/amicus` form rather than the test scratch path):
329
+
330
+ ```bash
331
+ $ amicus pack save review-bench --kind council \
332
+ --bench gemini,deepseek,gpt --chair opus \
333
+ --timeout 20 --max-cost 2 --description "Standard 3-model review bench"
334
+ Saved pack 'review-bench' v1.0.0 → ~/.config/amicus/packs/review-bench.json
335
+ ```
336
+
337
+ ```bash
338
+ $ amicus pack show review-bench
339
+ Pack 'review-bench' v1.0.0 [council] (dir: ~/.config/amicus/packs/review-bench.json)
340
+ hash: da084ba56162
341
+ description: Standard 3-model review bench
342
+ bench: gemini, deepseek, gpt
343
+ chair: opus
344
+ options: {"timeout":20,"maxCost":2}
345
+ validation: ok
346
+ ```
347
+
348
+ ```bash
349
+ $ amicus council run --pack review-bench --prompt-file briefing.md --out-dir council-run --json
350
+ ```
351
+
352
+ No `--models`/`--chair`/`--timeout`/`--max-cost` needed on that last line — they all came from the
353
+ pack. Confirmed against the run's own `run.json` for this exact invocation (irrelevant keys elided):
354
+
355
+ ```json
356
+ {
357
+ "bench": ["gemini", "deepseek", "gpt"],
358
+ "chair": "opus",
359
+ "pack": { "name": "review-bench", "hash": "da084ba56162", "source": "dir" },
360
+ "options": { "timeout": 20, "maxCost": 2, "gateway": "auto", "outDir": "..." }
361
+ }
362
+ ```
363
+
364
+ Adding an explicit flag overrides just that one value — `... --pack review-bench --chair gpt-pro`
365
+ keeps the pack's bench and cost/timeout options but chairs with `gpt-pro` instead of `opus`, and the
366
+ pack is still recorded on the run either way.
367
+
368
+ ---
369
+
220
370
  ## `amicus models` — The Model Catalog
221
371
 
222
372
  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).
@@ -293,7 +443,7 @@ $ amicus status demo123 --json
293
443
  "taskId": "demo123",
294
444
  "status": "complete",
295
445
  "elapsed": "5m 0s",
296
- "version": "4.4.1",
446
+ "version": "4.5.0",
297
447
  "model": "google/gemini-2.5-flash",
298
448
  "phase": "terminal"
299
449
  }
@@ -27,6 +27,13 @@
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
29
  blind: false,
30
+ // Task 19 (RN-5) + fix-wave (RN-5 amendment): the (run id, status) pair renderDetail() last
31
+ // computed state.blind's default for. Together they gate the recompute (in renderDetail(),
32
+ // below) to a run CHANGE or a STATUS change only: a same-run/same-status re-render (the
33
+ // blind toggle) keeps the user's own choice, while a same-run/CHANGED-status re-render (the
34
+ // live loop's running -> terminal refresh, or the abort-confirm re-read) still auto-reveals.
35
+ detailRunId: null,
36
+ detailRunStatus: null,
30
37
  labelByModel: {},
31
38
  listTimer: null,
32
39
  liveTimer: null, // LIVE (Task 15)
@@ -103,8 +110,23 @@
103
110
  return;
104
111
  }
105
112
 
106
- // blind default: computed ONCE per run-open from status (resolution 9)
107
- state.blind = window.AmicusLive.defaultBlind(d.run.status);
113
+ // blind default: computed from status (resolution 9) — only on a run CHANGE or a STATUS
114
+ // change for the same run. Task 19 (RN-5): a same-run/same-status re-render
115
+ // (renderDetail_preserveBlind, below, calls straight back in here on every blind toggle)
116
+ // must keep the user's own choice instead of recomputing the default every call —
117
+ // recomputing unconditionally is what forced the old code to paint twice and, via
118
+ // wireLazyPanels() a few lines down, collapse any lazy panel the user had open.
119
+ // ⚠️ Fix-wave (RN-5 amendment, controller ruling): run id ALONE also suppressed the
120
+ // pre-existing running -> terminal auto-reveal, since the live loop's terminal refresh
121
+ // (workspace-verbs.js's startLiveLoop tick) and the abort-confirm re-read both call
122
+ // openRun() on the SAME run id — same run, but a real status transition. Keying on run id
123
+ // AND status recomputes (auto-reveals) on that transition while still preserving a same-
124
+ // run/same-status call (the blind toggle).
125
+ if (state.detailRunId !== d.runId || state.detailRunStatus !== d.run.status) {
126
+ state.blind = window.AmicusLive.defaultBlind(d.run.status);
127
+ state.detailRunId = d.runId;
128
+ state.detailRunStatus = d.run.status;
129
+ }
108
130
  $('blind-toggle').checked = state.blind;
109
131
  state.labelByModel = {};
110
132
  d.derived.names.forEach(function (p) { state.labelByModel[p.model] = p.label; });
@@ -178,7 +200,6 @@
178
200
  });
179
201
 
180
202
  function renderDetail_preserveBlind() {
181
- var keep = state.blind;
182
203
  // ⚠️ Fix-wave item 1: renderDetail() itself early-returns safely for an unreadable run
183
204
  // (!d || d.error || !d.derived) — but this wrapper used to run past that guard
184
205
  // unconditionally, dereferencing the (nonexistent) derived model via renderSeatsPanel()
@@ -187,22 +208,22 @@
187
208
  // the error branch unhides #run-view before the derived-model guard, so the Blind
188
209
  // checkbox is live with nothing behind it.
189
210
  if (!state.detail || state.detail.error || !state.detail.derived) {
190
- $('blind-toggle').checked = keep;
211
+ $('blind-toggle').checked = state.blind;
191
212
  return;
192
213
  }
214
+ // ⚠️ Task 19 (RN-5): this used to call renderDetail() (which unconditionally stomped
215
+ // state.blind back to the run's status default), restore the user's pre-call value,
216
+ // and then re-paint header chips/seats/matrix/verdict/cost a SECOND time to compensate
217
+ // for the first call having painted with the wrong (default) blind state — a double
218
+ // paint, and (via wireLazyPanels(), called inside that first renderDetail()) a collapse
219
+ // of any lazy panel the user had open. Fixed at the root instead of compensated for:
220
+ // renderDetail() now recomputes the blind default only on a run CHANGE or a STATUS change
221
+ // (state.detailRunId/state.detailRunStatus, above) — this call changes neither, so it
222
+ // keeps state.blind exactly as the change listener above just set it, and
223
+ // workspace-panels.js's wireLazyPanels() (its own same-run guard, `lastWiredRunId`)
224
+ // refreshes any open panel in place rather than collapsing it (fix-wave, Fix 1). One
225
+ // renderDetail() call now paints correctly the first time — nothing left to restore.
193
226
  renderDetail();
194
- state.blind = keep;
195
- $('blind-toggle').checked = keep;
196
- // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): renderDetail() (just
197
- // called above) resets state.blind to the run's DEFAULT before this function restores the
198
- // user's chosen `keep` value — renderHeaderChips was painted during that inner call with
199
- // the (temporarily wrong) default blind state and, unlike seats/matrix/verdict/cost below,
200
- // was never repainted afterward. Re-render it here too, now that state.blind is correct.
201
- window.AmicusRender.renderHeaderChips($('run-chips'), state.detail.run, state.blind, labelOf);
202
- P.renderSeatsPanel();
203
- P.renderMatrixPanel();
204
- P.renderVerdictPanel();
205
- window.AmicusRender.renderCost($('cost-body'), state.detail.derived.cost, state.blind, labelOf);
206
227
  }
207
228
 
208
229
  $('run-list').addEventListener('keydown', function (e) {
@@ -227,7 +248,8 @@
227
248
 
228
249
  // ⚠️ PRE-FLIGHT (P4) + DE-ROT (F09): register the three prose `toggle` listeners exactly ONCE,
229
250
  // here at boot. wireLazyPanels() (called from renderDetail, on every run-open and blind-toggle)
230
- // only rewrites the per-run `loaders` spec map from now onit never adds a listener.
251
+ // rewrites the per-run `loaders` spec map every call andfix-wave, Fix 1 refreshes any
252
+ // already-open lazy panel in place on a same-run call; it never adds a listener.
231
253
  P.proseLoader('reviews-panel');
232
254
  P.proseLoader('bundle-panel');
233
255
  P.proseLoader('judges-panel');
@@ -19,6 +19,24 @@
19
19
  // inverted back to the model id that keys state.labelByModel, so blind labels would break.
20
20
  function sanitizeName(model) { return String(model).replace(/[^a-zA-Z0-9._-]/g, '-'); }
21
21
 
22
+ // ⚠️ Task 18 (RN-1): review-/judge- filenames used to be recomputed here via a bare
23
+ // sanitizeName(model) call, which is NOT injective — two distinct bench models that sanitize
24
+ // to the same name would both resolve to the SAME filename, so drillIntoJudge's
25
+ // `[data-artifact="..."]` lookup handed back whichever section matched first (model A's prose
26
+ // rendered under model B's name). derived.artifactsByModel (src/workspace/artifact-guard.js's
27
+ // artifactAllowlist, threaded through by run-detail.js) already carries the disambiguated
28
+ // (possibly `~2`/`~3`-suffixed) name per raw model — consult it FIRST. Fall back to the legacy
29
+ // computation only when the map itself is absent: older detail payloads (pre-v4.5 runs,
30
+ // live-doc consumers not yet updated to build the map) never carry it, and re-deriving via
31
+ // sanitizeName is exactly what those payloads always did, so it stays correct for them too.
32
+ function resolveArtifactName(model, kind) {
33
+ var A = window.AmicusApp;
34
+ var map = A.state.detail.derived && A.state.detail.derived.artifactsByModel;
35
+ var entry = map && map[model];
36
+ if (entry && entry[kind]) { return entry[kind]; }
37
+ return kind + '-' + sanitizeName(model) + '.md';
38
+ }
39
+
22
40
  function renderSeatsPanel() {
23
41
  var A = window.AmicusApp;
24
42
  var d = A.state.detail;
@@ -60,12 +78,16 @@
60
78
  // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
61
79
  // settled (the old code guessed with setTimeout(render, 300), which could fire before an
62
80
  // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
63
- // is idempotent per panel id and returns its in-flight promise; both the promise cache
64
- // (`loading`) and the per-run spec (`loaders`) are keyed by panel id and cleared/overwritten
65
- // by wireLazyPanels() on every run-open that clearing is what stops F09's stale-run
66
- // artifact requests.
81
+ // is idempotent per panel id and returns its in-flight promise; the promise cache
82
+ // (`loading`) and the per-run spec (`loaders`) are both keyed by panel id, but only
83
+ // `loading` is cleared by wireLazyPanels() on a run CHANGE only (Task 19, RN-5) and
84
+ // that clearing is what stops F09's stale-run artifact requests.
67
85
  var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
68
86
  var loading = {}; // panelId -> Promise (cleared per run by wireLazyPanels)
87
+ // Task 19 (RN-5): the run wireLazyPanels() last reset panels/loading for — gates the reset
88
+ // below to run CHANGES only. A same-run call (renderDetail() runs this on every blind toggle
89
+ // too, and on the live loop's terminal refresh) instead refreshes any open panel (Fix 1).
90
+ var lastWiredRunId = null;
69
91
 
70
92
  function loadPanel(panelId, bodyId, files) {
71
93
  var A = window.AmicusApp;
@@ -107,18 +129,31 @@
107
129
  }
108
130
 
109
131
  /**
110
- * Rewrites the per-run spec map and drops the previous run's cached load promises this
111
- * is precisely what stops F09's stale-run artifact requests. Safe to call on every
112
- * renderDetail() (run-open and blind-toggle alike); it registers no listeners itself.
132
+ * Rewrites the per-run spec map on every call. On a run CHANGE (tracked via the module-level
133
+ * `lastWiredRunId`, above), resets panel open/loaded state and drops the previous run's
134
+ * cached load promises — exactly what F09's stale-run protection needs. On a SAME-run call
135
+ * (Task 19, RN-5: renderDetail() calls this on every blind toggle too, and the live loop's
136
+ * terminal refresh) any panel the user already has open is instead refreshed in place — see
137
+ * Fix 1 below — never left showing stale-blind content, never collapsed. Registers no
138
+ * listeners itself.
113
139
  */
114
140
  function wireLazyPanels() {
115
141
  var A = window.AmicusApp;
116
- ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
117
- var p = A.$(id);
118
- p.dataset.loaded = '0';
119
- p.open = false;
120
- delete loading[id];
121
- });
142
+ // ⚠️ Fix-wave (Fix 4): keyed off `A.state.detail.runId`, not `A.state.runId` — the latter is
143
+ // set synchronously at the top of openRun(), before its workspace:get-run reply lands, so an
144
+ // out-of-order reply could make the two diverge. workspace-app.js's own run-change gate
145
+ // (renderDetail(), above `d.runId`) reads off the SAME `state.detail.runId`, so the two
146
+ // provably agree on whether this is a run change.
147
+ var sameRun = A.state.detail.runId === lastWiredRunId;
148
+ if (!sameRun) {
149
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
150
+ var p = A.$(id);
151
+ p.dataset.loaded = '0';
152
+ p.open = false;
153
+ delete loading[id];
154
+ });
155
+ lastWiredRunId = A.state.detail.runId;
156
+ }
122
157
  var bench = A.state.detail.run.bench || [];
123
158
  var debated = !!A.state.detail.run.debate;
124
159
  // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
@@ -141,7 +176,7 @@
141
176
  loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
142
177
  return bench.map(function (m) {
143
178
  var label = A.state.labelByModel[m];
144
- return { name: 'review-' + sanitizeName(m) + '.md', title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
179
+ return { name: resolveArtifactName(m, 'review'), title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
145
180
  }).filter(function (f) { return present(f.name); });
146
181
  } };
147
182
  loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
@@ -157,7 +192,7 @@
157
192
  loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
158
193
  var files = bench.map(function (m) {
159
194
  var label = A.state.labelByModel[m];
160
- return { name: 'judge-' + sanitizeName(m) + '.md', title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
195
+ return { name: resolveArtifactName(m, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
161
196
  });
162
197
  if (debated) {
163
198
  // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
@@ -168,13 +203,31 @@
168
203
  // judge- titles above, which mirror the brief verbatim), so it goes through
169
204
  // AmicusRender.display() — the single blind-flip definition — rather than adding a
170
205
  // fourth hand-rolled copy of the same ternary.
206
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this name used to be recomputed via a
207
+ // bare sanitizeName(m) call, ignoring the disambiguation map entirely — for a colliding
208
+ // pair BOTH models resolved to the same bare revote-<sanitized>.md name, reintroducing
209
+ // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
210
+ // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
211
+ // legacy fallback keeps older detail payloads (no artifactsByModel map) correct too.
171
212
  files = files.concat(bench.map(function (m) {
172
213
  var label = A.state.labelByModel[m];
173
- return { name: 'revote-' + sanitizeName(m) + '.md', title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
214
+ return { name: resolveArtifactName(m, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
174
215
  }));
175
216
  }
176
217
  return files.filter(function (f) { return present(f.name); });
177
218
  } };
219
+ // ⚠️ Fix-wave (Fix 1, RN-9): a same-run call (the blind toggle, or the live loop's
220
+ // running -> terminal refresh) must re-render any panel the user already has open, or it
221
+ // keeps showing content painted under the PREVIOUS blind state. renderProseSections()
222
+ // (workspace-render.js) clears its container before repainting, so this replaces sections
223
+ // in place rather than appending duplicates. Drop the cached promise first so loadPanel()
224
+ // actually re-fetches instead of returning its already-settled one.
225
+ if (sameRun) {
226
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
227
+ var p = A.$(id);
228
+ if (p.open) { delete loading[id]; loadPanel(id, loaders[id].bodyId, loaders[id].files); }
229
+ });
230
+ }
178
231
  }
179
232
 
180
233
  // ⚠️ DE-ROT (F38): on a --debate run the FINAL tally.json is rebuilt from the debate's
@@ -194,9 +247,14 @@
194
247
  var rv = ((A.state.debate && A.state.debate.revotes) || []).find(function (r) {
195
248
  return r.judge === judgePair.model && r.id === findingId;
196
249
  });
250
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this branch used to recompute the name via
251
+ // bare sanitizeName(judgePair.model), independently of the (already-fixed) judge branch
252
+ // right below it — for a colliding pair, drilling a re-vote on the SECOND model resolved
253
+ // to the bare name and cross-matched the FIRST model's genuine revote section. Both arms
254
+ // of this ternary now go through the same disambiguation-aware helper.
197
255
  var artifactName = rv
198
- ? 'revote-' + sanitizeName(judgePair.model) + '.md'
199
- : 'judge-' + sanitizeName(judgePair.model) + '.md';
256
+ ? resolveArtifactName(judgePair.model, 'revote')
257
+ : resolveArtifactName(judgePair.model, 'judge');
200
258
  var section = A.$('judges-body').querySelector('[data-artifact="' + artifactName + '"]');
201
259
  // A genuinely absent artifact is not an error here — the panel renders its own
202
260
  // "<file> not written yet" empty state (spec §9, last row).
@@ -205,8 +205,18 @@
205
205
  cells.forEach(function (c, i) {
206
206
  var td = row.children[i];
207
207
  if (td && td.textContent !== c) { td.textContent = c; }
208
+ if (td) { td.className = i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : ''); }
208
209
  });
209
210
  });
211
+ // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
212
+ // them, so table order was frozen at first render — wrong the moment a
213
+ // repair solo or new wave changes the composed doc's leg order mid-run.
214
+ seats.forEach(function (seat, i) {
215
+ var key = String(seat.id || seat.model);
216
+ var current = tbody.children[i];
217
+ var target = Array.prototype.slice.call(tbody.children).find(function (r) { return r.dataset.key === key; });
218
+ if (target && target !== current) { tbody.insertBefore(target, current || null); }
219
+ });
210
220
  Array.prototype.slice.call(tbody.children).forEach(function (row) {
211
221
  if (!seen[row.dataset.key]) { row.remove(); }
212
222
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/BourbonDog/amicus/schemas/council-run-live.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/council-run-live.schema.json",
4
4
  "title": "Amicus composed live council-run doc (amicus_status/buildCouncilStatusPayload, view:'live')",
5
5
  "type": "object",
6
6
  "required": ["taskId", "type", "runId", "runDir", "status", "currentStage", "stages", "legsTotal", "legsComplete", "elapsed", "exitCode", "version"],
@@ -30,8 +30,22 @@
30
30
  "chair": { "type": "string" },
31
31
  "critic": { "type": ["string", "null"] },
32
32
  "lenses": { "type": ["array", "null"], "items": { "type": "string" } },
33
+ "droppedMembers": {
34
+ "description": "Council preset members dropped during resolution before this run started (v4.5 Wave 2): an alias that no longer resolves, or a resolved id absent from a non-empty cached model catalog (a catalog-unknown/offline member is never dropped this way). Present only when at least one member was actually dropped; absent — never an empty array — otherwise. `bench` above already reflects the survivors only; this is purely an additional observability signal so a scripted/MCP caller can detect a shrunken bench without diffing `bench` against the preset's nominal member list.",
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "required": ["member", "reason"],
39
+ "properties": {
40
+ "member": { "type": "string" },
41
+ "reason": { "type": "string" }
42
+ }
43
+ }
44
+ },
33
45
  "labelMap": { "type": ["object", "null"], "additionalProperties": { "type": "string" } },
34
46
  "options": { "type": "object" },
47
+ "pack": { "type": "object" },
48
+ "template": { "type": "object" },
35
49
  "usage": { "type": "object" },
36
50
  "exitCode": { "type": ["number", "null"] },
37
51
  "budgetRefusals": {
@@ -13,7 +13,7 @@
13
13
  "type": "object",
14
14
  "required": ["code", "message"],
15
15
  "properties": {
16
- "code": { "enum": ["BAD_ARGS", "MISSING_PROMPT", "BAD_MODEL", "MISSING_KEY", "BAD_SESSION", "BUDGET_EXCEEDED", "INTERNAL", "COUNCIL_QUORUM", "COST_EXCEEDED", "COUNCIL_CLAUDE_REVIEW_INVALID"] },
16
+ "code": { "enum": ["BAD_ARGS", "MISSING_PROMPT", "BAD_MODEL", "MISSING_KEY", "BAD_SESSION", "BUDGET_EXCEEDED", "INTERNAL", "COUNCIL_QUORUM", "COST_EXCEEDED", "COUNCIL_CLAUDE_REVIEW_INVALID", "TEMPLATE_NOT_FOUND", "TEMPLATE_RENDER", "PACK_NOT_FOUND", "PACK_INVALID", "PACK_KIND_MISMATCH"] },
17
17
  "message": { "type": "string" },
18
18
  "hint": { "type": ["string", "null"] },
19
19
  "command": { "type": ["string", "null"] }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/BourbonDog/amicus/schemas/event.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/event.schema.json",
4
4
  "title": "Amicus observability event (events.jsonl line)",
5
5
  "type": "object",
6
6
  "required": ["schemaVersion", "type", "event", "ts", "id"],
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/pack.schema.json",
4
+ "title": "Amicus policy pack",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "type", "name", "version", "kind"],
7
+ "properties": {
8
+ "schemaVersion": { "const": 1 },
9
+ "type": { "const": "pack" },
10
+ "name": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$" },
11
+ "version": { "type": "string" },
12
+ "kind": { "enum": ["council", "fanout", "solo"] },
13
+ "description": { "type": "string" },
14
+ "bench": {
15
+ "oneOf": [
16
+ { "type": "string" },
17
+ { "type": "array", "items": { "type": "string" }, "minItems": 2 }
18
+ ]
19
+ },
20
+ "model": { "type": "string" },
21
+ "chair": { "type": ["string", "null"] },
22
+ "critic": { "type": ["string", "null"] },
23
+ "lenses": { "type": ["array", "null"], "items": { "type": "string" } },
24
+ "options": { "type": "object" },
25
+ "briefing": {
26
+ "type": "object",
27
+ "properties": { "template": { "type": ["string", "null"] } }
28
+ }
29
+ }
30
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/BourbonDog/amicus/schemas/progress.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/progress.schema.json",
4
4
  "title": "Amicus leg/solo progress snapshot (progress.json)",
5
5
  "type": "object",
6
6
  "required": ["schemaVersion", "type", "stage", "updatedAt"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/BourbonDog/amicus/schemas/run-live.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/run-live.schema.json",
4
4
  "title": "Amicus composed live single-session doc (amicus_status, view:'live')",
5
5
  "description": "amicus_status's single-session response always stamps type:'run' (stampEnvelope); documented here as a const rather than the brief's 'may be absent' guess to match real output.",
6
6
  "type": "object",
@@ -21,6 +21,7 @@
21
21
  "durationMs": { "type": ["number", "null"] },
22
22
  "sessionDir": { "type": ["string", "null"] },
23
23
  "opencodeSessionId": { "type": ["string", "null"] },
24
- "usage": { "type": ["object", "null"] }
24
+ "usage": { "type": ["object", "null"] },
25
+ "pack": { "type": "object" }
25
26
  }
26
27
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/BourbonDog/amicus/schemas/wave-live.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/BourbonDog/amicus/main/schemas/wave-live.schema.json",
4
4
  "title": "Amicus composed live wave doc (amicus_status, view:'live')",
5
5
  "type": "object",
6
6
  "required": ["taskId", "type", "status", "legs"],
@@ -28,6 +28,7 @@
28
28
  "completedAt": { "type": ["string", "null"] },
29
29
  "durationMs": { "type": ["number", "null"] },
30
30
  "usage": { "type": "object" },
31
- "notices": { "type": "array", "items": { "type": "string" } }
31
+ "notices": { "type": "array", "items": { "type": "string" } },
32
+ "pack": { "type": "object" }
32
33
  }
33
34
  }