amicus 4.5.4 → 4.6.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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +1 -1
  4. package/commands/council.md +1 -1
  5. package/docs/DISTRIBUTION.md +38 -11
  6. package/docs/usage.md +1 -1
  7. package/package.json +3 -2
  8. package/schemas/council-run.schema.json +20 -0
  9. package/schemas/council-verdict.schema.json +20 -0
  10. package/schemas/doctor.schema.json +23 -1
  11. package/src/cli-council-run-render.js +51 -0
  12. package/src/cli-handlers-council-run.js +45 -44
  13. package/src/cli-handlers-council.js +9 -3
  14. package/src/cli-handlers-doctor.js +16 -37
  15. package/src/cli-handlers-watch.js +1 -1
  16. package/src/cli.js +1 -1
  17. package/src/council/ledger.js +5 -1
  18. package/src/council/report-html.js +16 -1
  19. package/src/council/report.js +25 -1
  20. package/src/council/run-assemble.js +25 -7
  21. package/src/council/run-budget.js +14 -8
  22. package/src/council/run-chair.js +21 -4
  23. package/src/council/run-debate-stage.js +115 -0
  24. package/src/council/run-degrade.js +44 -0
  25. package/src/council/run-finalize.js +18 -3
  26. package/src/council/run-server.js +24 -7
  27. package/src/council/run-stage2.js +10 -2
  28. package/src/council/run-stages.js +23 -21
  29. package/src/council/run.js +39 -67
  30. package/src/council/verdict.js +74 -8
  31. package/src/mcp-council-bench.js +45 -0
  32. package/src/mcp-council-run.js +11 -28
  33. package/src/mcp-server.js +5 -1
  34. package/src/mcp-tools.js +8 -0
  35. package/src/utils/degrade.js +68 -0
  36. package/src/utils/doctor-degrade.js +51 -0
  37. package/src/utils/doctor-electron-mcp-check.js +64 -5
  38. package/src/utils/doctor-engine-check.js +14 -3
  39. package/src/utils/doctor-mcp-checks.js +10 -3
  40. package/src/utils/known-flags.js +2 -1
  41. package/src/utils/remediation-hints.js +5 -3
  42. package/src/utils/result-schema.js +6 -2
  43. package/src/utils/session-index-tmp-sweep.js +2 -1
  44. package/src/workspace/run-scan.js +5 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.5.4",
3
+ "version": "4.6.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,77 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [4.6.0] - 2026-08-02
9
+
10
+ ### Added (v4.6 milestone — the degrade announcement invariant, plans 1-4)
11
+
12
+ - **The ten-channel degrade announcement contract.** A council run can no longer degrade its
13
+ exit code without announcing what was lost: every loss routes through one sink
14
+ (`src/council/run-degrade.js`, the only code allowed to flip `degraded.value` — enforced by a
15
+ source-scan invariant test) and lands with mandatory *what/why/effect* on stderr,
16
+ `run.json.degrades[]`, `verdict.json.degrades[]`, and the report's new **"What was lost"**
17
+ section, all rendered in one voice.
18
+ - **`verdict.seatLoss` is now derived from the degrade records** (closes #84) — a dead critic
19
+ *leg* finally flips `criticSeated`, and `seatLoss` can no longer disagree with `degrades[]`.
20
+ The v4.5.2 seatLoss shape is unchanged (its tests passed byte-unedited).
21
+ - **Stage-2 judge legs get `runStats` rows** (closes #83) — per-leg cost attribution for ~38%
22
+ of a run's spend that had none, judge-tagged in the report's cost table.
23
+ - **`doctor` speaks the same language**: `doctor --json` gains additive `degrades[]`;
24
+ `doctor --fix` prints `Recovered:` lines for every repair; the engine hints state causes as
25
+ **unverified** instead of asserting an antivirus guess.
26
+ - **The Workspace is discoverable from the CLI**: `watch` usage names `--ui` (closes #80), and
27
+ a CLI council run with Electron present prints how to open the live Workspace (closes #81 —
28
+ the silence half; auto-open parity remains a product decision).
29
+
30
+ ### Changed (v4.6 — deliberate behavior changes)
31
+
32
+ - **A dropped preset member now degrades the run to exit 2 on every transport** (was: exit 0
33
+ with a `--json`-blind stderr notice). The loss is announced per-member with its reason.
34
+ - **A shared-server acquisition failure now exits degraded (2)** (was: stderr + run.json only,
35
+ exit 0) — the per-wave fallback is the racy configuration and the run says so.
36
+ - **Reported cost totals rise** for identical runs versus v4.5.x: judge legs now appear in
37
+ `runStats`. Consumers keying `runStats` by model must exclude `role: 'judge'` (as the
38
+ ledger's reliability join now does).
39
+ - In-run degrade notices hedge the exit-code claim truthfully ("will exit degraded (2)");
40
+ `engineMissing`/`reinstallEngineAv` hint prose changed to the unverified voice (commands
41
+ byte-identical).
42
+
43
+ ### Fixed (v4.6)
44
+
45
+ - **A dead Stage-1 leg was announced on no surface at all** (closes #85) — the only trace was
46
+ its absence from the stage entry's `taskIds`. Now named everywhere, with a regression pin.
47
+ - **The Stage-5 verdict rebuild silently destroyed `seatLoss`/`degrades[]`** (closes #87) —
48
+ `tally.json` carries neither, so the decisions flow dropped both; now preserved from the run
49
+ folder's verdict the same way the chair's synthesis already was, on both CLI and MCP.
50
+ - **`watch --ui` against an `--out-dir` run failed with a symptom, not a cause** (closes #82) —
51
+ the error now names the launch-directory pointer and the working invocation.
52
+
53
+ ### Fixed
54
+
55
+ - **`/amicus:council` lost all of its frontmatter at load time.** `commands/council.md`'s
56
+ `argument-hint` value began with `[material, path, or URL] [...]`, which YAML reads as a flow
57
+ sequence followed by a second, unexpected `[` — the whole block failed to parse, so the command
58
+ loaded with empty metadata: no description, no argument hint, and `disable-model-invocation:
59
+ true` silently dropped (the command was model-invocable, the opposite of the intent). The value
60
+ is now single-quoted. Present since `3900429` (Phase 9a, 2026-07-02); `claude plugin validate
61
+ .claude-plugin/plugin.json` failed on it, non-strict, that entire time.
62
+ - **The preflight that should have caught it was validating the wrong file.** With `.claude-plugin/`
63
+ holding both manifests, `claude plugin validate .` resolves the *marketplace* manifest and
64
+ reports `✔ Validation passed` without ever inspecting the plugin. `docs/DISTRIBUTION.md` §2 now
65
+ documents the path trap, prescribes `claude plugin validate .claude-plugin/plugin.json`, and
66
+ records why `--strict` is expected to fail here (the deliberately retained root-`CLAUDE.md`
67
+ warning).
68
+
69
+ ### Changed
70
+
71
+ - **`tests/plugin-commands.test.js` now YAML-parses frontmatter** for `commands/council.md` and
72
+ both skills, asserting `description`, `argument-hint` (as a *string*), and
73
+ `disable-model-invocation: true` survive parsing. The previous
74
+ `expect(md).toContain('argument-hint:')` substring checks passed happily against a file that
75
+ could not parse. `yaml` added as a devDependency for this.
76
+
6
77
  ## [4.5.4] - 2026-08-01
7
78
 
8
79
  ### Fixed
package/README.md CHANGED
@@ -441,7 +441,7 @@ $ amicus status demo123 --json
441
441
  "taskId": "demo123",
442
442
  "status": "complete",
443
443
  "elapsed": "5m 0s",
444
- "version": "4.5.4",
444
+ "version": "4.6.0",
445
445
  "model": "google/gemini-2.5-flash",
446
446
  "phase": "terminal"
447
447
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Run a structured multi-model LLM council review of the given material — wraps the second-opinion skill (independent reviews → anonymous cross-review → non-Claude chair verdict → accept/deny decisions).
3
- argument-hint: [material, path, or URL] [analysis request + criteria] [optional elements — e.g. "with a critic seat", "debate mode", "expert lenses", "chair verdict scale"]
3
+ argument-hint: '[material, path, or URL] [analysis request + criteria] [optional elements — e.g. "with a critic seat", "debate mode", "expert lenses", "chair verdict scale"]'
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -50,17 +50,36 @@ users only see a version bump when we bump it, not on every commit. Treat
50
50
  ### Preflight (run before every submission or major post-listing update)
51
51
 
52
52
  ```bash
53
- claude plugin validate . --strict
53
+ claude plugin validate .claude-plugin/plugin.json # the PLUGIN check — see the path trap below
54
+ claude plugin validate . # the marketplace manifest (separate check)
54
55
  claude --plugin-dir . # smoke: /amicus:council, /amicus:sidecar, /amicus:second-opinion, MCP tools
55
56
  npm test
56
57
  ```
57
58
 
58
- - `claude plugin validate . --strict` is the same structural check the
59
- review pipeline runs; `--strict` promotes unrecognized-field warnings to
60
- errors so nothing slips through that CI would later flag. It needs the
61
- Claude Code CLI installed locally it is **not** wired into this repo's
62
- CI (runners have no `claude` auth), so `tests/plugin-manifest.test.js` is
63
- the CI-side proxy for manifest completeness.
59
+ - **Path trap — `claude plugin validate .` does NOT validate the plugin.**
60
+ This repo's `.claude-plugin/` holds *both* `marketplace.json` and
61
+ `plugin.json`; given `.`, the CLI resolves the marketplace manifest, prints
62
+ `Validating marketplace manifest:` / `✔ Validation passed`, and never looks
63
+ at the plugin, its commands, or its skills. Always pass
64
+ `.claude-plugin/plugin.json` explicitly for the plugin check — that is the
65
+ surface the review pipeline evaluates. (This masked a real defect for a
66
+ month: `commands/council.md` shipped an unparseable `argument-hint` from
67
+ 2026-07-02 until 2026-08-01 while `.` reported clean. Read the first line
68
+ of the output — it names which manifest was actually checked.)
69
+ - `claude plugin validate` is the same structural check the review pipeline
70
+ runs. It needs the Claude Code CLI installed locally — it is **not** wired
71
+ into this repo's CI (runners have no `claude` auth), so
72
+ `tests/plugin-manifest.test.js` and `tests/plugin-commands.test.js` are the
73
+ CI-side proxies. The latter now YAML-parses the command and skill
74
+ frontmatter (`yaml` is a devDependency for exactly this); substring
75
+ assertions like `toContain('argument-hint:')` cannot see a parse failure,
76
+ which is how the `council.md` defect passed CI.
77
+ - **`--strict` is expected to FAIL here, by design.** It promotes the
78
+ root-`CLAUDE.md` warning ("not loaded as project context") to an error. The
79
+ plugin root is the repo root, and `CLAUDE.md` is this repo's development
80
+ context — we keep it deliberately. Use the non-strict run as the gate and
81
+ read the warning list by eye; a *new* warning is the signal, not the
82
+ `CLAUDE.md` one.
64
83
  - `claude --plugin-dir .` loads the plugin from the working tree so you can
65
84
  manually confirm `/amicus:council`, `/amicus:sidecar`, and
66
85
  `/amicus:second-opinion` all appear in the command picker exactly once,
@@ -68,10 +87,18 @@ npm test
68
87
  - `npm test` must be green, specifically `tests/plugin-manifest.test.js`
69
88
  and `tests/plugin-commands.test.js`.
70
89
 
71
- **Known current-tree preflight result (checked 2026-07-02, `p9/distribution`
72
- at a1bea3c):** `claude plugin validate . --strict` passes clean, exit 0.
73
- (History: `--strict` previously flagged an unknown `plugin.json → bugs`
74
- field; that field was removed in commit `4207485`, so the warning is gone.)
90
+ **Known current-tree preflight result (checked 2026-08-01, on
91
+ `fix/plugin-frontmatter-validation`):**
92
+ `claude plugin validate .claude-plugin/plugin.json`exit 0,
93
+ `✔ Validation passed with warnings` (the one retained root-`CLAUDE.md`
94
+ warning). With `--strict` → exit 1 on that same warning, as documented above.
95
+ `claude plugin validate .` → exit 0 on the marketplace manifest.
96
+
97
+ History of this line: it previously read "`claude plugin validate . --strict`
98
+ passes clean, exit 0" as of 2026-07-02 — measured against the *marketplace*
99
+ manifest via the path trap above, while the plugin itself had a hard
100
+ frontmatter error in `commands/council.md`. Older still: `--strict` flagged an
101
+ unknown `plugin.json → bugs` field, removed in commit `4207485`.
75
102
 
76
103
  ### Submit
77
104
 
package/docs/usage.md CHANGED
@@ -443,7 +443,7 @@ $ amicus status demo123 --json
443
443
  "taskId": "demo123",
444
444
  "status": "complete",
445
445
  "elapsed": "5m 0s",
446
- "version": "4.5.4",
446
+ "version": "4.6.0",
447
447
  "model": "google/gemini-2.5-flash",
448
448
  "phase": "terminal"
449
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.5.4",
3
+ "version": "4.6.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": [
@@ -95,7 +95,8 @@
95
95
  "lint-staged": "^16.3.2",
96
96
  "puppeteer": "^24.36.0",
97
97
  "sharp": "^0.35.3",
98
- "ws": "^8.19.0"
98
+ "ws": "^8.19.0",
99
+ "yaml": "^2.9.0"
99
100
  },
100
101
  "engines": {
101
102
  "node": ">=22.12.0"
@@ -82,6 +82,26 @@
82
82
  "at": { "type": "string" }
83
83
  }
84
84
  },
85
+ "degrades": {
86
+ "description": "v4.6 Plan 1: every degrade/heal the sink announced — additive, written by run-degrade.js.",
87
+ "type": "array",
88
+ "items": {
89
+ "type": "object",
90
+ "required": ["kind", "channel", "what", "why", "effect"],
91
+ "properties": {
92
+ "kind": { "enum": ["degrade", "heal"] },
93
+ "channel": { "type": "string" },
94
+ "what": { "type": "string" },
95
+ "why": { "type": "string" },
96
+ "effect": { "type": "string" },
97
+ "remedy": { "type": "string" },
98
+ "data": {
99
+ "type": "object",
100
+ "description": "v4.6 Plan 2: optional structured facts for derivations (e.g. dead-leg {seat,status,reason}, dead-wave {waveId,models,reason}). Prose fields stay the human surface."
101
+ }
102
+ }
103
+ }
104
+ },
85
105
  "debate": {
86
106
  "type": "object",
87
107
  "properties": {
@@ -205,6 +205,26 @@
205
205
  "criticRequested",
206
206
  "criticSeated"
207
207
  ]
208
+ },
209
+ "degrades": {
210
+ "description": "v4.6 Plan 2: what this run lost — copied verbatim from the sink at verdict assembly. Additive; absent on a clean run.",
211
+ "type": "array",
212
+ "items": {
213
+ "type": "object",
214
+ "required": ["kind", "channel", "what", "why", "effect"],
215
+ "properties": {
216
+ "kind": { "enum": ["degrade", "heal"] },
217
+ "channel": { "type": "string" },
218
+ "what": { "type": "string" },
219
+ "why": { "type": "string" },
220
+ "effect": { "type": "string" },
221
+ "remedy": { "type": "string" },
222
+ "data": {
223
+ "type": "object",
224
+ "description": "v4.6 Plan 2: optional structured facts for derivations (e.g. dead-leg {seat,status,reason}, dead-wave {waveId,models,reason}). Prose fields stay the human surface."
225
+ }
226
+ }
227
+ }
208
228
  }
209
229
  }
210
230
  }
@@ -21,7 +21,29 @@
21
21
  "name": { "type": "string" },
22
22
  "status": { "type": "string" },
23
23
  "message": { "type": ["string", "null"] },
24
- "hint": { "type": ["string", "null"] }
24
+ "hint": { "type": ["string", "null"] },
25
+ "fixed": { "type": "boolean" },
26
+ "fixDetail": { "type": "string" }
27
+ }
28
+ }
29
+ },
30
+ "degrades": {
31
+ "description": "v4.6 Plan 3: failed checks and --fix repairs in the shared degrade/heal vocabulary — additive, absent when clean.",
32
+ "type": "array",
33
+ "items": {
34
+ "type": "object",
35
+ "required": ["kind", "channel", "what", "why", "effect"],
36
+ "properties": {
37
+ "kind": { "enum": ["degrade", "heal"] },
38
+ "channel": { "type": "string" },
39
+ "what": { "type": "string" },
40
+ "why": { "type": "string" },
41
+ "effect": { "type": "string" },
42
+ "remedy": { "type": "string" },
43
+ "data": {
44
+ "type": "object",
45
+ "description": "v4.6 Plan 3: optional structured facts for derivations (doctor: {checkId}). Prose fields stay the human surface."
46
+ }
25
47
  }
26
48
  }
27
49
  }
@@ -0,0 +1,51 @@
1
+ // src/cli-council-run-render.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module cli-council-run-render
6
+ * Human-readable render of a finished `amicus council run` (the non-`--json`
7
+ * output path, v4.0 spec §4). Split out of cli-handlers-council-run.js (v4.6
8
+ * Plan 4 Task 2): that file sat at 298/300 lines and Plan 4 Task 3 adds the
9
+ * #81 Workspace notice, which needed the room. `renderRunHuman` is
10
+ * self-contained — no dependency on the handler's flag-parsing or engine
11
+ * dispatch — so it moves verbatim to its own leaf; the old home
12
+ * (cli-handlers-council-run.js) requires it back and re-exports it, so every
13
+ * existing import path keeps working unchanged.
14
+ */
15
+
16
+ function renderRunHuman(run) {
17
+ const lines = [
18
+ `Council run ${run.runId}: ${run.status} (exit ${run.exitCode})`,
19
+ ` bench: ${(run.bench || []).join(', ')} chair: ${run.chair}`,
20
+ ` dir: ${run.options && run.options.outDir}`,
21
+ ];
22
+ // v4.4: a cost line that omits unpriced legs reads as the whole bill. The
23
+ // diagnosis measured council-wsgate02 printing $0.3720 for a run that really
24
+ // spent $0.9859. Say what we know, then say what we cannot know — and print
25
+ // the line even when NOTHING resolved (the old `typeof amount === 'number'`
26
+ // guard silently dropped it, so a fully unpriced run looked free).
27
+ const u = run.usage || null;
28
+ const unknownLegs = u && typeof u.unknownLegs === 'number'
29
+ ? u.unknownLegs
30
+ : (u && u.cost && u.cost.unpricedLegs) || 0;
31
+ // v4.4 Task 2: a fully-priced run can still be short. `council-wsgate01`
32
+ // printed an unqualified $0.2821 for a run that really spent $0.3036 — every
33
+ // leg `reported`, and 100% of the gap one unattributed `explore` child session.
34
+ const subtreeLegs = u && typeof u.subtreeUnknownLegs === 'number'
35
+ ? u.subtreeUnknownLegs
36
+ : (u && u.cost && u.cost.subtreeUnknownLegs) || 0;
37
+ if (u && u.cost && (typeof u.cost.amount === 'number' || unknownLegs > 0 || subtreeLegs > 0)) {
38
+ const known = typeof u.cost.amount === 'number' ? `$${u.cost.amount.toFixed(4)}` : '$0.0000';
39
+ const gaps = [];
40
+ if (unknownLegs > 0) { gaps.push(`${unknownLegs} leg(s) unknown`); }
41
+ if (subtreeLegs > 0) { gaps.push(`${subtreeLegs} leg(s) with unattributed subagent child-session spend`); }
42
+ const tail = gaps.length > 0
43
+ ? ` + ${gaps.join(' + ')} — real spend is at least this much`
44
+ : '';
45
+ lines.push(` cost: ${known} (${u.cost.source})${tail}`);
46
+ }
47
+ if (run.error) { lines.push(` error: ${run.error.code}: ${run.error.message}`); }
48
+ return lines.join('\n') + '\n';
49
+ }
50
+
51
+ module.exports = { renderRunHuman };
@@ -13,6 +13,10 @@ const path = require('path');
13
13
  const { failJson, buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
14
14
  const { validateTaskId } = require('./utils/validators');
15
15
  const { GATEWAY_MODES } = require('./utils/model-descriptor');
16
+ // v4.6 Plan 4 Task 2: renderRunHuman moved to its own leaf (size gate); this
17
+ // file re-exports it below so every existing require() of this path still
18
+ // resolves it unchanged.
19
+ const { renderRunHuman } = require('./cli-council-run-render');
16
20
 
17
21
  const CHAIR_DEFAULT = 'deepseek';
18
22
 
@@ -40,9 +44,9 @@ function sanitizeCouncilName(name) {
40
44
 
41
45
  /**
42
46
  * Resolve bench models from --models XOR --council (mirrors handleFanout).
43
- * Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed --council
44
- * name when that branch was taken, else null threaded into runCouncil's
45
- * `councilName` option so council ledger rows can be attributed to a preset.
47
+ * Also returns `presetName` (v4.3 Task 3, spec §7.1: trimmed --council name,
48
+ * else null) and `droppedMembers`: a preset's own drops, orbare --models
49
+ * the parsed `--dropped-members` MCP→child passthrough (v4.6 Plan 4 Task 4b).
46
50
  */
47
51
  function resolveBench(args, useJson) {
48
52
  const hasModels = typeof args.models === 'string' && args.models.trim();
@@ -68,54 +72,40 @@ function resolveBench(args, useJson) {
68
72
  if (expanded.error) {
69
73
  return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }) };
70
74
  }
71
- if (expanded.dropped && expanded.dropped.length && !useJson) {
72
- process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
73
- }
74
- // v4.5 Wave 2: threaded into runCouncil's options — the ONLY prior signal
75
- // was the stderr-only Notice above, which --json mode never even prints.
75
+ // v4.5 Wave 2 Plan 4 Task 4: threaded into runCouncil's options — the
76
+ // sink now announces each dropped member, with reason, on every transport and surface.
76
77
  return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
77
78
  }
78
- return { bench: parseList(args.models), presetName: null, droppedMembers: [] };
79
+ if (args['dropped-members'] === undefined) {
80
+ return { bench: parseList(args.models), presetName: null, droppedMembers: [] };
81
+ }
82
+ let dm; try { dm = JSON.parse(args['dropped-members']); } catch { dm = null; }
83
+ if (!Array.isArray(dm) || !dm.every(d => d && typeof d.member === 'string' && typeof d.reason === 'string')) {
84
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
85
+ message: 'Error: --dropped-members must be a JSON array of {member, reason} entries' }) };
86
+ }
87
+ return { bench: parseList(args.models), presetName: null, droppedMembers: dm };
79
88
  }
80
89
 
81
- function renderRunHuman(run) {
82
- const lines = [
83
- `Council run ${run.runId}: ${run.status} (exit ${run.exitCode})`,
84
- ` bench: ${(run.bench || []).join(', ')} chair: ${run.chair}`,
85
- ` dir: ${run.options && run.options.outDir}`,
86
- ];
87
- // v4.4: a cost line that omits unpriced legs reads as the whole bill. The
88
- // diagnosis measured council-wsgate02 printing $0.3720 for a run that really
89
- // spent $0.9859. Say what we know, then say what we cannot know — and print
90
- // the line even when NOTHING resolved (the old `typeof amount === 'number'`
91
- // guard silently dropped it, so a fully unpriced run looked free).
92
- const u = run.usage || null;
93
- const unknownLegs = u && typeof u.unknownLegs === 'number'
94
- ? u.unknownLegs
95
- : (u && u.cost && u.cost.unpricedLegs) || 0;
96
- // v4.4 Task 2: a fully-priced run can still be short. `council-wsgate01`
97
- // printed an unqualified $0.2821 for a run that really spent $0.3036 — every
98
- // leg `reported`, and 100% of the gap one unattributed `explore` child session.
99
- const subtreeLegs = u && typeof u.subtreeUnknownLegs === 'number'
100
- ? u.subtreeUnknownLegs
101
- : (u && u.cost && u.cost.subtreeUnknownLegs) || 0;
102
- if (u && u.cost && (typeof u.cost.amount === 'number' || unknownLegs > 0 || subtreeLegs > 0)) {
103
- const known = typeof u.cost.amount === 'number' ? `$${u.cost.amount.toFixed(4)}` : '$0.0000';
104
- const gaps = [];
105
- if (unknownLegs > 0) { gaps.push(`${unknownLegs} leg(s) unknown`); }
106
- if (subtreeLegs > 0) { gaps.push(`${subtreeLegs} leg(s) with unattributed subagent child-session spend`); }
107
- const tail = gaps.length > 0
108
- ? ` + ${gaps.join(' + ')} — real spend is at least this much`
109
- : '';
110
- lines.push(` cost: ${known} (${u.cost.source})${tail}`);
111
- }
112
- if (run.error) { lines.push(` error: ${run.error.code}: ${run.error.message}`); }
113
- return lines.join('\n') + '\n';
90
+ /**
91
+ * Default real helpers; tests override via depsOverride (mirrors
92
+ * cli-handlers-spend.js's realDeps()/depsOverride convention).
93
+ */
94
+ function realDeps() {
95
+ return {
96
+ // #81 (spec §2): same pure presence probe doctor's electron checks use (src/cli-handlers-doctor.js).
97
+ getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
98
+ };
114
99
  }
115
100
 
116
- /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
117
- async function handleCouncilRun(args) {
101
+ /**
102
+ * @param {object} args parsed CLI args
103
+ * @param {object} [depsOverride] test seam (getElectronPath)
104
+ * @returns {Promise<number>} exit code
105
+ */
106
+ async function handleCouncilRun(args, depsOverride = {}) {
118
107
  const useJson = !!args.json;
108
+ const deps = { ...realDeps(), ...depsOverride };
119
109
 
120
110
  // v4.5 Task 12 (B7/F5): resolve --pack FIRST, above the Task-5 template
121
111
  // block, so a pack-filled args.template renders through that single
@@ -243,6 +233,17 @@ async function handleCouncilRun(args) {
243
233
  const { readCache } = require('./utils/model-catalog');
244
234
  const { runCouncil } = require('./council/run');
245
235
  const cfg = loadConfig() || {};
236
+
237
+ // #81 (spec §2): the GUI's existence was announced on NO surface from the
238
+ // CLI path — MCP launches auto-open, the CLI stayed silent. Auto-open
239
+ // parity is a product decision (deliberately not taken here); the SILENCE
240
+ // is the spec's to fix. Presence probe only — never launches. Placed here
241
+ // (runId/runDir already resolved, still before the engine await) so the
242
+ // notice is useful WHILE the run is live, not just after it finishes.
243
+ if (!useJson && deps.getElectronPath()) {
244
+ process.stderr.write(`Notice: the Council Workspace can render this run live — open it with: amicus watch ${runId} --ui\n`);
245
+ }
246
+
246
247
  const { exitCode, run } = await runCouncil({
247
248
  briefing: promptRes.prompt, models: bench, chair, critic, lenses,
248
249
  project, runId, runDir,
@@ -8,7 +8,7 @@ const { sumWaveUsage, formatCost } = require('./utils/pricing');
8
8
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
9
9
  const { buildReport } = require('./council/report');
10
10
  const { validateFindings, buildValidateDoc } = require('./council/findings');
11
- const { buildVerdict, readOverallVerdict, writeVerdictAtomic } = require('./council/verdict');
11
+ const { buildVerdict, readOverallVerdict, readPriorVerdictSurfaces, writeVerdictAtomic } = require('./council/verdict');
12
12
  const {
13
13
  runSave: runCouncilSave,
14
14
  runList: runCouncilList,
@@ -169,8 +169,14 @@ function runVerdict(args, useJson) {
169
169
  // one of only two homes of the chair's synthesis (the other is
170
170
  // chair-output.md); tally.json carries no copy. Recover it from the RUN
171
171
  // folder — the tally's own directory, not `-o` — before rebuilding.
172
- const overallVerdict = readOverallVerdict(path.dirname(path.resolve(tallyPath)), record.meta.runId);
173
- verdict = buildVerdict(record, decisions, { overallVerdict });
172
+ const runDir = path.dirname(path.resolve(tallyPath));
173
+ const overallVerdict = readOverallVerdict(runDir, record.meta.runId);
174
+ // #87: tally.json carries neither seatLoss nor degrades — recover both from
175
+ // the run folder's verdict the same way the chair line is recovered.
176
+ const prior = readPriorVerdictSurfaces(runDir, record.meta.runId);
177
+ verdict = buildVerdict(record, decisions, { overallVerdict,
178
+ ...(prior.seatLoss ? { seatLoss: prior.seatLoss } : {}),
179
+ ...(prior.degrades ? { degrades: prior.degrades } : {}) });
174
180
  }
175
181
  catch (e) {
176
182
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot build verdict: ${e.message}`,
@@ -171,40 +171,8 @@ async function runDoctorChecks(depsOverride = {}) {
171
171
  checks.push(await guardAsync('electron-mcp', 'Electron (MCP launch path)',
172
172
  () => electronMcpCheck.evaluateElectronMcp({ ...d, fixTimeoutMs: FIX_TIMEOUT_MS })));
173
173
 
174
- checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
175
- if (d.getElectronPath()) {
176
- return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
177
- }
178
- // Broken (missing / quarantined). With --fix, self-heal in place (#56):
179
- // repairElectron provisions the binary; {deferred} (no cache, no network)
180
- // maps to WARN — a deferred download is not a failure. Without --fix, just
181
- // point the user at `amicus doctor --fix`.
182
- if (d.fix) {
183
- let res;
184
- try {
185
- res = await d.repairElectron({ timeoutMs: FIX_TIMEOUT_MS });
186
- } catch (e) {
187
- return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
188
- }
189
- res = res || {};
190
- if (res.repaired) {
191
- return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
192
- }
193
- const why = res.reason ? ` — ${res.reason}` : '';
194
- // Quarantine (AV deleted electron.exe post-extract) is NOT a deferral and
195
- // must NEVER be silently retried: surface the allow-list instruction as a
196
- // WARN and STOP. No re-run of repairElectron here (no loop).
197
- const detail = res.quarantined
198
- ? `antivirus quarantine${why}`
199
- : res.deferred
200
- ? `deferred${why}`
201
- : res.contended
202
- ? `repair already in progress${why}`
203
- : `not provisioned${why}`;
204
- return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
205
- }
206
- return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
207
- }));
174
+ checks.push(await guardAsync('electron', 'Electron (interactive GUI)',
175
+ () => electronMcpCheck.evaluateElectronInteractive(d, { fixTimeoutMs: FIX_TIMEOUT_MS })));
208
176
 
209
177
  checks.push(guard('skills', 'Skills installed', () => (
210
178
  d.skillInstalled()
@@ -256,7 +224,7 @@ async function runDoctorChecks(depsOverride = {}) {
256
224
 
257
225
  const MARK = { ok: '✓', warn: '⚠', error: '✗' }; // ✓ ⚠ ✗
258
226
 
259
- function renderHuman(checks) {
227
+ function renderHuman(checks, degrades = []) {
260
228
  let out = 'amicus doctor\n\n';
261
229
  for (const c of checks) {
262
230
  out += `${MARK[c.status] || '?'} ${c.name}: ${c.message}\n`;
@@ -265,6 +233,13 @@ function renderHuman(checks) {
265
233
  const errors = checks.filter(c => c.status === 'error').length;
266
234
  const warns = checks.filter(c => c.status === 'warn').length;
267
235
  out += `\n${errors} error(s), ${warns} warning(s).\n`;
236
+ // D7: every --fix repair announces what it did, in the one voice. Failures
237
+ // are NOT repeated here — the ✗ rows above already carry them; degrade
238
+ // records are the --json/artifact surface.
239
+ const { formatDegrade } = require('./utils/degrade');
240
+ for (const r of degrades.filter(x => x.kind === 'heal')) {
241
+ out += formatDegrade(r);
242
+ }
268
243
  return out;
269
244
  }
270
245
 
@@ -281,14 +256,18 @@ async function handleDoctor(args, runChecks = runDoctorChecks) {
281
256
  // self-heal in place. Omitted (not false) when absent so the injected
282
257
  // test-double sees a clean "no fix" call.
283
258
  const checks = await runChecks(args.fix ? { fix: true } : undefined);
259
+ // v4.6 Plan 3: collect once, both paths consume — the shared degrade/heal
260
+ // vocabulary (spec §4/§6). Never affects the exit-code logic below.
261
+ const { collectDoctorDegrades } = require('./utils/doctor-degrade');
262
+ const degrades = collectDoctorDegrades(checks);
284
263
  if (useJson) {
285
264
  const { buildDoctorDoc } = require('./utils/result-schema');
286
265
  const VERSION = require('../package.json').version;
287
- const doc = buildDoctorDoc({ version: VERSION, timestamp: new Date().toISOString(), checks });
266
+ const doc = buildDoctorDoc({ version: VERSION, timestamp: new Date().toISOString(), checks, degrades });
288
267
  process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
289
268
  return doc.ok ? 0 : 1;
290
269
  }
291
- process.stdout.write(renderHuman(checks));
270
+ process.stdout.write(renderHuman(checks, degrades));
292
271
  return checks.some(c => c.status === 'error') ? 1 : 0;
293
272
  }
294
273
 
@@ -104,7 +104,7 @@ async function handleWatch(args) {
104
104
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
105
105
  const id = args._[1];
106
106
  if (!id || id === true) {
107
- process.stderr.write('Error: id is required for watch\nUsage: amicus watch <id> [--json] [--plain] [--interval <sec>]\n');
107
+ process.stderr.write('Error: id is required for watch\nUsage: amicus watch <id> [--json] [--plain] [--interval <sec>] [--ui]\n');
108
108
  return 1;
109
109
  }
110
110
  const check = validateTaskId(String(id));
package/src/cli.js CHANGED
@@ -397,7 +397,7 @@ Commands:
397
397
  council verdict <tally.json> [--decisions <d.json>] [-o <out.json>] Build + write verdict.json
398
398
  doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
399
399
  spend [--since 7d] [--json] Cross-run cost rollup from the spend ledger
400
- watch <id> [--json] [--plain] [--interval <sec>] Live-render a run from any terminal
400
+ watch <id> [--json] [--plain] [--interval <sec>] [--ui] Live-render a run from any terminal
401
401
  abort Abort a running session (or --all)
402
402
  setup Configure default model and aliases
403
403
  --api-keys Open API key setup window
@@ -18,7 +18,11 @@ function countSeverity(findings) {
18
18
  function buildLedgerRows(record) {
19
19
  const { meta, findings, streetCred, runStats, judged } = record;
20
20
  const sc = new Map(streetCred.map(s => [s.model, s]));
21
- const rs = new Map(runStats.filter(r => !DEBATE_ROLES.has(r.role)).map(r => [r.model, r]));
21
+ // Judge rows (#83, v4.6 Plan 2) are Stage-2 cost attribution, not seats: the
22
+ // join below is keyed by MODEL, and judges ARE bench models — without this
23
+ // exclusion the judge row would silently overwrite the seat row.
24
+ const rs = new Map(runStats.filter(r => !DEBATE_ROLES.has(r.role) && r.role !== 'judge')
25
+ .map(r => [r.model, r]));
22
26
  return meta.models.map(model => {
23
27
  const raised = findings.filter(f => f.raiser === model);
24
28
  const s = sc.get(model) || {};