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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -1
- package/README.md +8 -5
- package/bin/amicus.js +5 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +33 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +24 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/src/cli-handlers-council-run.js +61 -5
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +62 -27
- package/src/cli-handlers-watch.js +89 -0
- package/src/cli.js +58 -1
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +14 -1
- package/src/council/run-stages.js +13 -0
- package/src/council/run.js +32 -4
- package/src/headless.js +9 -1
- package/src/mcp-council-awareness.js +46 -1
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +38 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +149 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +58 -26
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +50 -30
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/spend-query.js +104 -0
- package/src/utils/api-key-store.js +7 -4
- package/src/utils/env-loader.js +0 -1
- package/src/utils/env-raw-store.js +13 -4
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- 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 */
|
package/src/mcp-spend.js
ADDED
|
@@ -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)) {
|
|
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 };
|