amicus 4.2.1 → 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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +7 -4
  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/error-classify.js +31 -0
  47. package/src/utils/model-tiers.js +1 -1
  48. package/src/utils/spend-ledger.js +24 -1
@@ -0,0 +1,104 @@
1
+ // src/spend-query.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module spend-query
6
+ * Pure query/rollup helpers for `amicus spend` (spec §7.3 filters/group-by,
7
+ * §6.3/resolved Q6 `wasted`). Split out of src/cli-handlers-spend.js (which
8
+ * re-exports these) to stay under the 300-line size gate — see that file's
9
+ * module docblock. No I/O, no CLI concerns: everything here is rows-in,
10
+ * rows/rollup-out and independently testable.
11
+ */
12
+
13
+ /**
14
+ * Valid `--group-by`/`groupBy` dimensions — the SINGLE source of truth shared
15
+ * by the CLI's validity check (cli-handlers-spend.js), the MCP `amicus_spend`
16
+ * tool's `groupBy` Zod enum (mcp-tools.js), and rowKey()'s switch below. Do
17
+ * NOT hand-copy this array elsewhere: a 7th dimension added here must reach
18
+ * both surfaces automatically, not just the one someone remembered to edit.
19
+ */
20
+ const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day'];
21
+
22
+ /** Cap on rows returned when a caller opts into raw rows (CLI --rows / MCP rows:true). */
23
+ const ROWS_CAP = 1000;
24
+
25
+ function emptyTokens() {
26
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
27
+ }
28
+
29
+ function addTokens(into, tokens) {
30
+ if (!tokens) { return; }
31
+ for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
32
+ }
33
+
34
+ /** Pure row filter over the additive attribution fields (spec 7.3). */
35
+ function filterRows(rows, f = {}) {
36
+ const cutoff = (f.since !== undefined && f.since !== null && f.now !== undefined) ? f.now - f.since * 86400000 : null;
37
+ return rows.filter((r) => {
38
+ if (f.wave && r.waveId !== f.wave) { return false; }
39
+ if (f.council && r.councilRunId !== f.council && r.councilName !== f.council) { return false; }
40
+ if (f.project && r.project !== f.project) { return false; }
41
+ if (f.model && !String(r.model || '').startsWith(f.model)) { return false; }
42
+ if (f.op && r.op !== f.op) { return false; }
43
+ if (f.failed && (r.status === 'complete' || !r.status)) { return false; }
44
+ if (cutoff !== null) { const t = Date.parse(r.ts); if (!Number.isFinite(t) || t < cutoff) { return false; } }
45
+ return true;
46
+ });
47
+ }
48
+
49
+ /** dimension -> row key. null/absent -> '(unattributed)'. `day` = the ISO date. */
50
+ function rowKey(row, dimension) {
51
+ switch (dimension) {
52
+ case 'model': return row.model || '(unattributed)';
53
+ case 'wave': return row.waveId || '(unattributed)';
54
+ case 'council': return row.councilRunId || row.councilName || '(unattributed)';
55
+ case 'project': return row.project || '(unattributed)';
56
+ case 'op': return row.op || '(unattributed)';
57
+ case 'day': return typeof row.ts === 'string' ? row.ts.slice(0, 10) : '(unattributed)';
58
+ default: return '(unattributed)';
59
+ }
60
+ }
61
+
62
+ /** Group rows into {key, amount, tokens, runs, sourceMix}, most-expensive first. */
63
+ function groupRows(rows, dimension) {
64
+ const map = new Map();
65
+ for (const r of rows) {
66
+ const key = rowKey(r, dimension);
67
+ if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
68
+ const b = map.get(key);
69
+ b.runs += 1;
70
+ addTokens(b.tokens, r.tokens);
71
+ const cost = r.cost || {};
72
+ if (typeof cost.amount === 'number') { b.amount += cost.amount; }
73
+ const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
74
+ b.sourceMix[src] += 1;
75
+ }
76
+ return [...map.values()].sort((a, b) => b.amount - a.amount);
77
+ }
78
+
79
+ /**
80
+ * Wasted spend = every row with an EXPLICIT non-complete status, bucketed by
81
+ * status (spec 6.3, resolved Q6). A row with status null/absent (pre-v4.3,
82
+ * or any row that never reached a terminal status write) is deliberately
83
+ * EXCLUDED here — not "complete" and not "wasted" — because we cannot know
84
+ * whether that historical run actually failed; counting it would fabricate
85
+ * a failure that was never recorded. Contrast with groupRows(), where a null
86
+ * dimension is a first-class '(unattributed)' bucket (grouping never drops
87
+ * a row); computeWasted intentionally drops it instead.
88
+ */
89
+ function computeWasted(rows) {
90
+ const out = { amount: 0, tokens: emptyTokens(), runs: 0, byStatus: {} };
91
+ for (const r of rows) {
92
+ if (r.status === 'complete' || !r.status) { continue; }
93
+ out.runs += 1;
94
+ addTokens(out.tokens, r.tokens);
95
+ const amt = (r.cost && typeof r.cost.amount === 'number') ? r.cost.amount : 0;
96
+ out.amount += amt;
97
+ if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0 }; }
98
+ out.byStatus[r.status].amount += amt;
99
+ out.byStatus[r.status].runs += 1;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ module.exports = { filterRows, groupRows, computeWasted, emptyTokens, addTokens, GROUP_DIMS, ROWS_CAP };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module error-classify
5
+ * Conservative classification of an OpenCode leg-error string into a trigger
6
+ * class (spec 6.2). Fallback substitution fires ONLY on capacity signals
7
+ * (rate-limit|overload). timeout is excluded (resolved Q3: a slow model on a
8
+ * heavy task is not a capacity signal — --retry-failed covers it); auth /
9
+ * validation never substitute. Misclassification cost is bounded either way:
10
+ * one extra cheaper attempt, or status quo.
11
+ */
12
+
13
+ const RATE_LIMIT = /429|rate ?limit|too many requests|quota|resource exhausted/i;
14
+ const OVERLOAD = /529|503|overload|capacity|server busy|service unavailable/i;
15
+ const AUTH = /401|403|unauthorized|forbidden|invalid api key|authentication/i;
16
+ const TIMEOUT = /timed? ?out|timeout|deadline exceeded/i;
17
+
18
+ /** @param {string} message @returns {'rate-limit'|'overload'|'auth'|'timeout'|'other'} */
19
+ function classifyLegError(message) {
20
+ const m = String(message || '');
21
+ if (RATE_LIMIT.test(m)) { return 'rate-limit'; }
22
+ if (OVERLOAD.test(m)) { return 'overload'; }
23
+ if (AUTH.test(m)) { return 'auth'; }
24
+ if (TIMEOUT.test(m)) { return 'timeout'; }
25
+ return 'other';
26
+ }
27
+
28
+ /** Only capacity signals trigger a cheaper-model substitution. */
29
+ function isRetryable(cls) { return cls === 'rate-limit' || cls === 'overload'; }
30
+
31
+ module.exports = { classifyLegError, isRetryable };
@@ -117,4 +117,4 @@ function resolveTier(vendor, tier, catalog) {
117
117
  return null;
118
118
  }
119
119
 
120
- module.exports = { TIERS, resolveTier };
120
+ module.exports = { TIERS, TIER_ORDER, resolveTier };
@@ -46,9 +46,20 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
46
46
  * @param {string} opts.model resolved model id (or alias, if that's all the caller has)
47
47
  * @param {'headless'|'interactive'|'leg'} opts.mode
48
48
  * @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}}|null} opts.usage
49
+ * @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
50
+ * @param {string} [opts.status] terminal status
51
+ * @param {string} [opts.councilRunId] council run id (additive attribution)
52
+ * @param {string} [opts.councilName] council name (additive attribution)
53
+ * @param {string} [opts.project] project directory (additive attribution)
54
+ * @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
55
+ * @param {number} [opts.attempt] fallback attempt count (omitted if absent)
56
+ * @param {string} [opts.substitutedFor] substituted model (omitted if absent)
57
+ * @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
49
58
  * @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
50
59
  */
51
- function appendSpend({ taskId, waveId, model, mode, usage }, ctx = {}) {
60
+ function appendSpend({ taskId, waveId, model, mode, usage,
61
+ op, status, councilRunId, councilName, project, gateway,
62
+ attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
52
63
  if (!usage) { return; }
53
64
  try {
54
65
  const dir = ctx.dir || getConfigDir();
@@ -62,7 +73,19 @@ function appendSpend({ taskId, waveId, model, mode, usage }, ctx = {}) {
62
73
  mode: mode || null,
63
74
  tokens: usage.tokens || null,
64
75
  cost: usage.cost || null,
76
+ // v4.3 additive attribution (spec 7.1). Nullable dimensions default to
77
+ // null (so a row is always groupable); linkage fields are OMITTED unless
78
+ // present (they only exist on fallback/retry rows).
79
+ op: op || null,
80
+ status: status || null,
81
+ councilRunId: councilRunId || null,
82
+ councilName: councilName || null,
83
+ project: project || null,
84
+ gateway: gateway || null,
65
85
  };
86
+ if (attempt !== undefined) { row.attempt = attempt; }
87
+ if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
88
+ if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
66
89
  fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
67
90
  } catch (e) {
68
91
  logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });