@adaptic/maestro 1.8.3 → 1.9.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 (43) hide show
  1. package/.claude/settings.json +11 -0
  2. package/agents/engineering-oversight/agent.md +44 -0
  3. package/agents/github-operator/agent.md +38 -0
  4. package/agents/inbox-processor/agent.md +39 -0
  5. package/bin/maestro.mjs +302 -4
  6. package/framework-features.json +107 -0
  7. package/lib/feature-init.mjs +297 -0
  8. package/package.json +5 -2
  9. package/scaffold/config/known-agents.json +57 -8
  10. package/scripts/cost/track-claude-usage.mjs +154 -0
  11. package/scripts/daemon/cadence-consumer.mjs +73 -2
  12. package/scripts/decisions/capture-decision.mjs +116 -0
  13. package/scripts/emergency-stop.sh +56 -19
  14. package/scripts/hooks/session-start-banner.sh +79 -0
  15. package/scripts/maintenance/backup-to-cloud.sh +124 -0
  16. package/scripts/rag/ingest.mjs +111 -0
  17. package/scripts/rag/search.mjs +119 -0
  18. package/scripts/resume-operations.sh +50 -13
  19. package/scripts/setup/init-backup.mjs +54 -0
  20. package/scripts/setup/init-cadence-bus.mjs +60 -0
  21. package/scripts/setup/init-cost-tracking.mjs +45 -0
  22. package/scripts/setup/init-decision-capture.mjs +66 -0
  23. package/scripts/setup/init-known-agents.mjs +57 -0
  24. package/scripts/setup/init-memory-executive.mjs +45 -0
  25. package/scripts/setup/init-rag.mjs +103 -0
  26. package/scripts/setup/init-session-router.mjs +38 -0
  27. package/workflows/continuous/backlog-executor.yaml +1 -1
  28. package/workflows/continuous/inbound-monitor.yaml +10 -10
  29. package/workflows/daily/applicant-triage.yaml +1 -1
  30. package/workflows/daily/comms-triage.yaml +2 -2
  31. package/workflows/daily/evening-wrap.yaml +1 -1
  32. package/workflows/daily/morning-brief.yaml +1 -1
  33. package/workflows/daily/slack-followup-sweep.yaml +2 -2
  34. package/workflows/event-driven/README.md +5 -5
  35. package/workflows/event-driven/agent-failure-investigation.yaml +1 -1
  36. package/workflows/event-driven/pr-review.yaml +6 -3
  37. package/workflows/monthly/board-readiness.yaml +1 -1
  38. package/workflows/quarterly/strategic-scenario-analysis.yaml +1 -1
  39. package/workflows/session-protocol.md +7 -7
  40. package/workflows/weekly/engineering-health.yaml +1 -1
  41. package/workflows/weekly/hiring-review.yaml +1 -1
  42. package/workflows/weekly/rollup-pipeline-review.yaml +1 -1
  43. package/workflows/weekly/strategic-memo.yaml +1 -1
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Maestro — Feature Init Tracking
3
+ *
4
+ * Pairs the framework's `framework-features.json` (versioned registry) with
5
+ * each agent's `.maestro/features.json` (per-agent installed state) so
6
+ * `maestro upgrade` and `maestro init` can:
7
+ *
8
+ * 1. Detect new framework features the agent has not yet initialised.
9
+ * 2. Auto-run low-risk init steps (mkdir, scaffold, copy).
10
+ * 3. Defer init steps that need user input (RAG repo selection, backup
11
+ * credentials) onto a `pending` queue.
12
+ * 4. Surface the pending queue via a SessionStart hook banner so the
13
+ * operator (or Claude) sees "run `maestro init` to complete setup"
14
+ * every time a Claude Code session opens in the agent's directory.
15
+ *
16
+ * Storage:
17
+ * ~/maestro/framework-features.json ← framework SOT
18
+ * ~/<agent>/.maestro/features.json ← agent's installed state
19
+ *
20
+ * Agent file shape:
21
+ * {
22
+ * "framework_version": "1.9.0",
23
+ * "schema_version": "1",
24
+ * "last_upgrade": "2026-05-12T10:00:00Z",
25
+ * "initialized": {
26
+ * "<feature-name>": { "version": "1", "initialized_at": "..." }
27
+ * },
28
+ * "pending": [
29
+ * { "feature": "...", "added_at": "...", "version": "1" }
30
+ * ]
31
+ * }
32
+ *
33
+ * The library is dep-free (only node:fs / node:path / node:child_process)
34
+ * and exits cleanly on missing files — fresh agents that haven't been
35
+ * upgraded yet simply have no `.maestro/features.json`.
36
+ */
37
+
38
+ import {
39
+ existsSync,
40
+ mkdirSync,
41
+ readFileSync,
42
+ writeFileSync,
43
+ } from "node:fs";
44
+ import { join, resolve, dirname } from "node:path";
45
+ import { spawnSync } from "node:child_process";
46
+
47
+ export const FEATURE_REGISTRY_RELATIVE = "framework-features.json";
48
+ export const AGENT_STATE_RELATIVE = ".maestro/features.json";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Path resolution
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function resolveAgentRoot(agentRoot) {
55
+ return resolve(agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd());
56
+ }
57
+
58
+ export function getStatePath(agentRoot) {
59
+ return join(resolveAgentRoot(agentRoot), AGENT_STATE_RELATIVE);
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Registry / state loaders
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Load the framework feature registry. Pass the absolute path to
68
+ * `framework-features.json` (typically resolved relative to the maestro
69
+ * CLI entry point). Returns the parsed registry or throws if missing.
70
+ */
71
+ export function loadRegistry(registryPath) {
72
+ if (!existsSync(registryPath)) {
73
+ throw new Error(`framework-features.json not found at ${registryPath}`);
74
+ }
75
+ try {
76
+ return JSON.parse(readFileSync(registryPath, "utf-8"));
77
+ } catch (err) {
78
+ throw new Error(`failed to parse framework-features.json: ${err.message}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Load the per-agent install state. Returns a fresh empty state if the
84
+ * file doesn't exist yet — callers should treat that as "every feature is
85
+ * pending init for the first time".
86
+ */
87
+ export function loadAgentState(agentRoot) {
88
+ const statePath = getStatePath(agentRoot);
89
+ if (!existsSync(statePath)) {
90
+ return {
91
+ framework_version: null,
92
+ schema_version: "1",
93
+ last_upgrade: null,
94
+ initialized: {},
95
+ pending: [],
96
+ };
97
+ }
98
+ try {
99
+ const raw = JSON.parse(readFileSync(statePath, "utf-8"));
100
+ // Defensive defaults so partially-written state still works.
101
+ raw.initialized = raw.initialized || {};
102
+ raw.pending = Array.isArray(raw.pending) ? raw.pending : [];
103
+ raw.schema_version = raw.schema_version || "1";
104
+ return raw;
105
+ } catch {
106
+ return {
107
+ framework_version: null,
108
+ schema_version: "1",
109
+ last_upgrade: null,
110
+ initialized: {},
111
+ pending: [],
112
+ };
113
+ }
114
+ }
115
+
116
+ export function saveAgentState(agentRoot, state) {
117
+ const statePath = getStatePath(agentRoot);
118
+ mkdirSync(dirname(statePath), { recursive: true });
119
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Diff between registry and agent state
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Return a list of features that need initialisation. A feature is "needs
128
+ * init" when the registry's `version` is strictly newer than the agent's
129
+ * recorded version, OR when the agent has no entry at all.
130
+ *
131
+ * Returns: [{ name, version, definition, status: "new" | "upgraded" }]
132
+ */
133
+ export function diffFeatures(registry, agentState) {
134
+ const out = [];
135
+ const installed = agentState.initialized || {};
136
+ for (const [name, def] of Object.entries(registry.features || {})) {
137
+ const current = installed[name];
138
+ if (!current) {
139
+ out.push({ name, version: def.version, definition: def, status: "new" });
140
+ continue;
141
+ }
142
+ if (String(current.version) !== String(def.version)) {
143
+ out.push({ name, version: def.version, definition: def, status: "upgraded" });
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Init runner
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Reconcile the agent's state with the registry. For each diffed feature:
155
+ * - if `init.auto === true` and `runAuto`, execute init.command (spawn
156
+ * /bin/bash -lc so wrappers / npx scripts resolve correctly).
157
+ * - else, add to pending list.
158
+ *
159
+ * Options:
160
+ * maestroRoot absolute path to ~/maestro (used to resolve init scripts)
161
+ * agentRoot absolute path to the agent's repo
162
+ * registry parsed registry (loaded by loadRegistry)
163
+ * runAuto boolean; default true. Set false during dry-run/plan modes.
164
+ * logger optional fn({ stage, feature, status, message })
165
+ *
166
+ * Returns: { ranAuto: [...], pending: [...], failed: [...] }
167
+ */
168
+ export function reconcileFeatures({ maestroRoot, agentRoot, registry, runAuto = true, logger } = {}) {
169
+ const root = resolveAgentRoot(agentRoot);
170
+ const state = loadAgentState(root);
171
+ const diff = diffFeatures(registry, state);
172
+ const result = { ranAuto: [], pending: [], failed: [] };
173
+ const log = logger || (() => {});
174
+ const now = new Date().toISOString();
175
+
176
+ for (const item of diff) {
177
+ const init = item.definition.init || {};
178
+ const isAuto = init.auto === true;
179
+
180
+ if (isAuto && runAuto) {
181
+ log({ stage: "running", feature: item.name, message: init.description });
182
+ const ran = runInitCommand({ maestroRoot, agentRoot: root, command: init.command });
183
+ if (ran.ok) {
184
+ state.initialized[item.name] = { version: String(item.version), initialized_at: now };
185
+ result.ranAuto.push(item.name);
186
+ log({ stage: "completed", feature: item.name });
187
+ } else {
188
+ result.failed.push({ name: item.name, error: ran.error });
189
+ // Push to pending so user can retry manually.
190
+ pushPending(state, item, now);
191
+ log({ stage: "failed", feature: item.name, message: ran.error });
192
+ }
193
+ } else {
194
+ pushPending(state, item, now);
195
+ result.pending.push(item.name);
196
+ log({ stage: "pending", feature: item.name, message: init.description });
197
+ }
198
+ }
199
+
200
+ // After reconciliation, prune any pending entries that are now
201
+ // initialised (e.g. user ran the init manually).
202
+ state.pending = (state.pending || []).filter((p) => !state.initialized[p.feature]);
203
+
204
+ state.framework_version = registry.framework_version || state.framework_version;
205
+ state.last_upgrade = now;
206
+ saveAgentState(root, state);
207
+ return result;
208
+ }
209
+
210
+ function pushPending(state, item, now) {
211
+ const existing = (state.pending || []).find((p) => p.feature === item.name);
212
+ if (existing) {
213
+ existing.version = String(item.version);
214
+ existing.added_at = existing.added_at || now;
215
+ return;
216
+ }
217
+ state.pending = state.pending || [];
218
+ state.pending.push({
219
+ feature: item.name,
220
+ version: String(item.version),
221
+ added_at: now,
222
+ title: item.definition.title || item.name,
223
+ description: item.definition.init?.description || "",
224
+ command: item.definition.init?.command || "",
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Run an init command in the agent repo. Accepts simple shell strings
230
+ * (executed via /bin/bash -lc) so init scripts can rely on PATH having
231
+ * homebrew/nvm bins. Always runs with cwd = agentRoot and inherits the
232
+ * caller's env plus AGENT_ROOT.
233
+ */
234
+ function runInitCommand({ maestroRoot, agentRoot, command }) {
235
+ if (!command || command === "true") return { ok: true };
236
+ // If the command names a path that exists relative to maestroRoot,
237
+ // resolve to its absolute path so the init script doesn't depend on
238
+ // the agent having a copy of the script already.
239
+ const parts = command.split(/\s+/);
240
+ // Support: `node scripts/setup/init-xxx.mjs`
241
+ if (parts[0] === "node" && parts[1]) {
242
+ const candidateAgent = join(agentRoot, parts[1]);
243
+ const candidateFramework = maestroRoot ? join(maestroRoot, parts[1]) : null;
244
+ let scriptPath = null;
245
+ if (existsSync(candidateAgent)) scriptPath = candidateAgent;
246
+ else if (candidateFramework && existsSync(candidateFramework)) scriptPath = candidateFramework;
247
+ if (scriptPath) {
248
+ const args = [scriptPath, ...parts.slice(2)];
249
+ const r = spawnSync(process.execPath, args, {
250
+ cwd: agentRoot,
251
+ env: { ...process.env, AGENT_ROOT: agentRoot, AGENT_DIR: agentRoot, MAESTRO_ROOT: maestroRoot || "" },
252
+ encoding: "utf-8",
253
+ });
254
+ if (r.status === 0) return { ok: true, stdout: r.stdout };
255
+ return { ok: false, error: (r.stderr || `exit ${r.status}`).trim() };
256
+ }
257
+ return { ok: false, error: `init script not found: ${parts[1]}` };
258
+ }
259
+ // Generic shell fallback.
260
+ const r = spawnSync("/bin/bash", ["-lc", command], {
261
+ cwd: agentRoot,
262
+ env: { ...process.env, AGENT_ROOT: agentRoot, AGENT_DIR: agentRoot, MAESTRO_ROOT: maestroRoot || "" },
263
+ encoding: "utf-8",
264
+ });
265
+ if (r.status === 0) return { ok: true, stdout: r.stdout };
266
+ return { ok: false, error: (r.stderr || `exit ${r.status}`).trim() };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Pending queue accessors (used by banner + `maestro init`)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export function listPending(agentRoot) {
274
+ const state = loadAgentState(agentRoot);
275
+ return state.pending || [];
276
+ }
277
+
278
+ export function markInitialised(agentRoot, featureName, version) {
279
+ const state = loadAgentState(agentRoot);
280
+ state.initialized = state.initialized || {};
281
+ state.initialized[featureName] = {
282
+ version: String(version),
283
+ initialized_at: new Date().toISOString(),
284
+ };
285
+ state.pending = (state.pending || []).filter((p) => p.feature !== featureName);
286
+ saveAgentState(agentRoot, state);
287
+ }
288
+
289
+ export function bannerSnapshot(agentRoot) {
290
+ const state = loadAgentState(agentRoot);
291
+ return {
292
+ pending_count: (state.pending || []).length,
293
+ pending: state.pending || [],
294
+ last_upgrade: state.last_upgrade,
295
+ framework_version: state.framework_version,
296
+ };
297
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,9 @@
12
12
  "./executor": "./lib/action-executor.js",
13
13
  "./singleton": "./lib/singleton.js",
14
14
  "./cadence-bus": "./lib/cadence-bus.mjs",
15
+ "./feature-init": "./lib/feature-init.mjs",
15
16
  "./tts": "./lib/tts.mjs",
17
+ "./framework-features.json": "./framework-features.json",
16
18
  "./package.json": "./package.json"
17
19
  },
18
20
  "files": [
@@ -35,7 +37,8 @@
35
37
  ".claude/settings.json",
36
38
  ".env.example",
37
39
  ".gitignore",
38
- "README.md"
40
+ "README.md",
41
+ "framework-features.json"
39
42
  ],
40
43
  "publishConfig": {
41
44
  "access": "public",
@@ -1,35 +1,84 @@
1
1
  {
2
- "_comment": "Registry of all AI agents in the organisation. Used by the daemon classifier to prevent cross-agent message interception — when a message @-mentions a specific agent, only that agent's daemon should respond. Each agent repo should have its own copy of this file. Update when agents are added or removed.",
2
+ "$schema": "https://adaptic.ai/schemas/maestro/known-agents.v1.json",
3
+ "schema_version": "1",
4
+ "_comment": "Registry of all AI agents in the organisation. Read by scripts/daemon/classifier.mjs to prevent cross-agent message interception — when a message @-mentions a specific agent's name or Slack ID, only that agent's daemon should respond. Each agent repo holds its own copy of this file. Edit when peer agents are added/removed. The framework's scaffolded version reflects the current Adaptic roster; agents may extend with peer agents specific to their own scope.",
3
5
  "agents": [
4
6
  {
5
7
  "name": "Sophie",
8
+ "fullName": "Sophie Nguyen",
6
9
  "slackId": "U099N1JFPRQ",
7
10
  "role": "Chief of Staff",
8
11
  "repo": "sophie-ai"
9
12
  },
10
13
  {
11
14
  "name": "Ravi",
15
+ "fullName": "Ravi Patel",
12
16
  "slackId": "U099N1JE0LA",
13
- "role": "CTO",
17
+ "role": "VP, AI Models & Learning Systems",
14
18
  "repo": "ravi-ai"
15
19
  },
20
+ {
21
+ "name": "Jacob",
22
+ "fullName": "Jacob Stein",
23
+ "slackId": "U099N19NXT4",
24
+ "role": "Chief AI Scientist",
25
+ "repo": "jacob-ai"
26
+ },
16
27
  {
17
28
  "name": "Lucas",
29
+ "fullName": "Lucas Carter",
18
30
  "slackId": "U099N17LVCJ",
19
31
  "role": "Head of Legal Ops",
20
32
  "repo": "lucas-ai"
21
33
  },
22
- {
23
- "name": "Jacob",
24
- "slackId": "U099N19NXT4",
25
- "role": "Head of Engineering",
26
- "repo": "jacob-ai"
27
- },
28
34
  {
29
35
  "name": "Hessa",
36
+ "fullName": "Hessa Mohammed",
30
37
  "slackId": "U0ALUES7ERY",
31
38
  "role": "Head of AI Research",
32
39
  "repo": "hessa-surface"
40
+ },
41
+ {
42
+ "name": "Isla",
43
+ "fullName": "Isla Brennan",
44
+ "slackId": "",
45
+ "role": "Head of Engineering",
46
+ "repo": "isla-ai"
47
+ },
48
+ {
49
+ "name": "Nadia",
50
+ "fullName": "Nadia Al-Saadi",
51
+ "slackId": "",
52
+ "role": "Head of Compliance",
53
+ "repo": "nadia-ai"
54
+ },
55
+ {
56
+ "name": "Luca",
57
+ "fullName": "Luca Romano",
58
+ "slackId": "",
59
+ "role": "Head of Product",
60
+ "repo": "luca-ai"
61
+ },
62
+ {
63
+ "name": "Amara",
64
+ "fullName": "Amara Okonkwo",
65
+ "slackId": "",
66
+ "role": "Head of Investor Relations",
67
+ "repo": "amara-ai"
68
+ },
69
+ {
70
+ "name": "Kai",
71
+ "fullName": "Kai Tanaka",
72
+ "slackId": "",
73
+ "role": "Head of Fund Operations",
74
+ "repo": "kai-ai"
75
+ },
76
+ {
77
+ "name": "Rowan",
78
+ "fullName": "Rowan Mitchell",
79
+ "slackId": "",
80
+ "role": "Head of Quant Engineering",
81
+ "repo": "rowan-ai"
33
82
  }
34
83
  ]
35
84
  }
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/cost/track-claude-usage.mjs — Lightweight cost ledger for
4
+ * Claude Code sessions spawned by the cadence consumer and dispatcher.
5
+ *
6
+ * Two modes:
7
+ *
8
+ * 1. `record` — Append a per-session row to state/cost-tracking/<date>.jsonl.
9
+ * Called by the cadence consumer after every spawn.
10
+ *
11
+ * node scripts/cost/track-claude-usage.mjs record \
12
+ * --cadence inbox-processor --model sonnet --duration-ms 12345 \
13
+ * --input-tokens 1500 --output-tokens 320 --exit 0
14
+ *
15
+ * 2. `summarise` — Rollup the last N days into state/dashboards/cost-summary.yaml.
16
+ * Called daily (cadence: nightly-cost-rollup) and on demand.
17
+ *
18
+ * node scripts/cost/track-claude-usage.mjs summarise --days 7
19
+ *
20
+ * Token-cost estimation uses a static price table (./pricing.json if present,
21
+ * otherwise sensible defaults). Numbers are estimates — the source of truth
22
+ * for billing is the Anthropic console. Goal here is local observability,
23
+ * not exact accounting.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
27
+ import { join, resolve, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+ const AGENT_DIR = process.env.AGENT_ROOT || process.env.AGENT_DIR || resolve(__dirname, "..", "..");
32
+ const LEDGER_DIR = join(AGENT_DIR, "state/cost-tracking");
33
+
34
+ // Default pricing (USD per 1K tokens). Update when Anthropic changes prices.
35
+ const DEFAULT_PRICES = {
36
+ opus: { input: 0.015, output: 0.075 },
37
+ sonnet: { input: 0.003, output: 0.015 },
38
+ haiku: { input: 0.0008, output: 0.004 },
39
+ };
40
+ const PRICING_FILE = join(__dirname, "pricing.json");
41
+ function loadPrices() {
42
+ if (!existsSync(PRICING_FILE)) return DEFAULT_PRICES;
43
+ try { return { ...DEFAULT_PRICES, ...JSON.parse(readFileSync(PRICING_FILE, "utf-8")) }; }
44
+ catch { return DEFAULT_PRICES; }
45
+ }
46
+
47
+ function todayUtc() { return new Date().toISOString().slice(0, 10); }
48
+
49
+ function parseFlags(argv) {
50
+ const flags = {};
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a.startsWith("--")) {
54
+ const key = a.slice(2);
55
+ if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags[key] = argv[++i];
56
+ else flags[key] = "true";
57
+ }
58
+ }
59
+ return flags;
60
+ }
61
+
62
+ function recordCmd(flags) {
63
+ mkdirSync(LEDGER_DIR, { recursive: true });
64
+ const row = {
65
+ ts: new Date().toISOString(),
66
+ cadence: flags.cadence || "unknown",
67
+ source: flags.source || "cadence-consumer",
68
+ model: (flags.model || "sonnet").toLowerCase(),
69
+ duration_ms: Number(flags["duration-ms"] || 0),
70
+ input_tokens: Number(flags["input-tokens"] || 0),
71
+ output_tokens: Number(flags["output-tokens"] || 0),
72
+ exit_code: Number(flags.exit ?? 0),
73
+ };
74
+ const prices = loadPrices()[row.model] || DEFAULT_PRICES.sonnet;
75
+ row.estimated_usd = +((row.input_tokens / 1000) * prices.input + (row.output_tokens / 1000) * prices.output).toFixed(6);
76
+ const file = join(LEDGER_DIR, `${todayUtc()}.jsonl`);
77
+ appendFileSync(file, JSON.stringify(row) + "\n");
78
+ process.stdout.write(JSON.stringify({ ok: true, file, estimated_usd: row.estimated_usd }) + "\n");
79
+ }
80
+
81
+ function summariseCmd(flags) {
82
+ const days = Math.max(1, Number(flags.days || 7));
83
+ if (!existsSync(LEDGER_DIR)) {
84
+ writeSummary({ window_days: days, totals: empty(), by_model: {}, by_cadence: {} });
85
+ return;
86
+ }
87
+ const cutoff = Date.now() - days * 86_400_000;
88
+ const totals = empty();
89
+ const byModel = {};
90
+ const byCadence = {};
91
+ for (const name of readdirSync(LEDGER_DIR)) {
92
+ if (!name.endsWith(".jsonl")) continue;
93
+ let body;
94
+ try { body = readFileSync(join(LEDGER_DIR, name), "utf-8"); } catch { continue; }
95
+ for (const line of body.split("\n")) {
96
+ if (!line.trim()) continue;
97
+ let row;
98
+ try { row = JSON.parse(line); } catch { continue; }
99
+ if (new Date(row.ts).getTime() < cutoff) continue;
100
+ totals.sessions += 1;
101
+ totals.input_tokens += row.input_tokens || 0;
102
+ totals.output_tokens += row.output_tokens || 0;
103
+ totals.estimated_usd = +(totals.estimated_usd + (row.estimated_usd || 0)).toFixed(6);
104
+ byModel[row.model] = byModel[row.model] || empty();
105
+ byModel[row.model].sessions += 1;
106
+ byModel[row.model].input_tokens += row.input_tokens || 0;
107
+ byModel[row.model].output_tokens += row.output_tokens || 0;
108
+ byModel[row.model].estimated_usd = +(byModel[row.model].estimated_usd + (row.estimated_usd || 0)).toFixed(6);
109
+ byCadence[row.cadence] = byCadence[row.cadence] || empty();
110
+ byCadence[row.cadence].sessions += 1;
111
+ byCadence[row.cadence].input_tokens += row.input_tokens || 0;
112
+ byCadence[row.cadence].output_tokens += row.output_tokens || 0;
113
+ byCadence[row.cadence].estimated_usd = +(byCadence[row.cadence].estimated_usd + (row.estimated_usd || 0)).toFixed(6);
114
+ }
115
+ }
116
+ writeSummary({ window_days: days, totals, by_model: byModel, by_cadence: byCadence });
117
+ }
118
+
119
+ function empty() { return { sessions: 0, input_tokens: 0, output_tokens: 0, estimated_usd: 0 }; }
120
+
121
+ function writeSummary(payload) {
122
+ const out = {
123
+ generated: new Date().toISOString(),
124
+ ...payload,
125
+ };
126
+ mkdirSync(join(AGENT_DIR, "state/dashboards"), { recursive: true });
127
+ const yaml = toYaml(out);
128
+ writeFileSync(join(AGENT_DIR, "state/dashboards/cost-summary.yaml"), yaml);
129
+ process.stdout.write(JSON.stringify({ ok: true, ...out }, null, 2) + "\n");
130
+ }
131
+
132
+ // Tiny YAML writer (good enough for cost-summary shape).
133
+ function toYaml(obj, indent = 0) {
134
+ const pad = " ".repeat(indent);
135
+ if (obj === null) return "null";
136
+ if (typeof obj === "string") return `"${obj.replace(/"/g, '\\"')}"`;
137
+ if (typeof obj !== "object") return String(obj);
138
+ if (Array.isArray(obj)) {
139
+ if (obj.length === 0) return "[]";
140
+ return "\n" + obj.map((v) => `${pad}- ${toYaml(v, indent + 1).trimStart()}`).join("\n");
141
+ }
142
+ const keys = Object.keys(obj);
143
+ if (keys.length === 0) return "{}";
144
+ return "\n" + keys.map((k) => `${pad}${k}:${typeof obj[k] === "object" && obj[k] !== null ? toYaml(obj[k], indent + 1) : " " + toYaml(obj[k], indent + 1)}`).join("\n");
145
+ }
146
+
147
+ const cmd = process.argv[2];
148
+ const flags = parseFlags(process.argv.slice(3));
149
+ if (cmd === "record") recordCmd(flags);
150
+ else if (cmd === "summarise" || cmd === "summarize") summariseCmd(flags);
151
+ else {
152
+ process.stderr.write(`usage: ${process.argv[1]} <record|summarise> [flags]\n`);
153
+ process.exit(2);
154
+ }
@@ -46,6 +46,7 @@
46
46
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
47
47
  import { join } from "node:path";
48
48
  import { spawn } from "node:child_process";
49
+ import { homedir } from "node:os";
49
50
 
50
51
  import {
51
52
  ensureBusDirs,
@@ -92,6 +93,40 @@ function defaultLogger(entry) {
92
93
  }
93
94
  }
94
95
 
96
+ /**
97
+ * Resolve an absolute path to the Claude CLI. launchd's bare environment
98
+ * does NOT include /Users/<u>/.local/bin or homebrew on PATH, so a plain
99
+ * `spawn('claude', …)` fails with ENOENT — which is exactly what was
100
+ * stuck in ravi-ai's DLQ. This resolver returns the first existing
101
+ * candidate among:
102
+ *
103
+ * 1. $CLAUDE_BIN env var (if set + executable)
104
+ * 2. ~/.local/bin/claude (default Claude Code install path)
105
+ * 3. /opt/homebrew/bin/claude (homebrew on Apple Silicon)
106
+ * 4. /usr/local/bin/claude (homebrew on Intel)
107
+ * 5. /usr/bin/claude
108
+ *
109
+ * Falls back to bare "claude" so the spawn's own error stays informative
110
+ * when nothing is found.
111
+ */
112
+ let _resolvedClaude = null;
113
+ function resolveClaudeBin() {
114
+ if (_resolvedClaude) return _resolvedClaude;
115
+ const envOverride = process.env.CLAUDE_BIN;
116
+ const candidates = [
117
+ envOverride,
118
+ join(homedir(), ".local/bin/claude"),
119
+ "/opt/homebrew/bin/claude",
120
+ "/usr/local/bin/claude",
121
+ "/usr/bin/claude",
122
+ ].filter(Boolean);
123
+ for (const c of candidates) {
124
+ if (existsSync(c)) { _resolvedClaude = c; return c; }
125
+ }
126
+ _resolvedClaude = "claude"; // last-resort; spawn will report ENOENT
127
+ return _resolvedClaude;
128
+ }
129
+
95
130
  /**
96
131
  * Spawn a sub-session running the cadence's trigger prompt and resolve
97
132
  * with { exit_code, durationMs }. Reads the prompt at call time so the
@@ -111,9 +146,27 @@ function realSpawnSession({ agentRoot, cadence, promptPath, timeoutMs, log }) {
111
146
  return;
112
147
  }
113
148
 
114
- const bin = process.env.CLAUDE_BIN || "claude";
149
+ const bin = resolveClaudeBin();
115
150
  const args = ["--print", "--dangerously-skip-permissions", body];
116
- const env = { ...process.env, AGENT_ROOT: agentRoot, AGENT_DIR: agentRoot };
151
+ // Augment PATH so any tool the subsession invokes (jq, node, etc.)
152
+ // can still be found. launchd's bare env strips /opt/homebrew/bin etc.
153
+ const augmentedPath = [
154
+ process.env.PATH || "",
155
+ `${homedir()}/.local/bin`,
156
+ "/opt/homebrew/bin",
157
+ "/opt/homebrew/sbin",
158
+ "/usr/local/bin",
159
+ "/usr/bin",
160
+ "/bin",
161
+ "/usr/sbin",
162
+ "/sbin",
163
+ ].filter(Boolean).join(":");
164
+ const env = {
165
+ ...process.env,
166
+ AGENT_ROOT: agentRoot,
167
+ AGENT_DIR: agentRoot,
168
+ PATH: augmentedPath,
169
+ };
117
170
  const started = Date.now();
118
171
 
119
172
  log({ level: "info", stage: "subsession_spawn", cadence, bin });
@@ -137,6 +190,24 @@ function realSpawnSession({ agentRoot, cadence, promptPath, timeoutMs, log }) {
137
190
  clearTimeout(timer);
138
191
  const durationMs = Date.now() - started;
139
192
  const exit_code = typeof code === "number" ? code : (signal ? -1 : -5);
193
+ // Record cost-ledger row. Token counts are 0 until we parse the
194
+ // session's JSON output; for now exit-code + duration are enough
195
+ // to spot pathological retry loops.
196
+ try {
197
+ const trackerPath = join(agentRoot, "scripts/cost/track-claude-usage.mjs");
198
+ if (existsSync(trackerPath)) {
199
+ spawn(process.execPath, [
200
+ trackerPath, "record",
201
+ "--cadence", cadence,
202
+ "--source", "cadence-consumer",
203
+ "--model", "sonnet",
204
+ "--duration-ms", String(durationMs),
205
+ "--input-tokens", "0",
206
+ "--output-tokens", "0",
207
+ "--exit", String(exit_code),
208
+ ], { stdio: "ignore", env: { ...env, AGENT_ROOT: agentRoot } }).unref();
209
+ }
210
+ } catch { /* cost tracking is best-effort */ }
140
211
  resolveOut({
141
212
  ok: exit_code === 0,
142
213
  exit_code,