amicus 4.2.0 → 4.3.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +37 -1
  3. package/README.md +8 -5
  4. package/bin/amicus.js +5 -0
  5. package/package.json +1 -1
  6. package/schemas/council-run-live.schema.json +33 -0
  7. package/schemas/event.schema.json +15 -0
  8. package/schemas/progress.schema.json +24 -0
  9. package/schemas/run-live.schema.json +15 -0
  10. package/schemas/spend.schema.json +26 -1
  11. package/schemas/wave-live.schema.json +15 -0
  12. package/src/cli-handlers-council-run.js +61 -5
  13. package/src/cli-handlers-run.js +26 -0
  14. package/src/cli-handlers-spend.js +62 -27
  15. package/src/cli-handlers-watch.js +89 -0
  16. package/src/cli.js +58 -1
  17. package/src/council/run-chair.js +10 -2
  18. package/src/council/run-debate.js +5 -1
  19. package/src/council/run-launch.js +14 -1
  20. package/src/council/run-stages.js +13 -0
  21. package/src/council/run.js +32 -4
  22. package/src/headless.js +9 -1
  23. package/src/mcp-council-awareness.js +46 -1
  24. package/src/mcp-council-run.js +28 -4
  25. package/src/mcp-notify.js +54 -0
  26. package/src/mcp-server.js +51 -1
  27. package/src/mcp-spend.js +125 -0
  28. package/src/mcp-tools.js +39 -0
  29. package/src/mcp-wait.js +28 -2
  30. package/src/observe/events.js +156 -0
  31. package/src/observe/follow.js +26 -0
  32. package/src/observe/live-doc.js +38 -0
  33. package/src/observe/on-complete.js +117 -0
  34. package/src/observe/watch-render.js +149 -0
  35. package/src/sidecar/continue.js +32 -0
  36. package/src/sidecar/fallback-chains.js +65 -0
  37. package/src/sidecar/fanout-leg-fallback.js +189 -0
  38. package/src/sidecar/fanout-leg.js +58 -26
  39. package/src/sidecar/fanout-retry.js +208 -0
  40. package/src/sidecar/fanout-validate.js +42 -4
  41. package/src/sidecar/fanout.js +50 -30
  42. package/src/sidecar/progress.js +5 -0
  43. package/src/sidecar/resume.js +12 -0
  44. package/src/sidecar/start.js +13 -1
  45. package/src/spend-query.js +104 -0
  46. package/src/utils/api-key-store.js +7 -4
  47. package/src/utils/env-loader.js +0 -1
  48. package/src/utils/env-raw-store.js +13 -4
  49. package/src/utils/error-classify.js +31 -0
  50. package/src/utils/model-tiers.js +1 -1
  51. package/src/utils/spend-ledger.js +24 -1
@@ -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();
@@ -0,0 +1,156 @@
1
+ // src/observe/events.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/events
6
+ * Surface B (spec 4.2): the append-only milestone event stream, one
7
+ * events.jsonl per wave dir / council run dir. Single-writer (the owning
8
+ * orchestrator) so ordering is trivially correct; a torn final line on a hard
9
+ * crash is acceptable and skipped by the tail reader (same tradeoff as the two
10
+ * ledgers). appendEvent NEVER throws (spec 8) — emitting an event must never
11
+ * fail a wave/leg. The reader is a poll-stat tail: no fs.watch anywhere
12
+ * (Windows reference platform, spec 3.1).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { logger } = require('../utils/logger');
18
+
19
+ const EVENTS_FILE = 'events.jsonl';
20
+ const EVENTS_SCHEMA_VERSION = 1;
21
+
22
+ /**
23
+ * Append one enveloped event line. Best-effort; swallows all failure.
24
+ * Reserved envelope keys — do not reuse these as payload field names, the
25
+ * stamped value always wins (spread order): schemaVersion, type, event, ts, id.
26
+ * @param {string} dir wave/council-run dir
27
+ * @param {{event:string, id:string}} payload event name + owning id + fields
28
+ */
29
+ function appendEvent(dir, payload) {
30
+ try {
31
+ const { event, id, ...rest } = payload || {};
32
+ const line = JSON.stringify({
33
+ schemaVersion: EVENTS_SCHEMA_VERSION, type: 'event',
34
+ event, ts: new Date().toISOString(), id, ...rest,
35
+ }) + '\n';
36
+ fs.appendFileSync(path.join(dir, EVENTS_FILE), line);
37
+ } catch (e) {
38
+ logger.debug('events append failed (best-effort, run unaffected)', { error: e.message });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create a poll-stat tail over an events file. Returns { poll() } — each call
44
+ * yields the events appended since the previous call (empty on no growth,
45
+ * missing file, or a transient open error). Holds an unterminated tail.
46
+ * @param {string} file absolute path to events.jsonl
47
+ */
48
+ function createEventTail(file) {
49
+ let offset = 0;
50
+ let carry = '';
51
+ return {
52
+ poll() {
53
+ let stat;
54
+ try { stat = fs.statSync(file); }
55
+ catch { return []; } // not-yet-exists / transient -> missed tick
56
+ if (stat.size <= offset) { return []; }
57
+ let chunk;
58
+ try {
59
+ const fd = fs.openSync(file, 'r');
60
+ try {
61
+ const buf = Buffer.alloc(stat.size - offset);
62
+ fs.readSync(fd, buf, 0, buf.length, offset);
63
+ chunk = buf.toString('utf-8');
64
+ } finally { fs.closeSync(fd); }
65
+ } catch { return []; } // EBUSY/EPERM -> missed tick, retry next poll
66
+ offset = stat.size;
67
+ const text = carry + chunk;
68
+ const nl = text.lastIndexOf('\n');
69
+ if (nl === -1) { carry = text; return []; }
70
+ carry = text.slice(nl + 1);
71
+ const out = [];
72
+ for (const line of text.slice(0, nl).split('\n')) {
73
+ if (!line.trim()) { continue; }
74
+ try { out.push(JSON.parse(line)); } catch { /* skip torn/corrupt */ }
75
+ }
76
+ return out;
77
+ },
78
+ };
79
+ }
80
+
81
+ // ---- Milestone emit helpers (Task 7, spec 4.2 vocabulary) ----
82
+ // Centralized here (not in fanout.js / run.js / run-chair.js / run-debate.js)
83
+ // per the v4.3 Task 7 structural decision: those four files sit close to the
84
+ // 300-line hard gate, so every call site below is a thin wrapper over
85
+ // appendEvent — which already never throws — so every helper inherits that
86
+ // never-fails guarantee for free. Keep this module dependency-free (fs + path
87
+ // + logger only); do not import result-schema or anything heavier here.
88
+ //
89
+ // Task 13 dual-sink: every helper takes an OPTIONAL trailing `follow` arg
90
+ // ({onEvent(event)}, from observe/follow.js). appendEvent (disk) stays
91
+ // UNCONDITIONAL; when `follow` is present, the SAME raw event object (pre
92
+ // envelope) is also handed to follow.onEvent — a live stderr mirror, not a
93
+ // durable record. Callers that omit `follow` (every pre-Task-13 call site)
94
+ // get the old behavior unchanged.
95
+
96
+ /** Wave lifecycle start: models resolved (post-routing) + derived leg ids. */
97
+ function emitWaveStarted(waveDir, waveId, models, legIds, follow) {
98
+ const evt = { event: 'wave-started', id: waveId, models, legIds };
99
+ appendEvent(waveDir, evt);
100
+ if (follow) { follow.onEvent(evt); }
101
+ }
102
+
103
+ /** Wave lifecycle end: fires AFTER wave.json is written (ordering guarantee). */
104
+ function emitWaveTerminal(waveDir, waveId, { status, counts, usage, exitCode }, follow) {
105
+ const evt = { event: 'wave-terminal', id: waveId, status, counts, usage, exitCode };
106
+ appendEvent(waveDir, evt);
107
+ if (follow) { follow.onEvent(evt); }
108
+ }
109
+
110
+ /** Leg lifecycle start, into the OWNING wave's events.jsonl (not the leg dir). */
111
+ function emitLegStarted(waveDir, waveId, legId, model, modelInput, follow) {
112
+ const evt = { event: 'leg-started', id: waveId, legId, model, modelInput };
113
+ appendEvent(waveDir, evt);
114
+ if (follow) { follow.onEvent(evt); }
115
+ }
116
+
117
+ /** Leg lifecycle end: fires AFTER the leg metadata patch + ledger append. */
118
+ function emitLegTerminal(waveDir, waveId, legId, { model, status, durationMs, usage }, follow) {
119
+ const evt = { event: 'leg-terminal', id: waveId, legId, model, status, durationMs, usage };
120
+ appendEvent(waveDir, evt);
121
+ if (follow) { follow.onEvent(evt); }
122
+ }
123
+
124
+ /** Council run lifecycle start. */
125
+ function emitRunStarted(runDir, runId, { bench, chair }, follow) {
126
+ const evt = { event: 'run-started', id: runId, bench, chair };
127
+ appendEvent(runDir, evt);
128
+ if (follow) { follow.onEvent(evt); }
129
+ }
130
+
131
+ /** Entering a council stage (stage1, stage2, chair, debate-defense, debate-revote, tally, verdict, ...). */
132
+ function emitStageStarted(runDir, runId, stage, waveId, follow) {
133
+ const evt = { event: 'stage-started', id: runId, stage, waveId };
134
+ appendEvent(runDir, evt);
135
+ if (follow) { follow.onEvent(evt); }
136
+ }
137
+
138
+ /** Leaving a council stage (status: complete/error/skipped/aborted). */
139
+ function emitStageTerminal(runDir, runId, stage, status, waveId, follow) {
140
+ const evt = { event: 'stage-terminal', id: runId, stage, status, waveId };
141
+ appendEvent(runDir, evt);
142
+ if (follow) { follow.onEvent(evt); }
143
+ }
144
+
145
+ /** Council run lifecycle end: fires AFTER the terminal run.json checkpoint. */
146
+ function emitRunTerminal(runDir, runId, status, exitCode, follow) {
147
+ const evt = { event: 'run-terminal', id: runId, status, exitCode };
148
+ appendEvent(runDir, evt);
149
+ if (follow) { follow.onEvent(evt); }
150
+ }
151
+
152
+ module.exports = {
153
+ appendEvent, createEventTail, EVENTS_FILE, EVENTS_SCHEMA_VERSION,
154
+ emitWaveStarted, emitWaveTerminal, emitLegStarted, emitLegTerminal,
155
+ emitRunStarted, emitStageStarted, emitStageTerminal, emitRunTerminal,
156
+ };
@@ -0,0 +1,26 @@
1
+ // src/observe/follow.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/follow
6
+ * --follow (spec 5.2): stream a run's OWN events as they are emitted, to
7
+ * stderr — no tailing, the orchestrator is the emitter. json mode -> NDJSON
8
+ * event lines (CI: --json --follow 2>progress.ndjson); human mode -> terse
9
+ * per-event lines replacing the 15 s heartbeat table. stdout contracts stay
10
+ * byte-identical (the --json final doc / human summary are untouched).
11
+ */
12
+
13
+ const { renderPlainLines } = require('./watch-render');
14
+
15
+ function createFollowPrinter({ json, stream } = {}) {
16
+ const out = stream || process.stderr;
17
+ return {
18
+ onEvent(event) {
19
+ if (json) { out.write(JSON.stringify(event) + '\n'); return; }
20
+ const [line] = renderPlainLines([event], null);
21
+ if (line) { out.write(line + '\n'); }
22
+ },
23
+ };
24
+ }
25
+
26
+ module.exports = { createFollowPrinter };
@@ -0,0 +1,38 @@
1
+ // src/observe/live-doc.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/live-doc
6
+ * Surface C (spec 4.3): the composed live doc is the amicus_status rollup with
7
+ * an additive `view:'live'` marker + per-leg usage. This module owns the WAVE +
8
+ * single-session composed shape. (DE-ROT: the COUNCIL composed doc is built in
9
+ * src/mcp-council-awareness.js:buildCouncilStatusPayload, NOT here — Task 9 Step 5
10
+ * marks it live + adds per-stage usage there.) Cost is resolved at READ time from progress.json's raw
11
+ * usage (spec 4.1) so pricing stays current; cost-by-seat NEVER touches a
12
+ * ledger (A8). The `type` is deliberately NOT renamed (resolved Q8) — `view`
13
+ * disambiguates live composed docs from terminal wave.json/run.json.
14
+ */
15
+
16
+ const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
17
+
18
+ const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout']);
19
+
20
+ /** Attach read-time-resolved usage to a leg from its raw progress usage. */
21
+ function enrichLegUsage(leg, progressUsage) {
22
+ if (!progressUsage || !progressUsage.tokens) { return leg; }
23
+ const resolved = resolveUsage({ model: leg.model, usageTotals: progressUsage });
24
+ return { ...leg, usage: { tokens: resolved.tokens, cost: resolved.cost } };
25
+ }
26
+
27
+ /** Stamp view:'live' on a non-terminal composed doc; no-op when terminal. */
28
+ function markLive(doc) {
29
+ if (doc && !TERMINAL.has(doc.status)) { doc.view = 'live'; }
30
+ return doc;
31
+ }
32
+
33
+ /** Sum enriched leg usage into a wave-level {tokens, cost} rollup. */
34
+ function rollupWaveUsage(legs) {
35
+ return sumWaveUsage((legs || []).map((l) => ({ usage: l.usage })));
36
+ }
37
+
38
+ module.exports = { enrichLegUsage, markLive, rollupWaveUsage, TERMINAL };