amicus 4.2.1 → 4.4.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 (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const runState = require('./council/run-state');
17
17
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
18
  const { isPathInside } = require('./project-root-allowlist');
19
+ const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
19
20
 
20
21
  function textResult(text, isError) {
21
22
  const result = { content: [{ type: 'text', text }] };
@@ -23,7 +24,14 @@ function textResult(text, isError) {
23
24
  return result;
24
25
  }
25
26
 
26
- /** Resolve the bench: models XOR council preset (amicus_fanout parity). */
27
+ /**
28
+ * Resolve the bench: models XOR council preset (amicus_fanout parity).
29
+ * Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
30
+ * preset name when that branch was taken, else null — this handler always
31
+ * spawns the CLI child with an already-expanded `--models` list (never
32
+ * `--council`), so the preset name would otherwise be lost; the caller
33
+ * forwards it via the internal `--council-name` passthrough instead.
34
+ */
27
35
  function resolveBenchInput(input) {
28
36
  const inputModels = Array.isArray(input.models) ? input.models : [];
29
37
  const hasModels = inputModels.length > 0;
@@ -34,11 +42,12 @@ function resolveBenchInput(input) {
34
42
  const { resolveCouncilMembers } = require('./utils/config');
35
43
  const { readCache } = require('./utils/model-catalog');
36
44
  const catalog = (readCache() || {}).models || [];
37
- const expanded = resolveCouncilMembers(input.council.trim(), catalog);
45
+ const presetName = input.council.trim();
46
+ const expanded = resolveCouncilMembers(presetName, catalog);
38
47
  if (expanded.error) { return { error: expanded.error }; }
39
- return { bench: expanded.models };
48
+ return { bench: expanded.models, presetName };
40
49
  }
41
- return { bench: inputModels };
50
+ return { bench: inputModels, presetName: null };
42
51
  }
43
52
 
44
53
  /**
@@ -49,6 +58,12 @@ function resolveBenchInput(input) {
49
58
  * @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
50
59
  */
51
60
  async function handleCouncilRunTool(input, project, helpers) {
61
+ // Task 15 (spec §5.3): validate onComplete FIRST, before any run dir is
62
+ // prepared — exec strings are rejected over MCP (the Zod enum on the tool
63
+ // def already rejects them at the call boundary; this is defense-in-depth
64
+ // for any caller that bypasses schema validation).
65
+ const oc = validateOnComplete(input.onComplete);
66
+ if (!oc.ok) { return textResult(oc.error, true); }
52
67
  const CHAIR_DEFAULT = 'deepseek';
53
68
  if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
54
69
  return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
@@ -62,6 +77,7 @@ async function handleCouncilRunTool(input, project, helpers) {
62
77
  const benchRes = resolveBenchInput(input);
63
78
  if (benchRes.error) { return textResult(benchRes.error, true); }
64
79
  const bench = benchRes.bench;
80
+ const presetName = benchRes.presetName;
65
81
  if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
66
82
  const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
67
83
  if (bench.includes(chair)) {
@@ -123,6 +139,10 @@ async function handleCouncilRunTool(input, project, helpers) {
123
139
  if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
124
140
  if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
125
141
  if (input.gateway) { args.push('--gateway', input.gateway); }
142
+ // v4.3 Task 3 (spec §7.1): the bench above is already expanded, so `--council`
143
+ // itself is never spawned (it would collide with `--models`) — this internal,
144
+ // undocumented flag carries the preset NAME through for attribution only.
145
+ if (presetName) { args.push('--council-name', presetName); }
126
146
  // v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
127
147
  // reason outDir is — an MCP client may send a relative path, and the child's cwd
128
148
  // is the run dir. Validation of the file itself stays in the spawned engine's
@@ -145,6 +165,10 @@ async function handleCouncilRunTool(input, project, helpers) {
145
165
  // read-merge-write has no lock (see run-state.writeSpawnPid).
146
166
  try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
147
167
  catch { /* best-effort */ }
168
+ // Task 15 (spec §5.3): the run is now known-launched under runId — mark it
169
+ // for a best-effort terminal notify. runWait's poll loop (mcp-wait.js) is
170
+ // the only code that later sees this council run reach terminal state.
171
+ if (oc.mode === 'mcp-notify') { requestMcpNotify(runId); }
148
172
 
149
173
  const body = JSON.stringify({
150
174
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure helpers + in-process registry for the MCP `onComplete: 'mcp-notify'`
3
+ * input (spec §5.3, Task 15).
4
+ * @module mcp-notify
5
+ *
6
+ * Security property (THE defining property of this module): exec is
7
+ * deliberately NOT exposed over MCP. A shell-exec tool input would be a
8
+ * prompt-injection amplifier — an MCP client acting on untrusted content
9
+ * could make amicus run arbitrary shell commands. So onComplete over MCP
10
+ * accepts ONLY 'mcp-notify'; any other value (especially a command string)
11
+ * is a validation error. Nothing is ever spawned/exec'd from this module or
12
+ * from the MCP path that consumes it — it only ever sends a notification.
13
+ *
14
+ * Delivery seam: amicus_fanout/amicus_council_run spawn a DETACHED CLI child
15
+ * and return 'running' immediately — there is no in-process finalize to
16
+ * notify from. Instead, a run that requested notify is marked here (mirrors
17
+ * mcp-wait.js's `_inProcessRuns` in-process Map), and the ONE place that
18
+ * later sees the run reach terminal state — the `runWait` poll loop in
19
+ * mcp-wait.js — consumes the mark and sends the notification. Advisory/
20
+ * best-effort throughout: a send failure never changes the run outcome.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ /** MCP onComplete accepts ONLY 'mcp-notify' — exec is deliberately not exposed
26
+ * over MCP (a shell-exec tool input would be a prompt-injection amplifier). */
27
+ function validateOnComplete(value) {
28
+ if (value === undefined || value === null) { return { ok: true, mode: null }; }
29
+ if (value === 'mcp-notify') { return { ok: true, mode: 'mcp-notify' }; }
30
+ return { ok: false, error: 'onComplete over MCP supports only \'mcp-notify\'; exec commands are not accepted over MCP.' };
31
+ }
32
+
33
+ /** Wrap a terminal event doc as an MCP logging notification payload. */
34
+ function buildNotifyPayload(terminalEvent) {
35
+ return { level: 'info', logger: 'amicus', data: terminalEvent };
36
+ }
37
+
38
+ /** taskId/runId -> true, for runs (owned by THIS MCP server process) that
39
+ * requested a best-effort mcp-notify on terminal. */
40
+ const _notifyRequests = new Map();
41
+
42
+ /** Mark a run for a best-effort terminal notify (called once the run's id is known and launch succeeded). */
43
+ function requestMcpNotify(taskId) {
44
+ _notifyRequests.set(taskId, true);
45
+ }
46
+
47
+ /** Once-semantics: true + delete on first read for a requested id; false (no-op) otherwise,
48
+ * including on a second call — so a re-wait on an already-terminal run never double-sends. */
49
+ function consumeMcpNotify(taskId) {
50
+ if (_notifyRequests.has(taskId)) { _notifyRequests.delete(taskId); return true; }
51
+ return false;
52
+ }
53
+
54
+ module.exports = { validateOnComplete, buildNotifyPayload, requestMcpNotify, consumeMcpNotify };
package/src/mcp-server.js CHANGED
@@ -18,6 +18,7 @@ const { recordSession } = require('./utils/session-index');
18
18
  const { fileURLToPath } = require('url');
19
19
  const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
20
20
  const { runWait, registerInProcessRun, settleInProcessRun } = require('./mcp-wait');
21
+ const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
21
22
  const { detectClient } = require('./utils/client-detect');
22
23
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
23
24
  const { sliceForRead } = require('./utils/read-slice');
@@ -587,6 +588,7 @@ const handlers = {
587
588
  }
588
589
 
589
590
  if (metadata.type === 'wave') {
591
+ const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
590
592
  const legs = (metadata.legs || []).map((legId) => {
591
593
  const m = readMetadata(legId, cwd);
592
594
  const leg = { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
@@ -599,6 +601,11 @@ const handlers = {
599
601
  leg.phase = deriveStage(leg.status, p.stage); // coarse: starting|generating|folding|terminal
600
602
  leg.latestPreview = p.latestPreview;
601
603
  leg.lastActivityAt = p.lastActivityAt;
604
+ // N3: enrichLegUsage returns the bare leg (no `usage` key) when
605
+ // progress carries no usage yet (e.g. solo interactive legs, Task 8)
606
+ // — merge only when present so we never put an undefined key on doc.
607
+ const enriched = enrichLegUsage(leg, p.usage);
608
+ if (enriched.usage) { leg.usage = enriched.usage; }
602
609
  } catch { /* no progress yet — leave base fields only */ }
603
610
  return leg;
604
611
  });
@@ -649,6 +656,11 @@ const handlers = {
649
656
  if (metadata.status === 'crashed' || metadata.status === 'error') {
650
657
  response.reason = metadata.reason || 'Unknown error';
651
658
  }
659
+ // Surface C (spec 4.3): additive read-time usage rollup + live marker.
660
+ // sumWaveUsage tolerates legs with no usage (A8: cost-by-seat from
661
+ // progress.json only, never a ledger) — safe even when no leg priced.
662
+ response.usage = rollupWaveUsage(legs);
663
+ markLive(response);
652
664
  const content = [{ type: 'text', text: JSON.stringify(response) }];
653
665
  appendVersionWarning(content);
654
666
  if (metadata.status === 'running') {
@@ -692,6 +704,17 @@ const handlers = {
692
704
  response.messageCount = progress.messages; // stable agent-facing alias
693
705
  response.phase = deriveStage(metadata.status, progress.stage); // coarse lifecycle
694
706
 
707
+ // Surface C (spec 4.3): read-time cost resolution over the RAW usage the
708
+ // Object.assign above just copied from progress.json (Task 8). N3: most
709
+ // interactive/GUI runs write no progress.usage at all — enrichLegUsage
710
+ // returns no `usage` key in that case, so NEVER assign it unconditionally
711
+ // (that would leave the doc holding the unresolved raw {tokens,costReported}
712
+ // shape, or an undefined key when progress.usage was absent).
713
+ const { enrichLegUsage } = require('./observe/live-doc');
714
+ const enr = enrichLegUsage({ model: metadata.model }, progress.usage);
715
+ if (enr.usage) { response.usage = enr.usage; }
716
+ else { delete response.usage; }
717
+
695
718
  // Stall detection: flag when no activity for 2+ minutes
696
719
  const STALL_THRESHOLD_MS = 120000;
697
720
  if (metadata.headless && progress.lastActivityMs !== null && progress.lastActivityMs > STALL_THRESHOLD_MS) {
@@ -711,6 +734,7 @@ const handlers = {
711
734
  if (metadata.status === 'crashed' || metadata.status === 'error') {
712
735
  response.reason = metadata.reason || 'Unknown error';
713
736
  }
737
+ require('./observe/live-doc').markLive(response);
714
738
  const content = [{ type: 'text', text: JSON.stringify(response) }];
715
739
  appendVersionWarning(content);
716
740
  if (metadata.status === 'running' && metadata.headless) {
@@ -719,11 +743,20 @@ const handlers = {
719
743
  return { content };
720
744
  },
721
745
 
722
- async amicus_wait(input, project) {
746
+ async amicus_wait(input, project, mcpServer) {
723
747
  // statusFn injection avoids a circular require and inherits amicus_status's
724
748
  // crash detection + wave leg rollup on every poll tick.
725
749
  return runWait(input, project, {
726
750
  statusFn: (i, p) => handlers.amicus_status(i, p),
751
+ // Task 15 (spec §5.3): best-effort mcp-notify delivery. mcpServer here
752
+ // is the McpServer instance the dispatch loop passes as the 3rd arg
753
+ // (server.js:register); mcpServer.server is the underlying low-level
754
+ // SDK Server that exposes sendLoggingMessage. A throw or an unsupported
755
+ // transport degrades silently — advisory only, never affects the wait.
756
+ notify: (payload) => {
757
+ try { if (mcpServer && mcpServer.server) { mcpServer.server.sendLoggingMessage(payload); } }
758
+ catch { /* best-effort; a send failure is a debug log only */ }
759
+ },
727
760
  });
728
761
  },
729
762
 
@@ -1026,6 +1059,12 @@ const handlers = {
1026
1059
 
1027
1060
  async amicus_fanout(input, project, mcpServer) {
1028
1061
  const cwd = project || getProjectDir(input.project);
1062
+ // Task 15 (spec §5.3): validate onComplete FIRST, before any wave dir /
1063
+ // metadata is written — exec strings are rejected over MCP (the Zod enum
1064
+ // on the tool def already rejects them at the call boundary; this is
1065
+ // defense-in-depth for any caller that bypasses schema validation).
1066
+ const oc = validateOnComplete(input.onComplete);
1067
+ if (!oc.ok) { return textResult(oc.error, true); }
1029
1068
  const { generateTaskId } = require('./sidecar/start');
1030
1069
  const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
1031
1070
 
@@ -1110,6 +1149,10 @@ const handlers = {
1110
1149
  } catch { /* best-effort */ }
1111
1150
  return textResult(`Failed to start fan-out: ${err.message}`, true);
1112
1151
  }
1152
+ // Task 15 (spec §5.3): the run is now known-launched under waveId — mark
1153
+ // it for a best-effort terminal notify. runWait's poll loop (mcp-wait.js)
1154
+ // is the only code that later sees this wave reach terminal state.
1155
+ if (oc.mode === 'mcp-notify') { requestMcpNotify(waveId); }
1113
1156
 
1114
1157
  const body = JSON.stringify(stampEnvelope('wave', {
1115
1158
  waveId, taskIds: legIds, status: 'running', mode: 'headless',
@@ -1196,6 +1239,13 @@ const handlers = {
1196
1239
  return textResult('Setup wizard launched. The Electron window should appear on your desktop.');
1197
1240
  },
1198
1241
  async amicus_guide() { return textResult(getGuideText()); },
1242
+
1243
+ // Read-only; deliberately NOT fenced (spec 7.3 — spend docs are ids/numbers/
1244
+ // paths by construction, never model-generated prose). `project` here is the
1245
+ // dispatch-resolved cwd, used only to expand a literal '.' filterProject —
1246
+ // see src/mcp-spend.js's module docblock for why the row filter isn't named
1247
+ // `project` itself.
1248
+ amicus_spend: (input, project) => require('./mcp-spend').amicus_spend(input, project),
1199
1249
  };
1200
1250
 
1201
1251
  /** Start the MCP server on stdio transport */
@@ -0,0 +1,125 @@
1
+ // src/mcp-spend.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module mcp-spend
6
+ * Read-only `amicus_spend` MCP tool (spec §7.3, resolved Q1). Mirrors the CLI
7
+ * `amicus spend` query flags over spend-ledger.jsonl. No fence: spend docs are
8
+ * ids/numbers/paths by construction (never model-generated text) — the schema
9
+ * commits to keeping it that way, so unlike other sidecar-facing MCP tools
10
+ * this handler never calls fenceSidecarOutput. `buildSpendResult` is the pure
11
+ * core (dir/cwd DI seam, no I/O beyond readSpendRows); `amicus_spend` is the
12
+ * async MCP entry the server dispatch loop calls.
13
+ *
14
+ * Every query primitive (filterRows/groupRows/computeWasted/GROUP_DIMS/
15
+ * ROWS_CAP from ./spend-query, aggregateSpend/buildSpendDoc from
16
+ * ./cli-handlers-spend) is reused verbatim from Task 4 — nothing here
17
+ * reimplements filtering, grouping, or the wasted rollup.
18
+ *
19
+ * Row-filter naming note: the CLI's `--project` flag mirrors 1:1 onto every
20
+ * other filter EXCEPT this one, which is `filterProject` here rather than
21
+ * `project`. The MCP dispatch wrapper (mcp-server.js) treats an input key
22
+ * literally named `project` as the tool's own working-directory selector and
23
+ * resolves/validates it against the allowed project roots BEFORE the handler
24
+ * ever runs — throwing for a path outside them. That's the right behavior for
25
+ * a cwd selector; it is wrong for this field, which is a pure ledger-row
26
+ * filter that must be able to name ANY project the ledger has ever recorded
27
+ * spend for (the ledger itself is global, keyed by config dir, not by cwd —
28
+ * see readSpendRows). Reusing `project` here would make filtering by an
29
+ * out-of-roots historical project throw instead of returning rows. Hence the
30
+ * distinct name.
31
+ */
32
+
33
+ const { readSpendRows } = require('./utils/spend-ledger');
34
+ const { aggregateSpend, buildSpendDoc, parseSinceDays } = require('./cli-handlers-spend');
35
+ const { filterRows, groupRows, computeWasted, GROUP_DIMS, ROWS_CAP } = require('./spend-query');
36
+ const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
37
+
38
+ /** @param {string} message @param {string} hint @returns {{content:Array, isError:true}} */
39
+ function errorResult(message, hint) {
40
+ const doc = buildErrorDoc({ code: ERROR_CODES.BAD_ARGS, message, hint });
41
+ return { content: [{ type: 'text', text: JSON.stringify(doc) }], isError: true };
42
+ }
43
+
44
+ /**
45
+ * Pure core: build the spend-doc MCP result for a given input + test seam.
46
+ * @param {{since?:string, wave?:string, council?:string, filterProject?:string,
47
+ * model?:string, op?:string, failed?:boolean, groupBy?:string, rows?:boolean}} [input]
48
+ * @param {{dir?:string, cwd?:string, now?:()=>number}} [ctx] test/DI seam — dir
49
+ * overrides the ledger's config dir (readSpendRows); cwd is the resolved
50
+ * project dir used to expand a literal '.' filterProject (CLI --project .
51
+ * parity); now overrides the clock used for `since` windowing (CLI --since
52
+ * test parity).
53
+ * @returns {{content:[{type:'text', text:string}], isError?:true}}
54
+ */
55
+ function buildSpendResult(input = {}, ctx = {}) {
56
+ const groupBy = input.groupBy || 'model';
57
+ if (!GROUP_DIMS.includes(groupBy)) {
58
+ return errorResult(
59
+ `invalid groupBy '${groupBy}'`,
60
+ `groupBy one of: ${GROUP_DIMS.join('|')}`
61
+ );
62
+ }
63
+
64
+ let windowDays = null;
65
+ if (input.since !== undefined) {
66
+ windowDays = parseSinceDays(input.since);
67
+ if (windowDays === null) {
68
+ return errorResult(
69
+ `invalid since '${input.since}'`,
70
+ "since must be an integer followed by 'd' (e.g. '7d')"
71
+ );
72
+ }
73
+ }
74
+
75
+ const rows = readSpendRows(ctx.dir);
76
+ const filters = {
77
+ wave: input.wave,
78
+ council: input.council,
79
+ model: input.model,
80
+ op: input.op,
81
+ failed: !!input.failed,
82
+ project: input.filterProject === '.' ? (ctx.cwd || process.cwd()) : input.filterProject,
83
+ };
84
+ const now = windowDays !== null ? (ctx.now ? ctx.now() : Date.now()) : undefined;
85
+ const filtered = filterRows(rows, { ...filters, since: windowDays, now });
86
+ const { total, byModel } = aggregateSpend(filtered);
87
+ const groups = groupRows(filtered, groupBy);
88
+ const wasted = computeWasted(filtered);
89
+
90
+ const doc = buildSpendDoc({
91
+ // credit stays null over MCP: the OpenRouter credit footer is a
92
+ // best-effort network probe the CLI path accepts blocking on —
93
+ // deliberately skipped here so a read-only local-file query never waits
94
+ // on the network. `since`/windowDays, by contrast, is a pure local
95
+ // filter (filterRows) with no network involved, so it IS threaded here.
96
+ total, byModel, windowDays, credit: null,
97
+ filters, groupBy, groups, wasted,
98
+ rows: input.rows ? filtered.slice(0, ROWS_CAP) : undefined,
99
+ rowsTruncated: input.rows ? filtered.length > ROWS_CAP : undefined,
100
+ });
101
+
102
+ // Spec §7.3: spend docs are ids/numbers/paths only, by construction — never
103
+ // fenced (contrast amicus_council_stats etc., which DO fence because they
104
+ // summarize model-raised prose).
105
+ return { content: [{ type: 'text', text: JSON.stringify(doc) }] };
106
+ }
107
+
108
+ /**
109
+ * MCP entry point. `project` is the dispatch-resolved cwd (mcp-server.js
110
+ * calls `handlers[name](input, project, server)`); this tool has no
111
+ * `project` input of its own (see module docblock), so it only uses it to
112
+ * expand a literal '.' `filterProject`. The 3rd positional slot is `server`
113
+ * in production (read only for `.dir`/`.now`, which it never has — a no-op)
114
+ * and a `{dir, now}` test seam in tests, mirroring handleSpend's
115
+ * depsOverride shape.
116
+ * @param {object} [input]
117
+ * @param {string} [project]
118
+ * @param {{dir?:string, now?:()=>number}} [testOverride]
119
+ * @returns {Promise<{content:Array, isError?:true}>}
120
+ */
121
+ async function amicus_spend(input, project, testOverride = {}) {
122
+ return buildSpendResult(input || {}, { cwd: project, dir: testOverride.dir, now: testOverride.now });
123
+ }
124
+
125
+ module.exports = { amicus_spend, buildSpendResult };
package/src/mcp-tools.js CHANGED
@@ -11,6 +11,7 @@ const { z } = require('zod');
11
11
  const { formatAliasNames } = require('./utils/config');
12
12
  const { READ_CAP_BYTES } = require('./utils/read-slice');
13
13
  const { GATEWAY_MODES } = require('./utils/model-descriptor');
14
+ const { GROUP_DIMS, ROWS_CAP } = require('./spend-query');
14
15
 
15
16
  /** Zod pattern for safe task IDs (alphanumeric, hyphens, underscores only) */
16
17
  const safeTaskId = z.string().regex(
@@ -360,6 +361,11 @@ function getTools() {
360
361
  'Claude Code session UUID for exact context matching. ' +
361
362
  'Prevents ambiguity when multiple sessions are active in the same project.'
362
363
  ),
364
+ onComplete: z.enum(['mcp-notify']).optional().describe(
365
+ 'Advisory: send an MCP info notification carrying the terminal event doc when the run ' +
366
+ 'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
367
+ 'commands are NOT accepted over MCP.'
368
+ ),
363
369
  project: z.string().optional().describe(
364
370
  'Optional project directory path. Auto-detected from working directory if omitted.'
365
371
  ),
@@ -486,11 +492,44 @@ function getTools() {
486
492
  'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
487
493
  'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
488
494
  ),
495
+ onComplete: z.enum(['mcp-notify']).optional().describe(
496
+ 'Advisory: send an MCP info notification carrying the terminal event doc when the run ' +
497
+ 'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
498
+ 'commands are NOT accepted over MCP.'
499
+ ),
489
500
  project: z.string().optional().describe(
490
501
  'Optional project directory path. Auto-detected from working directory if omitted.'
491
502
  ),
492
503
  },
493
504
  },
505
+ {
506
+ name: 'amicus_spend',
507
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
508
+ description:
509
+ 'Read-only cross-run cost rollup from the spend ledger (mirrors the CLI ' +
510
+ '`amicus spend` query flags). Filter by since/wave/council/project/model/op, keep ' +
511
+ 'only failed (wasted) rows, and group by a dimension. Returns a versioned ' +
512
+ 'spend doc (ids/numbers/paths only, never model-generated text) — never fenced.',
513
+ inputSchema: {
514
+ since: z.string().optional().describe(
515
+ "Only rows from the last N days, as an integer followed by 'd' (e.g. '7d')."
516
+ ),
517
+ wave: z.string().optional().describe('Only rows from this fan-out wave id.'),
518
+ council: z.string().optional().describe('Only rows from this council run id or preset name.'),
519
+ filterProject: z.string().optional().describe(
520
+ 'Only rows whose recorded project dir matches this absolute path (a ledger-row ' +
521
+ 'filter, NOT this tool\'s working-directory selector — the ledger is global, not ' +
522
+ 'per-project). Pass \'.\' to mean the current project dir.'
523
+ ),
524
+ model: z.string().optional().describe('Only rows whose model id starts with this.'),
525
+ op: z.enum(['start', 'continue', 'resume', 'leg']).optional().describe('Only rows for this operation.'),
526
+ failed: z.boolean().optional().describe('Only non-complete (wasted) rows.'),
527
+ groupBy: z.enum(GROUP_DIMS).optional().describe(
528
+ `Group the rollup by this dimension (default 'model'). One of: ${GROUP_DIMS.join(', ')}.`
529
+ ),
530
+ rows: z.boolean().optional().describe(`Include matching raw rows in the result, capped at ${ROWS_CAP}.`),
531
+ },
532
+ },
494
533
  {
495
534
  name: 'amicus_guide',
496
535
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
package/src/mcp-wait.js CHANGED
@@ -19,6 +19,7 @@
19
19
  'use strict';
20
20
 
21
21
  const { versionWarning } = require('./utils/version-info');
22
+ const { consumeMcpNotify, buildNotifyPayload } = require('./mcp-notify');
22
23
 
23
24
  const DEFAULT_WAIT_MS = Number(process.env.AMICUS_WAIT_DEFAULT_MS) || 50000;
24
25
  const MAX_WAIT_MS = Number(process.env.AMICUS_WAIT_MAX_MS) || 110000;
@@ -98,8 +99,12 @@ function buildWaitResult(snapshot, timedOut, waitedMs) {
98
99
  * Wait for a session/wave to reach a terminal state, or time out.
99
100
  * @param {{taskId?:string, waveId?:string, timeoutMs?:number, project?:string}} input
100
101
  * @param {string} project resolved project dir
101
- * @param {{statusFn:Function, sleep?:Function, now?:Function, pollIntervalMs?:number}} deps
102
+ * @param {{statusFn:Function, sleep?:Function, now?:Function, pollIntervalMs?:number, notify?:Function}} deps
102
103
  * statusFn(input, project) must be the amicus_status handler (or compatible).
104
+ * notify(payload), if given, is called at most once per run — only when the
105
+ * run requested it via requestMcpNotify (Task 15, spec §5.3) — right before
106
+ * returning a terminal result. Best-effort: a notify() throw is swallowed
107
+ * and never affects the wait result.
103
108
  * @returns {Promise<object>} MCP tool result
104
109
  */
105
110
  async function runWait(input, project, deps) {
@@ -143,7 +148,28 @@ async function runWait(input, project, deps) {
143
148
 
144
149
  if (snapshot) {
145
150
  lastSnapshot = snapshot;
146
- if (isTerminalSnapshot(snapshot)) { return buildWaitResult(snapshot, false, now() - started); }
151
+ if (isTerminalSnapshot(snapshot)) {
152
+ // Task 15 (spec §5.3): best-effort mcp-notify delivery. This is the
153
+ // ONE place that sees a fanout/council-run reach terminal state (they
154
+ // spawn a detached CLI child and return 'running' immediately, so
155
+ // there is no in-process finalize to notify from). consumeMcpNotify
156
+ // gives once-semantics; a notify() throw is swallowed and never
157
+ // changes the wait result below. Consume FIRST (unconditionally on a
158
+ // requested run) so the registry entry always drains — even if this
159
+ // runWait caller supplied no notify capability, the entry must not
160
+ // leak; the send is then gated on deps.notify.
161
+ if (consumeMcpNotify(taskId) && deps.notify) {
162
+ try {
163
+ const evt = {
164
+ event: snapshot.type === 'council-run' ? 'run-terminal' : 'wave-terminal',
165
+ id: taskId, status: snapshot.status,
166
+ exitCode: (snapshot.exitCode === undefined || snapshot.exitCode === null) ? undefined : snapshot.exitCode,
167
+ };
168
+ deps.notify(buildNotifyPayload(evt));
169
+ } catch { /* advisory; never affects the wait result */ }
170
+ }
171
+ return buildWaitResult(snapshot, false, now() - started);
172
+ }
147
173
  }
148
174
 
149
175
  const remaining = deadline - now();