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,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 };
@@ -60,6 +60,8 @@ function migrateEnvFileKey(envPath, oldName, newName) {
60
60
  const updated = content.replace(re, `${newName}=`);
61
61
  if (updated !== content) {
62
62
  fs.writeFileSync(envPath, updated, { mode: 0o600 });
63
+ // Re-assert 0600 on the existing secrets file (writeFileSync mode is create-only).
64
+ try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
63
65
  }
64
66
  } catch (_err) {
65
67
  // Best effort
@@ -149,10 +151,11 @@ function saveApiKey(provider, key) {
149
151
  if (!envVar) {
150
152
  return { success: false, error: `Unknown provider: ${provider}` };
151
153
  }
152
- // Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL).
153
- upsertEnvLine(getEnvPath(), envVar, key);
154
- // Also set process.env so the key is immediately available
155
- process.env[envVar] = key;
154
+ // Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL;
155
+ // strips CR/LF and returns the cleaned value).
156
+ const clean = upsertEnvLine(getEnvPath(), envVar, key);
157
+ // Also set process.env so the key is immediately available (sanitized to match disk)
158
+ process.env[envVar] = clean;
156
159
  return { success: true };
157
160
  }
158
161
 
@@ -53,7 +53,6 @@ function loadCredentials() {
53
53
  // process.env (same never-overwrite rule) so the engine's {env:VAR} finds it.
54
54
  try {
55
55
  const { getLocalProviders } = require('./local-providers');
56
- const fileEntries = loadEnvEntries();
57
56
  for (const entry of Object.values(getLocalProviders())) {
58
57
  if (entry.apiKeyEnv && !process.env[entry.apiKeyEnv]) {
59
58
  const v = fileEntries.get(entry.apiKeyEnv);
@@ -23,6 +23,10 @@ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
23
23
  * @param {string} value
24
24
  */
25
25
  function upsertEnvLine(envPath, envVar, value) {
26
+ // Strip CR/LF: a newline in the value corrupts the line-based .env (splits it,
27
+ // or bakes a trailing CR into the persisted/served token). Bearer/API-key schemes
28
+ // never contain newlines, so stripping cannot damage a legitimate token.
29
+ const clean = String(value).replace(/[\r\n]/g, '');
26
30
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
27
31
 
28
32
  let lines = [];
@@ -37,7 +41,7 @@ function upsertEnvLine(envPath, envVar, value) {
37
41
  let found = false;
38
42
  for (let i = 0; i < lines.length; i++) {
39
43
  if (lines[i].trim().startsWith(envVar + '=')) {
40
- lines[i] = `${envVar}=${value}`;
44
+ lines[i] = `${envVar}=${clean}`;
41
45
  found = true;
42
46
  break;
43
47
  }
@@ -46,10 +50,14 @@ function upsertEnvLine(envPath, envVar, value) {
46
50
  while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
47
51
  lines.pop();
48
52
  }
49
- lines.push(`${envVar}=${value}`);
53
+ lines.push(`${envVar}=${clean}`);
50
54
  }
51
55
 
52
56
  fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
57
+ // writeFileSync's mode only applies on CREATE; re-assert 0600 so an existing
58
+ // secrets file whose perms drifted is re-tightened. Best-effort (no-op on Windows).
59
+ try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
60
+ return clean;
53
61
  }
54
62
 
55
63
  /**
@@ -68,6 +76,7 @@ function deleteEnvLine(envPath, envVar) {
68
76
  lines.pop();
69
77
  }
70
78
  fs.writeFileSync(envPath, lines.length > 0 ? lines.join('\n') + '\n' : '', { mode: 0o600 });
79
+ try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
71
80
  }
72
81
  } catch (_err) {
73
82
  // Best-effort
@@ -89,8 +98,8 @@ function saveRawEnv(envVar, value) {
89
98
  return { success: false, error: `Invalid env var name: ${envVar}` };
90
99
  }
91
100
  const { getEnvPath } = require('./api-key-store'); // lazy: avoids a load-time cycle
92
- upsertEnvLine(getEnvPath(), envVar, value);
93
- process.env[envVar] = value;
101
+ const clean = upsertEnvLine(getEnvPath(), envVar, value);
102
+ process.env[envVar] = clean; // mirror the sanitized on-disk value
94
103
  return { success: true };
95
104
  }
96
105
 
@@ -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 });