amicus 1.7.5 → 1.7.7

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.
@@ -1,19 +1,40 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
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
- "author": { "name": "Christian Wagner" },
5
+ "author": {
6
+ "name": "Christian Wagner"
7
+ },
6
8
  "homepage": "https://bourbondog.github.io/amicus/",
7
9
  "repository": "https://github.com/BourbonDog/amicus",
8
10
  "bugs": "https://github.com/BourbonDog/amicus/issues",
9
11
  "license": "MIT",
10
- "keywords": ["claude-code", "multi-model", "llm", "council", "second-opinion", "sidecar", "gemini", "gpt", "deepseek"],
11
- "skills": ["./skills/sidecar", "./skills/second-opinion"],
12
+ "keywords": [
13
+ "claude-code",
14
+ "multi-model",
15
+ "llm",
16
+ "council",
17
+ "second-opinion",
18
+ "sidecar",
19
+ "gemini",
20
+ "gpt",
21
+ "deepseek"
22
+ ],
23
+ "skills": [
24
+ "./skills/sidecar",
25
+ "./skills/second-opinion"
26
+ ],
12
27
  "mcpServers": {
13
28
  "amicus": {
14
29
  "command": "npx",
15
- "args": ["-y", "amicus@latest", "mcp"],
16
- "env": { "AMICUS_SKIP_POSTINSTALL": "1" }
30
+ "args": [
31
+ "-y",
32
+ "amicus@latest",
33
+ "mcp"
34
+ ],
35
+ "env": {
36
+ "AMICUS_SKIP_POSTINSTALL": "1"
37
+ }
17
38
  }
18
39
  }
19
40
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.7] - 2026-07-01
9
+
10
+ Correctness patch from the 2026-07-01 full product review (multi-agent review, every finding adversarially
11
+ verified against source), executed subagent-driven with per-task adversarial review plus a final whole-branch review.
12
+
13
+ ### Fixed
14
+ - **Terminal errors now show their actionable hint.** Human-mode errors printed only the message while `--json`
15
+ carried a `hint` field; the hint now prints on a second ` → …` line. Budget-gate refusals finally tell you the
16
+ offending model, the threshold, and the `--max-cost` / `--no-cost-gate` overrides.
17
+ - **Spawned sidecars no longer inherit Amicus's own MCP server.** The recursive-spawn guard only excluded a server
18
+ literally named `sidecar`, but the product registers as `amicus` — so every child model inherited the full
19
+ Amicus toolset and could spawn recursively. Children now exclude any inherited entry that *is* Amicus, matched
20
+ by name **or** by what the command actually runs (`amicus mcp`, `npx … amicus … mcp`, a `bin/amicus.js … mcp`
21
+ path). Note: this strip has no opt-out — a deliberately configured nested Amicus MCP entry is also removed from
22
+ spawned children.
23
+ - **Shared-server crash detection actually works.** The crash/restart machinery listened on an event emitter the
24
+ real server handle never exposed, so it was dead code — a dead engine silently degraded every later session.
25
+ A pid liveness poll now drives detection and restart, and shutting down during the restart backoff cancels the
26
+ pending restart instead of spawning a server nobody asked for.
27
+
28
+ ### Changed
29
+ - **`amicus continue` and `amicus resume` now report failures truthfully** (behavior change): error exits 1,
30
+ timeout exits 2, abort exits 130/143/2 — previously both always exited 0 and recorded the session as
31
+ `complete` even when the model errored or timed out. The session record now finalizes `error`/`timed-out`
32
+ accordingly (interactive sessions that legitimately end with an empty summary still finalize `complete`).
33
+ Scripts that gated on exit code 0 for these verbs will now see real failures.
34
+
35
+ ## [1.7.6] - 2026-07-01
36
+
37
+ A second independent review (GLM 5.2), adversarially verified against source, then fixed across 11 lanes.
38
+ 20 of 22 confirmed findings fixed; 2 partial (deferred as follow-ups). Full unit suite green.
39
+
40
+ ### Security
41
+ - **The `project`/`cwd` MCP input is now sandboxed.** Previously any caller could pass an arbitrary directory
42
+ (e.g. a system path) and Amicus would create session files and spawn a sidecar there. A new project-root
43
+ allow-list rejects out-of-bounds paths **before** any filesystem write or spawn, while still allowing paths
44
+ under your home directory, the current working directory, `AMICUS_PROJECT_DIR`/`AMICUS_PROJECT_ROOTS`, or the
45
+ MCP client's advertised root — so legitimate `--cwd` use is unaffected.
46
+ - **Folded-back sidecar summaries are fenced as untrusted output.** `amicus_read`'s returned summary — produced
47
+ by an arbitrary model — is now wrapped in a read-only fence (mirroring the outbound conversation fence), so
48
+ model prose entering the orchestrator's context is marked as data, not instructions.
49
+ - **The Electron content view no longer shares the privileged bridge.** The embedded OpenCode web view gets a
50
+ minimal preload that exposes nothing privileged, and IPC handlers validate the sender, so only the toolbar can
51
+ trigger update/settings actions.
52
+
53
+ ### Fixed
54
+ - **A crashed OpenCode server is now detected.** The shared-server crash/restart machinery was unreachable (no
55
+ exit listener was ever attached); a server exit is now wired to the restart path.
56
+ - **Session metadata is written atomically** (temp file + rename), so a crash mid-write can no longer corrupt
57
+ `metadata.json` and silently mask an abort marker.
58
+ - **Port lookup works on Windows.** The stale-process cleanup used a hardcoded `lsof` (a no-op on Windows); it
59
+ now uses the cross-platform `netstat`-based lookup.
60
+ - **A fan-out leg whose setup throws no longer sinks the whole wave** — the leg is turned into an error result
61
+ and `wave.json` is still written.
62
+ - **The setup window can't hang on a spawn failure** — a spawn error now resolves cleanly instead of leaving the
63
+ launch promise pending forever, and the Electron child is killed on parent exit.
64
+ - **Project-scoped `opencode.json` resolves against the target project**, not the launcher's working directory.
65
+ - Smaller correctness/cleanup fixes: single-peer-agreed council findings now count as corroborated; unknown
66
+ council verdicts are guarded; tool-call turns render a summary instead of blank; quote-aware `--mcp` command
67
+ parsing; a model-object shape guard; a single shared duration formatter; timed mirror teardown; a lock on the
68
+ continuation session; and canonical session-route separators.
69
+
70
+ ### Known follow-ups
71
+ - Fencing `amicus_council_tally`/`amicus_verdict` (they return JSON records, so they need a field-level fence).
72
+ - Removing the now-dead top-level `tool_use` formatter branch (blocked on an unrelated test assertion).
73
+
8
74
  ## [1.7.5] - 2026-07-01
9
75
 
10
76
  A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
package/LICENSE CHANGED
@@ -1,27 +1,7 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 John Renaldi Claude Sidecar
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
22
-
23
-
24
- Copyright (c) 2026 Christian Wagner Amicus
3
+ Copyright (c) 2026 Christian Wagner
4
+ Copyright (c) 2025 John Renaldi
25
5
 
26
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
27
7
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -57,13 +57,37 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
57
57
 
58
58
  ## Quick start
59
59
 
60
- **Install:**
60
+ **Install** — pick whichever fits; all deliver the same CLI, MCP server, and both skills:
61
+
62
+ **As a Claude Code plugin** — the most native path if you use Claude Code:
63
+
64
+ ```text
65
+ /plugin marketplace add BourbonDog/amicus
66
+ /plugin install amicus@bourbondog-amicus
67
+ /reload-plugins
68
+ ```
69
+
70
+ Claude Code registers the MCP server and both skills for you — nothing to configure. (The standalone Electron window is npm-only, and the first council/sidecar call downloads the OpenCode engine.)
71
+
72
+ **With the install script** — macOS, Linux, or Windows (needs [Node.js](https://nodejs.org) ≥ 18):
73
+
74
+ ```bash
75
+ # macOS / Linux
76
+ curl -fsSL https://raw.githubusercontent.com/BourbonDog/amicus/main/install.sh | sh
77
+ ```
78
+
79
+ ```powershell
80
+ # Windows (PowerShell)
81
+ irm https://raw.githubusercontent.com/BourbonDog/amicus/main/install.ps1 | iex
82
+ ```
83
+
84
+ **With npm** — the canonical path (needs [Node.js](https://nodejs.org) ≥ 18):
61
85
 
62
86
  ```bash
63
87
  npm install -g amicus
64
88
  ```
65
89
 
66
- The postinstall step auto-configures everything — no manual registration:
90
+ For the **npm** and **install-script** paths, a postinstall auto-configures everything — no manual registration:
67
91
 
68
92
  - Registers the **MCP server** in Claude Code and in Claude Desktop / Cowork, so the Amicus tools appear natively.
69
93
  - Installs **both skills** into `~/.claude/skills/` — `second-opinion` (the council) and `sidecar` (the chat skill).
package/bin/amicus.js CHANGED
@@ -95,10 +95,10 @@ async function main() {
95
95
  await handleList(args);
96
96
  break;
97
97
  case 'resume':
98
- await handleResume(args);
98
+ exitCode = await handleResume(args);
99
99
  break;
100
100
  case 'continue':
101
- await handleContinue(args);
101
+ exitCode = await handleContinue(args);
102
102
  break;
103
103
  case 'read':
104
104
  await handleRead(args);
@@ -188,7 +188,7 @@ async function handleResume(args) {
188
188
 
189
189
  const { resumeSidecar } = require('../src/index');
190
190
 
191
- await resumeSidecar({
191
+ return await resumeSidecar({
192
192
  taskId,
193
193
  project: args.cwd,
194
194
  headless: args['no-ui'],
@@ -242,7 +242,7 @@ async function handleContinue(args) {
242
242
 
243
243
  const { continueSidecar } = require('../src/index');
244
244
 
245
- await continueSidecar({
245
+ return await continueSidecar({
246
246
  taskId,
247
247
  newTaskId: args['task-id'],
248
248
  briefing: args.prompt || args.briefing,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * IPC Guard Helpers
3
+ *
4
+ * Small, pure helpers extracted from main.js so the security-sensitive bits are
5
+ * unit-testable (main.js itself runs heavy Electron side effects at import).
6
+ *
7
+ * - isPrivilegedSender: pin privileged IPC handlers to the toolbar window so a
8
+ * compromised/remote page in the OpenCode BrowserView cannot invoke them (M9).
9
+ * - isAllowedContentNavigation: pin the OpenCode BrowserView to its localhost
10
+ * origin so it cannot be navigated off to an attacker-controlled page (M9).
11
+ * - handleFatalException: EPIPE stays a no-op; any other uncaught exception is
12
+ * logged and then quits the app rather than leaving a wedged invisible shell
13
+ * (L10).
14
+ */
15
+
16
+ /**
17
+ * Whether an IPC event originated from the privileged toolbar window.
18
+ * @param {{ sender?: object }} event - The ipcMain event.
19
+ * @param {() => (object|null)} getToolbarWindow - Returns the toolbar BrowserWindow (or null).
20
+ * @returns {boolean} true only when the sender is the toolbar window's webContents.
21
+ */
22
+ function isPrivilegedSender(event, getToolbarWindow) {
23
+ const win = getToolbarWindow();
24
+ if (!win || win.isDestroyed?.()) { return false; }
25
+ return Boolean(event && event.sender && event.sender === win.webContents);
26
+ }
27
+
28
+ /**
29
+ * Whether a navigation target is allowed for the OpenCode content BrowserView.
30
+ * Only the OpenCode localhost origin (any path) is permitted; everything else
31
+ * (external http(s), file:, etc.) is blocked. data: URLs are allowed so the
32
+ * in-app load-error page can render.
33
+ * @param {string} targetUrl - The URL the view is trying to navigate to.
34
+ * @param {string} allowedOrigin - e.g. 'http://localhost:4096'.
35
+ * @returns {boolean}
36
+ */
37
+ function isAllowedContentNavigation(targetUrl, allowedOrigin) {
38
+ if (typeof targetUrl !== 'string' || !targetUrl) { return false; }
39
+ if (targetUrl.startsWith('data:')) { return true; }
40
+ try {
41
+ const target = new URL(targetUrl);
42
+ const allowed = new URL(allowedOrigin);
43
+ return target.origin === allowed.origin;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Handle a process 'uncaughtException'. EPIPE is a benign no-op; anything else
51
+ * is logged and then quits the app so a genuinely unexpected error cannot leave
52
+ * an invisible, silently-hung shell.
53
+ * @param {Error & { code?: string }} err
54
+ * @param {{ quit: () => void, log?: (err: Error) => void }} deps
55
+ */
56
+ function handleFatalException(err, { quit, log } = {}) {
57
+ if (err && err.code === 'EPIPE') { return; }
58
+ if (log) {
59
+ log(err);
60
+ } else {
61
+ console.error('Uncaught exception:', err);
62
+ }
63
+ if (typeof quit === 'function') { quit(); }
64
+ }
65
+
66
+ module.exports = { isPrivilegedSender, isAllowedContentNavigation, handleFatalException };
package/electron/main.js CHANGED
@@ -24,6 +24,11 @@ const { attachLoadFailsafe, buildLoadErrorHTML } = require('./load-failsafe');
24
24
  const { buildSessionRoute } = require('./session-route');
25
25
  const { buildOpencodeThemeCSS } = require('./opencode-theme');
26
26
  const { TOKENS } = require('../src/design/tokens');
27
+ const {
28
+ isPrivilegedSender,
29
+ isAllowedContentNavigation,
30
+ handleFatalException,
31
+ } = require('./ipc-guard');
27
32
 
28
33
  const ICON_PATH = path.join(__dirname, 'assets', 'icon.png');
29
34
 
@@ -39,8 +44,10 @@ process.stderr.on('error', (err) => {
39
44
  if (err.code === 'EPIPE') { return; }
40
45
  });
41
46
  process.on('uncaughtException', (err) => {
42
- if (err.code === 'EPIPE') { return; }
43
- console.error('Uncaught exception:', err);
47
+ // EPIPE stays a benign no-op; any other uncaught exception is logged and then
48
+ // quits the app so a genuinely unexpected error cannot leave a wedged,
49
+ // invisible shell hanging in the background (L10).
50
+ handleFatalException(err, { quit: () => app.quit() });
44
51
  });
45
52
  process.on('unhandledRejection', (reason) => {
46
53
  console.error('Unhandled rejection:', reason);
@@ -135,13 +142,26 @@ function createAmicusWindow() {
135
142
  mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(toolbarHtml)}`);
136
143
  mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
137
144
 
138
- // BrowserView for OpenCode content
145
+ // BrowserView for OpenCode content. It uses a MINIMAL preload that exposes no
146
+ // privileged bridge — the OpenCode page must not be able to reach the fold /
147
+ // settings / update IPC (M9). The toolbar window keeps preload.js.
139
148
  contentView = new BrowserView({
140
149
  webPreferences: {
141
- preload: path.join(__dirname, 'preload.js'),
150
+ preload: path.join(__dirname, 'preload-content.js'),
142
151
  contextIsolation: true, nodeIntegration: false,
143
152
  }
144
153
  });
154
+
155
+ // Pin the content view to the OpenCode localhost origin: block any attempt to
156
+ // navigate it off-origin or open new windows (defense-in-depth, M9). data:
157
+ // URLs (the in-app load-error page) are still allowed by the guard.
158
+ contentView.webContents.on('will-navigate', (event, targetUrl) => {
159
+ if (!isAllowedContentNavigation(targetUrl, OPENCODE_URL)) {
160
+ logger.warn('Blocked content-view navigation', { targetUrl });
161
+ event.preventDefault();
162
+ }
163
+ });
164
+ contentView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
145
165
  // Load OpenCode off-screen first; only attach BrowserView after rebranding
146
166
  // to prevent the OpenCode logo/splash from flashing during load.
147
167
  mainWindow.on('resize', updateContentBounds);
@@ -400,25 +420,35 @@ function rebrandUI() {
400
420
  // IPC Handlers
401
421
  // ============================================================================
402
422
 
423
+ // These handlers are privileged (fold/settings/update/resize). Only the toolbar
424
+ // window may invoke them — the OpenCode content BrowserView must not (M9). The
425
+ // content view no longer gets a bridge preload, but we still validate the
426
+ // sender as belt-and-suspenders in case a future preload change reintroduces one.
427
+ const fromToolbar = (event) => isPrivilegedSender(event, () => mainWindow);
428
+
403
429
  // Amicus mode: fold
404
- ipcMain.handle('sidecar:fold', () => {
430
+ ipcMain.handle('sidecar:fold', (event) => {
431
+ if (!fromToolbar(event)) { return; }
405
432
  return foldHandler.triggerFold(mainWindow, contentView);
406
433
  });
407
434
 
408
435
  // Amicus mode: open settings in a child window
409
- ipcMain.handle('sidecar:open-settings', () => {
436
+ ipcMain.handle('sidecar:open-settings', (event) => {
437
+ if (!fromToolbar(event)) { return; }
410
438
  createSettingsChildWindow();
411
439
  });
412
440
 
413
441
  // Update check
414
- ipcMain.handle('sidecar:get-update-info', async () => {
442
+ ipcMain.handle('sidecar:get-update-info', async (event) => {
443
+ if (!fromToolbar(event)) { return null; }
415
444
  const { getUpdateInfo, initUpdateCheck } = require('../src/utils/updater');
416
445
  await initUpdateCheck();
417
446
  return getUpdateInfo();
418
447
  });
419
448
 
420
449
  // Perform update
421
- ipcMain.handle('sidecar:perform-update', async () => {
450
+ ipcMain.handle('sidecar:perform-update', async (event) => {
451
+ if (!fromToolbar(event)) { return { success: false, error: 'unauthorized' }; }
422
452
  const { performUpdate } = require('../src/utils/updater');
423
453
  const result = await performUpdate();
424
454
  if (mainWindow && !mainWindow.isDestroyed()) {
@@ -428,7 +458,8 @@ ipcMain.handle('sidecar:perform-update', async () => {
428
458
  });
429
459
 
430
460
  // Resize toolbar area (called when update banner shows/hides)
431
- ipcMain.handle('sidecar:resize-toolbar', (_event, height) => {
461
+ ipcMain.handle('sidecar:resize-toolbar', (event, height) => {
462
+ if (!fromToolbar(event)) { return; }
432
463
  currentToolbarH = height;
433
464
  updateContentBounds();
434
465
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Content Preload - OpenCode BrowserView (minimal, no privileged bridge)
3
+ *
4
+ * The OpenCode Web UI is remote-ish content: it should NOT be able to reach the
5
+ * privileged sidecar IPC (fold/open-settings/perform-update/...). This preload
6
+ * therefore exposes NOTHING via contextBridge — it only injects the cosmetic
7
+ * branding CSS. The toolbar window keeps preload.js; the content view uses this.
8
+ *
9
+ * (M9 defense-in-depth: the toolbar and the content view no longer share the
10
+ * bridge-exposing preload.)
11
+ */
12
+
13
+ /**
14
+ * Inject CSS to hide OpenCode branding and match the window background color to
15
+ * prevent a white flash on load. Cosmetic only — must never throw.
16
+ */
17
+ function injectBrandingCss() {
18
+ try {
19
+ const style = document.createElement('style');
20
+ style.textContent = [
21
+ 'html, body { background-color: var(--bg, #0a0a0a) !important; }',
22
+ '#root > div > header { display: none !important; }',
23
+ 'svg[viewBox="0 0 234 42"] { display: none !important; }',
24
+ ].join('\n');
25
+ document.documentElement.appendChild(style);
26
+ } catch {
27
+ // cosmetic — a failed injection must not break the preload
28
+ }
29
+ }
30
+
31
+ // documentElement is still null while the preload evaluates; defer until the
32
+ // DOM exists so the injection cannot null-deref.
33
+ if (document.documentElement) {
34
+ injectBrandingCss();
35
+ } else {
36
+ document.addEventListener('DOMContentLoaded', injectBrandingCss);
37
+ }
@@ -12,16 +12,23 @@
12
12
  *
13
13
  * Pure function: no electron, no fs, no process state. Safe to unit-test.
14
14
  *
15
+ * The directory is canonicalized before encoding so incidental separator
16
+ * differences (Windows '\\' vs '/', mixed/duplicate separators) produce the same
17
+ * route segment — otherwise OpenCode normalizing '/' vs '\\' would yield a
18
+ * mismatched base64url and a "session not found" route.
19
+ *
15
20
  * @param {string} baseUrl - OpenCode server base URL (e.g. http://localhost:4096)
16
21
  * @param {string} [sessionId] - OpenCode session id; falsy → return baseUrl only
17
22
  * @param {string} sessionDirectory - The directory the session is scoped to
18
23
  * @returns {string} Fully-qualified route URL, or baseUrl when no session id.
19
24
  */
25
+ const { canonicalProjectPath } = require('../src/utils/project-path');
26
+
20
27
  function buildSessionRoute(baseUrl, sessionId, sessionDirectory) {
21
28
  if (!sessionId) {
22
29
  return baseUrl;
23
30
  }
24
- const seg = Buffer.from(sessionDirectory).toString('base64url');
31
+ const seg = Buffer.from(canonicalProjectPath(sessionDirectory)).toString('base64url');
25
32
  return `${baseUrl}/${seg}/session/${sessionId}`;
26
33
  }
27
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
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
  "keywords": [
6
6
  "claude",
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const { formatCost } = require('../utils/pricing');
11
+ const { formatDuration } = require('../utils/format-duration');
11
12
  const { TIER_ORDER, SYMBOL } = require('./report');
12
13
  const { tokenCss } = require('../design/tokens');
13
14
 
@@ -29,7 +30,7 @@ function esc(s) {
29
30
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
30
31
  }
31
32
  function num(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
32
- function dur(ms) { return (ms === null || ms === undefined) ? '—' : `${Math.round(ms / 1000)}s`; }
33
+ function dur(ms) { return formatDuration(ms, '—'); }
33
34
 
34
35
  function renderHtml(m) {
35
36
  const h = m.header;
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  const { formatCost, sumWaveUsage } = require('../utils/pricing');
13
+ const { formatDuration } = require('../utils/format-duration');
13
14
 
14
15
  const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
15
16
  const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
@@ -49,7 +50,7 @@ function toModel(verdict, wave) {
49
50
  }
50
51
 
51
52
  function fmtNum(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
52
- function fmtDur(ms) { return (ms === null || ms === undefined) ? '—' : `${Math.round(ms / 1000)}s`; }
53
+ function fmtDur(ms) { return formatDuration(ms, '—'); }
53
54
 
54
55
  function renderMd(m) {
55
56
  const h = m.header;
@@ -5,6 +5,13 @@
5
5
  * Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
6
6
  * (the raiser's own adjudication is excluded by the caller).
7
7
  * Exhaustive and mutually exclusive over all (a,d).
8
+ *
9
+ * Uncontested agreement is Confirmed: either a strong majority (a>=2 && a>d)
10
+ * or a lone corroborating peer with no dispute (a=1 && d===0). The latter must
11
+ * not rank weaker than a lone disputing peer (a=0,d=1, which is Contested); the
12
+ * `confidence` flag ('thin' when a+d<=1) is what separates single-peer
13
+ * corroboration from a multi-peer majority. Singleton is now reserved for the
14
+ * no-signal case (a=0,d=0).
8
15
  * @param {number} a - peer agree count
9
16
  * @param {number} d - peer dispute count
10
17
  * @returns {{tier:string, confidence:'thin'|'solid'}}
@@ -12,7 +19,7 @@
12
19
  function assignTier(a, d) {
13
20
  let tier;
14
21
  if (d >= 2 && d > a) { tier = 'Disputed'; }
15
- else if (a >= 2 && a > d) { tier = 'Confirmed'; }
22
+ else if ((a >= 2 && a > d) || (a === 1 && d === 0)) { tier = 'Confirmed'; }
16
23
  else if (d >= 1) { tier = 'Contested'; }
17
24
  else { tier = 'Singleton'; }
18
25
  const confidence = (a + d <= 1) ? 'thin' : 'solid';
@@ -82,9 +89,17 @@ function tally(input) {
82
89
  }
83
90
  const outFindings = findings.map(f => {
84
91
  const votes = byFinding.get(f.id) || [];
85
- const peers = votes.filter(v => v.judge !== f.raiser);
92
+ // Only exclude the raiser's own vote when a raiser is known; the raiser is
93
+ // populated by the orchestrator (not the reviewer JSON), so an unset raiser
94
+ // must not silently drop a real peer vote (L8).
95
+ const peers = f.raiser ? votes.filter(v => v.judge !== f.raiser) : votes;
86
96
  const basis = { a: 0, d: 0, n: 0 };
87
- for (const v of peers) { basis[VERDICTS[v.verdict]] += 1; }
97
+ // Skip unknown verdict strings so a stray value can't corrupt the basis via
98
+ // basis[undefined] = NaN (L9).
99
+ for (const v of peers) {
100
+ const key = VERDICTS[v.verdict];
101
+ if (key !== undefined) { basis[key] += 1; }
102
+ }
88
103
  const { tier, confidence } = assignTier(basis.a, basis.d);
89
104
  return { id: f.id, raiser: f.raiser, severity: f.severity, tier, basis, confidence,
90
105
  tierOverride: null, adjudications: votes };
@@ -94,16 +94,45 @@ function extractContent(message) {
94
94
  return content;
95
95
  }
96
96
 
97
- // Array content (Claude API format with text blocks)
97
+ // Array content (Claude API format with text/tool blocks). Text blocks pass
98
+ // through; non-text blocks (tool_use / tool_result) are summarized instead of
99
+ // dropped, so tool-only assistant turns don't render as an empty string.
98
100
  if (Array.isArray(content)) {
99
101
  return content
100
- .map(block => block.text || '')
102
+ .map(block => summarizeBlock(block))
101
103
  .join('');
102
104
  }
103
105
 
104
106
  return '';
105
107
  }
106
108
 
109
+ /**
110
+ * Summarize a single content block to a display string.
111
+ * Text blocks return their text; tool_use/tool_result blocks return a short
112
+ * placeholder so they aren't silently collapsed to '' by extractContent.
113
+ * @param {object} block - A Claude API content block
114
+ * @returns {string} Display text for the block
115
+ */
116
+ function summarizeBlock(block) {
117
+ if (!block || typeof block !== 'object') {
118
+ return '';
119
+ }
120
+
121
+ if (typeof block.text === 'string') {
122
+ return block.text;
123
+ }
124
+
125
+ if (block.type === 'tool_use') {
126
+ return `[tool_use: ${block.name || 'unknown'}]`;
127
+ }
128
+
129
+ if (block.type === 'tool_result') {
130
+ return '[tool_result]';
131
+ }
132
+
133
+ return '';
134
+ }
135
+
107
136
  /**
108
137
  * Format a single message for context output
109
138
  * Spec Reference: §5.3 Context Format