agent-orchestrator-kit 0.5.0 → 0.6.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/CHANGELOG.md +13 -0
- package/README.md +16 -7
- package/bin/agent-orchestrator.js +469 -23
- package/bin/spend-collect.js +414 -0
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +7 -7
- package/templates/.agents/rules/session-handoff.mdc +1 -1
- package/templates/.agents/skills/agent-orchestration/SKILL.md +2 -2
- package/templates/.agents/subagents/session-handoff.md +1 -1
- package/templates/scripts/cursor-spend-hook.cjs +74 -0
|
@@ -5,6 +5,7 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
|
|
|
5
5
|
import { join, dirname, basename, resolve } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
+
import { collectSpend } from './spend-collect.js';
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const KIT_ROOT = join(__dirname, '..');
|
|
@@ -69,6 +70,7 @@ const GITIGNORE_LINES = [
|
|
|
69
70
|
'.agents/figma.local.env',
|
|
70
71
|
'.agents/github.local.env',
|
|
71
72
|
'.agents/gitlab.local.env',
|
|
73
|
+
'.agents/spend/',
|
|
72
74
|
];
|
|
73
75
|
|
|
74
76
|
const FIGMA_ENV_REL = join('.agents', 'figma.local.env');
|
|
@@ -84,6 +86,11 @@ const GITLAB_ENV_EXAMPLE_REL = join('.agents', 'gitlab.local.env.example');
|
|
|
84
86
|
const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
|
|
85
87
|
const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
|
|
86
88
|
const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
|
|
89
|
+
const CURSOR_SPEND_HOOK_REL = join('scripts', 'cursor-spend-hook.cjs');
|
|
90
|
+
const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
|
|
91
|
+
const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
|
|
92
|
+
const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop'];
|
|
93
|
+
const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
87
94
|
const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
|
|
88
95
|
const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
|
|
89
96
|
const OPTIONAL_MCP_SEED_STRIP = ['github', 'gitlab', 'browser'];
|
|
@@ -361,6 +368,108 @@ function refreshOptionalMcpManagedFiles(projectDir) {
|
|
|
361
368
|
refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
|
|
362
369
|
}
|
|
363
370
|
|
|
371
|
+
function cursorSpendHookEntryOk(projectDir) {
|
|
372
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
373
|
+
if (!existsSync(hooksPath)) return false;
|
|
374
|
+
let config;
|
|
375
|
+
try {
|
|
376
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
const hooks = config && typeof config === 'object' ? config.hooks : null;
|
|
381
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
382
|
+
return CURSOR_SPEND_HOOK_EVENTS.every((event) => {
|
|
383
|
+
const entries = hooks[event];
|
|
384
|
+
return Array.isArray(entries)
|
|
385
|
+
&& entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Mandatory spend capture: every kit project must record Cursor token usage
|
|
390
|
+
// locally so handoff/archive can collect real spend without manual flags.
|
|
391
|
+
function ensureCursorSpendHook(projectDir) {
|
|
392
|
+
const result = { script: false, hooksJson: false, error: null };
|
|
393
|
+
const src = join(KIT_ROOT, 'templates', CURSOR_SPEND_HOOK_REL);
|
|
394
|
+
const dest = join(projectDir, CURSOR_SPEND_HOOK_REL);
|
|
395
|
+
if (existsSync(src)) {
|
|
396
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
397
|
+
copyFileSync(src, dest);
|
|
398
|
+
result.script = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
402
|
+
let config = { version: 1, hooks: {} };
|
|
403
|
+
if (existsSync(hooksPath)) {
|
|
404
|
+
try {
|
|
405
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
406
|
+
} catch {
|
|
407
|
+
result.error = `${CURSOR_HOOKS_JSON_REL} is not valid JSON — fix it, then re-run any kit command`;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!config || typeof config !== 'object') config = { version: 1, hooks: {} };
|
|
412
|
+
if (config.version == null) config.version = 1;
|
|
413
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
414
|
+
let changed = !existsSync(hooksPath);
|
|
415
|
+
for (const event of CURSOR_SPEND_HOOK_EVENTS) {
|
|
416
|
+
const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
|
|
417
|
+
const present = entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
418
|
+
if (!present) {
|
|
419
|
+
entries.push({ command: CURSOR_SPEND_HOOK_COMMAND });
|
|
420
|
+
config.hooks[event] = entries;
|
|
421
|
+
changed = true;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (changed) {
|
|
425
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
426
|
+
writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
427
|
+
result.hooksJson = true;
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function reportCursorSpendHook(projectDir, emit) {
|
|
433
|
+
const result = ensureCursorSpendHook(projectDir);
|
|
434
|
+
if (result.error) {
|
|
435
|
+
emit.warn(`Cursor spend hook: ${result.error}`);
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
|
|
439
|
+
if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop + subagentStop spend hook)`);
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function countCursorUsageRecords(projectDir) {
|
|
444
|
+
const filePath = join(projectDir, CURSOR_USAGE_FILE_REL);
|
|
445
|
+
if (!existsSync(filePath)) return null;
|
|
446
|
+
try {
|
|
447
|
+
return readFileSync(filePath, 'utf-8').split('\n').filter((line) => line.trim()).length;
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function printSpendHealth(projectDir) {
|
|
454
|
+
console.log(pc.bold('\nSpend capture'));
|
|
455
|
+
const scriptOk = existsSync(join(projectDir, CURSOR_SPEND_HOOK_REL));
|
|
456
|
+
const entryOk = cursorSpendHookEntryOk(projectDir);
|
|
457
|
+
const records = countCursorUsageRecords(projectDir);
|
|
458
|
+
const cursorState = scriptOk && entryOk
|
|
459
|
+
? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
|
|
460
|
+
: 'not configured — run npx agent-orchestrator-kit update';
|
|
461
|
+
console.log(` cursor ${cursorState}`);
|
|
462
|
+
const home = process.env.HOME || '';
|
|
463
|
+
const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
|
|
464
|
+
console.log(` claude ${claudeOk ? 'ok (~/.claude/projects)' : 'no local Claude data'}`);
|
|
465
|
+
const ampDir = process.env.AMP_DATA_DIR && String(process.env.AMP_DATA_DIR).trim()
|
|
466
|
+
? String(process.env.AMP_DATA_DIR).trim()
|
|
467
|
+
: join(home, '.local', 'share', 'amp');
|
|
468
|
+
const ampOk = existsSync(join(ampDir, 'threads'));
|
|
469
|
+
console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
|
|
470
|
+
console.log('');
|
|
471
|
+
}
|
|
472
|
+
|
|
364
473
|
function parseGitRemoteHostname(url) {
|
|
365
474
|
const raw = String(url || '').trim();
|
|
366
475
|
if (!raw) return '';
|
|
@@ -698,6 +807,7 @@ function runMcpSetup(projectDir, { vcs = '', browser = true } = {}) {
|
|
|
698
807
|
refreshFigmaManagedFiles(projectDir);
|
|
699
808
|
refreshMemoryManagedFiles(projectDir);
|
|
700
809
|
refreshOptionalMcpManagedFiles(projectDir);
|
|
810
|
+
reportCursorSpendHook(projectDir, log);
|
|
701
811
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
702
812
|
|
|
703
813
|
const selected = resolveMcpSetupVcs(projectDir, vcs);
|
|
@@ -894,6 +1004,42 @@ function applyRuntimeToFields(fields, opts, env) {
|
|
|
894
1004
|
return true;
|
|
895
1005
|
}
|
|
896
1006
|
|
|
1007
|
+
const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
|
|
1008
|
+
|
|
1009
|
+
function resolveModel(opts, env) {
|
|
1010
|
+
const flag = opts && opts.model != null ? String(opts.model).trim() : '';
|
|
1011
|
+
if (flag) return flag;
|
|
1012
|
+
const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
|
|
1013
|
+
if (fromEnv) return fromEnv;
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function resolvePlatform(opts, env) {
|
|
1018
|
+
const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
|
|
1019
|
+
if (flag) {
|
|
1020
|
+
const lower = flag.toLowerCase();
|
|
1021
|
+
if (!VALID_PLATFORMS.has(lower)) {
|
|
1022
|
+
return { error: 'invalid --platform (use cursor, claude, or amp)' };
|
|
1023
|
+
}
|
|
1024
|
+
return { value: lower };
|
|
1025
|
+
}
|
|
1026
|
+
const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
|
|
1027
|
+
if (fromEnv) {
|
|
1028
|
+
const lower = fromEnv.toLowerCase();
|
|
1029
|
+
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1030
|
+
return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
|
|
1031
|
+
}
|
|
1032
|
+
return { value: null };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function warnMissingModel() {
|
|
1036
|
+
console.error('metrics: session.model is null — pass --model <llm-product-id> or set AOK_MODEL');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function warnMissingUsd() {
|
|
1040
|
+
console.error('metrics: spend.costUsd is null — USD spend was not collected');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
897
1043
|
function gitTry(projectDir, command) {
|
|
898
1044
|
try {
|
|
899
1045
|
const stdout = execSync(command, {
|
|
@@ -1301,6 +1447,35 @@ function emptySpendTotals() {
|
|
|
1301
1447
|
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
1302
1448
|
}
|
|
1303
1449
|
|
|
1450
|
+
function emptyPlatformSpend(source = 'none') {
|
|
1451
|
+
return {
|
|
1452
|
+
inputTokens: null,
|
|
1453
|
+
outputTokens: null,
|
|
1454
|
+
totalTokens: null,
|
|
1455
|
+
costUsd: null,
|
|
1456
|
+
ampCredits: null,
|
|
1457
|
+
source,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function defaultSpendByPlatform() {
|
|
1462
|
+
return {
|
|
1463
|
+
cursor: emptyPlatformSpend(),
|
|
1464
|
+
claude: emptyPlatformSpend(),
|
|
1465
|
+
amp: emptyPlatformSpend(),
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function mergeSpendByPlatform(raw) {
|
|
1470
|
+
const base = defaultSpendByPlatform();
|
|
1471
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
|
|
1472
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
1473
|
+
const row = raw[key] && typeof raw[key] === 'object' && !Array.isArray(raw[key]) ? raw[key] : {};
|
|
1474
|
+
base[key] = { ...base[key], ...row };
|
|
1475
|
+
}
|
|
1476
|
+
return base;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1304
1479
|
function defaultMetrics(changeName, nowIso) {
|
|
1305
1480
|
return {
|
|
1306
1481
|
version: METRICS_VERSION,
|
|
@@ -1309,6 +1484,8 @@ function defaultMetrics(changeName, nowIso) {
|
|
|
1309
1484
|
updatedAt: nowIso,
|
|
1310
1485
|
archivedAt: null,
|
|
1311
1486
|
spend: emptySpendTotals(),
|
|
1487
|
+
spendByPlatform: defaultSpendByPlatform(),
|
|
1488
|
+
spendByModel: [],
|
|
1312
1489
|
totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
|
|
1313
1490
|
phases: {},
|
|
1314
1491
|
sessions: [],
|
|
@@ -1334,6 +1511,8 @@ function loadMetricsFile(filePath, changeName, nowIso) {
|
|
|
1334
1511
|
version: METRICS_VERSION,
|
|
1335
1512
|
change: changeName,
|
|
1336
1513
|
spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
|
|
1514
|
+
spendByPlatform: mergeSpendByPlatform(parsed.spendByPlatform),
|
|
1515
|
+
spendByModel: Array.isArray(parsed.spendByModel) ? parsed.spendByModel : [],
|
|
1337
1516
|
totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
|
|
1338
1517
|
phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
|
|
1339
1518
|
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
@@ -1373,6 +1552,156 @@ function isoOrNull(value) {
|
|
|
1373
1552
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
1374
1553
|
}
|
|
1375
1554
|
|
|
1555
|
+
function sessionFieldOrSources(session, key) {
|
|
1556
|
+
if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
|
|
1557
|
+
let sum = null;
|
|
1558
|
+
for (const src of session.sources || []) {
|
|
1559
|
+
sum = addNullable(sum, numOrNull(src[key]));
|
|
1560
|
+
}
|
|
1561
|
+
return sum;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function lastSessionEndedAt(metrics) {
|
|
1565
|
+
let last = null;
|
|
1566
|
+
for (const session of metrics.sessions || []) {
|
|
1567
|
+
if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
|
|
1568
|
+
}
|
|
1569
|
+
return last;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function existingSourceIdSet(metrics) {
|
|
1573
|
+
const ids = new Set();
|
|
1574
|
+
for (const session of metrics.sessions || []) {
|
|
1575
|
+
for (const src of session.sources || []) {
|
|
1576
|
+
if (src && src.id != null && src.id !== '') ids.add(String(src.id));
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return ids;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function uniqueSourceModels(sources) {
|
|
1583
|
+
const seen = [];
|
|
1584
|
+
for (const src of sources || []) {
|
|
1585
|
+
if (src.model && !seen.includes(src.model)) seen.push(src.model);
|
|
1586
|
+
}
|
|
1587
|
+
return seen;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function primaryModelFromSources(sources) {
|
|
1591
|
+
if (!sources || !sources.length) return null;
|
|
1592
|
+
const ranked = [...sources].sort((a, b) => {
|
|
1593
|
+
const ta = a.totalTokens ?? 0;
|
|
1594
|
+
const tb = b.totalTokens ?? 0;
|
|
1595
|
+
if (tb !== ta) return tb - ta;
|
|
1596
|
+
const platformCmp = String(a.platform || '').localeCompare(String(b.platform || ''));
|
|
1597
|
+
if (platformCmp !== 0) return platformCmp;
|
|
1598
|
+
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
1599
|
+
});
|
|
1600
|
+
const model = ranked[0] && ranked[0].model;
|
|
1601
|
+
return model == null || model === '' ? null : String(model);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function hasSpendOverride(opts) {
|
|
1605
|
+
return opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function sessionTotalsFromFlags(opts) {
|
|
1609
|
+
const inputTokens = numOrNull(opts.inputTokens);
|
|
1610
|
+
const outputTokens = numOrNull(opts.outputTokens);
|
|
1611
|
+
let totalTokens = numOrNull(opts.totalTokens);
|
|
1612
|
+
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1613
|
+
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
inputTokens,
|
|
1617
|
+
outputTokens,
|
|
1618
|
+
totalTokens,
|
|
1619
|
+
costUsd: numOrNull(opts.costUsd),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function sessionTotalsFromSources(sources) {
|
|
1624
|
+
let inputTokens = null;
|
|
1625
|
+
let outputTokens = null;
|
|
1626
|
+
let totalTokens = null;
|
|
1627
|
+
let costUsd = null;
|
|
1628
|
+
for (const src of sources || []) {
|
|
1629
|
+
inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
|
|
1630
|
+
outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
|
|
1631
|
+
totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
|
|
1632
|
+
if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
|
|
1633
|
+
}
|
|
1634
|
+
return { inputTokens, outputTokens, totalTokens, costUsd };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
1638
|
+
try {
|
|
1639
|
+
return collectSpend({
|
|
1640
|
+
cwd: process.cwd(),
|
|
1641
|
+
windowStart,
|
|
1642
|
+
windowEnd,
|
|
1643
|
+
existingSourceIds: existingSourceIdSet(metrics),
|
|
1644
|
+
env: process.env,
|
|
1645
|
+
homedir: process.env.HOME,
|
|
1646
|
+
});
|
|
1647
|
+
} catch {
|
|
1648
|
+
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function applyCollectedSessionFields(session, sources, resolvedModel, opts) {
|
|
1653
|
+
session.sources = sources;
|
|
1654
|
+
const uniqueModels = uniqueSourceModels(sources);
|
|
1655
|
+
session.model = primaryModelFromSources(sources) || resolvedModel || null;
|
|
1656
|
+
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
1657
|
+
const totals = hasSpendOverride(opts) ? sessionTotalsFromFlags(opts) : sessionTotalsFromSources(sources);
|
|
1658
|
+
session.inputTokens = totals.inputTokens;
|
|
1659
|
+
session.outputTokens = totals.outputTokens;
|
|
1660
|
+
session.totalTokens = totals.totalTokens;
|
|
1661
|
+
session.costUsd = totals.costUsd;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function recomputeSpendMaps(metrics) {
|
|
1665
|
+
const byPlatform = defaultSpendByPlatform();
|
|
1666
|
+
const byModel = new Map();
|
|
1667
|
+
for (const session of metrics.sessions || []) {
|
|
1668
|
+
for (const src of session.sources || []) {
|
|
1669
|
+
const platform = src.platform;
|
|
1670
|
+
if (platform && byPlatform[platform]) {
|
|
1671
|
+
const bucket = byPlatform[platform];
|
|
1672
|
+
bucket.inputTokens = addNullable(bucket.inputTokens, numOrNull(src.inputTokens));
|
|
1673
|
+
bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
|
|
1674
|
+
bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
|
|
1675
|
+
bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
|
|
1676
|
+
bucket.ampCredits = addNullable(bucket.ampCredits, numOrNull(src.ampCredits));
|
|
1677
|
+
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
1678
|
+
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
1679
|
+
else if (platform === 'cursor') bucket.source = 'cursor-hook';
|
|
1680
|
+
}
|
|
1681
|
+
if (src.model) {
|
|
1682
|
+
const key = `${src.model}::${src.platform || ''}`;
|
|
1683
|
+
const row = byModel.get(key) || {
|
|
1684
|
+
model: src.model,
|
|
1685
|
+
platform: src.platform || null,
|
|
1686
|
+
inputTokens: null,
|
|
1687
|
+
outputTokens: null,
|
|
1688
|
+
totalTokens: null,
|
|
1689
|
+
costUsd: null,
|
|
1690
|
+
ampCredits: null,
|
|
1691
|
+
};
|
|
1692
|
+
row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
|
|
1693
|
+
row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
|
|
1694
|
+
row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
|
|
1695
|
+
row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
|
|
1696
|
+
row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
|
|
1697
|
+
byModel.set(key, row);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
metrics.spendByPlatform = byPlatform;
|
|
1702
|
+
metrics.spendByModel = [...byModel.values()];
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1376
1705
|
function recomputeMetricsAggregates(metrics) {
|
|
1377
1706
|
const phases = {};
|
|
1378
1707
|
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
@@ -1390,12 +1719,17 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
1390
1719
|
phase.sessions += 1;
|
|
1391
1720
|
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
1392
1721
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
1393
|
-
const value =
|
|
1722
|
+
const value = sessionFieldOrSources(session, spendKey);
|
|
1394
1723
|
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
1395
1724
|
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
1396
1725
|
}
|
|
1397
1726
|
if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
|
|
1398
1727
|
if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
|
|
1728
|
+
if (Array.isArray(session.models)) {
|
|
1729
|
+
for (const model of session.models) {
|
|
1730
|
+
if (model && !phase.models.includes(model)) phase.models.push(model);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1399
1733
|
phases[key] = phase;
|
|
1400
1734
|
}
|
|
1401
1735
|
if (firstStart && lastEnd) {
|
|
@@ -1404,6 +1738,7 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
1404
1738
|
metrics.phases = phases;
|
|
1405
1739
|
metrics.totals = totals;
|
|
1406
1740
|
metrics.spend = spend;
|
|
1741
|
+
recomputeSpendMaps(metrics);
|
|
1407
1742
|
}
|
|
1408
1743
|
|
|
1409
1744
|
function metricsRecordSessionStart(projectDir, changeName, role) {
|
|
@@ -1422,13 +1757,12 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1422
1757
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
1423
1758
|
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1424
1759
|
const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
|
|
1425
|
-
const
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
metrics.sessions.push({
|
|
1760
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env) : opts.model;
|
|
1761
|
+
const windowStart = (metrics.pending && metrics.pending.startedAt) || lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1762
|
+
const collected = opts.collect === false
|
|
1763
|
+
? { sources: [] }
|
|
1764
|
+
: runCollectSpend(metrics, windowStart, nowIso);
|
|
1765
|
+
const session = {
|
|
1432
1766
|
startedAt,
|
|
1433
1767
|
endedAt: nowIso,
|
|
1434
1768
|
durationMs,
|
|
@@ -1436,29 +1770,59 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1436
1770
|
phase: phaseForRole(fields.closedRole),
|
|
1437
1771
|
runtime: fields.runtime || 'local',
|
|
1438
1772
|
agentId: fields.agentId || 'none',
|
|
1439
|
-
model:
|
|
1773
|
+
model: resolvedModel || null,
|
|
1774
|
+
platform: opts.platform || null,
|
|
1440
1775
|
tasks: fields.tasks || null,
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1776
|
+
sources: [],
|
|
1777
|
+
inputTokens: null,
|
|
1778
|
+
outputTokens: null,
|
|
1779
|
+
totalTokens: null,
|
|
1780
|
+
costUsd: null,
|
|
1781
|
+
};
|
|
1782
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts);
|
|
1783
|
+
metrics.sessions.push(session);
|
|
1446
1784
|
metrics.pending = null;
|
|
1447
1785
|
metrics.updatedAt = nowIso;
|
|
1448
1786
|
recomputeMetricsAggregates(metrics);
|
|
1787
|
+
if (session.model == null) warnMissingModel();
|
|
1449
1788
|
saveMetricsFile(filePath, metrics);
|
|
1450
1789
|
return filePath;
|
|
1451
1790
|
}
|
|
1452
1791
|
|
|
1453
|
-
function metricsFinalizeArchive(targetDir, changeName) {
|
|
1792
|
+
function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
1454
1793
|
const filePath = join(targetDir, 'metrics.json');
|
|
1455
|
-
if (!existsSync(filePath)) return null;
|
|
1456
1794
|
const nowIso = new Date().toISOString();
|
|
1457
1795
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1796
|
+
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1797
|
+
const collected = opts.collect === false
|
|
1798
|
+
? { sources: [] }
|
|
1799
|
+
: runCollectSpend(metrics, windowStart, nowIso);
|
|
1800
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env) : opts.model;
|
|
1801
|
+
const session = {
|
|
1802
|
+
startedAt: nowIso,
|
|
1803
|
+
endedAt: nowIso,
|
|
1804
|
+
durationMs: null,
|
|
1805
|
+
role: 'Archiver',
|
|
1806
|
+
phase: phaseForRole('Archiver'),
|
|
1807
|
+
runtime: opts.runtime || 'local',
|
|
1808
|
+
agentId: opts.agentId || 'none',
|
|
1809
|
+
model: resolvedModel || null,
|
|
1810
|
+
platform: opts.platform || null,
|
|
1811
|
+
tasks: opts.tasks || null,
|
|
1812
|
+
sources: [],
|
|
1813
|
+
inputTokens: null,
|
|
1814
|
+
outputTokens: null,
|
|
1815
|
+
totalTokens: null,
|
|
1816
|
+
costUsd: null,
|
|
1817
|
+
};
|
|
1818
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, {});
|
|
1819
|
+
metrics.sessions.push(session);
|
|
1458
1820
|
metrics.archivedAt = nowIso;
|
|
1459
1821
|
metrics.pending = null;
|
|
1460
1822
|
metrics.updatedAt = nowIso;
|
|
1461
1823
|
recomputeMetricsAggregates(metrics);
|
|
1824
|
+
if (session.model == null) warnMissingModel();
|
|
1825
|
+
if (metrics.spend.costUsd === null) warnMissingUsd();
|
|
1462
1826
|
saveMetricsFile(filePath, metrics);
|
|
1463
1827
|
return filePath;
|
|
1464
1828
|
}
|
|
@@ -2352,6 +2716,9 @@ program
|
|
|
2352
2716
|
refreshMemoryManagedFiles(projectDir);
|
|
2353
2717
|
ensureMemoryMcpEntry(projectDir);
|
|
2354
2718
|
|
|
2719
|
+
log.title('Configuring Cursor spend hook');
|
|
2720
|
+
reportCursorSpendHook(projectDir, log);
|
|
2721
|
+
|
|
2355
2722
|
if (opts.hooks) {
|
|
2356
2723
|
log.title('Installing pre-commit gate');
|
|
2357
2724
|
const hookResult = runHooksSetup(projectDir);
|
|
@@ -2414,6 +2781,8 @@ program
|
|
|
2414
2781
|
log.title('Configuring Memory MCP');
|
|
2415
2782
|
refreshMemoryManagedFiles(projectDir);
|
|
2416
2783
|
ensureMemoryMcpEntry(projectDir);
|
|
2784
|
+
log.title('Configuring Cursor spend hook');
|
|
2785
|
+
reportCursorSpendHook(projectDir, log);
|
|
2417
2786
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2418
2787
|
|
|
2419
2788
|
log.ok(`Updated to v${KIT_VERSION}`);
|
|
@@ -2476,6 +2845,9 @@ program
|
|
|
2476
2845
|
log.title('Configuring Memory MCP');
|
|
2477
2846
|
ensureMemoryMcpEntry(projectDir);
|
|
2478
2847
|
|
|
2848
|
+
log.title('Configuring Cursor spend hook');
|
|
2849
|
+
reportCursorSpendHook(projectDir, log);
|
|
2850
|
+
|
|
2479
2851
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2480
2852
|
|
|
2481
2853
|
log.ok('Sync complete');
|
|
@@ -2512,6 +2884,7 @@ program
|
|
|
2512
2884
|
}
|
|
2513
2885
|
|
|
2514
2886
|
printMcpHealth(projectDir);
|
|
2887
|
+
printSpendHealth(projectDir);
|
|
2515
2888
|
printSkillHealth(projectDir);
|
|
2516
2889
|
});
|
|
2517
2890
|
|
|
@@ -2640,6 +3013,9 @@ program
|
|
|
2640
3013
|
.option('--sync', 'merge delta specs into openspec/specs/ before archiving')
|
|
2641
3014
|
.option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
|
|
2642
3015
|
.option('--force', 'confirm archiving without merge when delta specs exist', false)
|
|
3016
|
+
.option('--model <name>', 'LLM product id recorded on the Archiver session')
|
|
3017
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
3018
|
+
.option('--no-collect', 'Skip local spend adapters when finalizing metrics')
|
|
2643
3019
|
.action((name, opts) => {
|
|
2644
3020
|
const projectDir = process.cwd();
|
|
2645
3021
|
const fail = (msg) => {
|
|
@@ -2650,6 +3026,15 @@ program
|
|
|
2650
3026
|
|
|
2651
3027
|
if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
|
|
2652
3028
|
|
|
3029
|
+
const platformResult = resolvePlatform(opts, process.env);
|
|
3030
|
+
if (platformResult.error) {
|
|
3031
|
+
log.err(platformResult.error);
|
|
3032
|
+
process.exitCode = 1;
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3036
|
+
const resolvedModel = resolveModel(opts, process.env);
|
|
3037
|
+
|
|
2653
3038
|
let status;
|
|
2654
3039
|
try {
|
|
2655
3040
|
const out = execSync(`npx openspec status --change ${name} --json`, {
|
|
@@ -2773,7 +3158,14 @@ program
|
|
|
2773
3158
|
};
|
|
2774
3159
|
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
2775
3160
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2776
|
-
const metricsPath = metricsFinalizeArchive(targetDir, name
|
|
3161
|
+
const metricsPath = metricsFinalizeArchive(targetDir, name, {
|
|
3162
|
+
model: resolvedModel,
|
|
3163
|
+
platform: platformResult.value || null,
|
|
3164
|
+
runtime: fields.runtime,
|
|
3165
|
+
agentId: fields.agentId,
|
|
3166
|
+
tasks: fields.tasks,
|
|
3167
|
+
collect: opts.collect !== false,
|
|
3168
|
+
});
|
|
2777
3169
|
|
|
2778
3170
|
console.log(`change: ${name}`);
|
|
2779
3171
|
console.log(`schema: ${status.schemaName || 'unknown'}`);
|
|
@@ -2781,7 +3173,7 @@ program
|
|
|
2781
3173
|
console.log(`sync: ${syncStatus}`);
|
|
2782
3174
|
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
2783
3175
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
2784
|
-
|
|
3176
|
+
console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
|
|
2785
3177
|
log.ok(`archived ${name}`);
|
|
2786
3178
|
});
|
|
2787
3179
|
|
|
@@ -2982,12 +3374,14 @@ program
|
|
|
2982
3374
|
.option('--agent-id <id>', 'Cloud agent identifier')
|
|
2983
3375
|
.option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
|
|
2984
3376
|
.option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
|
|
2985
|
-
.option('--model <name>', '
|
|
3377
|
+
.option('--model <name>', 'LLM product id recorded in metrics.json')
|
|
3378
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
2986
3379
|
.option('--input-tokens <n>', 'Input tokens spent in this session')
|
|
2987
3380
|
.option('--output-tokens <n>', 'Output tokens spent in this session')
|
|
2988
3381
|
.option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
|
|
2989
3382
|
.option('--cost-usd <usd>', 'Cost of this session in USD')
|
|
2990
3383
|
.option('--no-metrics', 'Skip recording this session into metrics.json')
|
|
3384
|
+
.option('--no-collect', 'Skip local spend adapters for this persist')
|
|
2991
3385
|
.action((changeName, opts) => {
|
|
2992
3386
|
const projectDir = process.cwd();
|
|
2993
3387
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -3048,6 +3442,9 @@ program
|
|
|
3048
3442
|
const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
|
|
3049
3443
|
log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
|
|
3050
3444
|
}
|
|
3445
|
+
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3446
|
+
if (spendHook.error) log.warn(`Cursor spend hook: ${spendHook.error}`);
|
|
3447
|
+
else if (spendHook.hooksJson) log.ok('Cursor spend hook installed — restart Cursor once to activate it');
|
|
3051
3448
|
return;
|
|
3052
3449
|
}
|
|
3053
3450
|
|
|
@@ -3115,6 +3512,15 @@ program
|
|
|
3115
3512
|
return;
|
|
3116
3513
|
}
|
|
3117
3514
|
|
|
3515
|
+
const platformResult = resolvePlatform(opts, process.env);
|
|
3516
|
+
if (platformResult.error) {
|
|
3517
|
+
log.err(platformResult.error);
|
|
3518
|
+
process.exitCode = 1;
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3522
|
+
const resolvedModel = resolveModel(opts, process.env);
|
|
3523
|
+
|
|
3118
3524
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
3119
3525
|
fields.prompt = prompt;
|
|
3120
3526
|
writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
@@ -3124,14 +3530,20 @@ program
|
|
|
3124
3530
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
3125
3531
|
console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
|
|
3126
3532
|
|
|
3533
|
+
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3534
|
+
if (spendHook.error) console.error(pc.yellow(' !'), `Cursor spend hook: ${spendHook.error}`);
|
|
3535
|
+
else if (spendHook.hooksJson) console.error(pc.green(' ✓'), 'Cursor spend hook installed — restart Cursor once to activate it');
|
|
3536
|
+
|
|
3127
3537
|
if (opts.metrics !== false) {
|
|
3128
3538
|
const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
|
|
3129
3539
|
startedAt: opts.startedAt,
|
|
3130
|
-
model:
|
|
3540
|
+
model: resolvedModel,
|
|
3541
|
+
platform: platformResult.value || null,
|
|
3131
3542
|
inputTokens: opts.inputTokens,
|
|
3132
3543
|
outputTokens: opts.outputTokens,
|
|
3133
3544
|
totalTokens: opts.totalTokens,
|
|
3134
3545
|
costUsd: opts.costUsd,
|
|
3546
|
+
collect: opts.collect !== false,
|
|
3135
3547
|
});
|
|
3136
3548
|
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
3137
3549
|
}
|
|
@@ -3144,7 +3556,7 @@ program
|
|
|
3144
3556
|
|
|
3145
3557
|
program
|
|
3146
3558
|
.command('metrics [change-name]')
|
|
3147
|
-
.description('Show recorded session metrics for a change: time per phase, tokens, cost,
|
|
3559
|
+
.description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
|
|
3148
3560
|
.option('--json', 'Print raw metrics.json', false)
|
|
3149
3561
|
.action((changeName, opts) => {
|
|
3150
3562
|
const projectDir = process.cwd();
|
|
@@ -3193,7 +3605,7 @@ program
|
|
|
3193
3605
|
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
3194
3606
|
if (phaseKeys.length) {
|
|
3195
3607
|
console.log('');
|
|
3196
|
-
console.log('phase sessions time tokens cost
|
|
3608
|
+
console.log('phase sessions time tokens cost roles models');
|
|
3197
3609
|
for (const key of phaseKeys) {
|
|
3198
3610
|
const phase = metrics.phases[key];
|
|
3199
3611
|
const cols = [
|
|
@@ -3202,12 +3614,46 @@ program
|
|
|
3202
3614
|
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
3203
3615
|
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
3204
3616
|
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
3205
|
-
phase.agents.join(', ') || '—',
|
|
3617
|
+
(phase.agents.join(', ') || '—').padEnd(20),
|
|
3618
|
+
phase.models.join(', ') || '—',
|
|
3206
3619
|
];
|
|
3207
3620
|
console.log(cols.join(' '));
|
|
3208
3621
|
}
|
|
3209
3622
|
}
|
|
3210
3623
|
|
|
3624
|
+
const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
|
|
3625
|
+
console.log('');
|
|
3626
|
+
console.log('by platform:');
|
|
3627
|
+
console.log('platform tokens cost credits source');
|
|
3628
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
3629
|
+
const row = byPlatform[key] || emptyPlatformSpend();
|
|
3630
|
+
console.log([
|
|
3631
|
+
key.padEnd(10),
|
|
3632
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3633
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3634
|
+
formatMetricsNumber(row.ampCredits).padEnd(9),
|
|
3635
|
+
row.source || 'none',
|
|
3636
|
+
].join(' '));
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
console.log('');
|
|
3640
|
+
console.log('by model:');
|
|
3641
|
+
console.log('model platform tokens cost credits');
|
|
3642
|
+
const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
|
|
3643
|
+
if (!byModel.length) {
|
|
3644
|
+
console.log('— — — — —');
|
|
3645
|
+
} else {
|
|
3646
|
+
for (const row of byModel) {
|
|
3647
|
+
console.log([
|
|
3648
|
+
String(row.model || '—').padEnd(20),
|
|
3649
|
+
String(row.platform || '—').padEnd(10),
|
|
3650
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3651
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3652
|
+
formatMetricsNumber(row.ampCredits),
|
|
3653
|
+
].join(' '));
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3211
3657
|
if (metrics.sessions.length) {
|
|
3212
3658
|
console.log('');
|
|
3213
3659
|
console.log('recent sessions:');
|