amicus 1.9.1 → 2.1.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +140 -113
- package/src/mcp-tools.js +58 -24
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +34 -12
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +41 -11
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
|
@@ -31,7 +31,7 @@ Operating lessons from each run fold back into `MODEL-NOTES.md` (with approval),
|
|
|
31
31
|
|
|
32
32
|
**Before launching any model, READ `MODEL-NOTES.md`** (next to this file). It holds the operating rules and per-model quirks that decide whether a run succeeds or silently fails. These were learned the hard way; skipping them wastes runs and produces empty results that look like answers.
|
|
33
33
|
|
|
34
|
-
**Transport rule — CLI not on PATH:** every command below assumes the `amicus` CLI. If `amicus` is not on PATH (typical for **plugin-only installs**), run the identical commands as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest fanout --models "m1,m2,m3" --prompt-file <path> --json`), or use the equivalent MCP tools (`amicus_fanout`, `amicus_start`, `amicus_status`, `amicus_read`, `amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) — council briefings are always self-contained (`--no-context`), so MCP transport is equivalent.
|
|
34
|
+
**Transport rule — CLI not on PATH:** every command below assumes the `amicus` CLI. If `amicus` is not on PATH (typical for **plugin-only installs**), run the identical commands as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest fanout --models "m1,m2,m3" --prompt-file <path> --json`), or use the equivalent MCP tools (`amicus_fanout`, `amicus_start`, `amicus_wait`, `amicus_status`, `amicus_read`, `amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) — council briefings are always self-contained (`--no-context`), so MCP transport is equivalent.
|
|
35
35
|
|
|
36
36
|
## When to use
|
|
37
37
|
|
|
@@ -124,7 +124,7 @@ Always quote the `--models` list — unquoted, PowerShell splits on commas and t
|
|
|
124
124
|
|
|
125
125
|
Run it in the background (`run_in_background: true`); you are notified on completion — do not
|
|
126
126
|
poll. `fanout` is headless by definition. The command exits when every leg is terminal and prints
|
|
127
|
-
ONE JSON wave document on stdout (`schemaVersion:
|
|
127
|
+
ONE JSON wave document on stdout (`schemaVersion: 2`; the wave's id field is `waveId`, each leg's id is `taskId`): check `status` (`complete` | `partial` |
|
|
128
128
|
`error`), `counts`, and each leg in `legs[]` — a leg's `summary` field IS that model's review;
|
|
129
129
|
`model`/`modelInput` identify the reviewer (`model` is the resolved id, `modelInput` the alias you passed — use the alias for `review-<model>.md` filenames); `status`/`error` identify failures. Exit code 0 =
|
|
130
130
|
all legs complete, 2 = partial (apply the wave-degrade rules below), 1 = error/aborted. (To re-fetch a single leg later: `amicus read <taskId> --json`.)
|
|
@@ -141,9 +141,10 @@ amicus start --model <redteam-model> --no-ui --json \
|
|
|
141
141
|
Its stdout is a single run document; the `summary` field is the review.
|
|
142
142
|
|
|
143
143
|
**Cowork / no-Bash environments:** use the MCP tools instead — `amicus_fanout` (briefing via
|
|
144
|
-
file) returns `{waveId, taskIds[]}` immediately
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
file) returns `{waveId, taskIds[]}` immediately. Preferred: call `amicus_wait` with the waveId —
|
|
145
|
+
one blocking call per wave; re-call it while it returns `timedOut: true`. Fallback: poll
|
|
146
|
+
`amicus_status`. Either way, `amicus_read` each leg when done. The council's briefings are always
|
|
147
|
+
self-contained (`--no-context`), so MCP transport is equivalent.
|
|
147
148
|
|
|
148
149
|
**Required structured output from every model.** Instruct each council model to produce:
|
|
149
150
|
|
|
@@ -165,16 +166,16 @@ equivalent.
|
|
|
165
166
|
|
|
166
167
|
Instruct models to emit the structured JSON verbatim after the prose, without preamble, so it parses cleanly.
|
|
167
168
|
|
|
168
|
-
|
|
169
|
+
Save each leg's full output (prose + findings block) to the run folder as `review-<model>.md`
|
|
170
|
+
(one file per reviewer) before moving on.
|
|
171
|
+
|
|
172
|
+
**After the wave returns, validate each leg's findings block** by running `amicus council validate <leg-file> --json` (a thin CLI wrapper over `validateFindings`, Unit A — `src/council/findings.js`). It reads the leg's saved `review-<model>.md` and prints `{ok, findings, errors}`. Exit codes are a **tri-state** contract: `0` when `ok:true` (well-formed, proceed), `2` when `ok:false` (validation failed — a distinct, scriptable outcome, not a crash), `1` (`BAD_ARGS`) for a missing/unreadable file. If a leg's JSON fails validation (`ok:false` / exit 2):
|
|
169
173
|
1. Issue a **solo `start --json`** re-prompt to that one model: "re-emit only the findings JSON, fixing: \<errors\>." Keep the first-pass prose. (Solo `start` passes through the **same budget gate** as `fanout`. If launching the wave required `--max-cost <$>` or `--no-cost-gate`, pass the **same flag on every repair re-prompt and on the chair call** — otherwise the gate can refuse a repair or the chair mid-council.)
|
|
170
174
|
2. If still malformed, retry **once more** (cap = **2** re-prompts total).
|
|
171
175
|
3. If still malformed after 2 retries, mark the review `unstructured` and hand-parse its prose into the schema. The review proceeds — never dropped for a formatting miss.
|
|
172
176
|
|
|
173
177
|
Record per-model **conformance** (`clean` | `repaired` | `unstructured`) for inclusion in the tally input's `runStats` and the Stage-6 MODEL-NOTES note.
|
|
174
178
|
|
|
175
|
-
Save each leg's full output (prose + findings block) to the run folder as `review-<model>.md`
|
|
176
|
-
(one file per reviewer) before moving on.
|
|
177
|
-
|
|
178
179
|
**"Claude in the council" (when toggled on):** Claude also produces a **fresh** Stage-1 review on the artifact in the identical findings format — a new structured pass on the artifact, not a formalization of anything said upstream. This review is added to the bundle as one more anonymous entry. Claude does not rank or adjudicate in Stage 2 (it holds the label map), and does not chair in Stage 3. Save it as `review-claude.md`.
|
|
179
180
|
|
|
180
181
|
**Wave-degrade rules (Stage 1).** Read failures from the wave document — never silently ignore
|
|
@@ -210,7 +211,7 @@ amicus fanout --models "<m1,m2,m3>" --prompt-file <run-folder>/_tmp-bundle-stage
|
|
|
210
211
|
(Background, same JSON handling as Stage 1.) Each judge's leg `summary` is its ranking +
|
|
211
212
|
adjudication response. **Stage-2 degrade:** a judge leg dies → tally over the surviving judges
|
|
212
213
|
(≥ 1) and disclose the reduced bench in `crossreview-matrix.md`; tier definitions are unchanged
|
|
213
|
-
(they already count "judges engaged").
|
|
214
|
+
(they already count "judges engaged").
|
|
214
215
|
|
|
215
216
|
**Judge-briefing hardening (required).** Open `_tmp-bundle-stage2.md` with this preamble, verbatim, as its first line:
|
|
216
217
|
|
|
@@ -218,6 +219,8 @@ adjudication response. **Stage-2 degrade:** a judge leg dies → tally over the
|
|
|
218
219
|
|
|
219
220
|
Plan-agent judges have wandered to tools mid-adjudication (reading files instead of judging and returning only narration), and a tool-capable judge can read the de-anonymized `review-<model>.md` files in the run folder — an anonymization leak. The preamble closes both. **Scratch-cwd (optional second layer):** launch the Stage-2 wave (and the Stage-3 chair call) with `--cwd <run-folder>/_scratch/` — create the empty directory first — so even a wandering agent finds nothing to read. Caveat: those legs' session records then live under `_scratch/.claude/amicus_sessions/`, so any later `amicus read <taskId>` for them needs the same `--cwd`.
|
|
220
221
|
|
|
222
|
+
Each judge is asked to do two things on the bundle:
|
|
223
|
+
|
|
221
224
|
**Task A — Rank.** Order the reviews from most to least accurate and insightful. End the response with a parseable block in exactly this format (no other text on those lines):
|
|
222
225
|
|
|
223
226
|
```
|
|
@@ -242,12 +245,14 @@ As each judge's ranking + adjudication response returns, collect it (the raw per
|
|
|
242
245
|
|
|
243
246
|
**Five-keys checklist — verify `tally-input.json` has ALL of:** `meta` (with `meta.models`), `findings`, `adjudications`, `rankings`, `runStats` (`runStats` may be `[]`; the other four are required). Do not call `tally` until all five are present.
|
|
244
247
|
|
|
245
|
-
Then call:
|
|
248
|
+
Then call, saving the printed `record` to `<run-folder>/tally.json` (Stage 5's `amicus council verdict` reads it back from disk):
|
|
246
249
|
|
|
247
250
|
```
|
|
248
|
-
amicus council tally <run-folder>/tally-input.json --json
|
|
251
|
+
amicus council tally <run-folder>/tally-input.json --json > <run-folder>/tally.json
|
|
249
252
|
```
|
|
250
253
|
|
|
254
|
+
**Windows PowerShell 5.1 caveat:** that `>` redirect writes UTF-16 under legacy Windows PowerShell 5.1 (fine on pwsh 7+ or bash), which corrupts `tally.json` for Stage 5's `amicus council verdict` and surfaces as a confusing `BAD_ARGS` there instead of here — on 5.1 pipe through `| Out-File -Encoding utf8` (or run under pwsh 7+) instead of a bare `>`.
|
|
255
|
+
|
|
251
256
|
The output `record` carries the deterministic tiers (Disputed / Confirmed / Contested / Singleton), `confidence` (`solid` | `thin`), both street-cred numbers (`withSelf` and `peersOnly`), the validated `runStats`, and `tierCounts`. **Claude may override a `thin`-confidence tier at the margins** before Stage 4 — record the override in `tierOverride: {from, to, reason}`; the matrix and `verdict.json` surface it. De-anonymize and write the tally results to `crossreview-matrix.md` — the adjudication grid plus the street-cred table. This data feeds Stage 3 (chair briefing) and is never re-anonymized or forwarded to any council model.
|
|
252
257
|
|
|
253
258
|
---
|
|
@@ -334,7 +339,7 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
|
|
|
334
339
|
- `review-<model>.md` × N (already saved in Stage 1)
|
|
335
340
|
- `crossreview-matrix.md` — the de-anonymized adjudication grid and street-cred table
|
|
336
341
|
- `verdict.md` (already saved in Stage 3)
|
|
337
|
-
- `verdict.json` — write
|
|
342
|
+
- `verdict.json` — write by running `amicus council verdict <run-folder>/tally.json --decisions <run-folder>/decisions.json -o <run-folder>/verdict.json` (a thin CLI wrapper over `buildVerdict(record, decisions)` + `writeVerdictAtomic`, `src/council/verdict.js`). `<run-folder>/tally.json` is the `record` saved from the Stage-2 `amicus council tally` call. `<run-folder>/decisions.json` is a **JSON array**, one object per finding: `{id, decision, applied?, duplicateOf?, tierOverride?}` — `id` is the run-global label id (e.g. `A1`); `decision` is the Stage-4 outcome (accepted / denied / modified / deferred); `applied` (optional bool) marks whether the accepted change was actually applied to the artifact in Stage 5; `duplicateOf` (optional) links to another finding's id when Claude identified a duplicate; `tierOverride` (optional) carries any `{from, to, reason}` override recorded in Stage 2. Save this array to `<run-folder>/decisions.json` first, then run the command — it parses the tally record and the decisions file, calls `buildVerdict`, and writes the schema-stamped machine-readable record to the run folder via the same atomic tmp+rename convention the function always used.
|
|
338
343
|
- `report.md` — the chair's synthesis + the full Stage-4 decision log + a summary of what was
|
|
339
344
|
applied (+ the "How Claude's review fared" readout when "Claude in the council" is on) + a
|
|
340
345
|
**run-stats table**: one row per model call — **stage** (which stage you launched the call for)
|
|
@@ -343,15 +348,17 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
|
|
|
343
348
|
— exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
|
|
344
349
|
invent a figure. Add a wave **total cost** row from the wave document's
|
|
345
350
|
`usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`; never invent a value.
|
|
346
|
-
- **Renderer:** once `verdict.json` is written,
|
|
347
|
-
`amicus council report <run-folder>/verdict.json --
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
shareable page. This emits the
|
|
351
|
+
- **Renderer:** once `verdict.json` is written, run
|
|
352
|
+
`amicus council report <run-folder>/verdict.json --html > <run-folder>/report.html` — a
|
|
353
|
+
**separate, deterministic** artifact, not report.md itself. **`report.html` is the default
|
|
354
|
+
final artifact to hand the user** — a self-contained, shareable page. This emits the
|
|
351
355
|
adjudication matrix (finding × judge), the peers-only street-cred table, the
|
|
352
356
|
findings-by-tier groupings (Disputed-first), and the per-model + wave cost —
|
|
353
|
-
deterministic data only.
|
|
354
|
-
|
|
357
|
+
deterministic data only. To assemble report.md, also run
|
|
358
|
+
`amicus council report <run-folder>/verdict.json --md` (no redirect — read its stdout) and
|
|
359
|
+
paste that Markdown into report.md as one section; reserve the rest of report.md's prose for
|
|
360
|
+
the chair's synthesis and the decision log. Prefer the renderer's Markdown over
|
|
361
|
+
hand-assembling the matrix by hand.
|
|
355
362
|
|
|
356
363
|
Tell the user exactly which files were written and where, leading with `report.html`, **and present the verdict inline in chat** — the chair's overall assessment (verbatim or lightly trimmed) plus the tier counts (Confirmed/Disputed/Contested/Singleton) and what was applied. Never hand over only file paths.
|
|
357
364
|
|
|
@@ -465,14 +472,9 @@ Always **rank recommendations by fit**, state the trade-off for each option, and
|
|
|
465
472
|
- `review-<model>.md` ×N — raw Stage 1 reviews (plus `review-claude.md` when "Claude in the council" is on)
|
|
466
473
|
- `crossreview-matrix.md` — adjudication grid + de-anonymized street-cred table
|
|
467
474
|
- `verdict.md` — the chair's synthesis (prose)
|
|
468
|
-
- `verdict.json` — schema-stamped machine-readable record: tally output + Stage-4 decisions, written via `
|
|
469
|
-
- `report.md` —
|
|
470
|
-
|
|
471
|
-
block. Cost is `usage.cost.amount` (USD); mark it with its `usage.cost.source`
|
|
472
|
-
— exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
|
|
473
|
-
invent a figure. Add a wave **total cost** row from the wave document's
|
|
474
|
-
`usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`.
|
|
475
|
-
- `report.html` — the same report rendered as a self-contained page (`amicus council report <verdict.json> --html`); the default artifact to share.
|
|
475
|
+
- `verdict.json` — schema-stamped machine-readable record: tally output + Stage-4 decisions, written via `amicus council verdict` at Stage 5
|
|
476
|
+
- `report.md` — Claude-authored; full contract defined once in *Stage 5 → Run-folder artifacts* above (chair's synthesis + Stage-4 decision log + run-stats table).
|
|
477
|
+
- `report.html` — a **separate, deterministic** artifact generated by the `amicus council report <verdict.json> --html` renderer directly from `verdict.json` (no chair prose, no decision-log narrative — see Stage 5's *Renderer* note); the default artifact to share.
|
|
476
478
|
- Reviewed copy: `<stem>-reviewed.<ext>`, next to the source.
|
|
477
479
|
- Temp working files (`_tmp-*.md`: extracts, stage briefings, red-team brief, bundle, chair packet, proposed
|
|
478
480
|
MODEL-NOTES diff) live in the run folder and are cleaned up at the end of the run — the proposed-diff file
|
package/skills/sidecar/SKILL.md
CHANGED
|
@@ -45,7 +45,7 @@ These rules are mandatory for every amicus invocation in this skill:
|
|
|
45
45
|
5. **When the user asks to query MULTIPLE LLMs simultaneously** (e.g., "ask Gemini AND ChatGPT", "compare Gemini vs GPT"), ALWAYS use `--no-ui` (headless) for all of them unless the user explicitly requests interactive. Opening multiple Electron windows at once is disruptive. Launch them all in parallel with `run_in_background: true`.
|
|
46
46
|
6. **When the SAME prompt should go to N models, use `amicus fanout --models "a,b,c" --prompt-file <path> --json`** (one headless wave, one JSON result) instead of N separate start calls. Different prompts per model → separate parallel `amicus start --no-ui` calls.
|
|
47
47
|
7. **For a SINGLE-model sidecar, DEFAULT to interactive** — omit `--no-ui` so the Electron UI opens and the user can watch, converse, and click Fold. Use `--no-ui` for a single model only when the user asks for headless/autonomous, or for unattended bulk automation. Interactive launches still use `run_in_background: true`.
|
|
48
|
-
8. **If `amicus` is not on PATH** (typical for plugin-only installs), run every command in this skill as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest start --model gemini --prompt "..."`), or use the MCP tools (`amicus_start`, `amicus_status`, `amicus_read`, …) instead. Do not conclude the tool is broken because `amicus` is not found.
|
|
48
|
+
8. **If `amicus` is not on PATH** (typical for plugin-only installs), run every command in this skill as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest start --model gemini --prompt "..."`), or use the MCP tools (`amicus_start`, `amicus_wait`, `amicus_status`, `amicus_read`, …) instead. Do not conclude the tool is broken because `amicus` is not found.
|
|
49
49
|
|
|
50
50
|
## Installation
|
|
51
51
|
|
|
@@ -65,7 +65,7 @@ amicus --version
|
|
|
65
65
|
|
|
66
66
|
### MCP Server (Auto-Registered)
|
|
67
67
|
|
|
68
|
-
On install, an MCP server is auto-registered for Claude Cowork and Claude Desktop. If you're in an MCP-enabled environment, you can use `amicus_start`, `amicus_status`, `amicus_read`, and other MCP tools directly instead of CLI commands. Call `amicus_guide` for detailed usage instructions.
|
|
68
|
+
On install, an MCP server is auto-registered for Claude Cowork and Claude Desktop. If you're in an MCP-enabled environment, you can use `amicus_start`, `amicus_wait`, `amicus_status`, `amicus_read`, and other MCP tools directly instead of CLI commands. Call `amicus_guide` for detailed usage instructions.
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
@@ -177,7 +177,7 @@ amicus start --model anthropic/<model-name> --prompt "..."
|
|
|
177
177
|
|
|
178
178
|
### Agent Selection Guidelines
|
|
179
179
|
|
|
180
|
-
**Chat mode (default)** — no `--agent` flag needed. Reads are auto-approved, writes and bash commands require user permission in the Electron UI:
|
|
180
|
+
**Chat mode (interactive default)** — no `--agent` flag needed. Reads are auto-approved, writes and bash commands require user permission in the Electron UI:
|
|
181
181
|
```bash
|
|
182
182
|
# Default — good for questions, analysis, and guided work
|
|
183
183
|
amicus start --model gemini --prompt "Analyze the auth flow and suggest improvements"
|
|
@@ -199,7 +199,7 @@ amicus start --model gemini --prompt "Implement the login feature" --agent Build
|
|
|
199
199
|
|
|
200
200
|
| Mode | Use When |
|
|
201
201
|
|------|----------|
|
|
202
|
-
| **Chat** (default) | Questions, analysis, guided exploration — you control what gets written |
|
|
202
|
+
| **Chat** (interactive default) | Questions, analysis, guided exploration — you control what gets written |
|
|
203
203
|
| **Plan** | Comprehensive read-only analysis where no changes should happen |
|
|
204
204
|
| **Build** | Offloading implementation tasks where full autonomy is desired |
|
|
205
205
|
|
|
@@ -250,14 +250,17 @@ amicus start \
|
|
|
250
250
|
- `--prompt-file <path>`: Read the prompt/briefing from a UTF-8 file (mutually exclusive with
|
|
251
251
|
`--prompt`). Use for long or multi-line briefings.
|
|
252
252
|
- `--json`: With `--no-ui`, emit the run result as one stable JSON document on stdout
|
|
253
|
-
(`schemaVersion:
|
|
253
|
+
(`schemaVersion: 2`; the `summary` field is the model's output).
|
|
254
254
|
- `--no-validate-model`: Skip the model-catalog pre-flight check (validation is on by default).
|
|
255
|
-
- `--agent <agent>`: Agent mode (controls tool permissions). If omitted, defaults to
|
|
255
|
+
- `--agent <agent>`: Agent mode (controls tool permissions). If omitted, defaults to
|
|
256
|
+
**Chat** in interactive mode and **Build** in headless (`--no-ui`) mode — `chat`
|
|
257
|
+
stalls without user interaction, so headless runs need an agent that doesn't wait
|
|
258
|
+
on write/bash approval.
|
|
256
259
|
|
|
257
260
|
**Primary Agents (for `amicus start`):**
|
|
258
|
-
- `Chat` **(default)**: Reads auto-approved, writes/bash require user permission
|
|
261
|
+
- `Chat` **(interactive default)**: Reads auto-approved, writes/bash require user permission
|
|
259
262
|
- `Plan`: Read-only mode - no file modifications possible
|
|
260
|
-
- `Build
|
|
263
|
+
- `Build` **(headless default)**: Full tool access - all operations auto-approved
|
|
261
264
|
|
|
262
265
|
**Custom Agents:**
|
|
263
266
|
Custom agents defined in `~/.config/opencode/agents/` or `.opencode/agents/` are passed through directly.
|
|
@@ -573,16 +576,16 @@ Amicus uses OpenCode's agent framework with three primary modes:
|
|
|
573
576
|
|
|
574
577
|
| Agent | Reads | Writes/Edits | Bash | Default |
|
|
575
578
|
|-------|-------|-------------|------|---------|
|
|
576
|
-
| **Chat** | auto | asks permission | asks permission |
|
|
579
|
+
| **Chat** | auto | asks permission | asks permission | Interactive |
|
|
577
580
|
| **Plan** | auto | denied | denied | No |
|
|
578
|
-
| **Build** | auto | auto | auto |
|
|
581
|
+
| **Build** | auto | auto | auto | Headless |
|
|
579
582
|
|
|
580
|
-
#### Chat Agent (Default)
|
|
583
|
+
#### Chat Agent (Interactive Default)
|
|
581
584
|
|
|
582
|
-
Conversational mode — reads are auto-approved, writes and bash commands prompt for user permission in the UI. This is the default when no `--agent` flag is provided.
|
|
585
|
+
Conversational mode — reads are auto-approved, writes and bash commands prompt for user permission in the UI. This is the default when no `--agent` flag is provided **in interactive mode**; headless (`--no-ui`) runs default to Build instead (see the Headless section below).
|
|
583
586
|
|
|
584
587
|
```bash
|
|
585
|
-
# These are equivalent — Chat is the default
|
|
588
|
+
# These are equivalent — Chat is the interactive default
|
|
586
589
|
amicus start --model gemini --prompt "Analyze the auth flow"
|
|
587
590
|
amicus start --model gemini --prompt "Analyze the auth flow" --agent Chat
|
|
588
591
|
```
|
|
@@ -747,13 +750,13 @@ The summary includes:
|
|
|
747
750
|
|
|
748
751
|
### Handling Config Update Notices
|
|
749
752
|
|
|
750
|
-
When amicus emits `[AMICUS_CONFIG_UPDATE]` on stderr
|
|
753
|
+
When amicus emits `[AMICUS_CONFIG_UPDATE]` on stderr, the user's model aliases have changed (via `amicus setup`). The message contains a hash comment and a markdown alias table.
|
|
751
754
|
|
|
752
755
|
**Action required:**
|
|
753
756
|
1. Find the "Model Aliases" section in the project's CLAUDE.md
|
|
754
757
|
2. Replace that section's content with the block provided in the message
|
|
755
758
|
3. If no "Model Aliases" section exists, add one after the title line
|
|
756
|
-
4. Do **not** remove the `<!-- amicus-config-hash: ... -->` comment
|
|
759
|
+
4. Do **not** remove the `<!-- amicus-config-hash: ... -->` comment — it tracks config changes and prevents duplicate notifications
|
|
757
760
|
|
|
758
761
|
The update is purely informational (alias mappings) and safe to apply without user confirmation.
|
|
759
762
|
|
|
@@ -781,7 +784,7 @@ If a relevant sidecar exists:
|
|
|
781
784
|
### Example 1: Interactive Debugging (Chat Mode - Default)
|
|
782
785
|
|
|
783
786
|
```bash
|
|
784
|
-
#
|
|
787
|
+
# Chat mode (interactive default) — can read freely, asks before writing
|
|
785
788
|
amicus start \
|
|
786
789
|
--model gpt \
|
|
787
790
|
--session-id "$(ls -t ~/.claude/projects/-Users-john-myproject/*.jsonl | head -1 | xargs basename .jsonl)" \
|
|
@@ -846,7 +849,7 @@ The mutex approach looks correct. Add tests."
|
|
|
846
849
|
|
|
847
850
|
### "Missing Authentication header" in Claude Code or CI
|
|
848
851
|
|
|
849
|
-
API keys in `~/.zshrc` are not available in non-interactive shells. Resolution order: `process.env` > `~/.config/amicus/.env` (legacy `~/.config/sidecar/.env`
|
|
852
|
+
API keys in `~/.zshrc` are not available in non-interactive shells. Resolution order: `process.env` > `~/.config/amicus/.env` (the legacy `~/.config/sidecar/.env` fallback was removed in v2.0.0 — see `docs/SHIMS.md`) > `~/.local/share/opencode/auth.json` (first wins). Fix:
|
|
850
853
|
1. Run `amicus setup` (stores keys in `~/.config/amicus/.env`)
|
|
851
854
|
2. Or move exports to `~/.zshenv`
|
|
852
855
|
3. Or add credentials to `~/.local/share/opencode/auth.json`
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Abort Handler (B21-rest extraction)
|
|
3
|
+
*
|
|
4
|
+
* Split out of src/cli-handlers.js — that file was already near the 300-line
|
|
5
|
+
* size gate and had no headroom for the --json branch added here. Re-exported
|
|
6
|
+
* from src/cli-handlers.js so existing callers/tests are unaffected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { validateTaskId, safeSessionDir } = require('./utils/validators');
|
|
14
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
15
|
+
const { buildAbortResult } = require('./utils/result-schema');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle 'amicus abort --all --json': mark every running session aborted.
|
|
19
|
+
* @returns {number} exit code (always 0 — even a no-op --all is a success)
|
|
20
|
+
*/
|
|
21
|
+
function handleAbortAllJson(project) {
|
|
22
|
+
const { enumerateSessions } = require('./sidecar/read');
|
|
23
|
+
const { markAborted } = require('./utils/session-abort');
|
|
24
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
25
|
+
const running = enumerateSessions(project, { status: 'running' });
|
|
26
|
+
const aborted = [];
|
|
27
|
+
for (const s of running) {
|
|
28
|
+
if (markAborted(resolveExistingSessionDir(project, s.id), 'abort --all')) { aborted.push(s.id); }
|
|
29
|
+
}
|
|
30
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'all', taskId: null, aborted }), null, 2));
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle 'amicus abort <taskId> --json' for a single session or a wave.
|
|
36
|
+
* Mirrors the human-mode logic in handleAbort below but emits ONE doc on
|
|
37
|
+
* stdout instead of the multi-line console.log prose; the same waitThenKill
|
|
38
|
+
* fallback still runs, its narration routed to stderr instead of stdout.
|
|
39
|
+
* @returns {Promise<number>} exit code (always 0 for a resolved abort doc/error
|
|
40
|
+
* doc — both are "the command ran"; ok:false is signaled inside the doc)
|
|
41
|
+
*/
|
|
42
|
+
async function handleAbortTaskJson(args, taskId) {
|
|
43
|
+
const project = args.cwd || process.cwd();
|
|
44
|
+
const sessionDir = safeSessionDir(project, taskId);
|
|
45
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(metaPath)) {
|
|
48
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} not found` }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let meta;
|
|
52
|
+
try {
|
|
53
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
54
|
+
} catch (_err) {
|
|
55
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} has malformed metadata` }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (meta.status !== 'running') {
|
|
59
|
+
// Not a hard error — the task exists — but nothing was aborted by this call.
|
|
60
|
+
// Scope must reflect the task's own type (wave vs session), not assume
|
|
61
|
+
// 'session' — a terminal wave still has meta.type === 'wave'.
|
|
62
|
+
const scope = meta.type === 'wave' ? 'wave' : 'session';
|
|
63
|
+
console.log(JSON.stringify(buildAbortResult({ scope, taskId, aborted: [] }), null, 2));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { markAborted } = require('./utils/session-abort');
|
|
68
|
+
|
|
69
|
+
if (meta.type === 'wave') {
|
|
70
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
71
|
+
const aborted = [];
|
|
72
|
+
for (const legId of meta.legs || []) {
|
|
73
|
+
const legDir = resolveExistingSessionDir(project, legId);
|
|
74
|
+
try {
|
|
75
|
+
const legMeta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8'));
|
|
76
|
+
if (legMeta.status === 'running') {
|
|
77
|
+
if (markAborted(legDir, 'wave abort')) { aborted.push(legId); }
|
|
78
|
+
}
|
|
79
|
+
} catch { /* skip unreadable leg */ }
|
|
80
|
+
}
|
|
81
|
+
// Only report the wave itself as aborted if its own markAborted write
|
|
82
|
+
// succeeded — mirrors the leg gating above and the --all/single-session
|
|
83
|
+
// gating (aborted[] must list ids ACTUALLY marked aborted per
|
|
84
|
+
// buildAbortResult's doc-comment).
|
|
85
|
+
if (markAborted(sessionDir, 'manual abort')) { aborted.unshift(taskId); }
|
|
86
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'wave', taskId, aborted }), null, 2));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const wasMarked = markAborted(sessionDir, 'manual abort');
|
|
91
|
+
|
|
92
|
+
// Same fallback direct-kill as human mode (see the comment on the
|
|
93
|
+
// equivalent block in handleAbort below) — json mode still needs the
|
|
94
|
+
// process actually signalled, it just can't narrate it on stdout (stdout
|
|
95
|
+
// must carry ONLY the doc). Route the same chatter to stderr instead.
|
|
96
|
+
if (meta.pid) {
|
|
97
|
+
const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
|
|
98
|
+
const graceSec = Math.ceil(abortGraceMs() / 1000);
|
|
99
|
+
process.stderr.write(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...\n`);
|
|
100
|
+
const { killed, exited } = await waitThenKill(meta.pid);
|
|
101
|
+
if (killed.length > 0) {
|
|
102
|
+
process.stderr.write(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).\n`);
|
|
103
|
+
} else if (exited.length > 0) {
|
|
104
|
+
process.stderr.write('Process exited cleanly.\n');
|
|
105
|
+
} else {
|
|
106
|
+
process.stderr.write(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// aborted[] must list ids ACTUALLY marked aborted (buildAbortResult's doc
|
|
111
|
+
// comment) — gate on markAborted's own return, matching --all/wave-leg gating.
|
|
112
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'session', taskId, aborted: wasMarked ? [taskId] : [] }), null, 2));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Handle 'sidecar abort' command
|
|
118
|
+
* Marks a running session as aborted
|
|
119
|
+
* @returns {Promise<number|undefined>} exit code (json mode only; human mode
|
|
120
|
+
* uses process.exit internally on failure paths and implicitly returns 0)
|
|
121
|
+
*/
|
|
122
|
+
async function handleAbort(args) {
|
|
123
|
+
const useJson = !!args.json;
|
|
124
|
+
|
|
125
|
+
if (args.all) {
|
|
126
|
+
const project = args.cwd || process.cwd();
|
|
127
|
+
if (useJson) { return handleAbortAllJson(project); }
|
|
128
|
+
|
|
129
|
+
const { enumerateSessions } = require('./sidecar/read');
|
|
130
|
+
const { markAborted } = require('./utils/session-abort');
|
|
131
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
132
|
+
// A session may complete between enumeration and the write (TOCTOU); the
|
|
133
|
+
// window is tiny for a local CLI and markAborted is best-effort, so we count
|
|
134
|
+
// only sessions actually marked aborted.
|
|
135
|
+
const running = enumerateSessions(project, { status: 'running' });
|
|
136
|
+
if (running.length === 0) {
|
|
137
|
+
console.log('No running sessions to abort.');
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
let aborted = 0;
|
|
141
|
+
for (const s of running) {
|
|
142
|
+
if (markAborted(resolveExistingSessionDir(project, s.id), 'abort --all')) {
|
|
143
|
+
aborted++;
|
|
144
|
+
console.log(`Aborted ${s.id}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log(`Aborted ${aborted} running session(s).`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const taskId = args._[1];
|
|
152
|
+
|
|
153
|
+
if (!taskId) {
|
|
154
|
+
if (useJson) { process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: 'Error: task_id is required for abort' })); }
|
|
155
|
+
console.error('Error: task_id is required for abort');
|
|
156
|
+
console.error('Usage: amicus abort <task_id>');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const taskIdCheck = validateTaskId(taskId);
|
|
161
|
+
if (!taskIdCheck.valid) {
|
|
162
|
+
if (useJson) { process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error })); }
|
|
163
|
+
console.error(taskIdCheck.error);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (useJson) { return await handleAbortTaskJson(args, taskId); }
|
|
168
|
+
|
|
169
|
+
const project = args.cwd || process.cwd();
|
|
170
|
+
const sessionDir = safeSessionDir(project, taskId);
|
|
171
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(metaPath)) {
|
|
174
|
+
console.error(`Session ${taskId} not found`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let meta;
|
|
179
|
+
try {
|
|
180
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
181
|
+
} catch (_err) {
|
|
182
|
+
console.error(`Session ${taskId} has malformed metadata`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
// Guard against a completed/terminal session: without this, metadata.pid
|
|
186
|
+
// still holds a value forever and `amicus abort <completed-task>` would
|
|
187
|
+
// wait the grace window then TerminateProcess whatever unrelated process
|
|
188
|
+
// now owns that (possibly recycled) pid. Mirrors MCP's amicus_abort guard
|
|
189
|
+
// (src/mcp-server.js) — same wording, no re-mark, no kill.
|
|
190
|
+
if (meta.status !== 'running') {
|
|
191
|
+
console.log(`Session ${taskId} is not running (status: ${meta.status}).`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { markAborted } = require('./utils/session-abort');
|
|
196
|
+
|
|
197
|
+
// F4: aborting a wave aborts every still-running leg too.
|
|
198
|
+
if (meta.type === 'wave') {
|
|
199
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
200
|
+
let aborted = 0;
|
|
201
|
+
for (const legId of meta.legs || []) {
|
|
202
|
+
const legDir = resolveExistingSessionDir(project, legId);
|
|
203
|
+
try {
|
|
204
|
+
const legMeta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8'));
|
|
205
|
+
// TOCTOU: a leg may complete between this read and markAborted —
|
|
206
|
+
// best-effort, same contract as abort --all above.
|
|
207
|
+
if (legMeta.status === 'running') {
|
|
208
|
+
if (markAborted(legDir, 'wave abort')) { aborted++; }
|
|
209
|
+
}
|
|
210
|
+
} catch { /* skip unreadable leg */ }
|
|
211
|
+
}
|
|
212
|
+
markAborted(sessionDir, 'manual abort');
|
|
213
|
+
console.log(`Wave ${taskId} marked as aborted (${aborted} running leg(s) aborted).`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
markAborted(sessionDir, 'manual abort');
|
|
218
|
+
console.log(`Session ${taskId} marked as aborted.`);
|
|
219
|
+
|
|
220
|
+
// Phase 3: fallback direct-kill for a session that does not honor the
|
|
221
|
+
// marker. Headless loops poll the marker every ~2s and the interactive
|
|
222
|
+
// abort watch does too, so the normal outcome is a graceful exit during
|
|
223
|
+
// the grace window; only a wedged/legacy process gets SIGTERM. The wait is
|
|
224
|
+
// awaited on purpose — bin/amicus.js arms its force-exit watchdog only
|
|
225
|
+
// after this handler returns.
|
|
226
|
+
if (meta.pid) {
|
|
227
|
+
const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
|
|
228
|
+
const graceSec = Math.ceil(abortGraceMs() / 1000);
|
|
229
|
+
console.log(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...`);
|
|
230
|
+
const { killed, exited } = await waitThenKill(meta.pid);
|
|
231
|
+
if (killed.length > 0) {
|
|
232
|
+
console.log(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).`);
|
|
233
|
+
} else if (exited.length > 0) {
|
|
234
|
+
console.log('Process exited cleanly.');
|
|
235
|
+
} else {
|
|
236
|
+
// 3.1 contract: an EPERM-unkillable pid lands in NEITHER array —
|
|
237
|
+
// it is still alive and we could not signal it. Say so honestly.
|
|
238
|
+
console.log(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { handleAbort };
|
|
@@ -6,6 +6,13 @@ const { deriveReliability, appendRun } = require('./council/ledger');
|
|
|
6
6
|
const { sumWaveUsage, formatCost } = require('./utils/pricing');
|
|
7
7
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
8
8
|
const { buildReport } = require('./council/report');
|
|
9
|
+
const { validateFindings } = require('./council/findings');
|
|
10
|
+
const { buildVerdict, writeVerdictAtomic } = require('./council/verdict');
|
|
11
|
+
const {
|
|
12
|
+
runSave: runCouncilSave,
|
|
13
|
+
runList: runCouncilList,
|
|
14
|
+
runShow: runCouncilShow,
|
|
15
|
+
} = require('./council/presets-cli');
|
|
9
16
|
|
|
10
17
|
function runTally(inputPath, useJson, opts = {}) {
|
|
11
18
|
if (!inputPath) {
|
|
@@ -87,6 +94,93 @@ function runReport(args, useJson) {
|
|
|
87
94
|
return 0;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/**
|
|
98
|
+
* `amicus council validate <file>` — thin wrapper over `validateFindings`
|
|
99
|
+
* (src/council/findings.js). Tri-state outcome, distinct from the usual
|
|
100
|
+
* two-state (0/1) CLI convention:
|
|
101
|
+
* exit 0 ok:true — findings block is well-formed
|
|
102
|
+
* exit 2 ok:false — findings block parsed as a *result*, but
|
|
103
|
+
* validation failed (a distinct, scriptable
|
|
104
|
+
* outcome — mirrors the repo's exit-2
|
|
105
|
+
* "completed-with-failure" convention)
|
|
106
|
+
* exit 1 BAD_ARGS envelope — missing/unreadable input file
|
|
107
|
+
*/
|
|
108
|
+
function runValidate(filePath, useJson) {
|
|
109
|
+
if (!filePath) {
|
|
110
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council validate needs a <file> path',
|
|
111
|
+
hint: 'amicus council validate <file> [--json]' });
|
|
112
|
+
}
|
|
113
|
+
let text;
|
|
114
|
+
try { text = fs.readFileSync(filePath, 'utf-8'); }
|
|
115
|
+
catch (e) {
|
|
116
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read ${filePath}: ${e.message}`,
|
|
117
|
+
hint: 'pass a Stage-1 reviewer output file (prose + trailing ```json findings block)' });
|
|
118
|
+
}
|
|
119
|
+
const result = validateFindings(text);
|
|
120
|
+
process.stdout.write(useJson ? JSON.stringify(result, null, 2) + '\n' : renderValidate(result));
|
|
121
|
+
return result.ok ? 0 : 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderValidate(result) {
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
const hist = {};
|
|
127
|
+
for (const f of result.findings) { hist[f.severity] = (hist[f.severity] || 0) + 1; }
|
|
128
|
+
const parts = Object.keys(hist).map(sev => `${sev} ${hist[sev]}`).join(', ');
|
|
129
|
+
const n = result.findings.length;
|
|
130
|
+
return `OK — ${n} finding${n === 1 ? '' : 's'}${parts ? ` (${parts})` : ''}\n`;
|
|
131
|
+
}
|
|
132
|
+
return 'INVALID\n' + result.errors.map(e => ` ${e.code}: ${e.detail}`).join('\n') + '\n';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `amicus council verdict <tally.json> --decisions <decisions.json> [-o|--out <out.json>]`
|
|
137
|
+
* Thin wrapper over `buildVerdict` + `writeVerdictAtomic` (src/council/verdict.js).
|
|
138
|
+
* `--decisions` is optional (buildVerdict defaults decisions to []). Writes to
|
|
139
|
+
* `-o`/`--out` (default `./verdict.json`) via the atomic tmp+rename convention.
|
|
140
|
+
*/
|
|
141
|
+
function runVerdict(args, useJson) {
|
|
142
|
+
const tallyPath = args._[2];
|
|
143
|
+
if (!tallyPath) {
|
|
144
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council verdict needs a <tally.json> path',
|
|
145
|
+
hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
|
|
146
|
+
}
|
|
147
|
+
let record;
|
|
148
|
+
try { record = JSON.parse(fs.readFileSync(tallyPath, 'utf-8')); }
|
|
149
|
+
catch (e) {
|
|
150
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read ${tallyPath}: ${e.message}`,
|
|
151
|
+
hint: 'pass a valid tally.json (from `amicus council tally` / amicus_council_tally)' });
|
|
152
|
+
}
|
|
153
|
+
let decisions = [];
|
|
154
|
+
const decisionsPath = args.decisions;
|
|
155
|
+
if (decisionsPath) {
|
|
156
|
+
try { decisions = JSON.parse(fs.readFileSync(decisionsPath, 'utf-8')); }
|
|
157
|
+
catch (e) {
|
|
158
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read --decisions ${decisionsPath}: ${e.message}`,
|
|
159
|
+
hint: 'pass a valid decisions.json array or omit --decisions' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const outPath = args.out || './verdict.json';
|
|
163
|
+
let verdict;
|
|
164
|
+
try { verdict = buildVerdict(record, decisions); }
|
|
165
|
+
catch (e) {
|
|
166
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot build verdict: ${e.message}`,
|
|
167
|
+
hint: 'either tally.json needs meta, findings[], streetCred[], runStats, tierCounts, or decisions.json must be a JSON array of {id, decision, …} objects' });
|
|
168
|
+
}
|
|
169
|
+
writeVerdictAtomic(outPath, verdict);
|
|
170
|
+
process.stdout.write(useJson ? JSON.stringify(verdict, null, 2) + '\n' : renderVerdict(verdict, outPath));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderVerdict(v, outPath) {
|
|
175
|
+
const counts = {};
|
|
176
|
+
for (const f of v.findings) {
|
|
177
|
+
const key = f.decision || 'undecided';
|
|
178
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
179
|
+
}
|
|
180
|
+
const parts = Object.keys(counts).map(k => `${k} ${counts[k]}`).join(' ');
|
|
181
|
+
return `Verdict (schema v${v.schemaVersion}, ${v.runId}) → ${outPath}\n ${parts}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
90
184
|
/** @param {{_:string[], json?:boolean}} args @returns {Promise<number>} */
|
|
91
185
|
async function handleCouncil(args) {
|
|
92
186
|
const sub = args._[1];
|
|
@@ -94,8 +188,14 @@ async function handleCouncil(args) {
|
|
|
94
188
|
if (sub === 'tally') { return runTally(args._[2], useJson, { append: !args['no-ledger'] }); }
|
|
95
189
|
if (sub === 'stats') { return runStats(useJson); }
|
|
96
190
|
if (sub === 'report') { return runReport(args, useJson); }
|
|
191
|
+
if (sub === 'validate') { return runValidate(args._[2], useJson); }
|
|
192
|
+
if (sub === 'verdict') { return runVerdict(args, useJson); }
|
|
193
|
+
if (sub === 'save') { return runCouncilSave(args._[2], args.models, useJson); }
|
|
194
|
+
if (sub === 'list') { return runCouncilList(useJson); }
|
|
195
|
+
if (sub === 'show') { return runCouncilShow(args._[2], useJson); }
|
|
97
196
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
98
|
-
message: `unknown council subcommand '${sub || ''}'`,
|
|
197
|
+
message: `unknown council subcommand '${sub || ''}'`,
|
|
198
|
+
hint: 'amicus council tally|stats|report|validate|verdict|save|list|show' });
|
|
99
199
|
}
|
|
100
200
|
|
|
101
201
|
module.exports = { handleCouncil };
|