amicus 1.7.4 → 1.7.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
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": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,82 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.6] - 2026-07-01
9
+
10
+ A second independent review (GLM 5.2), adversarially verified against source, then fixed across 11 lanes.
11
+ 20 of 22 confirmed findings fixed; 2 partial (deferred as follow-ups). Full unit suite green.
12
+
13
+ ### Security
14
+ - **The `project`/`cwd` MCP input is now sandboxed.** Previously any caller could pass an arbitrary directory
15
+ (e.g. a system path) and Amicus would create session files and spawn a sidecar there. A new project-root
16
+ allow-list rejects out-of-bounds paths **before** any filesystem write or spawn, while still allowing paths
17
+ under your home directory, the current working directory, `AMICUS_PROJECT_DIR`/`AMICUS_PROJECT_ROOTS`, or the
18
+ MCP client's advertised root — so legitimate `--cwd` use is unaffected.
19
+ - **Folded-back sidecar summaries are fenced as untrusted output.** `amicus_read`'s returned summary — produced
20
+ by an arbitrary model — is now wrapped in a read-only fence (mirroring the outbound conversation fence), so
21
+ model prose entering the orchestrator's context is marked as data, not instructions.
22
+ - **The Electron content view no longer shares the privileged bridge.** The embedded OpenCode web view gets a
23
+ minimal preload that exposes nothing privileged, and IPC handlers validate the sender, so only the toolbar can
24
+ trigger update/settings actions.
25
+
26
+ ### Fixed
27
+ - **A crashed OpenCode server is now detected.** The shared-server crash/restart machinery was unreachable (no
28
+ exit listener was ever attached); a server exit is now wired to the restart path.
29
+ - **Session metadata is written atomically** (temp file + rename), so a crash mid-write can no longer corrupt
30
+ `metadata.json` and silently mask an abort marker.
31
+ - **Port lookup works on Windows.** The stale-process cleanup used a hardcoded `lsof` (a no-op on Windows); it
32
+ now uses the cross-platform `netstat`-based lookup.
33
+ - **A fan-out leg whose setup throws no longer sinks the whole wave** — the leg is turned into an error result
34
+ and `wave.json` is still written.
35
+ - **The setup window can't hang on a spawn failure** — a spawn error now resolves cleanly instead of leaving the
36
+ launch promise pending forever, and the Electron child is killed on parent exit.
37
+ - **Project-scoped `opencode.json` resolves against the target project**, not the launcher's working directory.
38
+ - Smaller correctness/cleanup fixes: single-peer-agreed council findings now count as corroborated; unknown
39
+ council verdicts are guarded; tool-call turns render a summary instead of blank; quote-aware `--mcp` command
40
+ parsing; a model-object shape guard; a single shared duration formatter; timed mirror teardown; a lock on the
41
+ continuation session; and canonical session-route separators.
42
+
43
+ ### Known follow-ups
44
+ - Fencing `amicus_council_tally`/`amicus_verdict` (they return JSON records, so they need a field-level fence).
45
+ - Removing the now-dead top-level `tool_use` formatter branch (blocked on an unrelated test assertion).
46
+
47
+ ## [1.7.5] - 2026-07-01
48
+
49
+ A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
50
+
51
+ ### Fixed
52
+ - **Long prompts no longer truncate on Windows.** The `amicus_start` and `amicus_continue` MCP
53
+ handlers passed the full prompt inline on the spawned command line, which silently truncated once
54
+ it crossed Windows's ~32 KB argument cap — so a sidecar could run against a corrupted briefing with
55
+ no error. Both paths now write the prompt to a `briefing.md` in the session directory and pass
56
+ `--prompt-file`, matching the existing fanout handler; the CLI `continue` command learned
57
+ `--prompt-file` as well.
58
+ - **`getMessages` no longer masks SDK error responses.** An error-shaped response with no `data`
59
+ array was indistinguishable from "zero messages" in the poll loop; it now logs a warning carrying
60
+ the session id and surfaced error while still returning `[]`.
61
+ - **`getSessionDir` rejects path-traversal task ids.** A defense-in-depth containment guard (the same
62
+ check style used elsewhere in the codebase) throws on a task id that would escape the sessions dir.
63
+ - **Cross-platform `auth.json` discovery.** The one-time OpenCode key-import path was hardcoded to the
64
+ Unix XDG location; it now probes `$XDG_DATA_HOME`, `~/.local/share`, and `%APPDATA%` (Windows) and
65
+ uses the first that exists.
66
+
67
+ ### Changed
68
+ - **The fold-completion marker is harder to spoof.** A bare `[SIDECAR_FOLD]` echoed mid-output (e.g. a
69
+ model reproducing these instructions or summarizing a prior sidecar session) no longer forces a
70
+ premature fold — the marker now completes a run only when it is the final non-empty line of output,
71
+ with the existing idle/timeout fallbacks unchanged so a run can never hang.
72
+ - **The conversation-mirror tool-call buffer is bounded.** Capped at 2000 entries with a separate
73
+ dedup set, so a very long tool-heavy session can't grow it without limit.
74
+ - **Unknown `--no-*` flags are treated as boolean.** They no longer swallow the following positional
75
+ argument (`--no-x=value` still records its inline value; allowlisted flags are unchanged).
76
+ - **`--prompt-file` validation is order-independent.** `validateStartArgs` now resolves the prompt
77
+ source itself, so validation no longer depends on the handler having resolved it first.
78
+
79
+ ### Docs
80
+ - **Corrected the `tiktoken` dependency note.** It is declared but unused; token sizing uses a
81
+ `length/4` heuristic. Added caveat comments at both estimators. (Removing the unused dependency is
82
+ tracked as a follow-up.)
83
+
8
84
  ## [1.7.4] - 2026-06-30
9
85
 
10
86
  ### Fixed
package/bin/amicus.js CHANGED
@@ -215,6 +215,19 @@ async function handleContinue(args) {
215
215
  process.exit(1);
216
216
  }
217
217
 
218
+ // BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
219
+ // follow-up prompt via file, dodging the ~32KB Windows command-line cap.
220
+ if (args['prompt-file'] !== undefined) {
221
+ const { resolvePromptSource } = require('../src/utils/prompt-source');
222
+ const promptRes = resolvePromptSource(args);
223
+ if (promptRes.error) {
224
+ console.error(promptRes.error);
225
+ process.exit(1);
226
+ }
227
+ args.prompt = promptRes.prompt;
228
+ delete args['prompt-file'];
229
+ }
230
+
218
231
  if (!args.prompt && !args.briefing) {
219
232
  console.error('Error: --prompt is required for continue');
220
233
  process.exit(1);
@@ -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.4",
3
+ "version": "1.7.6",
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",
@@ -28,6 +28,10 @@ async function handleStart(args) {
28
28
  const promptRes = resolvePromptSource(args);
29
29
  if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
30
30
  args.prompt = promptRes.prompt;
31
+ // Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
32
+ // guard would otherwise re-run resolvePromptSource with both prompt and
33
+ // prompt-file set and trip its mutually-exclusive branch.
34
+ delete args['prompt-file'];
31
35
  }
32
36
  if (args.json && !args['no-ui']) {
33
37
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  validateApiKey,
17
17
  validateThinkingLevel
18
18
  } = require('./utils/validators');
19
+ const { resolvePromptSource } = require('./utils/prompt-source');
19
20
  const { logger } = require('./utils/logger');
20
21
 
21
22
  /**
@@ -78,6 +79,14 @@ function parseArgs(argv) {
78
79
  continue;
79
80
  }
80
81
 
82
+ // Unknown negation flags: known --no-* flags are already handled by
83
+ // isBooleanFlag() above; treat any *unregistered* --no-* token as a
84
+ // boolean so it can never swallow the following positional as a value.
85
+ if (key.startsWith('no-')) {
86
+ result[key] = true;
87
+ continue;
88
+ }
89
+
81
90
  // Options with values
82
91
  if (next && !next.startsWith('--')) {
83
92
  result[key] = parseValue(key, next);
@@ -153,6 +162,19 @@ function parseValue(key, value) {
153
162
  * @returns {{ valid: boolean, error?: string }}
154
163
  */
155
164
  function validateStartArgs(args) {
165
+ // Resolve the prompt source (--prompt XOR --prompt-file) here so validation
166
+ // is self-contained and order-independent: a caller need not have run
167
+ // resolvePromptSource() first. Skipped when args.prompt is already a plain
168
+ // string (the classic path / already-resolved by handleStart), so the empty
169
+ // '' case still falls through to the presence/content checks below.
170
+ if (args['prompt-file'] !== undefined || args.prompt === undefined || args.prompt === true) {
171
+ const res = resolvePromptSource(args);
172
+ if (res.error) {
173
+ return { valid: false, code: 'MISSING_PROMPT', error: res.error };
174
+ }
175
+ args.prompt = res.prompt;
176
+ }
177
+
156
178
  // Required: --prompt (presence check)
157
179
  if (!args.prompt) {
158
180
  return { valid: false, error: 'Error: --prompt is required' };
@@ -28,6 +28,8 @@ function estimateTokenCount(text) {
28
28
  return 0;
29
29
  }
30
30
 
31
+ // Intentional cheap heuristic — not a real BPE tokenizer; under/over-counts
32
+ // for CJK, code, and punctuation-dense text. (ceil, vs floor in context.js.)
31
33
  return Math.ceil(text.length / 4);
32
34
  }
33
35
 
package/src/context.js CHANGED
@@ -52,7 +52,8 @@ function estimateTokens(text) {
52
52
  if (!text || typeof text !== 'string') {
53
53
  return 0;
54
54
  }
55
- // Spec specifies ~4 chars per token
55
+ // Intentional cheap heuristic (spec §5.3: ~4 chars/token). Not a real BPE
56
+ // tokenizer — under/over-counts for CJK, code, and punctuation-dense text.
56
57
  return Math.floor(text.length / 4);
57
58
  }
58
59
 
@@ -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 };
package/src/headless.js CHANGED
@@ -21,6 +21,27 @@ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/con
21
21
  const FOLD_MARKER = '[SIDECAR_FOLD]';
22
22
  const COMPLETE_MARKER = FOLD_MARKER; // backward compat
23
23
 
24
+ /**
25
+ * #BL-7: the fold marker is the fixed public string [SIDECAR_FOLD]. A model can
26
+ * legitimately emit it on its own line mid-output — summarizing a prior sidecar,
27
+ * reproducing these instructions, or from scraped content — which used to force a
28
+ * PREMATURE fold. Harden by requiring the marker to be the FINAL non-empty line
29
+ * of the output: a standalone marker followed by MORE content is treated as
30
+ * echoed prose, not a completion signal. Only the true trailing marker folds.
31
+ *
32
+ * @param {string} output - Accumulated assistant output
33
+ * @returns {number} char index where the trailing marker line begins, or -1
34
+ */
35
+ function findTrailingFoldMarker(output) {
36
+ if (!output) { return -1; }
37
+ // The marker must be the last non-empty line: it sits alone on its line
38
+ // (only intra-line whitespace around it) and NOTHING but whitespace follows
39
+ // to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
40
+ // end — a bare marker followed by more prose is echoed content, not a signal.
41
+ const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
42
+ return m ? m.index : -1;
43
+ }
44
+
24
45
  /**
25
46
  * Default timeout: 15 minutes per spec §6.2
26
47
  */
@@ -399,10 +420,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
399
420
  elapsed: Date.now() - startTime
400
421
  });
401
422
 
402
- // Check for completion marker on its own line (not inline in prose).
403
- // Models may mention [SIDECAR_FOLD] when describing code only treat
404
- // it as a signal when it appears as a standalone line.
405
- if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(mirror.output)) {
423
+ // Check for the completion marker as the FINAL non-empty line (#BL-7).
424
+ // Models may emit [SIDECAR_FOLD] on its own line mid-output (echoing a
425
+ // prior sidecar, these instructions, or scraped content) only treat
426
+ // it as a completion signal when nothing but blank lines follow it.
427
+ if (findTrailingFoldMarker(mirror.output) !== -1) {
406
428
  completed = true;
407
429
  break;
408
430
  }
@@ -608,7 +630,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
608
630
  }
609
631
 
610
632
  /**
611
- * Extract summary from output (everything before [SIDECAR_FOLD])
633
+ * Extract summary from output (everything before the trailing [SIDECAR_FOLD])
612
634
  * Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
613
635
  *
614
636
  * @param {string} output - Raw output from OpenCode
@@ -619,13 +641,13 @@ function extractSummary(output) {
619
641
  return '';
620
642
  }
621
643
 
622
- // Split on the fold marker only when it appears on its own line.
623
- // Models may mention [SIDECAR_FOLD] inline when describing code
624
- // only treat it as a delimiter when standalone.
625
- const markerRegex = /^\s*\[SIDECAR_FOLD\]\s*$/m;
626
- const match = output.match(markerRegex);
627
- if (match) {
628
- return output.slice(0, match.index).trim();
644
+ // Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
645
+ // A [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
646
+ // instructions, or from scraped content) is NOT a delimiter keep it as
647
+ // content. Only the true trailing marker is stripped.
648
+ const idx = findTrailingFoldMarker(output);
649
+ if (idx !== -1) {
650
+ return output.slice(0, idx).trim();
629
651
  }
630
652
  return output.trim();
631
653
  }
@@ -659,6 +681,7 @@ module.exports = {
659
681
  waitForServer,
660
682
  withTimeout,
661
683
  extractSummary,
684
+ findTrailingFoldMarker,
662
685
  formatFoldOutput,
663
686
  DEFAULT_TIMEOUT,
664
687
  FOLD_MARKER,
@@ -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