@adaptic/maestro 1.8.4 → 1.9.1
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/settings.json +11 -0
- package/agents/engineering-oversight/agent.md +44 -0
- package/agents/github-operator/agent.md +38 -0
- package/agents/inbox-processor/agent.md +39 -0
- package/bin/maestro.mjs +302 -4
- package/framework-features.json +107 -0
- package/lib/feature-init.mjs +297 -0
- package/package.json +5 -2
- package/scaffold/config/known-agents.json +57 -8
- package/scripts/cost/track-claude-usage.mjs +154 -0
- package/scripts/daemon/cadence-consumer.mjs +287 -12
- package/scripts/daemon/cadence-consumer.test.mjs +69 -0
- package/scripts/decisions/capture-decision.mjs +116 -0
- package/scripts/emergency-stop.sh +56 -19
- package/scripts/hooks/session-start-banner.sh +79 -0
- package/scripts/maintenance/backup-to-cloud.sh +124 -0
- package/scripts/rag/ingest.mjs +111 -0
- package/scripts/rag/search.mjs +119 -0
- package/scripts/resume-operations.sh +50 -13
- package/scripts/setup/init-backup.mjs +54 -0
- package/scripts/setup/init-cadence-bus.mjs +60 -0
- package/scripts/setup/init-cost-tracking.mjs +45 -0
- package/scripts/setup/init-decision-capture.mjs +66 -0
- package/scripts/setup/init-known-agents.mjs +57 -0
- package/scripts/setup/init-memory-executive.mjs +45 -0
- package/scripts/setup/init-rag.mjs +103 -0
- package/scripts/setup/init-session-router.mjs +38 -0
|
@@ -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.
|
|
3
|
+
"version": "1.9.1",
|
|
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
|
-
"
|
|
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": "
|
|
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
|
+
}
|