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.
@@ -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 = numOrNull(session[spendKey]);
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 inputTokens = numOrNull(opts.inputTokens);
1426
- const outputTokens = numOrNull(opts.outputTokens);
1427
- let totalTokens = numOrNull(opts.totalTokens);
1428
- if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1429
- totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
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: opts.model || null,
1773
+ model: resolvedModel || null,
1774
+ platform: opts.platform || null,
1440
1775
  tasks: fields.tasks || null,
1441
- inputTokens,
1442
- outputTokens,
1443
- totalTokens,
1444
- costUsd: numOrNull(opts.costUsd),
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
- if (metricsPath) console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
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>', 'Model used in this session (recorded in metrics.json)')
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: opts.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, agents, models')
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 agents');
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:');