amicus 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +15 -3
  4. package/electron/main.js +4 -1
  5. package/package.json +3 -1
  6. package/schemas/abort.schema.json +17 -0
  7. package/schemas/alias-audit.schema.json +17 -0
  8. package/schemas/council-run.schema.json +37 -0
  9. package/schemas/council-stats.schema.json +28 -0
  10. package/schemas/council-tally.schema.json +70 -0
  11. package/schemas/council-validate.schema.json +22 -0
  12. package/schemas/council-verdict.schema.json +47 -0
  13. package/schemas/doctor.schema.json +29 -0
  14. package/schemas/error.schema.json +23 -0
  15. package/schemas/model-catalog.schema.json +19 -0
  16. package/schemas/run.schema.json +26 -0
  17. package/schemas/spend.schema.json +16 -0
  18. package/schemas/wave.schema.json +33 -0
  19. package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
  20. package/skills/second-opinion/SKILL.md +8 -0
  21. package/src/cli-handlers-abort.js +29 -0
  22. package/src/cli-handlers-council-run.js +168 -0
  23. package/src/cli-handlers-council.js +8 -5
  24. package/src/cli-handlers-status.js +35 -4
  25. package/src/cli.js +9 -0
  26. package/src/council/anonymize.js +76 -0
  27. package/src/council/briefings-stage2.js +150 -0
  28. package/src/council/briefings.js +141 -0
  29. package/src/council/findings.js +13 -1
  30. package/src/council/ledger.js +13 -1
  31. package/src/council/parse-stage2.js +103 -0
  32. package/src/council/run-assemble.js +100 -0
  33. package/src/council/run-launch.js +99 -0
  34. package/src/council/run-stages.js +187 -0
  35. package/src/council/run-state.js +122 -0
  36. package/src/council/run.js +269 -0
  37. package/src/council/tally.js +3 -1
  38. package/src/council/verdict.js +9 -2
  39. package/src/headless.js +24 -25
  40. package/src/mcp-council-run.js +267 -0
  41. package/src/mcp-server.js +87 -28
  42. package/src/mcp-tools.js +50 -0
  43. package/src/prompt-builder.js +36 -19
  44. package/src/sidecar/electron-lock.js +4 -1
  45. package/src/sidecar/fanout-leg.js +2 -2
  46. package/src/sidecar/fanout.js +1 -1
  47. package/src/sidecar/resume.js +7 -2
  48. package/src/utils/abort-result.js +1 -1
  49. package/src/utils/error-doc.js +2 -0
  50. package/src/utils/fold-marker.js +21 -0
  51. package/src/utils/route-error.js +26 -0
  52. package/src/utils/start-helpers.js +19 -10
  53. package/src/utils/untrusted-fence.js +8 -7
@@ -4,7 +4,7 @@
4
4
  * Spec Reference: §6 Fold Mechanism, §9 Implementation
5
5
  * Constructs system prompts for sidecar sessions in both interactive and headless modes.
6
6
  */
7
- const { buildFoldMarker } = require('./utils/fold-marker');
7
+ const { buildFoldMarker, stripFoldMarkers } = require('./utils/fold-marker');
8
8
 
9
9
  /**
10
10
  * Summary template for fold output per spec §6.1
@@ -78,17 +78,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
78
78
  * @param {string} [mode='code'] - Agent mode ('code', 'ask', or 'plan')
79
79
  * @param {string} [summaryLength='normal'] - Desired summary length for headless mode
80
80
  * @param {string} [client='code-local'] - Client type for branding
81
- * @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). Only used in
82
- * headless mode: the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of
83
- * the legacy bare `[SIDECAR_FOLD]`, so runHeadless's detector (which must be given
84
- * this SAME nonce) can't be forced into completing by a model that merely echoes the
85
- * public, guessable bare marker. Callers that build a headless prompt and intend to
86
- * run it should generate one nonce (utils/fold-marker.generateFoldNonce()) BEFORE
87
- * calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
88
- * options.nonce. Omitted in interactive mode (GUI fold is exit-code driven, not
89
- * marker-detected) and harmless to omit in headless mode too buildHeadlessModeSection
90
- * falls back to the legacy bare marker, matching runHeadless's own no-nonce fallback.
81
+ * @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). REQUIRED in
82
+ * headless mode; ignored in interactive mode. In headless mode the model is instructed
83
+ * to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare `[SIDECAR_FOLD]`, so
84
+ * runHeadless's detector (which must be given this SAME nonce) can't be forced into
85
+ * completing by a model that merely echoes the public, guessable bare marker. Callers
86
+ * that build a headless prompt generate one nonce (utils/fold-marker.generateFoldNonce())
87
+ * BEFORE calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
88
+ * options.nonce. Omitting it in headless mode THROWS (see @throws) there is no
89
+ * bare-marker fallback on this live path: a real prompt must never advertise the
90
+ * guessable marker. Interactive mode ignores the nonce entirely (GUI fold is
91
+ * exit-code driven, not marker-detected).
91
92
  * @returns {{system: string, userMessage: string}} Separated prompts
93
+ * @throws {TypeError} If `headless` is true and no `nonce` is supplied.
92
94
  *
93
95
  * @example
94
96
  * const { system, userMessage } = buildPrompts(
@@ -101,6 +103,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
101
103
  * // Use: POST /session/:id/message { system, parts: [{ type: 'text', text: userMessage }] }
102
104
  */
103
105
  function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client, nonce) {
106
+ // 15b.3 (#BL-7 residual): a headless run MUST carry a per-run nonce so the
107
+ // model is instructed to emit the unguessable `[SIDECAR_FOLD:<nonce>]` — never
108
+ // the public, guessable bare `[SIDECAR_FOLD]`. buildPrompts is the live
109
+ // orchestration boundary (start / continue / fanout / mcp-server all route
110
+ // through it, and all four already generate and pass a nonce), so a forgotten
111
+ // nonce fails loud HERE rather than silently baking the bare marker into an
112
+ // executed prompt. Mirrors headless.js's producer precedent — extractSummary /
113
+ // formatFoldOutput throw a TypeError when the nonce is missing. Interactive
114
+ // mode is exempt: its fold is exit-code driven, not marker-detected.
115
+ if (headless && !nonce) {
116
+ throw new TypeError('buildPrompts requires a per-run nonce for headless mode (15b.3/v4.0 §9)');
117
+ }
118
+
104
119
  const systemSections = [
105
120
  buildHeader(client),
106
121
  buildEnvironmentSection(project, mode),
@@ -108,11 +123,11 @@ function buildPrompts(briefing, context, project, headless, mode, summaryLength
108
123
  ];
109
124
 
110
125
  // Strip fold markers from context so the model doesn't mimic them from
111
- // previous sidecar outputs in the conversation history. Matches BOTH the
112
- // legacy bare `[SIDECAR_FOLD]` and any nonced `[SIDECAR_FOLD:<nonce>]`
113
- // a resumed/continued conversation's history can carry either shape
114
- // depending on when the prior turn ran (15b.3).
115
- const cleanContext = context ? context.replace(/\[SIDECAR_FOLD(:[^\]]*)?\]/g, '') : context;
126
+ // previous sidecar outputs in the conversation history. stripFoldMarkers
127
+ // (src/utils/fold-marker.js) removes BOTH the legacy bare `[SIDECAR_FOLD]`
128
+ // and any nonced `[SIDECAR_FOLD:<nonce>]` marker-only lines vanish
129
+ // entirely; inline occurrences are removed in place (v4.0 §9).
130
+ const cleanContext = stripFoldMarkers(context);
116
131
 
117
132
  let userMessage;
118
133
  if (headless) {
@@ -254,9 +269,11 @@ Keep track of key findings as you work.`;
254
269
  * @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). When provided,
255
270
  * the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare
256
271
  * `[SIDECAR_FOLD]` — see buildPrompts' @param doc for the full rationale. Falls back to
257
- * the legacy bare marker when omitted (keeps this function usable standalone / by the
258
- * deprecated buildSystemPrompt(), which has no orchestration-layer caller to source a
259
- * nonce from).
272
+ * the legacy bare marker when omitted. NOTE: buildPrompts the live orchestration path
273
+ * now THROWS rather than reach this helper without a nonce in headless mode, so the
274
+ * fallback branch is reachable ONLY via the deprecated buildSystemPrompt(), which has no
275
+ * orchestration-layer caller to source a nonce from and launches no real runs. Its bare
276
+ * marker is therefore inert: nothing on a live run path ever advertises the guessable form.
260
277
  * @returns {string}
261
278
  */
262
279
  function buildHeadlessModeSection(summaryLength, nonce) {
@@ -22,7 +22,10 @@ const STALE_MS = 15 * 60 * 1000;
22
22
 
23
23
  /** Temp-dir lockfile path, keyed by the electron install dir. */
24
24
  function lockPathFor(electronDir) {
25
- const key = Buffer.from(electronDir).toString('hex').slice(0, 16);
25
+ // Hash the FULL path: a truncated hex prefix collapsed distinct installs that
26
+ // share a leading path segment (C:\Users…, /home/us…) onto one lockfile,
27
+ // defeating per-install isolation. Mirrors src/utils/engine-lock.js.
28
+ const key = require('crypto').createHash('sha1').update(electronDir).digest('hex').slice(0, 16);
26
29
  return path.join(os.tmpdir(), `amicus-electron-repair-${key}.lock`);
27
30
  }
28
31
 
@@ -64,7 +64,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
64
64
  * Run one leg end-to-end: session record → runHeadless (shared server) →
65
65
  * leg finalize. Never throws — always resolves to a run document.
66
66
  */
67
- async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
67
+ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, directory }) {
68
68
  const { IdleWatchdog } = require('../utils/idle-watchdog');
69
69
  const { markAborted } = require('../utils/session-abort');
70
70
  const { runHeadless } = require('../headless');
@@ -107,7 +107,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
107
107
  result = await runHeadless(
108
108
  leg.model, systemPrompt, userMessage, legId, project,
109
109
  timeoutMs, agent || 'build',
110
- { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce }
110
+ { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory }
111
111
  );
112
112
  } catch (err) {
113
113
  result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
@@ -245,7 +245,7 @@ async function runFanout(options) {
245
245
  leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
246
246
  timeoutMs, agent: options.agent, client, server,
247
247
  summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
248
- foldNonce,
248
+ foldNonce, directory: options.directory,
249
249
  })
250
250
  : Promise.resolve(buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }))
251
251
  )));
@@ -17,7 +17,7 @@ const {
17
17
  } = require('./session-utils');
18
18
  const { acquireLock, releaseLock } = require('../utils/session-lock');
19
19
  const { runHeadless } = require('../headless');
20
- const { extractNonceFromText, generateFoldNonce } = require('../utils/fold-marker');
20
+ const { extractNonceFromText, generateFoldNonce, stripFoldMarkers } = require('../utils/fold-marker');
21
21
  const { logger } = require('../utils/logger');
22
22
 
23
23
  /** Load session metadata from session directory */
@@ -83,7 +83,12 @@ function buildResumeUserMessage(briefing, conversation) {
83
83
 
84
84
  if (conversation) {
85
85
  parts.push('## PREVIOUS CONVERSATION\n');
86
- parts.push(conversation);
86
+ // v4.0 §9: the replay used to carry the previous turn's valid nonced
87
+ // [SIDECAR_FOLD:<nonce>] marker verbatim (BACKLOG "Resume nonce-echo
88
+ // hazard"). Each run mints a fresh nonce so the stale marker could never
89
+ // fold the new run, but a wire-format token has no business riding into
90
+ // the new prompt — strip every marker line before embedding.
91
+ parts.push(stripFoldMarkers(conversation));
87
92
  parts.push('\n---\n');
88
93
  parts.push('## RESUME\n');
89
94
  parts.push('You are resuming a previous session. Continue from where you left off.');
@@ -16,7 +16,7 @@ const { SCHEMA_VERSION } = require('./result-schema-version');
16
16
  * a specific taskId that exists yet was not running (already terminal) reports
17
17
  * ok:false so a scripted caller can tell "nothing happened" from "you aborted N".
18
18
  * @param {object} opts
19
- * @param {'session'|'wave'|'all'} opts.scope
19
+ * @param {'session'|'wave'|'all'|'council-run'} opts.scope
20
20
  * @param {string|null} opts.taskId - null for scope:'all'
21
21
  * @param {string[]} opts.aborted - ids actually marked aborted (session/wave id + any legs)
22
22
  * @returns {object} abort document
@@ -20,6 +20,8 @@ const ERROR_CODES = Object.freeze({
20
20
  BAD_SESSION: 'BAD_SESSION', // task id missing / invalid / not found
21
21
  BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', // the WS-2 #10 spend gate
22
22
  INTERNAL: 'INTERNAL', // unexpected pre-flight throw
23
+ COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
24
+ COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
23
25
  });
24
26
 
25
27
  /**
@@ -70,10 +70,31 @@ function extractNonceFromText(text) {
70
70
  return m ? m[1] : null;
71
71
  }
72
72
 
73
+ /**
74
+ * v4.0 §9 (BL-7 done-done): remove every fold-marker occurrence — bare
75
+ * `[SIDECAR_FOLD]` and nonced `[SIDECAR_FOLD:<nonce>]` — from a text.
76
+ * A line consisting of ONLY a marker (plus horizontal whitespace) is removed
77
+ * together with its line terminator; a marker embedded mid-line is removed in
78
+ * place, keeping the rest of the line. Superset of prompt-builder.js's old
79
+ * inline regex (which left empty lines behind) — used by the resume replay
80
+ * (src/sidecar/resume.js) and buildPrompts (src/prompt-builder.js) so a
81
+ * replayed conversation never carries a stale wire-format token.
82
+ * @param {string|null|undefined} text
83
+ * @returns {string|null|undefined} falsy input is returned unchanged
84
+ */
85
+ function stripFoldMarkers(text) {
86
+ if (!text) { return text; }
87
+ const marker = `\\[${FOLD_MARKER_PREFIX}(:[^\\]]*)?\\]`;
88
+ const markerOnlyLine = new RegExp(`^[^\\S\\r\\n]*${marker}[^\\S\\r\\n]*(?:\\r?\\n|$)`, 'gm');
89
+ const inlineMarker = new RegExp(marker, 'g');
90
+ return text.replace(markerOnlyLine, '').replace(inlineMarker, '');
91
+ }
92
+
73
93
  module.exports = {
74
94
  FOLD_MARKER_PREFIX,
75
95
  generateFoldNonce,
76
96
  buildFoldMarker,
77
97
  trailingFoldMarkerRegex,
78
98
  extractNonceFromText,
99
+ stripFoldMarkers,
79
100
  };
@@ -145,9 +145,35 @@ function toCliMessage(result) {
145
145
  return lines.join('\n');
146
146
  }
147
147
 
148
+ /**
149
+ * v4.0 §7: map a RouteResult onto error-doc fields ({code, message, hint})
150
+ * for buildErrorDoc/failJson — the CLI --json and MCP failure surfaces.
151
+ * Key-shaped reasons map to MISSING_KEY; everything else is BAD_MODEL.
152
+ * Suggestions are inlined into the message text (the error doc has no
153
+ * structured suggestions slot; the hint carries the fix line).
154
+ * @param {object} result a RouteResult with kind 'error' or 'selection_required'
155
+ * @returns {{code: string, message: string, hint: (string|null)}}
156
+ */
157
+ function toErrorDocFields(result) {
158
+ const { ERROR_CODES } = require('./error-doc');
159
+ const KEY_REASONS = ['no_openrouter_key', 'no_direct_key', 'no_key_for_vendor'];
160
+ const err = toStructuredError(result);
161
+ const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
162
+ let message = err.requested ? `${sentence} (requested "${err.requested}")` : sentence;
163
+ if (err.suggestions.length > 0) {
164
+ message += ` Did you mean: ${err.suggestions.map((s) => s && s.model).filter(Boolean).join(', ')}?`;
165
+ }
166
+ return {
167
+ code: KEY_REASONS.includes(err.reason) ? ERROR_CODES.MISSING_KEY : ERROR_CODES.BAD_MODEL,
168
+ message,
169
+ hint: FIX_HINTS[err.reason] || null,
170
+ };
171
+ }
172
+
148
173
  module.exports = {
149
174
  toStructuredError,
150
175
  toCliMessage,
176
+ toErrorDocFields,
151
177
  REASON_TEXT,
152
178
  ROUTE_ERROR_REASONS,
153
179
  SELECTION_REQUIRED_REASON,
@@ -46,7 +46,8 @@ function deriveAlias(args) {
46
46
  async function resolveLaunchModel(args) {
47
47
  const { resolveGatewayMode } = require('./config');
48
48
  const { resolveRouteForLaunch } = require('./route-launch');
49
- const { toCliMessage, toStructuredError } = require('./route-error');
49
+ const { toCliMessage, toErrorDocFields } = require('./route-error');
50
+ const { failJson, ERROR_CODES } = require('./error-doc');
50
51
  const { resolveModelInputOrDefault } = require('./model-input-default');
51
52
 
52
53
  const gatewayMode = resolveGatewayMode(args.gateway);
@@ -66,9 +67,13 @@ async function resolveLaunchModel(args) {
66
67
  // via model-input-default.js, so this lookup lives in exactly one place.
67
68
  const modelInput = resolveModelInputOrDefault(args.model);
68
69
  if (modelInput === undefined) {
69
- process.stderr.write(
70
- 'No model specified and no default configured. Run \'amicus setup\' to set a default model.\n'
71
- );
70
+ const message = 'No model specified and no default configured. Run \'amicus setup\' to set a default model.';
71
+ if (args.json) {
72
+ // v4.0 §7: --json pre-flight failures land on STDOUT as the error doc.
73
+ failJson(true, { code: ERROR_CODES.BAD_MODEL, message, hint: 'amicus setup' });
74
+ } else {
75
+ process.stderr.write(`${message}\n`);
76
+ }
72
77
  process.exit(1);
73
78
  }
74
79
 
@@ -106,17 +111,21 @@ async function resolveLaunchModel(args) {
106
111
  provenance: result.provenance || {},
107
112
  };
108
113
  } catch (err) {
109
- process.stderr.write(`${err.message || 'Model selection cancelled.'}\n`);
114
+ const message = err.message || 'Model selection cancelled.';
115
+ if (args.json) {
116
+ failJson(true, { code: ERROR_CODES.BAD_MODEL, message,
117
+ hint: 'Pass --model <vendor/model> explicitly to skip the picker.' });
118
+ } else {
119
+ process.stderr.write(`${message}\n`);
120
+ }
110
121
  process.exit(1);
111
122
  }
112
123
  }
113
124
 
114
- // 'error': render and exit. (A non-interactive run never reaches
115
- // kind:'selection_required'allowSelection is false there, so the router
116
- // resolves a catalog miss straight to kind:'error' with reason
117
- // 'model_not_found' instead; see gateway-router.js's catalogGate.)
125
+ // 'error': render and exit. Under --json the error doc goes to STDOUT
126
+ // (v4.0 §7 was toStructuredError on stderr); human stderr is unchanged.
118
127
  if (args.json) {
119
- process.stderr.write(`${JSON.stringify(toStructuredError(result))}\n`);
128
+ failJson(true, toErrorDocFields(result));
120
129
  } else {
121
130
  process.stderr.write(`${toCliMessage(result)}\n`);
122
131
  }
@@ -9,13 +9,14 @@
9
9
  * marked as data, not instructions.
10
10
  *
11
11
  * Applies to every prose channel a sidecar model's output reaches an agent
12
- * through: MCP amicus_read (summary, wave summary, conversation) and the
13
- * CLI's non-JSON stdout (read summary/conversation/wave-human, and the
14
- * foreground start/continue/resume summary echo). It must NOT be applied to
15
- * JSON contracts (--json stdout, amicus_council_tally/verdict) or metadata
16
- * (amicus_read mode=metadata) those are structured data a caller parses,
17
- * not prose read directly by an LLM, and wrapping them would break the
18
- * contract.
12
+ * through: MCP amicus_read (summary, wave summary, conversation), the CLI's
13
+ * non-JSON stdout (read summary/conversation/wave-human, and the foreground
14
+ * start/continue/resume summary echo), and since v4.0 (H9) — the council
15
+ * MCP tools' JSON returns (amicus_council_tally / amicus_council_stats /
16
+ * amicus_verdict), whose docs embed untrusted model-raised findings; the JSON
17
+ * stays intact inside the fence. It must NOT be applied to CLI --json stdout
18
+ * (the byte-parseable programmatic channel) or amicus_read mode=metadata —
19
+ * structured data a caller parses, where wrapping would break the contract.
19
20
  */
20
21
  'use strict';
21
22