agent-orchestrator-kit 0.4.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, {
@@ -1290,6 +1436,433 @@ function readHandoffFields(projectDir, changeName) {
1290
1436
  return { filePath, fields: fieldsFromSections(changeName, sections) };
1291
1437
  }
1292
1438
 
1439
+ const METRICS_VERSION = 1;
1440
+ const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
1441
+
1442
+ function metricsFilePath(projectDir, changeName) {
1443
+ return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1444
+ }
1445
+
1446
+ function emptySpendTotals() {
1447
+ return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
1448
+ }
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
+
1479
+ function defaultMetrics(changeName, nowIso) {
1480
+ return {
1481
+ version: METRICS_VERSION,
1482
+ change: changeName,
1483
+ createdAt: nowIso,
1484
+ updatedAt: nowIso,
1485
+ archivedAt: null,
1486
+ spend: emptySpendTotals(),
1487
+ spendByPlatform: defaultSpendByPlatform(),
1488
+ spendByModel: [],
1489
+ totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
1490
+ phases: {},
1491
+ sessions: [],
1492
+ pending: null,
1493
+ };
1494
+ }
1495
+
1496
+ function loadMetricsFile(filePath, changeName, nowIso) {
1497
+ if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
1498
+ let parsed;
1499
+ try {
1500
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
1501
+ } catch {
1502
+ return defaultMetrics(changeName, nowIso);
1503
+ }
1504
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1505
+ return defaultMetrics(changeName, nowIso);
1506
+ }
1507
+ const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
1508
+ return {
1509
+ ...base,
1510
+ ...parsed,
1511
+ version: METRICS_VERSION,
1512
+ change: changeName,
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 : [],
1516
+ totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
1517
+ phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
1518
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
1519
+ };
1520
+ }
1521
+
1522
+ function saveMetricsFile(filePath, metrics) {
1523
+ mkdirSync(dirname(filePath), { recursive: true });
1524
+ writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
1525
+ }
1526
+
1527
+ function numOrNull(value) {
1528
+ if (value == null || value === '') return null;
1529
+ const n = Number(value);
1530
+ return Number.isFinite(n) ? n : null;
1531
+ }
1532
+
1533
+ function addNullable(a, b) {
1534
+ if (a == null && b == null) return null;
1535
+ return (a ?? 0) + (b ?? 0);
1536
+ }
1537
+
1538
+ function phaseForRole(role) {
1539
+ const value = String(role || '').toLowerCase();
1540
+ if (/explor/.test(value)) return 'explore';
1541
+ if (/review/.test(value)) return 'review';
1542
+ if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
1543
+ if (/architect|propose/.test(value)) return 'spec';
1544
+ if (/design/.test(value)) return 'design';
1545
+ if (/archiv/.test(value)) return 'archive';
1546
+ return 'other';
1547
+ }
1548
+
1549
+ function isoOrNull(value) {
1550
+ if (!value) return null;
1551
+ const ms = Date.parse(String(value));
1552
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
1553
+ }
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
+
1705
+ function recomputeMetricsAggregates(metrics) {
1706
+ const phases = {};
1707
+ const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
1708
+ const spend = emptySpendTotals();
1709
+ let firstStart = null;
1710
+ let lastEnd = null;
1711
+ for (const session of metrics.sessions) {
1712
+ totals.sessions += 1;
1713
+ if (session.runtime === 'cloud') totals.cloudSessions += 1;
1714
+ totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
1715
+ if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
1716
+ if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
1717
+ const key = session.phase || 'other';
1718
+ const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
1719
+ phase.sessions += 1;
1720
+ phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
1721
+ for (const spendKey of METRICS_SPEND_KEYS) {
1722
+ const value = sessionFieldOrSources(session, spendKey);
1723
+ phase[spendKey] = addNullable(phase[spendKey], value);
1724
+ spend[spendKey] = addNullable(spend[spendKey], value);
1725
+ }
1726
+ if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
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
+ }
1733
+ phases[key] = phase;
1734
+ }
1735
+ if (firstStart && lastEnd) {
1736
+ totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
1737
+ }
1738
+ metrics.phases = phases;
1739
+ metrics.totals = totals;
1740
+ metrics.spend = spend;
1741
+ recomputeSpendMaps(metrics);
1742
+ }
1743
+
1744
+ function metricsRecordSessionStart(projectDir, changeName, role) {
1745
+ const filePath = metricsFilePath(projectDir, changeName);
1746
+ const nowIso = new Date().toISOString();
1747
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
1748
+ metrics.pending = { startedAt: nowIso, role: role || '' };
1749
+ metrics.updatedAt = nowIso;
1750
+ saveMetricsFile(filePath, metrics);
1751
+ return filePath;
1752
+ }
1753
+
1754
+ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1755
+ const filePath = metricsFilePath(projectDir, fields.changeName);
1756
+ const nowIso = new Date().toISOString();
1757
+ const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
1758
+ const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1759
+ const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
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 = {
1766
+ startedAt,
1767
+ endedAt: nowIso,
1768
+ durationMs,
1769
+ role: fields.closedRole || '',
1770
+ phase: phaseForRole(fields.closedRole),
1771
+ runtime: fields.runtime || 'local',
1772
+ agentId: fields.agentId || 'none',
1773
+ model: resolvedModel || null,
1774
+ platform: opts.platform || null,
1775
+ tasks: fields.tasks || null,
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);
1784
+ metrics.pending = null;
1785
+ metrics.updatedAt = nowIso;
1786
+ recomputeMetricsAggregates(metrics);
1787
+ if (session.model == null) warnMissingModel();
1788
+ saveMetricsFile(filePath, metrics);
1789
+ return filePath;
1790
+ }
1791
+
1792
+ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1793
+ const filePath = join(targetDir, 'metrics.json');
1794
+ const nowIso = new Date().toISOString();
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);
1820
+ metrics.archivedAt = nowIso;
1821
+ metrics.pending = null;
1822
+ metrics.updatedAt = nowIso;
1823
+ recomputeMetricsAggregates(metrics);
1824
+ if (session.model == null) warnMissingModel();
1825
+ if (metrics.spend.costUsd === null) warnMissingUsd();
1826
+ saveMetricsFile(filePath, metrics);
1827
+ return filePath;
1828
+ }
1829
+
1830
+ function formatMetricsDuration(durationMs) {
1831
+ if (durationMs == null || !Number.isFinite(durationMs)) return '—';
1832
+ const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
1833
+ const hours = Math.floor(totalSeconds / 3600);
1834
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1835
+ const seconds = totalSeconds % 60;
1836
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1837
+ if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1838
+ return `${seconds}s`;
1839
+ }
1840
+
1841
+ function formatMetricsNumber(value) {
1842
+ return value == null ? '—' : String(value);
1843
+ }
1844
+
1845
+ function formatMetricsCost(value) {
1846
+ return value == null ? '—' : `$${Number(value).toFixed(2)}`;
1847
+ }
1848
+
1849
+ function resolveMetricsFile(projectDir, changeName) {
1850
+ const activePath = metricsFilePath(projectDir, changeName);
1851
+ if (existsSync(activePath)) return { filePath: activePath, archived: false };
1852
+ const archiveDir = join(projectDir, 'openspec', 'changes', 'archive');
1853
+ if (existsSync(archiveDir)) {
1854
+ const folders = readdirSync(archiveDir)
1855
+ .filter((name) => name === changeName || name.endsWith(`-${changeName}`))
1856
+ .sort()
1857
+ .reverse();
1858
+ for (const folder of folders) {
1859
+ const archivedPath = join(archiveDir, folder, 'metrics.json');
1860
+ if (existsSync(archivedPath)) return { filePath: archivedPath, archived: true };
1861
+ }
1862
+ }
1863
+ return { filePath: activePath, archived: false, missing: true };
1864
+ }
1865
+
1293
1866
  function parseFigmaUrl(url) {
1294
1867
  try {
1295
1868
  const parsed = new URL(url);
@@ -2143,6 +2716,9 @@ program
2143
2716
  refreshMemoryManagedFiles(projectDir);
2144
2717
  ensureMemoryMcpEntry(projectDir);
2145
2718
 
2719
+ log.title('Configuring Cursor spend hook');
2720
+ reportCursorSpendHook(projectDir, log);
2721
+
2146
2722
  if (opts.hooks) {
2147
2723
  log.title('Installing pre-commit gate');
2148
2724
  const hookResult = runHooksSetup(projectDir);
@@ -2205,6 +2781,8 @@ program
2205
2781
  log.title('Configuring Memory MCP');
2206
2782
  refreshMemoryManagedFiles(projectDir);
2207
2783
  ensureMemoryMcpEntry(projectDir);
2784
+ log.title('Configuring Cursor spend hook');
2785
+ reportCursorSpendHook(projectDir, log);
2208
2786
  mergeGitignore(projectDir, GITIGNORE_LINES);
2209
2787
 
2210
2788
  log.ok(`Updated to v${KIT_VERSION}`);
@@ -2267,6 +2845,9 @@ program
2267
2845
  log.title('Configuring Memory MCP');
2268
2846
  ensureMemoryMcpEntry(projectDir);
2269
2847
 
2848
+ log.title('Configuring Cursor spend hook');
2849
+ reportCursorSpendHook(projectDir, log);
2850
+
2270
2851
  mergeGitignore(projectDir, GITIGNORE_LINES);
2271
2852
 
2272
2853
  log.ok('Sync complete');
@@ -2303,6 +2884,7 @@ program
2303
2884
  }
2304
2885
 
2305
2886
  printMcpHealth(projectDir);
2887
+ printSpendHealth(projectDir);
2306
2888
  printSkillHealth(projectDir);
2307
2889
  });
2308
2890
 
@@ -2431,6 +3013,9 @@ program
2431
3013
  .option('--sync', 'merge delta specs into openspec/specs/ before archiving')
2432
3014
  .option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
2433
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')
2434
3019
  .action((name, opts) => {
2435
3020
  const projectDir = process.cwd();
2436
3021
  const fail = (msg) => {
@@ -2441,6 +3026,15 @@ program
2441
3026
 
2442
3027
  if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
2443
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
+
2444
3038
  let status;
2445
3039
  try {
2446
3040
  const out = execSync(`npx openspec status --change ${name} --json`, {
@@ -2564,6 +3158,14 @@ program
2564
3158
  };
2565
3159
  writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
2566
3160
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
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
+ });
2567
3169
 
2568
3170
  console.log(`change: ${name}`);
2569
3171
  console.log(`schema: ${status.schemaName || 'unknown'}`);
@@ -2571,6 +3173,7 @@ program
2571
3173
  console.log(`sync: ${syncStatus}`);
2572
3174
  console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
2573
3175
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
3176
+ console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
2574
3177
  log.ok(`archived ${name}`);
2575
3178
  });
2576
3179
 
@@ -2770,6 +3373,15 @@ program
2770
3373
  .option('--runtime <runtime>', 'Session runtime: local | cloud')
2771
3374
  .option('--agent-id <id>', 'Cloud agent identifier')
2772
3375
  .option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
3376
+ .option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
3377
+ .option('--model <name>', 'LLM product id recorded in metrics.json')
3378
+ .option('--platform <platform>', 'Session platform: cursor | claude | amp')
3379
+ .option('--input-tokens <n>', 'Input tokens spent in this session')
3380
+ .option('--output-tokens <n>', 'Output tokens spent in this session')
3381
+ .option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
3382
+ .option('--cost-usd <usd>', 'Cost of this session in USD')
3383
+ .option('--no-metrics', 'Skip recording this session into metrics.json')
3384
+ .option('--no-collect', 'Skip local spend adapters for this persist')
2773
3385
  .action((changeName, opts) => {
2774
3386
  const projectDir = process.cwd();
2775
3387
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -2826,6 +3438,13 @@ program
2826
3438
  } else {
2827
3439
  log.warn(`Memory JSON empty or missing at ${memoryPath}`);
2828
3440
  }
3441
+ if (opts.metrics !== false && existsSync(changeDir)) {
3442
+ const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
3443
+ log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
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');
2829
3448
  return;
2830
3449
  }
2831
3450
 
@@ -2893,6 +3512,15 @@ program
2893
3512
  return;
2894
3513
  }
2895
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
+
2896
3524
  const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
2897
3525
  fields.prompt = prompt;
2898
3526
  writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
@@ -2902,10 +3530,140 @@ program
2902
3530
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
2903
3531
  console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
2904
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
+
3537
+ if (opts.metrics !== false) {
3538
+ const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
3539
+ startedAt: opts.startedAt,
3540
+ model: resolvedModel,
3541
+ platform: platformResult.value || null,
3542
+ inputTokens: opts.inputTokens,
3543
+ outputTokens: opts.outputTokens,
3544
+ totalTokens: opts.totalTokens,
3545
+ costUsd: opts.costUsd,
3546
+ collect: opts.collect !== false,
3547
+ });
3548
+ console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
3549
+ }
3550
+
2905
3551
  if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
2906
3552
 
2907
3553
  console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
2908
3554
  process.stdout.write(`${prompt}\n`);
2909
3555
  });
2910
3556
 
3557
+ program
3558
+ .command('metrics [change-name]')
3559
+ .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
3560
+ .option('--json', 'Print raw metrics.json', false)
3561
+ .action((changeName, opts) => {
3562
+ const projectDir = process.cwd();
3563
+ let name = changeName;
3564
+ if (!name) {
3565
+ const resolved = resolveHandoffChange(projectDir, changeName);
3566
+ if (!resolved) {
3567
+ log.err('No active change found. Pass a name: npx agent-orchestrator-kit metrics <name>');
3568
+ process.exitCode = 1;
3569
+ return;
3570
+ }
3571
+ if (resolved.ambiguous) {
3572
+ log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
3573
+ process.exitCode = 1;
3574
+ return;
3575
+ }
3576
+ name = resolved;
3577
+ }
3578
+
3579
+ const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
3580
+ if (missing) {
3581
+ log.err(`No metrics.json for ${name}`);
3582
+ log.info(`Expected: ${filePath.replace(`${projectDir}/`, '')}`);
3583
+ log.info('Metrics are recorded by: handoff --restore (session start) and handoff <name> (session end)');
3584
+ process.exitCode = 1;
3585
+ return;
3586
+ }
3587
+
3588
+ const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
3589
+ if (opts.json) {
3590
+ process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
3591
+ return;
3592
+ }
3593
+
3594
+ log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
3595
+ console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
3596
+ console.log(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
3597
+ console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
3598
+ console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
3599
+ console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
3600
+ console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
3601
+ if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
3602
+ if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
3603
+
3604
+ const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
3605
+ const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
3606
+ if (phaseKeys.length) {
3607
+ console.log('');
3608
+ console.log('phase sessions time tokens cost roles models');
3609
+ for (const key of phaseKeys) {
3610
+ const phase = metrics.phases[key];
3611
+ const cols = [
3612
+ key.padEnd(10),
3613
+ String(phase.sessions).padEnd(9),
3614
+ formatMetricsDuration(phase.durationMs).padEnd(9),
3615
+ formatMetricsNumber(phase.totalTokens).padEnd(9),
3616
+ formatMetricsCost(phase.costUsd).padEnd(9),
3617
+ (phase.agents.join(', ') || '—').padEnd(20),
3618
+ phase.models.join(', ') || '—',
3619
+ ];
3620
+ console.log(cols.join(' '));
3621
+ }
3622
+ }
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
+
3657
+ if (metrics.sessions.length) {
3658
+ console.log('');
3659
+ console.log('recent sessions:');
3660
+ for (const session of metrics.sessions.slice(-5)) {
3661
+ const spendLabel = session.totalTokens != null || session.costUsd != null
3662
+ ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
3663
+ : '';
3664
+ console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
3665
+ }
3666
+ }
3667
+ });
3668
+
2911
3669
  program.parse();