amicus 4.9.6 → 4.9.8

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 (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +246 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +24 -4
  6. package/docs/configuration.md +43 -15
  7. package/docs/council.md +140 -3
  8. package/docs/electron-testing.md +133 -0
  9. package/docs/troubleshooting.md +14 -7
  10. package/docs/usage.md +8 -4
  11. package/package.json +1 -1
  12. package/schemas/council-verdict.schema.json +3 -1
  13. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  14. package/src/cli-council-run-tools.js +168 -0
  15. package/src/cli-handlers-council-run.js +6 -6
  16. package/src/cli.js +23 -1
  17. package/src/council/briefings-chair.js +1 -1
  18. package/src/council/briefings-task.js +11 -5
  19. package/src/council/briefings.js +25 -7
  20. package/src/council/report-lost-rows.js +89 -0
  21. package/src/council/report-md.js +3 -1
  22. package/src/council/report.js +3 -2
  23. package/src/council/run-degrade.js +22 -1
  24. package/src/council/run-finish.js +23 -1
  25. package/src/council/run-launch.js +33 -4
  26. package/src/council/run-retry-launch.js +9 -4
  27. package/src/council/run-retry.js +3 -0
  28. package/src/council/run-seat-tools-verify.js +296 -0
  29. package/src/council/run-seat-tools.js +274 -0
  30. package/src/council/run-server.js +41 -6
  31. package/src/council/run-stage1-launch.js +8 -3
  32. package/src/council/run.js +21 -21
  33. package/src/council/seat-tools.js +299 -0
  34. package/src/council/verdict-seats-reviewed.js +76 -6
  35. package/src/headless.js +136 -6
  36. package/src/mcp-council-pack-map.js +24 -0
  37. package/src/mcp-council-run.js +17 -15
  38. package/src/mcp-server.js +2 -2
  39. package/src/mcp-tools.js +15 -4
  40. package/src/opencode-client.js +26 -0
  41. package/src/pack/pack-validate.js +3 -1
  42. package/src/prompt-builder.js +2 -2
  43. package/src/sidecar/electron-exe-rel.js +131 -0
  44. package/src/sidecar/electron-install.js +7 -12
  45. package/src/sidecar/electron-layout.js +31 -31
  46. package/src/sidecar/electron-native-plan.js +23 -5
  47. package/src/sidecar/electron-native-rescue.js +55 -16
  48. package/src/sidecar/electron-rescue-notice.js +18 -1
  49. package/src/sidecar/fanout.js +7 -1
  50. package/src/sidecar/heartbeat.js +46 -0
  51. package/src/sidecar/session-utils.js +7 -34
  52. package/src/sidecar/zip-from-buffer.js +16 -5
  53. package/src/sidecar/zip-local-name-scan.js +238 -0
  54. package/src/sidecar/zip-name-scan.js +5 -0
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/degrade.js +8 -0
package/docs/council.md CHANGED
@@ -22,6 +22,7 @@ orchestration recipe. This page is the reference for the artifacts that recipe p
22
22
  - [The pipeline, end to end](#the-pipeline-end-to-end)
23
23
  - [`amicus council run`](#amicus-council-run)
24
24
  - [Task mode (`--intent task`)](#task-mode---intent-task)
25
+ - [Tool access per usage (`--tools`)](#tool-access-per-usage---tools)
25
26
  - [Debate mode](#debate-mode)
26
27
  - [Council Workspace (GUI)](#council-workspace-gui)
27
28
  - [Auto-open on `amicus_council_run` (v4.5)](#auto-open-on-amicus_council_run-v45)
@@ -114,6 +115,7 @@ amicus council run --prompt-file <briefing.md>
114
115
  [--template <name|path>] [--artifact <file>] [--var k=v] # v4.5, see docs/usage.md#briefing-templates
115
116
  [--pack <name|path>] # v4.5, see docs/usage.md#policy-packs
116
117
  [--intent review|task] # v4.9, see Task mode below
118
+ [--tools <a,b,c>] [--agent Plan|Build] # v4.9.8, see Tool access below
117
119
  ```
118
120
 
119
121
  **The headless engine (v4.0).** Everything the `second-opinion` skill orchestrates by hand in
@@ -372,6 +374,101 @@ empty-section wordings — is used verbatim in both intents. One vocabulary, two
372
374
  - **Task runs build no reliability history**, so they never contribute to — and never benefit from —
373
375
  `amicus council stats`, including the ledger-driven chair-fallback promotion.
374
376
 
377
+ ### Tool access per usage (`--tools`)
378
+
379
+ Tool access is a property of the **run**, set by the caller according to whether the seats
380
+ must go and get their material — not a property of the mode (spec 2026-09-11 §2). Every
381
+ council leg runs as one of two agents the run's own OpenCode server registers:
382
+
383
+ - **`council-seat`** — stage-1 seats (the bench wave, the critic, the lenses) and their retries.
384
+ Its tools are the intent's default ∪ `--tools`: **task mode** defaults to `webfetch` (the
385
+ co-worker can research); **review mode** defaults to none (the artifact under review arrives
386
+ in the briefing — `--artifact`, `--pack`, or pasted). `--tools read,grep,glob,bash` opts local
387
+ tools in, even for task mode — opt-in is deliberate.
388
+ - **`council-support`** — repair re-prompts, the Stage-2 judges, debate legs and the chair.
389
+ No tools, ever: their briefings already say so, and the agent now enforces it.
390
+ The wildcard deny also covers the engine's own doom-loop and question prompts, so the engine
391
+ never blocks a headless leg on a tool-level prompt; a model that asks its question in prose
392
+ simply ends its turn (what a leg does after a refusal is the §7 live check).
393
+
394
+ `task` and `skill` are refused (`task` spawns child sessions amicus cannot observe; `skill` is
395
+ where a seat starts reading the harness instead of the brief), as are `edit`, `write`,
396
+ `apply_patch` (a seat never modifies the tree), `question` (a headless leg has no human) and
397
+ `invalid`. Every seat tool — the intent's default included — is validated against the engine's
398
+ declared list when the engine lists its tools; an unknown id is `BAD_ARGS` naming what the engine
399
+ declares. `--agent Plan|Build` is the escape hatch: every leg runs on the engine's own agent, no
400
+ council agents, no allowlist; it cannot be combined with `--tools`. Its legs run with the run
401
+ directory (inside the project) as their working directory, so a seat can read the run's own
402
+ records, the label map included — use it only where that is acceptable.
403
+ `Build` is edit-capable: unlike the council agents' fenced allowlist, it can edit files and run
404
+ commands over that directory (the CLI prints a Notice when you opt into it); `Plan` is the
405
+ pre-4.9.8 default: it denies edits but allows reads, searches and the shell (measured on the
406
+ pinned engine), so it is the escape hatch that restores v4.9.7's behaviour exactly, and the CLI
407
+ prints a Notice for it too (every leg — judges and the chair included — can run commands).
408
+
409
+ Refusals land in a run directory that already exists, so the refusal itself is recorded (the same
410
+ order the other pre-spend checks use); nothing is launched and nothing is spent. When the engine
411
+ cannot list its tools at all, a defaults-only run continues on the recorded degrade while an
412
+ explicit opt-in is refused — in practice this only decides a defaults-only run's outcome when the
413
+ shared server is otherwise up and only the tool-ids endpoint itself fails; a server that cannot
414
+ start at all is now caught by the engine-rendered verification below regardless of intent (ruling
415
+ P2-R38).
416
+
417
+ **Engine-rendered verification.** After registration, the run reads back what the engine actually
418
+ rendered for `council-seat`/`council-support` — the run directory, its `_scratch` support-leg
419
+ directory, and the project tree too when a local tool is opted in (ruling P2-R39) — and refuses
420
+ before any launch if an `opencode.json` or `.opencode/agent` file the engine loads for that
421
+ directory (the tree's, or your global config) altered them — naming that config as the cause
422
+ and `--agent` as the knowingly-unprotected alternative (ruling P2-R33). An `external_directory`
423
+ allow after the wildcard deny is exempted only when it is the engine's own tool-output cache
424
+ under its own XDG-first data directory (`$XDG_DATA_HOME/opencode` when set, else
425
+ `~/.local/share/opencode` — ruling P2-R42, matching how `src/utils/auth-json.js`/
426
+ `src/utils/engine-log.js` already resolve it); any other one reads as a widened agent too
427
+ (ruling P2-R40). Unverifiable (no shared server to ask) REFUSES whenever
428
+ verification can run at all — a defaults-only run included, no more quiet degrade (ruling
429
+ P2-R38); it is skipped only when a caller supplies its own transport with no way to ask the
430
+ engine at all (test-only — production always VERIFIES and refuses when it has no server to ask).
431
+ When the run's own shared OpenCode server fails to start, that IS "no server to ask": a default
432
+ (non-`--agent`) run now refuses before any launch rather than falling back — the per-wave
433
+ fallback servers that used to absorb a shared-server failure for every run now serve only an
434
+ `--agent` run, whose verification is skipped by design and so never has to ask at all (ruling
435
+ P2-R43). `--agent Plan` restores v4.9.7's per-wave behaviour exactly.
436
+
437
+ **Run-directory placement with a local tool.** A seat that can read the project tree must
438
+ not be able to read this run's sibling sessions, so with any local tool opted in the run dir
439
+ must sit **outside** the project tree (`--out-dir`), under your home, tmp or
440
+ `AMICUS_PROJECT_ROOTS`; the seats are then scoped to the project tree (`external_directory:
441
+ deny`) while their metadata stays in the run dir. Over MCP the run dir stays inside the
442
+ project, so local tools are refused there with the CLI named; tools that never touch the tree
443
+ (`webfetch`, `websearch`, `todowrite`) over MCP are fine.
444
+
445
+ **What the seat is told.** With no tools it gets the same no-tools sentence as the chair
446
+ (`Do NOT use any tools or read any files; …`), with tools one line naming exactly them, and
447
+ under `--agent` a line saying it runs as the engine's named agent with its own tool set. The
448
+ config enforces; the sentence informs — study run E1 showed gemini makes zero tool calls when
449
+ told not to.
450
+
451
+ **Secrets.** With `read` opted in, the seat agent denies `.env`, `.env.*` and `.envrc` files at
452
+ the engine (the match is case-sensitive on Linux; no other spelling is fenced) — the seat gets a
453
+ refusal and the leg continues (the deny rules are measured to render after the seat's own
454
+ `read=allow`, and CI now models the engine's own evaluator — transcribed from its source — over
455
+ the real rendering, confirming `.env`/`.env.*`/`.envrc` deny and an ordinary file allows; the
456
+ refusal itself is exercised by the release ritual's live `--tools read` run, not by the probe).
457
+ The CLI prints a Notice for `read` too, naming those three fenced names and asking you to keep
458
+ other secrets out.
459
+ `grep`, `glob` and `bash` have no per-file fence: opting them in trusts every seat with
460
+ everything in the tree, `.env` included — grep returns its contents, glob lists its name — and
461
+ the CLI prints a Notice when you opt any of grep, glob or bash in. Keep secrets out of any tree
462
+ you point a `bash`, `grep` or `glob` seat at.
463
+ With a local tool the seat's engine session is rooted at the project tree, so the engine also
464
+ loads that tree's own opencode config; do not point a local-tools seat at a tree you do not trust.
465
+
466
+ `bash` is outside every fence: a bash seat runs commands as you — it can reach the run
467
+ directory outside the tree (this run's own records included: the label map that anonymizes the
468
+ bench and every review already on disk, so bench anonymity and independence do not hold under
469
+ bash), your home directory and the network, and the `webfetch` deny does not bind a shell. Opt
470
+ it in only where that is acceptable; the CLI prints a Notice when you do.
471
+
375
472
  ### Debate mode
376
473
 
377
474
  `--debate` adds a **Stage-2.5 rebuttal round** between cross-review and the final tally
@@ -594,6 +691,19 @@ things make that safe:
594
691
  that is present and empty counts as "I found nothing" — the same line `countAttemptedFindings`
595
692
  draws when it checks a repair against the count the original declared.
596
693
 
694
+ **When the original carried no block at all there is nothing to check the repair against**, so
695
+ the repair is accepted and the seat's `runStats` row carries `findingsUnverified: true` — the
696
+ seat is counted as reviewed, its findings are tallied, and nothing verified them. Since v4.9.8
697
+ (#242) that fact is surfaced rather than merely recorded: `verdict.json`'s `seatsReviewed`
698
+ counts the seat under `unverified`, and the report's **What was lost** gains a row for it
699
+ (*seat X's findings came from a repair of a response with no findings block — nothing verified
700
+ them*). A repair that returned a **different number** of findings than the original declared is
701
+ refused instead — `repairRefused: {code, detail}` on the row, `conformance: unstructured`, no
702
+ findings tallied, the seat's own prose still reaching the judges — and gets a `repair-refused`
703
+ row naming the code. Since council #248 round 2 it is also counted under `seatsReviewed.refused`,
704
+ printed on the check title and sticky comment as `(N refused)`, and named on the end-of-run
705
+ stderr line.
706
+
597
707
  This closes a contradiction that used to be shipped in every run: the anti-sycophancy clause in
598
708
  each Stage-1 briefing says "An empty severity category is a valid result", while the validator
599
709
  rejected exactly that answer — so the only way for a reviewer to satisfy the schema was to
@@ -669,7 +779,7 @@ under "Stage-2 → tally assembly recipe"). It needs **all five top-level keys**
669
779
  | `findings[]` | array | One entry per finding across all reviews: `{id, raiser, severity}` (`claim` may ride along but isn't required by the tally engine). `id` is the run-global label (e.g. `A1`, `B2`) assigned during Stage-2 assembly, not the reviewer's local integer id. `raiserSeat?` (**v4.8**) — the raising **seat's** id (`deepseek#1`), emit-only-when-it-differs-from-the-alias, so a bench with no repeated alias never carries it. `raiser` stays the alias in every case. |
670
780
  | `adjudications[]` | array | One entry per (judge × finding): `{findingId, judge, verdict}`, `verdict ∈ {agree, dispute, neutral}`. `seat?` (**v4.8**) — the judging **seat's** id, on the same emit-when-different terms as `findings[].raiserSeat`; `judge` stays the alias. Include every judge's verdict on every finding, **including the raiser's own adjudication of its own finding** — the engine excludes it automatically when scoring (don't pre-filter it). ⚠️ **v4.8: that exclusion is seat-conditional.** When a vote *and* its finding both carry a seat id, the engine compares **seats** (`v.seat !== f.raiserSeat`), so on a bench that repeats an alias a twin's genuine vote on its twin's finding is now counted instead of discarded. When either side carries no seat id — a legacy document, a hand-assembled one, or a real run whose leg failed to bind to its seat — it falls back to comparing **aliases**, which is the pre-v4.8 behaviour and still drops that twin's vote. Never fill in a seat id you did not observe just to unlock the seat compare. ⚠️ **`""` is not a model id.** The schema accepts an empty string for `raiser` and for `judge`, but the engine cannot identify a vote it has no name for: when a finding's `raiser` is empty or missing, every vote whose `judge` is also empty or missing is excluded from `basis` and counted in `findings[].unattributedPeerDrops`. A **seat id on both sides overrides this** — it is a stronger identity than either name, so a seated vote is scored (or excluded as the raiser's own) regardless of what `raiser` and `judge` say. Send the real alias, or expect the vote not to be scored. |
671
781
  | `rankings[]` | array | One entry per judge: `{judge, order}`. `order` is that judge's `FINAL RANKING:` block translated to model ids, e.g. `["gpt", "deepseek"]` (ties may use a nested array, e.g. `[["gpt","deepseek"], "mistral"]`). `seat?` (**v4.8**) — the judge's own seat id, on the same emit-when-different terms as `adjudications[].seat`; `judge` stays the alias. `orderSeats?` (**v4.8**) — the seat-valued parallel of `order`, slot for slot (a tied slot is a nested array there too): each slot is a seat id where the ranked model's seat is known, `null` where it is not, and the whole key is emitted only when at least one slot is non-null. **Two consumers read it.** Street cred keys on `orderSeats` when present and falls back to the alias otherwise — the mechanism that lets a twin bench's two street-cred rows diverge instead of collapsing into one. **v4.8** — the **chair packet** is the second: its peer-rankings block zips `orderSeats` onto `order` slot for slot, so the chair reads seat ids where the run knows them and the ranked alias where it does not. A tied slot is zipped element by element, and a `null` slot renders the alias rather than the word `null`. |
672
- | `runStats[]` | array | One row per paid launch (v4.7 spec §5 D1/D2 — no longer capped at one row per model; see the role roster below): `{model, role, wasChair, conformance, status, durationMs, usage, waveId?, resolvedModel?, seat?}`. `seat?` (**v4.8**) is the row's seat **id**, emit-only-when-it-differs-from-that-seat's-own-alias — so only a bench that repeats an alias carries it. Only the two producers that *have* a seat pass one: the primary reviewing-seat rows and the dead-seat rows. A `judge`, `chair-attempt`, `repair` or `superseded` row never carries it (all four are excluded from the ledger join, so a seat stamp there could never win it), and neither do the off-bench chair rows or the synthetic `claude` row, which have no seat at all. Two seats of one alias that **both** died usually get **two** rows: each carries its own `seat` id where the run bound that seat's leg, and **no** `seat` where it could not — an unidentified dead seat is counted but never named. They still collapse into a **single** row in two cases, both of them seats the run genuinely cannot tell apart: both legs missing a task id, and a run with no seat table behind it — the deliberate floor, since inventing an identity there would be a guess. A retry wave that came back with **fewer legs than it launched** was a third such case and is **no longer**: v4.8 T-A4 made the retry reconcile count a key's SLOTS rather than test its presence, so both twins get a row (measured end to end through `runStage1`: 1 primary dead-seat row before, 2 after, with the superseded rows unchanged at 2 — one of the two rows carries `usage: null`, and **which one is arbitrary**: neither row names a seat, so the alias's billed total is split across its anonymous rows by row ORDER, never by identity). ⚠️ **Corrected in v4.8** — this cell previously claimed the two *always* collapse into one row carrying no `seat`. That was already wrong for **bound** twins the day it was written (the two-row behaviour landed 2026-08-13, this sentence 2026-08-14), and v4.8 closed the unbound half for every retry outcome, the partial return included (that last one in T-A4). May be `[]`. Any leg with no run document gets `durationMs: null, usage: null` — never invent a value. `waveId` is emit-only-when-set. `resolvedModel?` (v4.7) — the executable id that actually served the row's leg, emit-only-when-set; leg-less rows (the give-up chair row, dead seats with no leg, the claude row) never carry it. `model` stays the council alias. ⚠️ **One row shape carries `usage` with NO `waveId`, `resolvedModel` or `durationMs` (v4.8)**: an unidentified dead seat on an alias the bench repeats, where the run holds a billed retry leg it cannot attribute to either twin. The cost is real and is counted in the run total, but every per-seat execution fact is withheld rather than guessed — so do **not** assume `usage` implies `waveId`, and do not treat a null `durationMs` as "this seat cost nothing". |
782
+ | `runStats[]` | array | One row per paid launch (v4.7 spec §5 D1/D2 — no longer capped at one row per model; see the role roster below): `{model, role, wasChair, conformance, status, durationMs, usage, waveId?, resolvedModel?, seat?}` — plus `findingsUnverified?: true` / `repairRefused?: {code, detail}` (the two halves of the repair contract's outcome, see the repair paragraph under `amicus council validate`) and `ttftMs?` (v4.9 W13). `seat?` (**v4.8**) is the row's seat **id**, emit-only-when-it-differs-from-that-seat's-own-alias — so only a bench that repeats an alias carries it. Only the two producers that *have* a seat pass one: the primary reviewing-seat rows and the dead-seat rows. A `judge`, `chair-attempt`, `repair` or `superseded` row never carries it (all four are excluded from the ledger join, so a seat stamp there could never win it), and neither do the off-bench chair rows or the synthetic `claude` row, which have no seat at all. Two seats of one alias that **both** died usually get **two** rows: each carries its own `seat` id where the run bound that seat's leg, and **no** `seat` where it could not — an unidentified dead seat is counted but never named. They still collapse into a **single** row in two cases, both of them seats the run genuinely cannot tell apart: both legs missing a task id, and a run with no seat table behind it — the deliberate floor, since inventing an identity there would be a guess. A retry wave that came back with **fewer legs than it launched** was a third such case and is **no longer**: v4.8 T-A4 made the retry reconcile count a key's SLOTS rather than test its presence, so both twins get a row (measured end to end through `runStage1`: 1 primary dead-seat row before, 2 after, with the superseded rows unchanged at 2 — one of the two rows carries `usage: null`, and **which one is arbitrary**: neither row names a seat, so the alias's billed total is split across its anonymous rows by row ORDER, never by identity). ⚠️ **Corrected in v4.8** — this cell previously claimed the two *always* collapse into one row carrying no `seat`. That was already wrong for **bound** twins the day it was written (the two-row behaviour landed 2026-08-13, this sentence 2026-08-14), and v4.8 closed the unbound half for every retry outcome, the partial return included (that last one in T-A4). May be `[]`. Any leg with no run document gets `durationMs: null, usage: null` — never invent a value. `waveId` is emit-only-when-set. `resolvedModel?` (v4.7) — the executable id that actually served the row's leg, emit-only-when-set; leg-less rows (the give-up chair row, dead seats with no leg, the claude row) never carry it. `model` stays the council alias. ⚠️ **One row shape carries `usage` with NO `waveId`, `resolvedModel` or `durationMs` (v4.8)**: an unidentified dead seat on an alias the bench repeats, where the run holds a billed retry leg it cannot attribute to either twin. The cost is real and is counted in the run total, but every per-seat execution fact is withheld rather than guessed — so do **not** assume `usage` implies `waveId`, and do not treat a null `durationMs` as "this seat cost nothing". |
673
783
 
674
784
  **`runStats[].role` roster (v4.7 row-per-launch).** Every leg the run budget counts gets exactly
675
785
  one row, so a seat that needed a repair or lost a leg to a retry can now show up more than once.
@@ -852,6 +962,7 @@ named here. That is why the v4.8 keys below each needed their own line.
852
962
  - `seats` — **v4.8**, optional. The tally record's `meta.seats` (same `{id, alias, role, lens, position}` shape), promoted to the top level next to `seatLoss`. Present only when the tally record carried one, i.e. only when the bench repeated an alias. It is what makes the `alias#N` ids on `findings[].raiserSeat`, `adjudications[].seat` and `runStats[].seat` resolvable from the verdict **alone** — before v4.8 the verdict named seats it could not resolve. `council report` reads it to give each seat its own adjudication-matrix column; when it is absent, or is not an array of objects each carrying a string `id`, the **adjudication matrix** falls back to alias space whole and renders exactly as it did before v4.8. ⚠️ **That fallback is the matrix's alone — it is not a whole-document guarantee.** The street-cred table beside it labels each row from `streetCred[].seat` whenever the row carries one, a predicate independent of this key, so a verdict with seated `streetCred[]` rows and no usable `seats` renders seat ids in the street-cred table and aliases in the matrix (measured on an absent `seats`, a non-array `seats`, and an array-of-strings `seats`). In-process both fields come from the same twin bench and travel together; the split is reachable on a hand-assembled or externally-supplied record, which `buildVerdict`'s own docblock names. A verdict written before v4.8 carries no `streetCred[].seat` at all and is unaffected.
853
963
  - `findings[].raiserSeat` — **v4.8**, optional. The raising seat's id, carried through from the tally record; absent unless the bench repeated an alias. `findings[].raiser` stays the alias.
854
964
  - `findings[].sameModelCorroboration` — **v4.8**, optional, `true` only. Carried through from the tally record; see the tally-record notes above for the stamp's meaning **and for the two directions in which it is wrong** (it misses one model behind two aliases, and it fires falsely on one alias behind two executables).
965
+ - `seatsReviewed` — **#202**, optional: `{reviewed, unverified, refused, of}`, derived from `runStats` at build time (never passed in) and emitted only when the record carries at least one bench-role row (`seat`, `critic`, `lens:<slug>`), so `0 of 0` is never written. `of` counts bench seats post-retry (a healed seat once — its first attempt is `role:'superseded'`), `reviewed` those whose leg completed, and `unverified` (**v4.9.8**, #242) those among them whose findings came from a repair of a response with no parseable findings block (`runStats[].findingsUnverified`); the tally scored those findings, nothing verified the repair, and the flag is not a stub detector (a real review whose trailing JSON block was malformed carries it too). `refused` (**v4.9.8**, council #248 round 2) counts those whose repair was refused (`runStats[].repairRefused`): the seat contributed no findings at all and would otherwise read as a full reviewer on every gate surface. Both are always written with the census and never more than `reviewed` — the census and the report share one predicate (a bench role, a completed leg, and the literal `true` or a plain-object `repairRefused`), so a flagged row that is not a completed bench seat is counted nowhere and rendered nowhere. The report's `unverified-repair` and `repair-refused` rows name such seats; the check title and sticky comment print `(N unverified)` and `(N refused)` only when non-zero; at the end of a run whose counts are non-zero the CLI prints one `Notice:` line on stderr naming the seats.
855
966
  - All other keys (`runId`, `council`, `findings`, `streetCred`, `runStats`, `tierCounts`) are passed through unchanged from the tally record.
856
967
 
857
968
  **Write path:** always atomic — a `<out>.tmp-<pid>` file is written first, then renamed over the
@@ -897,8 +1008,12 @@ tier-count summary table — headed **Verdict summary** on a review run and **An
897
1008
  task run, since a task run produces an answer, not a verdict, and on a task run followed by the
898
1009
  one-line concurrence qualifier (*tiers report peer concurrence, never verification*) — a
899
1010
  **What was lost** section
900
- when the run degraded (plus a **Notes** list for informational records, e.g. a task run's
901
- ledger-skipped announcement), the **adjudication matrix** (finding × judge, `✓`/`✗`/`–` with
1011
+ when the run degraded — and, since v4.9.8 (#242), one row per seat whose findings came from a
1012
+ repair of a response with no findings block (channel `unverified-repair`) and one per refused
1013
+ repair (channel `repair-refused`, naming the code), derived from `runStats[]` when the report is
1014
+ built, so re-rendering an older `verdict.json` shows them too — plus a **Notes** list for
1015
+ informational records (e.g. a task run's ledger-skipped announcement), the **adjudication
1016
+ matrix** (finding × judge, `✓`/`✗`/`–` with
902
1017
  `*` marking the raiser's own vote), the **peers-only street-cred table**, **findings grouped by
903
1018
  tier** (Disputed first), and a **cost table** (per-model status/duration/cost + wave total,
904
1019
  sourced from `runStats[].usage`).
@@ -1263,6 +1378,28 @@ need Claude or a live council run to regenerate it.
1263
1378
 
1264
1379
  ---
1265
1380
 
1381
+ ## Leg completion and `session.status`
1382
+
1383
+ A headless leg ends on the first of: the engine reporting `idle`; its last message
1384
+ finalizing (two stable polls); the no-output backstop; the tool-stall detector; the
1385
+ tool-settle ceiling (when a tool call never settles); a retry the engine schedules
1386
+ past the leg deadline (`RETRY_BEYOND_DEADLINE`); or the leg `--timeout`. The
1387
+ flat-output heuristic that used to end a leg after 30 stable polls now runs only
1388
+ when `session.status` reports anything other than a working engine (busy or retry)
1389
+ — unavailable, unrecognised — **or** when a tool call is live (where the bounded
1390
+ ceiling governs). A finalized message always ends on the stable-finished path, whatever
1391
+ the status. A busy (or retrying) engine with no live tool is a model still answering
1392
+ — the poller cannot see its text until the message finalizes — so the leg waits. A
1393
+ busy-but-wedged session therefore ends by `--timeout`, and is named that. At debug
1394
+ level the trace names the veto once per flat stretch (and again whenever a non-zero
1395
+ count is reset) and the exit line records the last engine status. When the fallback
1396
+ heuristic ends an unfinalized message, the leg logs a warning (warn level) naming the
1397
+ status it saw. A retry whose next attempt lies beyond the leg deadline ends the leg
1398
+ at once as `RETRY_BEYOND_DEADLINE` — unless the last message has already finalized (the
1399
+ leg completes normally) or a tool call is live (the bounded tool-settle ceiling governs).
1400
+
1401
+ ---
1402
+
1266
1403
  ## See also
1267
1404
 
1268
1405
  - **[skills/second-opinion/SKILL.md](../skills/second-opinion/SKILL.md)** — the orchestration
@@ -469,3 +469,136 @@ setTimeout(() => { ws.close(); process.exit(0); }, 3000);
469
469
  ```
470
470
 
471
471
  **Note:** `window.sidecar` is `undefined` in the toolbar (see Known Limitations above). The toolbar communicates with the main process via `window.__amicusUpdateAction` polling, not IPC.
472
+
473
+ ---
474
+
475
+ ## The darwin `.app` bundle — what CI proves, and what it still does not
476
+
477
+ `.github/workflows/darwin-bundle.yml` drives `scripts/probe-darwin-extract.js` on
478
+ `macos-latest`. It is the only place amicus's real extract path meets a real `.app`
479
+ bundle, a real POSIX `fs.symlinkSync`, a real `fs.realpathSync` and a real dyld.
480
+
481
+ ### The filing it closes, and the way it closes it
482
+
483
+ v4.9.6 added a symlink target-escape check (`src/sidecar/zip-entry-write.js ::
484
+ writeSymlink`) that `extract-zip` does not have. The v4.9.7 filing was that it could
485
+ **reject** a real darwin layout. That is **refuted by measurement on the real bytes**,
486
+ not by this job:
487
+
488
+ | artifact | bytes | records | symlinks | dir entries |
489
+ | --- | --- | --- | --- | --- |
490
+ | `electron-v43.1.1-darwin-arm64.zip` | 122,054,683 | 585 | 14 | 310 |
491
+ | `electron-v43.1.1-darwin-x64.zip` | 123,952,132 | 585 | 14 | 310 |
492
+ | `electron-v43.1.1-linux-x64.zip` | 124,861,804 | 74 | **0** | 0 |
493
+ | `electron-v43.1.1-linux-arm64.zip` | 124,456,257 | 74 | **0** | 0 |
494
+
495
+ All 14 darwin targets are relative (`A`, `Versions/Current/Resources`, …); none carries a
496
+ `..` component; none is absolute; **0 of the 585 entry names traverse a symlinked
497
+ component**. The check is purely lexical (`path.resolve` then `path.relative`), so on that
498
+ shape it cannot fire. The extraction root is itself realpath'd
499
+ (`src/sidecar/zip-from-buffer.js :: extractZipBuffer`), which is why macOS's
500
+ `/var` -> `/private/var` does not turn every link into an escape — the one mechanism that
501
+ could have made the filing correct.
502
+
503
+ **Linux is settled outright and gets no job**: both linux artifacts hold 74 entries and
504
+ zero symlinks, so `writeSymlink` is unreachable there. The filing's "darwin/linux" narrows
505
+ to "darwin".
506
+
507
+ ### What each assertion pins, and the mutant it kills
508
+
509
+ | id | assertion | mutant it kills |
510
+ | --- | --- | --- |
511
+ | A1a | `resolveAnchor({selfElectronDir: null})` reads the `checksums.json` under test | dropping the `selfElectronDir` override, which silently re-anchors on the repo's own `node_modules` |
512
+ | A1b | `repairElectron({cacheOnly:true})` repairs **and** the gate says `verified` | any change that stops a byte-exact artifact verifying — extract and exec both stay green |
513
+ | A1c | with **no** anchor the same bytes extract but are marked `unverified` | dropping the `verdict !== 'verified'` mark, i.e. a silent degrade of the trust route |
514
+ | A2 | `resolveElectronBinary` names the real launcher and its exec bit survived | a wrong `platformExe` darwin arm, or a `path.txt` naming a different basename |
515
+ | A3a | all 14 archive symlinks are symlinks on disk with byte-identical targets | deleting the `if (symlink)` branch in `placeEntry` — links become regular files |
516
+ | A3b | the five structural framework links exist with their exact targets | writing the **resolved absolute** path instead of the archive's relative target (still runs on the runner; breaks when the tree moves) |
517
+ | A4 | parity vs the tree `@electron-internal/extract-zip` produced | a flat file mode, or a `continue` that silently drops entries — **report-only on its first cut** |
518
+ | A5 | `Electron --version` runs | the same mutant as A3a, observed through dyld: the launcher's Mach-O carries `LC_RPATH @executable_path/../Frameworks` and `LC_LOAD_DYLIB @rpath/Electron Framework.framework/Electron Framework`, so it loads the ~192 MB framework **through two of the fourteen links** |
519
+ | A6i | an escaping relative target is refused, nothing planted | dropping the `..`-prefix limbs of the three-limb test |
520
+ | A6ii | an **absolute POSIX** target is refused | dropping the `startsWith('..' + sep)` limb — on win32 `/etc/passwd` becomes `C:\etc\passwd` and a *different* limb catches it, so the Windows suite proves the wrong arithmetic |
521
+ | A6iii | SYMLINKCHAIN refused by a **real** `realpath`: 3 links made, no victim | reverting the target resolution to `path.dirname(dest)` — the lexical-dirname bug that extracts with no error at all |
522
+
523
+ A4 is deliberately **report-only, exit 0, full diff printed** on its first cut: nothing has
524
+ ever measured that the two extractors agree on directory modes under the runner's umask, so
525
+ making an unmeasured comparison a blocking gate buys a red for reasons unrelated to
526
+ symlinks. Promote it once one clean run exists. Everything else gates from day one.
527
+
528
+ A6iii is the assertion that matters most: in the jest suite the same archive is pinned by a
529
+ `realpathSync` **the test itself injects** (`tests/electron-custody.test.js`, describe
530
+ `symlinks — the darwin .app shape, which cannot be run here`). That is a rule read off the
531
+ surface its own writer wrote. On the runner the three `.` links exist on disk and the kernel
532
+ answers.
533
+
534
+ ### `npm ci` does NOT provision Electron — the job asks for it explicitly
535
+
536
+ `electron@43.1.1` ships **no install script at all**: its `package.json` has no `scripts`
537
+ field (it exposes `install.js` only as the `install-electron` bin), and `package-lock.json`
538
+ carries no `hasInstallScript` for it. So npm never fetches the ~122 MB binary, on any
539
+ platform or any install path. Measured on run `34246117877`: plain `npm ci` on ubuntu,
540
+ macos and windows alike finished in 15-38 s and left amicus's own postinstall reporting
541
+ `the Electron GUI binary is not provisioned yet` — an empty electron cache. The lockfile's
542
+ per-platform installable counts (588 / 588 / 587) match the observed `added N packages`
543
+ exactly **with `electron` included**, so the package is present and only the binary is
544
+ missing.
545
+
546
+ The job therefore runs `node node_modules/electron/install.js` in its own step, with one
547
+ retry. It deliberately does **not** use `AMICUS_PREFETCH_ELECTRON=1`, which
548
+ `scripts/postinstall.js` routes through amicus's own `repairElectron` — the A4 diff would
549
+ then compare amicus against amicus.
550
+
551
+ ### What it does not prove
552
+
553
+ - **darwin x64.** `macos-latest` is arm64. The x64 artifact was measured at an identical
554
+ shape (585 / 14 / 310), so the residual is small, but no x64 leg exists. Intel runner
555
+ labels changed during 2025 — check GitHub's current list before adding one.
556
+ - **`mas`.** Unreachable in production; no caller passes `platform: 'mas'`.
557
+ - **A case-sensitive APFS volume.** Runners default to case-insensitive; 0 case-insensitive
558
+ name collisions were measured across the 585 entries, but "low exposure" there is
559
+ inference, not measurement.
560
+ - **Future electron layouts, between bumps.** The job proves the version pinned in
561
+ `package-lock.json` at run time. A bump is caught by the `package-lock.json` path filter on
562
+ `pull_request`/`push`, not by the cron.
563
+ - **The native-rescue hatch.** `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`, `ditto` and Info-ZIP
564
+ `unzip` symlink behaviour on darwin stay unmeasured — that is the B2 lane, not this one.
565
+ - **`codesign`.** The archive carries **zero** `_CodeSignature` entries, so
566
+ `codesign --verify` on the extracted bundle would assert nothing. Only the embedded ad-hoc
567
+ Mach-O signature exists, and A5 succeeding is the only evidence it survived byte-exact
568
+ extraction. Do not add a codesign step and call it coverage.
569
+ - **Destination-failure classification on darwin** (ENOSPC, read-only `dist/`, EACCES) and
570
+ **`promoteDist` on APFS** — the job calls the promote once, on a happy path.
571
+ - **A trailing-slash symlink entry.** An entry whose *name* ends with `/` while its mode bits
572
+ say `IFLNK` is turned into a real directory before the symlink branch is reached, so the
573
+ escape check never runs. The real artifact has zero such entries, so no darwin job will
574
+ ever exercise it; it belongs in the platform-independent suite.
575
+
576
+ ### Triggers, cost, and the required-check caveat
577
+
578
+ Paths-filtered `pull_request` **and** `push: [main]` (so a bump is proven at merge time),
579
+ plus a weekly cron and `workflow_dispatch`. The cron re-proves the pinned version against the
580
+ live release asset and the current runner image — the two inputs no path filter can see — and
581
+ is the weakest trigger on purpose: a schedule GitHub delays or drops is silent.
582
+
583
+ The job owns a ~122 MB download plus two ~600 MB extractions on a 3-vCPU / 8 GB runner;
584
+ budget 4-6 minutes. It is not free, and macOS *concurrency* rather than minutes is the
585
+ binding constraint on a public repo — `ci.yml` already burns two macOS legs per push.
586
+
587
+ Because both event triggers carry a `paths:` filter, the job reports **skipped** when nothing
588
+ matches, so **it cannot be a required status check as written**. Making it required means
589
+ dropping `paths:` and moving the guard inside the job (a `git diff --name-only` early exit) —
590
+ a pattern this repo does not currently use.
591
+
592
+ ### Running it by hand on a Mac
593
+
594
+ ```bash
595
+ npm ci --foreground-scripts
596
+ rm -rf node_modules/electron/dist node_modules/electron/path.txt
597
+ node node_modules/electron/install.js # the artifact + the A4 reference tree
598
+ node scripts/probe-darwin-extract.js --preflight
599
+ node scripts/probe-darwin-extract.js
600
+ ```
601
+
602
+ The `rm -rf` is not cosmetic: `install.js` short-circuits on a populated `dist/`, and on a dev
603
+ Mac that `dist/` may well be amicus's own self-heal output — which would make A4 compare
604
+ amicus against amicus. The workflow does the same removal for the same reason.
@@ -452,10 +452,13 @@ That is what a swapped mirror or a planted cache file looks like. It is **also**
452
452
 
453
453
  **Fix, in order:**
454
454
 
455
- - **Online, do nothing.** Amicus downloads the artifact again with the digest pinned. A truncated download heals itself.
455
+ - **Online, do nothing.** Amicus downloads the artifact again with the digest pinned. A truncated download
456
+ heals itself. The exception is an archive amicus refuses outright for path traversal — that is terminal
457
+ by design and is not re-fetched, because the problem is what the archive *contains*, not that it arrived
458
+ incomplete.
456
459
  - **Air-gapped, re-copy first.** Copy the cache directory again from the machine that downloaded it. A partial copy is the usual cause, and a fresh copy costs you nothing.
457
460
  - **Only if you cannot obtain another copy: the native-extractor rescue.** Set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` and provision again. You do not have to have set it in advance: the run that printed the message above left the archive in place precisely so this one has something to work on. Amicus then writes the bytes it hashed to a path inside the Electron package and hands that path to your OS's own extractor (`tar` / `Expand-Archive` / `ditto` / `unzip`) — the same tools that handled the archive before amicus extracted in memory at all. **This is not a safe operation, and it is not described as one.** Between amicus writing the file and the child process opening it, anything running as your user can substitute it, and what that child extracts is promoted into `dist/` without being hashed again. Amicus prints the whole trade on stderr before it spawns anything, and the result is reported `unverified` even when the artifact's own sha256 matched. Unset the variable afterwards: it also downgrades a digest-mismatch refusal to a warning ([configuration.md](./configuration.md#gui-and-debug)).
458
- - **What the rescue will *not* do,** whatever this variable is set to: retry an archive refused for path traversal (`REFUSED (unsafe archive)` — terminal by design), retry an extraction that *stalled* (the timeout exists to stop work, not to hand it to a child process), rescue bytes that contradict the published digest (they are known wrong), or paper over a full or unwritable disk. Each of those says something different from "this archive cannot be read", and only the last of those is a rescue amicus was given. Before it hands anything over, amicus also reads the archive's *entry names* and refuses any that would write outside the destination — because an archive can break the extractor early enough that its own traversal check never ran. That name check cannot see inside an archive whose central directory is unreadable, and cannot see a symlink target at all; [configuration.md](./configuration.md#gui-and-debug) states both residuals and names which extractors were measured to refuse a `..` entry themselves.
461
+ - **What the rescue will *not* do,** whatever this variable is set to: retry an archive refused for path traversal (`REFUSED (unsafe archive)` — terminal by design), retry an extraction that *stalled* (the timeout exists to stop work, not to hand it to a child process), rescue bytes that contradict the published digest (they are known wrong), or paper over a full or unwritable disk. Each of those says something different from "this archive cannot be read", and only the last of those is a rescue amicus was given. Before it hands anything over, amicus also reads the archive's *entry names* and refuses any that would write outside the destination — because an archive can break the extractor early enough that its own traversal check never ran. It reads them in **both** tables a zip declares them in, so cutting the tail off an archive no longer hides its names, and the two tables disagreeing does not let one through. It still cannot see a symlink target, and an archive that defeats both walks still reaches the extractor — the notice printed before the spawn says which names were checked; [configuration.md](./configuration.md#gui-and-debug) states every residual and names which extractors were measured to refuse a `..` entry themselves.
459
462
  - Headless runs and the full council work without the GUI throughout.
460
463
 
461
464
  ---
@@ -514,11 +517,15 @@ The third line names which way the read failed:
514
517
  guaranteed either way: a half-written tree is never what `dist/` contains, and a kill mid-extract
515
518
  leaves the previous `dist/` exactly where it was.
516
519
  - **A promote never removes a working `dist/` to make room.** If the old tree cannot be renamed out of
517
- the way (a handle held on it, or an AV filter denying the move) and it holds a usable executable,
518
- the repair refuses and leaves it untouched rather than deleting it with no way back. If it holds no
519
- executable it is not an install, and it is replaced. In the one case where the tree was renamed away
520
- and neither the swap nor the rollback could run, the previous `dist/` is intact at
521
- `.amicus-retired-<hex>` and the error names it rename it back to `dist/` to restore it.
520
+ the way (a handle held on it, or an AV filter denying the move) and it holds a usable executable
521
+ the one `path.txt` names, or this platform's default when `path.txt` is absent, unreadable or blank
522
+ the repair refuses and leaves it untouched rather than deleting it with no way back. That covers
523
+ a package cross-installed for another platform through `npm_config_platform`, whose `path.txt` names
524
+ an executable this platform never looks for; through v4.9.6 the guard asked only about this
525
+ platform's default name, so such a tree was read as "not an install" and a promote that FAILED
526
+ deleted it. If it holds neither, it is not an install, and it is replaced. In the one case where the
527
+ tree was renamed away and neither the swap nor the rollback could run, the previous `dist/` is
528
+ intact at `.amicus-retired-<hex>` and the error names it — rename it back to `dist/` to restore it.
522
529
  - **A related refusal**, `Refusing to provision electron: … is not a usable artifact name`, means the
523
530
  `version` in the Electron package's own `package.json` is not a plausible version string. Amicus
524
531
  builds the artifact filename from it and refuses to use anything that is not a plain filename, since
package/docs/usage.md CHANGED
@@ -98,7 +98,7 @@ amicus start --model deepseek --prompt "Generate tests" --no-ui --timeout 30
98
98
  | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. | |
99
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)* |
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
+ > Agents: **Chat** auto-approves reads and asks before writes/bash (interactive default); **Build** has full tool access (headless default); **Plan** denies edits (reads, searches and shell allowed). `--agent Chat` is interactive-only and incompatible with `--no-ui`.
102
102
 
103
103
  **Catalog validation.** For an explicit `--model`, the model is checked against the live catalog before launch — a typo'd name fails fast with same-vendor suggestions. For a model inherited from a previous session (`continue`/`resume` without `--model`), validation is **advisory**: a warning is printed but the session starts anyway. Skip with `--no-validate-model`.
104
104
 
@@ -204,6 +204,8 @@ amicus council run --prompt-file briefing.md --models gemini,glm --chair deepsee
204
204
  | `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
205
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. |
206
206
  | `--intent <review\|task>` | The run's intent (v4.9). `review` — the default — is never stored; `task` marks a task-mode run, recorded as `intent: "task"` on `run.json`/`verdict.json` and kept out of the reliability ledger. Over MCP: the `intent` param on `amicus_council_run`, and a hand-assembled `amicus_council_tally` input may carry `meta.intent` the same emit-when-`task` way. What forks stage by stage — and what stays byte-identical — is in [docs/council.md § Task mode](./council.md#task-mode---intent-task). |
207
+ | `--tools <a,b,c>` | Tool ids stage-1 seats may use, by the engine's own ids (v4.9.8). Task mode defaults to `webfetch`, review to none; `task`/`skill`/`edit`/`write`/`apply_patch`/`question`/`invalid` are refused; anything else is validated against the engine before launch. A local tool (anything that is not `webfetch`/`websearch`/`todowrite` — `read`, `grep`, `glob`, `bash`, …) needs `--out-dir` outside the project tree. With `read` opted in, `.env`, `.env.*` and `.envrc` files are denied at the engine (case-sensitive on Linux; no other spelling is fenced), and the CLI prints a Notice for `read` too, naming those three fenced names and asking you to keep other secrets out; `grep`, `glob` and `bash` have no such per-file fence (grep returns `.env` contents, glob lists `.env` names) and the CLI prints a Notice when you opt any of them in. `bash` is outside every fence (run directory, home, network — including this run's own records: the label map and every review already on disk, so bench anonymity and independence do not hold under bash). Cannot be combined with `--agent`. Over MCP: the `tools` param (tools that never touch the tree — `webfetch`, `websearch`, `todowrite` — only). See [docs/council.md § Tool access](./council.md#tool-access-per-usage---tools). |
208
+ | `--agent <Plan\|Build>` | Escape hatch: run every leg on the engine's own agent instead of the council agents (no allowlist). Its legs run with the run directory (inside the project) as their working directory, so a seat can read the run's own records, the label map included — use it only where that is acceptable. `Build` is edit-capable — it can edit files and run commands there, and the CLI prints a Notice when you opt into it; `Plan` denies edits but allows reads, searches and the shell — the pre-4.9.8 default, so it restores v4.9.7's behaviour, and the CLI prints a Notice for it too (every leg, judges and the chair included, can run commands). Cannot be combined with `--tools`. Over MCP: the `agent` param. |
207
209
 
208
210
  **Exit codes:** `0` full run · `2` degraded but reportable (fewer than 2 judges, chair failure —
209
211
  `overallVerdict: null` — a cost ceiling hit after the tally, or a `--max-cost` ceiling set over a
@@ -303,7 +305,7 @@ Then invoke it with `--pack <name|path>` on `start` / `fanout` / `council run`
303
305
  | `fanout` | `bench` (a saved council name, or an array of ≥2 members) | — | `timeout`, `maxCost`, `gateway`, `agent`, `thinking`, `summaryLength`, `noContext`, `contextTurns`, `contextMaxTokens` |
304
306
  | `solo` | `model` | — | `timeout`, `maxCost`, `gateway`, `agent`, `thinking`, `summaryLength`, `noUi`, `noContext`, `contextTurns`, `contextMaxTokens` |
305
307
 
306
- `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. Those releases recorded `medium` on EVERY session's metadata, **a fanout leg's included**, whether or not the flag was typed (a level nothing ever sent), so a pack saved with `pack save --from-run` on 4.9.3 or earlier copied it into `options.thinking` on **fanout packs as well as solo ones** — where it then applies to every seat of the bench at once. Such a pack now SENDS it: refused on every model that does not declare `medium` (kimi-k3, Haiku 4.5, deepseek-v4-pro among the curated routes), and on a model that DOES declare it the level really goes out — so a pack that was inert can now change a run's cost and behaviour. Delete the key or re-save the pack from a run that requested a level.
308
+ `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; `--agent` / the MCP `agent` param are the only way to set a council run's agent — v4.9.8 — and summaryLength is hardcoded `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. Those releases recorded `medium` on EVERY session's metadata, **a fanout leg's included**, whether or not the flag was typed (a level nothing ever sent), so a pack saved with `pack save --from-run` on 4.9.3 or earlier copied it into `options.thinking` on **fanout packs as well as solo ones** — where it then applies to every seat of the bench at once. Such a pack now SENDS it: refused on every model that does not declare `medium` (kimi-k3, Haiku 4.5, deepseek-v4-pro among the curated routes), and on a model that DOES declare it the level really goes out — so a pack that was inert can now change a run's cost and behaviour. Delete the key or re-save the pack from a run that requested a level.
307
309
 
308
310
  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).
309
311
 
@@ -540,7 +542,7 @@ $ amicus status demo123 --json
540
542
  "taskId": "demo123",
541
543
  "status": "complete",
542
544
  "elapsed": "5m 0s",
543
- "version": "4.9.6",
545
+ "version": "4.9.8",
544
546
  "model": "google/gemini-2.5-flash",
545
547
  "phase": "terminal"
546
548
  }
@@ -970,6 +972,8 @@ Every tool below also takes an optional `project` — an absolute path naming th
970
972
  - `debate` — add a Stage-2.5 rebuttal round before the chair synthesizes.
971
973
  - `claudeReviewFile` — path to Claude's own review, included as a judged entry. Claude is reviewed and ranked like a seat, but never judges or chairs.
972
974
  - `intent` — `task` marks a task-mode run (recorded on `run.json`/`verdict.json`, kept out of the reliability ledger): seats produce the deliverable and the chair closes with `ANSWER:` on a disjoint scale. `review` is the default and is never stored. See [docs/council.md § Task mode](./council.md#task-mode---intent-task).
975
+ - `tools` — tool ids stage-1 seats may use, by the engine's own ids (task mode defaults to `webfetch`, review to none). Over MCP only tools that never touch the tree (`webfetch`, `websearch`, `todowrite`) can be opted in: the MCP run directory stays inside the project, so a seat with a local tool (`read`, `grep`, `glob`, `bash`, …) is refused with a message naming the CLI's `--tools` + `--out-dir` (outside the project) instead. `task`, `skill`, `question`, `invalid`, `edit`, `write` and `apply_patch` are always refused. Cannot be combined with `agent`.
976
+ - `agent` — escape hatch: run every leg on the engine's own agent instead of the council agents, with no tool allowlist. `Plan` or `Build` only — unlike `amicus_start`'s `agent`, `Chat` is not accepted here. Cannot be combined with `tools`.
973
977
  - `ui` — auto-open the Council Workspace window for this run. Default: opens under Claude Code (local) when Electron and a display exist and `workspace.autoOpen` is not `false`.
974
978
  - `onComplete`, `pack`, `tag` — as on `amicus_fanout`.
975
979
 
@@ -987,7 +991,7 @@ The `--agent` option controls which OpenCode agent drives the session:
987
991
  |-------|-------------|-------------|
988
992
  | **Chat** | Interactive conversation | Reads freely, asks before writes/bash |
989
993
  | **Build** | Full-access primary agent (headless default) | Read, write, bash, task |
990
- | **Plan** | Read-only analysis | Read-only |
994
+ | **Plan** | Analysis without edits | Read, search, bash; no edits |
991
995
 
992
996
  `--agent Chat` is interactive-only and incompatible with `--no-ui`. Custom agents defined in `~/.config/opencode/agents/` or `.opencode/agents/` are also supported.
993
997
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.6",
3
+ "version": "4.9.8",
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": [
@@ -231,9 +231,11 @@
231
231
  },
232
232
  "seatsReviewed": {
233
233
  "type": "object",
234
- "description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted.",
234
+ "description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted. v4.9.8 (#242): `unverified` (bench seats among `reviewed` whose findings came from a repair of a response with no parseable findings block — runStats[].findingsUnverified) and, since council #248 round 2, `refused` (those whose repair was refused — runStats[].repairRefused) join the census. buildVerdict writes it whenever it writes the census, since that version (0 is a measurement); it is declared, not required, so documents written before it still validate.",
235
235
  "properties": {
236
236
  "reviewed": { "type": "integer", "minimum": 0, "description": "Bench seats whose leg completed." },
237
+ "unverified": { "type": "integer", "minimum": 0, "description": "v4.9.8 (#242): bench seats, counted in `reviewed` too, whose findings came from a repair of a response with no parseable findings block — the tally scored those findings and nothing verified the repair. Not a stub count. A refused repair (runStats[].repairRefused) is not counted here. Never more than `reviewed`: the census and the report share one predicate (a bench role, a completed leg, the literal true), so a flagged row that is not a completed bench seat is counted nowhere and rendered nowhere." },
238
+ "refused": { "type": "integer", "minimum": 0, "description": "v4.9.8 (#242; council #248 round 2): bench seats, counted in `reviewed` too, whose repair was refused (runStats[].repairRefused) — the repair broke its count contract, so the seat contributed NO findings; visible here because a seat that contributed nothing must not read as a full reviewer on the gate. Always written with the census since v4.9.8; never more than `reviewed` (the census and the report share one predicate)." },
237
239
  "of": { "type": "integer", "minimum": 1, "description": "Bench seats benched, post-retry." }
238
240
  },
239
241
  "required": ["reviewed", "of"],
@@ -27,6 +27,12 @@ the deliverable rests on (same JSON shape, same severity enum, `location` = sour
27
27
  the word `assumption`); the judge's ranking axis becomes *how well the work was done* rather than
28
28
  how accurate a critique was; and the chair closes on the ANSWER scale below.
29
29
 
30
+ **v4.9.8 — tools are per run.** The engine registers two agents per council run (`council-seat`
31
+ for stage-1 legs, `council-support` for everything else) and composes the seat's tools sentence
32
+ itself: no tools → the shared no-tools sentence; tools → *"Your tools: webfetch. You have no
33
+ others — …"* (`src/council/seat-tools.js :: seatToolsSentence`). The manual path copies that
34
+ line into each seat's `_tmp-*` briefing; see `docs/council.md § Tool access`.
35
+
30
36
  ---
31
37
 
32
38
  ## Standard anti-sycophancy clause (ALL Stage-1 briefings — not an optional element)