agent-orchestrator-kit 0.5.0 → 0.7.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,14 @@ 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_SPEND_COLLECT_REL = join('scripts', 'cursor-spend-collect.cjs');
91
+ const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
92
+ const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
93
+ const CURSOR_SPEND_COLLECT_COMMAND = 'node scripts/cursor-spend-collect.cjs';
94
+ const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop', 'afterAgentResponse'];
95
+ const CURSOR_SPEND_COLLECT_EVENTS = ['sessionEnd'];
96
+ const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
87
97
  const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
88
98
  const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
89
99
  const OPTIONAL_MCP_SEED_STRIP = ['github', 'gitlab', 'browser'];
@@ -152,6 +162,7 @@ const HANDOFF_SECTIONS = [
152
162
  'Subagents to spawn',
153
163
  'Constraints',
154
164
  'Runtime',
165
+ 'Metrics',
155
166
  'Prompt',
156
167
  ];
157
168
  const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
@@ -361,6 +372,122 @@ function refreshOptionalMcpManagedFiles(projectDir) {
361
372
  refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
362
373
  }
363
374
 
375
+ function hookEventHasCommand(hooks, event, needle) {
376
+ const entries = hooks[event];
377
+ return Array.isArray(entries)
378
+ && entries.some((entry) => entry && String(entry.command || '').includes(needle));
379
+ }
380
+
381
+ function cursorSpendHookEntryOk(projectDir) {
382
+ const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
383
+ if (!existsSync(hooksPath)) return false;
384
+ let config;
385
+ try {
386
+ config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
387
+ } catch {
388
+ return false;
389
+ }
390
+ const hooks = config && typeof config === 'object' ? config.hooks : null;
391
+ if (!hooks || typeof hooks !== 'object') return false;
392
+ const writesOk = CURSOR_SPEND_HOOK_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-hook.cjs'));
393
+ const collectOk = CURSOR_SPEND_COLLECT_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-collect.cjs'));
394
+ return writesOk && collectOk;
395
+ }
396
+
397
+ // Mandatory spend capture: every kit project must record Cursor token usage
398
+ // locally so handoff/archive can collect real spend without manual flags.
399
+ function ensureManagedScript(projectDir, rel) {
400
+ const src = join(KIT_ROOT, 'templates', rel);
401
+ const dest = join(projectDir, rel);
402
+ if (!existsSync(src)) return false;
403
+ mkdirSync(dirname(dest), { recursive: true });
404
+ copyFileSync(src, dest);
405
+ return true;
406
+ }
407
+
408
+ function mergeHookCommands(config, events, command, needle) {
409
+ let changed = false;
410
+ for (const event of events) {
411
+ const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
412
+ if (!entries.some((entry) => entry && String(entry.command || '').includes(needle))) {
413
+ entries.push({ command });
414
+ config.hooks[event] = entries;
415
+ changed = true;
416
+ }
417
+ }
418
+ return changed;
419
+ }
420
+
421
+ function ensureCursorSpendHook(projectDir) {
422
+ const result = { script: false, hooksJson: false, error: null };
423
+ if (ensureManagedScript(projectDir, CURSOR_SPEND_HOOK_REL)) result.script = true;
424
+ ensureManagedScript(projectDir, CURSOR_SPEND_COLLECT_REL);
425
+
426
+ const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
427
+ let config = { version: 1, hooks: {} };
428
+ if (existsSync(hooksPath)) {
429
+ try {
430
+ config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
431
+ } catch {
432
+ result.error = `${CURSOR_HOOKS_JSON_REL} is not valid JSON — fix it, then re-run any kit command`;
433
+ return result;
434
+ }
435
+ }
436
+ if (!config || typeof config !== 'object') config = { version: 1, hooks: {} };
437
+ if (config.version == null) config.version = 1;
438
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
439
+ let changed = !existsSync(hooksPath);
440
+ changed = mergeHookCommands(config, CURSOR_SPEND_HOOK_EVENTS, CURSOR_SPEND_HOOK_COMMAND, 'cursor-spend-hook.cjs') || changed;
441
+ changed = mergeHookCommands(config, CURSOR_SPEND_COLLECT_EVENTS, CURSOR_SPEND_COLLECT_COMMAND, 'cursor-spend-collect.cjs') || changed;
442
+ if (changed) {
443
+ mkdirSync(dirname(hooksPath), { recursive: true });
444
+ writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
445
+ result.hooksJson = true;
446
+ }
447
+ return result;
448
+ }
449
+
450
+ function reportCursorSpendHook(projectDir, emit) {
451
+ const result = ensureCursorSpendHook(projectDir);
452
+ if (result.error) {
453
+ emit.warn(`Cursor spend hook: ${result.error}`);
454
+ return result;
455
+ }
456
+ if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
457
+ if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop / subagentStop / afterAgentResponse + sessionEnd collect)`);
458
+ return result;
459
+ }
460
+
461
+ function countCursorUsageRecords(projectDir) {
462
+ const filePath = join(projectDir, CURSOR_USAGE_FILE_REL);
463
+ if (!existsSync(filePath)) return null;
464
+ try {
465
+ return readFileSync(filePath, 'utf-8').split('\n').filter((line) => line.trim()).length;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ function printSpendHealth(projectDir) {
472
+ console.log(pc.bold('\nSpend capture'));
473
+ const scriptOk = existsSync(join(projectDir, CURSOR_SPEND_HOOK_REL));
474
+ const entryOk = cursorSpendHookEntryOk(projectDir);
475
+ const records = countCursorUsageRecords(projectDir);
476
+ const cursorState = scriptOk && entryOk
477
+ ? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
478
+ : 'optional — not configured (init/update/sync/mcp-setup)';
479
+ console.log(` cursor ${cursorState}`);
480
+ const home = process.env.HOME || '';
481
+ const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
482
+ console.log(` claude ${claudeOk ? 'ok (~/.claude/projects)' : 'no local Claude data'}`);
483
+ const ampDir = process.env.AMP_DATA_DIR && String(process.env.AMP_DATA_DIR).trim()
484
+ ? String(process.env.AMP_DATA_DIR).trim()
485
+ : join(home, '.local', 'share', 'amp');
486
+ const ampOk = existsSync(join(ampDir, 'threads'));
487
+ console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
488
+ console.log('');
489
+ }
490
+
364
491
  function parseGitRemoteHostname(url) {
365
492
  const raw = String(url || '').trim();
366
493
  if (!raw) return '';
@@ -698,6 +825,7 @@ function runMcpSetup(projectDir, { vcs = '', browser = true } = {}) {
698
825
  refreshFigmaManagedFiles(projectDir);
699
826
  refreshMemoryManagedFiles(projectDir);
700
827
  refreshOptionalMcpManagedFiles(projectDir);
828
+ reportCursorSpendHook(projectDir, log);
701
829
  mergeGitignore(projectDir, GITIGNORE_LINES);
702
830
 
703
831
  const selected = resolveMcpSetupVcs(projectDir, vcs);
@@ -894,6 +1022,173 @@ function applyRuntimeToFields(fields, opts, env) {
894
1022
  return true;
895
1023
  }
896
1024
 
1025
+ const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
1026
+ const METRICS_NULL_TOKENS = new Set(['unknown', 'none', 'n/a', 'na', '-', '—', '–', 'null']);
1027
+
1028
+ function emptyMetricsFields(warnings = []) {
1029
+ return {
1030
+ platform: null,
1031
+ model: null,
1032
+ inputTokens: null,
1033
+ outputTokens: null,
1034
+ totalTokens: null,
1035
+ costUsd: null,
1036
+ ampCredits: null,
1037
+ spendSource: null,
1038
+ warnings,
1039
+ };
1040
+ }
1041
+
1042
+ function normalizeMetricsBlank(value) {
1043
+ const raw = value == null ? '' : String(value).trim();
1044
+ if (!raw) return null;
1045
+ const lower = raw.toLowerCase();
1046
+ if (METRICS_NULL_TOKENS.has(lower) || METRICS_NULL_TOKENS.has(raw)) return null;
1047
+ return raw;
1048
+ }
1049
+
1050
+ function parseMetricsNumber(raw, key, warnings) {
1051
+ const normalized = normalizeMetricsBlank(raw);
1052
+ if (normalized == null) return null;
1053
+ const cleaned = normalized.replace(/[$,\s]/g, '');
1054
+ const n = Number(cleaned);
1055
+ if (!Number.isFinite(n)) {
1056
+ warnings.push(`metrics: unparsable ${key} in ## Metrics: ${raw}`);
1057
+ return null;
1058
+ }
1059
+ return n;
1060
+ }
1061
+
1062
+ function parseMetricsSection(body) {
1063
+ const warnings = [];
1064
+ const values = {};
1065
+ const present = new Set();
1066
+ for (const line of String(body || '').split(/\r?\n/)) {
1067
+ const match = line.match(/^\s*[-*]\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/);
1068
+ if (!match) continue;
1069
+ const key = match[1].toLowerCase();
1070
+ values[key] = match[2].trim();
1071
+ present.add(key);
1072
+ }
1073
+ const result = emptyMetricsFields(warnings);
1074
+ const platformRaw = normalizeMetricsBlank(values.platform);
1075
+ if (platformRaw) {
1076
+ const lower = platformRaw.toLowerCase();
1077
+ if (VALID_PLATFORMS.has(lower)) result.platform = lower;
1078
+ else {
1079
+ result.platform = null;
1080
+ warnings.push(`metrics: invalid platform in ## Metrics: ${platformRaw}`);
1081
+ }
1082
+ }
1083
+ result.model = normalizeMetricsBlank(values.model);
1084
+ result.inputTokens = parseMetricsNumber(values.input_tokens, 'input_tokens', warnings);
1085
+ result.outputTokens = parseMetricsNumber(values.output_tokens, 'output_tokens', warnings);
1086
+ result.totalTokens = present.has('total_tokens')
1087
+ ? parseMetricsNumber(values.total_tokens, 'total_tokens', warnings)
1088
+ : null;
1089
+ result.costUsd = parseMetricsNumber(values.cost_usd, 'cost_usd', warnings);
1090
+ result.ampCredits = parseMetricsNumber(values.amp_credits, 'amp_credits', warnings);
1091
+ result.spendSource = normalizeMetricsBlank(values.spend_source);
1092
+ return result;
1093
+ }
1094
+
1095
+ function formatMetricsField(value) {
1096
+ return value == null || value === '' ? 'unknown' : String(value);
1097
+ }
1098
+
1099
+ function renderMetricsSection(metrics) {
1100
+ const m = metrics || emptyMetricsFields();
1101
+ return `## Metrics
1102
+ - platform: ${formatMetricsField(m.platform)}
1103
+ - model: ${formatMetricsField(m.model)}
1104
+ - input_tokens: ${formatMetricsField(m.inputTokens)}
1105
+ - output_tokens: ${formatMetricsField(m.outputTokens)}
1106
+ - cost_usd: ${formatMetricsField(m.costUsd)}
1107
+ - amp_credits: ${formatMetricsField(m.ampCredits)}
1108
+ - spend_source: ${formatMetricsField(m.spendSource)}`;
1109
+ }
1110
+
1111
+ function printMetricsSectionWarnings(metrics, platformAlreadyWarned) {
1112
+ for (const warning of (metrics && metrics.warnings) || []) {
1113
+ if (platformAlreadyWarned && /invalid platform/i.test(warning)) continue;
1114
+ console.error(warning);
1115
+ }
1116
+ }
1117
+
1118
+ function firstNonNull(...values) {
1119
+ for (const value of values) {
1120
+ if (value != null) return value;
1121
+ }
1122
+ return null;
1123
+ }
1124
+
1125
+ function resolveModel(opts, env, reported) {
1126
+ const flag = opts && opts.model != null ? String(opts.model).trim() : '';
1127
+ if (flag) return flag;
1128
+ const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
1129
+ if (fromReport) return fromReport;
1130
+ const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
1131
+ if (fromEnv) return fromEnv;
1132
+ return null;
1133
+ }
1134
+
1135
+ function envFlagOn(value) {
1136
+ if (value == null) return false;
1137
+ const normalized = String(value).trim().toLowerCase();
1138
+ return normalized !== '' && normalized !== '0' && normalized !== 'false';
1139
+ }
1140
+
1141
+ function inferPlatformFromHost(env) {
1142
+ if (!env) return null;
1143
+ if (env.AMP_CURRENT_THREAD != null && String(env.AMP_CURRENT_THREAD).trim()) return 'amp';
1144
+ if (env.AMP_THREAD_ID != null && String(env.AMP_THREAD_ID).trim()) return 'amp';
1145
+ if (envFlagOn(env.CURSOR_AGENT) || (env.CURSOR_CONVERSATION_ID != null && String(env.CURSOR_CONVERSATION_ID).trim())) {
1146
+ return 'cursor';
1147
+ }
1148
+ if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || (env.CLAUDE_CODE_ENTRYPOINT != null && String(env.CLAUDE_CODE_ENTRYPOINT).trim())) {
1149
+ return 'claude';
1150
+ }
1151
+ return null;
1152
+ }
1153
+
1154
+ function resolvePlatform(opts, env, reported) {
1155
+ const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
1156
+ if (flag) {
1157
+ const lower = flag.toLowerCase();
1158
+ if (!VALID_PLATFORMS.has(lower)) {
1159
+ return { error: 'invalid --platform (use cursor, claude, or amp)' };
1160
+ }
1161
+ return { value: lower };
1162
+ }
1163
+ const fromReport = reported && reported.platform != null ? String(reported.platform).trim() : '';
1164
+ if (fromReport) {
1165
+ const lower = fromReport.toLowerCase();
1166
+ if (VALID_PLATFORMS.has(lower)) return { value: lower };
1167
+ return { value: null, warn: 'invalid platform in ## Metrics (use cursor, claude, or amp)' };
1168
+ }
1169
+ const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
1170
+ if (fromEnv) {
1171
+ const lower = fromEnv.toLowerCase();
1172
+ if (VALID_PLATFORMS.has(lower)) return { value: lower };
1173
+ return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
1174
+ }
1175
+ return { value: inferPlatformFromHost(env) };
1176
+ }
1177
+
1178
+ function warnMissingModel() {
1179
+ console.error('metrics: session.model is null — pass --model <llm-product-id> or set AOK_MODEL');
1180
+ }
1181
+
1182
+ function warnMissingUsd() {
1183
+ console.error('metrics: spend.costUsd is null — USD spend was not collected');
1184
+ }
1185
+
1186
+ function warnUnreportedSelfReport() {
1187
+ console.error(
1188
+ 'metrics: session spend is unreported — fill ## Metrics in handoff.md with platform, model, input_tokens, output_tokens, cost_usd, amp_credits, spend_source (use unknown when a value is missing)',
1189
+ );
1190
+ }
1191
+
897
1192
  function gitTry(projectDir, command) {
898
1193
  try {
899
1194
  const stdout = execSync(command, {
@@ -994,7 +1289,9 @@ ${fields.constraints}
994
1289
 
995
1290
  ## Runtime
996
1291
  - runtime: ${runtime}
997
- - agent_id: ${agentId}${prompt}
1292
+ - agent_id: ${agentId}
1293
+
1294
+ ${renderMetricsSection(fields.metrics)}${prompt}
998
1295
  `;
999
1296
  }
1000
1297
 
@@ -1015,6 +1312,7 @@ function fieldsFromSections(changeName, sections, extra = {}) {
1015
1312
  constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
1016
1313
  runtime: extra.runtime || runtimeParsed.runtime,
1017
1314
  agentId: extra.agentId || runtimeParsed.agentId,
1315
+ metrics: extra.metrics || parseMetricsSection(sectionOr(sections, 'Metrics', '')),
1018
1316
  status: extra.status || '',
1019
1317
  tasks: extra.tasks || '',
1020
1318
  review: extra.review || '',
@@ -1301,6 +1599,35 @@ function emptySpendTotals() {
1301
1599
  return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
1302
1600
  }
1303
1601
 
1602
+ function emptyPlatformSpend(source = 'none') {
1603
+ return {
1604
+ inputTokens: null,
1605
+ outputTokens: null,
1606
+ totalTokens: null,
1607
+ costUsd: null,
1608
+ ampCredits: null,
1609
+ source,
1610
+ };
1611
+ }
1612
+
1613
+ function defaultSpendByPlatform() {
1614
+ return {
1615
+ cursor: emptyPlatformSpend(),
1616
+ claude: emptyPlatformSpend(),
1617
+ amp: emptyPlatformSpend(),
1618
+ };
1619
+ }
1620
+
1621
+ function mergeSpendByPlatform(raw) {
1622
+ const base = defaultSpendByPlatform();
1623
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
1624
+ for (const key of ['cursor', 'claude', 'amp']) {
1625
+ const row = raw[key] && typeof raw[key] === 'object' && !Array.isArray(raw[key]) ? raw[key] : {};
1626
+ base[key] = { ...base[key], ...row };
1627
+ }
1628
+ return base;
1629
+ }
1630
+
1304
1631
  function defaultMetrics(changeName, nowIso) {
1305
1632
  return {
1306
1633
  version: METRICS_VERSION,
@@ -1309,6 +1636,8 @@ function defaultMetrics(changeName, nowIso) {
1309
1636
  updatedAt: nowIso,
1310
1637
  archivedAt: null,
1311
1638
  spend: emptySpendTotals(),
1639
+ spendByPlatform: defaultSpendByPlatform(),
1640
+ spendByModel: [],
1312
1641
  totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
1313
1642
  phases: {},
1314
1643
  sessions: [],
@@ -1334,6 +1663,8 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1334
1663
  version: METRICS_VERSION,
1335
1664
  change: changeName,
1336
1665
  spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
1666
+ spendByPlatform: mergeSpendByPlatform(parsed.spendByPlatform),
1667
+ spendByModel: Array.isArray(parsed.spendByModel) ? parsed.spendByModel : [],
1337
1668
  totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
1338
1669
  phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
1339
1670
  sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
@@ -1373,6 +1704,323 @@ function isoOrNull(value) {
1373
1704
  return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
1374
1705
  }
1375
1706
 
1707
+ function sessionFieldOrSources(session, key) {
1708
+ if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
1709
+ let sum = null;
1710
+ for (const src of session.sources || []) {
1711
+ sum = addNullable(sum, numOrNull(src[key]));
1712
+ }
1713
+ return sum;
1714
+ }
1715
+
1716
+ function lastSessionEndedAt(metrics) {
1717
+ let last = null;
1718
+ for (const session of metrics.sessions || []) {
1719
+ if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
1720
+ }
1721
+ return last;
1722
+ }
1723
+
1724
+ function collectWindowStart(metrics) {
1725
+ return lastSessionEndedAt(metrics) || metrics.createdAt;
1726
+ }
1727
+
1728
+ function existingSourceIdSet(metrics) {
1729
+ const ids = new Set();
1730
+ for (const session of metrics.sessions || []) {
1731
+ for (const src of session.sources || []) {
1732
+ if (src && src.id != null && src.id !== '') ids.add(String(src.id));
1733
+ }
1734
+ }
1735
+ return ids;
1736
+ }
1737
+
1738
+ function uniqueSourceModels(sources) {
1739
+ const seen = [];
1740
+ for (const src of sources || []) {
1741
+ if (src.model && !seen.includes(src.model)) seen.push(src.model);
1742
+ }
1743
+ return seen;
1744
+ }
1745
+
1746
+ function rankSources(sources) {
1747
+ return [...sources].sort((a, b) => {
1748
+ const ta = a.totalTokens ?? 0;
1749
+ const tb = b.totalTokens ?? 0;
1750
+ if (tb !== ta) return tb - ta;
1751
+ const platformCmp = String(a.platform || '').localeCompare(String(b.platform || ''));
1752
+ if (platformCmp !== 0) return platformCmp;
1753
+ return String(a.id || '').localeCompare(String(b.id || ''));
1754
+ });
1755
+ }
1756
+
1757
+ function primaryModelFromSources(sources) {
1758
+ if (!sources || !sources.length) return null;
1759
+ const top = rankSources(sources)[0];
1760
+ const model = top && top.model;
1761
+ return model == null || model === '' ? null : String(model);
1762
+ }
1763
+
1764
+ function primaryPlatformFromSources(sources) {
1765
+ if (!sources || !sources.length) return null;
1766
+ const platform = rankSources(sources)[0] && rankSources(sources)[0].platform;
1767
+ return platform && VALID_PLATFORMS.has(platform) ? platform : null;
1768
+ }
1769
+
1770
+ function hasSpendOverride(opts) {
1771
+ return Boolean(
1772
+ opts
1773
+ && (opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null || opts.ampCredits != null),
1774
+ );
1775
+ }
1776
+
1777
+ function reportedHasSpendNumbers(reported) {
1778
+ if (!reported) return false;
1779
+ return (
1780
+ reported.inputTokens != null
1781
+ || reported.outputTokens != null
1782
+ || reported.totalTokens != null
1783
+ || reported.costUsd != null
1784
+ || reported.ampCredits != null
1785
+ );
1786
+ }
1787
+
1788
+ function sourceAmpCredits(sources) {
1789
+ let sum = null;
1790
+ for (const src of sources || []) {
1791
+ sum = addNullable(sum, numOrNull(src.ampCredits));
1792
+ }
1793
+ return sum;
1794
+ }
1795
+
1796
+ function resolveSessionSpend(opts, reported, sources) {
1797
+ const flags = opts || {};
1798
+ const self = reported || emptyMetricsFields();
1799
+ const fromSources = sessionTotalsFromSources(sources || []);
1800
+ const sourceCredits = sourceAmpCredits(sources || []);
1801
+ const flagInput = numOrNull(flags.inputTokens);
1802
+ const flagOutput = numOrNull(flags.outputTokens);
1803
+ const flagTotal = numOrNull(flags.totalTokens);
1804
+ const flagCost = numOrNull(flags.costUsd);
1805
+ const flagCredits = numOrNull(flags.ampCredits);
1806
+ const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
1807
+ const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
1808
+ let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
1809
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1810
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1811
+ }
1812
+ const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
1813
+ const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
1814
+ let spendSource = 'unreported';
1815
+ if (self.spendSource) spendSource = String(self.spendSource);
1816
+ else if (hasSpendOverride(flags)) spendSource = 'flag';
1817
+ else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
1818
+ else if (
1819
+ fromSources.inputTokens != null
1820
+ || fromSources.outputTokens != null
1821
+ || fromSources.totalTokens != null
1822
+ || fromSources.costUsd != null
1823
+ || sourceCredits != null
1824
+ ) {
1825
+ spendSource = 'adapter';
1826
+ }
1827
+ return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
1828
+ }
1829
+
1830
+ function sessionTotalsFromFlags(opts) {
1831
+ const inputTokens = numOrNull(opts.inputTokens);
1832
+ const outputTokens = numOrNull(opts.outputTokens);
1833
+ let totalTokens = numOrNull(opts.totalTokens);
1834
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1835
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1836
+ }
1837
+ return {
1838
+ inputTokens,
1839
+ outputTokens,
1840
+ totalTokens,
1841
+ costUsd: numOrNull(opts.costUsd),
1842
+ };
1843
+ }
1844
+
1845
+ function sessionTotalsFromSources(sources) {
1846
+ let inputTokens = null;
1847
+ let outputTokens = null;
1848
+ let totalTokens = null;
1849
+ let costUsd = null;
1850
+ for (const src of sources || []) {
1851
+ inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
1852
+ outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
1853
+ totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
1854
+ if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
1855
+ }
1856
+ return { inputTokens, outputTokens, totalTokens, costUsd };
1857
+ }
1858
+
1859
+ function runCollectSpend(metrics, windowStart, windowEnd) {
1860
+ try {
1861
+ return collectSpend({
1862
+ cwd: process.cwd(),
1863
+ windowStart,
1864
+ windowEnd,
1865
+ existingSourceIds: existingSourceIdSet(metrics),
1866
+ env: process.env,
1867
+ homedir: process.env.HOME,
1868
+ });
1869
+ } catch {
1870
+ return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
1871
+ }
1872
+ }
1873
+
1874
+ function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
1875
+ session.sources = sources || [];
1876
+ const uniqueModels = uniqueSourceModels(session.sources);
1877
+ if (!session.model) session.model = resolvedModel || primaryModelFromSources(session.sources) || null;
1878
+ if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
1879
+ if (uniqueModels.length > 1) session.models = uniqueModels;
1880
+ const spend = resolveSessionSpend(opts, reported, session.sources);
1881
+ session.inputTokens = spend.inputTokens;
1882
+ session.outputTokens = spend.outputTokens;
1883
+ session.totalTokens = spend.totalTokens;
1884
+ session.costUsd = spend.costUsd;
1885
+ session.ampCredits = spend.ampCredits;
1886
+ session.spendSource = spend.spendSource;
1887
+ }
1888
+
1889
+ function sessionTotalsLookOverridden(session) {
1890
+ const fromSources = sessionTotalsFromSources(session.sources || []);
1891
+ return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
1892
+ const sessionVal = numOrNull(session[key]);
1893
+ const sourceVal = numOrNull(fromSources[key]);
1894
+ if (sessionVal == null) return false;
1895
+ if (sourceVal == null) return true;
1896
+ return sessionVal !== sourceVal;
1897
+ });
1898
+ }
1899
+
1900
+ function metricsBackfillLastSession(projectDir, changeName) {
1901
+ const resolved = resolveMetricsFile(projectDir, changeName);
1902
+ if (resolved.missing) return { filePath: resolved.filePath, added: 0, missing: true };
1903
+ return metricsBackfillFile(resolved.filePath, changeName);
1904
+ }
1905
+
1906
+ function metricsBackfillFile(filePath, changeName) {
1907
+ const nowIso = new Date().toISOString();
1908
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
1909
+ const sessions = metrics.sessions || [];
1910
+ if (!sessions.length) return { filePath, added: 0, empty: true };
1911
+ const last = sessions[sessions.length - 1];
1912
+ const windowStart = last.startedAt || collectWindowStart(metrics);
1913
+ const collected = runCollectSpend(metrics, windowStart, nowIso);
1914
+ const incoming = collected.sources || [];
1915
+ if (!incoming.length) return { filePath, added: 0 };
1916
+ const overridden = sessionTotalsLookOverridden(last);
1917
+ const merged = [...(last.sources || []), ...incoming];
1918
+ const keepReportedTotals = overridden
1919
+ || last.spendSource === 'flag'
1920
+ || last.spendSource === 'self-report'
1921
+ || (last.spendSource && last.spendSource !== 'adapter' && last.spendSource !== 'unreported');
1922
+ if (keepReportedTotals) {
1923
+ last.sources = merged;
1924
+ const uniqueModels = uniqueSourceModels(merged);
1925
+ if (uniqueModels.length > 1) last.models = uniqueModels;
1926
+ } else {
1927
+ applyCollectedSessionFields(last, merged, last.model, {}, {
1928
+ model: last.model,
1929
+ platform: last.platform,
1930
+ });
1931
+ }
1932
+ metrics.updatedAt = nowIso;
1933
+ recomputeMetricsAggregates(metrics);
1934
+ saveMetricsFile(filePath, metrics);
1935
+ return { filePath, added: incoming.length };
1936
+ }
1937
+
1938
+ function adapterSourceName(platform) {
1939
+ if (platform === 'claude') return 'claude-jsonl';
1940
+ if (platform === 'amp') return 'amp-thread';
1941
+ if (platform === 'cursor') return 'cursor-hook';
1942
+ return null;
1943
+ }
1944
+
1945
+ function spendTuple(obj) {
1946
+ return {
1947
+ inputTokens: numOrNull(obj && obj.inputTokens),
1948
+ outputTokens: numOrNull(obj && obj.outputTokens),
1949
+ totalTokens: numOrNull(obj && obj.totalTokens),
1950
+ costUsd: numOrNull(obj && obj.costUsd),
1951
+ ampCredits: numOrNull(obj && obj.ampCredits),
1952
+ };
1953
+ }
1954
+
1955
+ function spendTuplesMatch(a, b) {
1956
+ return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].every((key) => {
1957
+ const left = a[key];
1958
+ const right = b[key];
1959
+ if (left == null && right == null) return true;
1960
+ return left === right;
1961
+ });
1962
+ }
1963
+
1964
+ function addSpendNums(target, nums) {
1965
+ target.inputTokens = addNullable(target.inputTokens, nums.inputTokens);
1966
+ target.outputTokens = addNullable(target.outputTokens, nums.outputTokens);
1967
+ target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
1968
+ target.costUsd = addNullable(target.costUsd, nums.costUsd);
1969
+ target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
1970
+ }
1971
+
1972
+ function recomputeSpendMaps(metrics) {
1973
+ const byPlatform = defaultSpendByPlatform();
1974
+ const byModel = new Map();
1975
+
1976
+ function addModelRow(model, platform, nums) {
1977
+ if (!model) return;
1978
+ const key = `${model}::${platform || ''}`;
1979
+ const row = byModel.get(key) || {
1980
+ model,
1981
+ platform: platform || null,
1982
+ inputTokens: null,
1983
+ outputTokens: null,
1984
+ totalTokens: null,
1985
+ costUsd: null,
1986
+ ampCredits: null,
1987
+ };
1988
+ addSpendNums(row, nums);
1989
+ byModel.set(key, row);
1990
+ }
1991
+
1992
+ function addSourceRow(src) {
1993
+ const nums = spendTuple(src);
1994
+ if (src.platform && byPlatform[src.platform]) {
1995
+ addSpendNums(byPlatform[src.platform], nums);
1996
+ const label = adapterSourceName(src.platform);
1997
+ if (label) byPlatform[src.platform].source = label;
1998
+ }
1999
+ addModelRow(src.model, src.platform, nums);
2000
+ }
2001
+
2002
+ for (const session of metrics.sessions || []) {
2003
+ const sessionNums = spendTuple(session);
2004
+ const sourceTotals = sessionTotalsFromSources(session.sources || []);
2005
+ sourceTotals.ampCredits = sourceAmpCredits(session.sources || []);
2006
+ const sources = session.sources || [];
2007
+ const sourcesMatchSession = sources.length > 0 && spendTuplesMatch(sessionNums, sourceTotals);
2008
+
2009
+ if (sourcesMatchSession) {
2010
+ for (const src of sources) addSourceRow(src);
2011
+ continue;
2012
+ }
2013
+
2014
+ if (session.platform && byPlatform[session.platform]) {
2015
+ addSpendNums(byPlatform[session.platform], sessionNums);
2016
+ }
2017
+ addModelRow(session.model, session.platform, sessionNums);
2018
+ for (const src of sources) addSourceRow(src);
2019
+ }
2020
+ metrics.spendByPlatform = byPlatform;
2021
+ metrics.spendByModel = [...byModel.values()];
2022
+ }
2023
+
1376
2024
  function recomputeMetricsAggregates(metrics) {
1377
2025
  const phases = {};
1378
2026
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
@@ -1390,12 +2038,17 @@ function recomputeMetricsAggregates(metrics) {
1390
2038
  phase.sessions += 1;
1391
2039
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
1392
2040
  for (const spendKey of METRICS_SPEND_KEYS) {
1393
- const value = numOrNull(session[spendKey]);
2041
+ const value = sessionFieldOrSources(session, spendKey);
1394
2042
  phase[spendKey] = addNullable(phase[spendKey], value);
1395
2043
  spend[spendKey] = addNullable(spend[spendKey], value);
1396
2044
  }
1397
2045
  if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
1398
2046
  if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
2047
+ if (Array.isArray(session.models)) {
2048
+ for (const model of session.models) {
2049
+ if (model && !phase.models.includes(model)) phase.models.push(model);
2050
+ }
2051
+ }
1399
2052
  phases[key] = phase;
1400
2053
  }
1401
2054
  if (firstStart && lastEnd) {
@@ -1404,6 +2057,7 @@ function recomputeMetricsAggregates(metrics) {
1404
2057
  metrics.phases = phases;
1405
2058
  metrics.totals = totals;
1406
2059
  metrics.spend = spend;
2060
+ recomputeSpendMaps(metrics);
1407
2061
  }
1408
2062
 
1409
2063
  function metricsRecordSessionStart(projectDir, changeName, role) {
@@ -1422,13 +2076,13 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1422
2076
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
1423
2077
  const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1424
2078
  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({
2079
+ const reported = opts.reported || fields.metrics || emptyMetricsFields();
2080
+ const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2081
+ const windowStart = collectWindowStart(metrics);
2082
+ const collected = opts.collect === true
2083
+ ? runCollectSpend(metrics, windowStart, nowIso)
2084
+ : { sources: [] };
2085
+ const session = {
1432
2086
  startedAt,
1433
2087
  endedAt: nowIso,
1434
2088
  durationMs,
@@ -1436,30 +2090,73 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1436
2090
  phase: phaseForRole(fields.closedRole),
1437
2091
  runtime: fields.runtime || 'local',
1438
2092
  agentId: fields.agentId || 'none',
1439
- model: opts.model || null,
2093
+ model: resolvedModel || null,
2094
+ platform: opts.platform || null,
1440
2095
  tasks: fields.tasks || null,
1441
- inputTokens,
1442
- outputTokens,
1443
- totalTokens,
1444
- costUsd: numOrNull(opts.costUsd),
1445
- });
2096
+ sources: [],
2097
+ inputTokens: null,
2098
+ outputTokens: null,
2099
+ totalTokens: null,
2100
+ costUsd: null,
2101
+ ampCredits: null,
2102
+ spendSource: 'unreported',
2103
+ };
2104
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2105
+ metrics.sessions.push(session);
1446
2106
  metrics.pending = null;
1447
2107
  metrics.updatedAt = nowIso;
1448
2108
  recomputeMetricsAggregates(metrics);
1449
2109
  saveMetricsFile(filePath, metrics);
2110
+ if (opts.collect === true) metricsBackfillFile(filePath, fields.changeName);
2111
+ const latest = loadMetricsFile(filePath, fields.changeName, nowIso);
2112
+ const last = (latest.sessions || []).at(-1);
2113
+ if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
2114
+ if (!last || last.model == null) warnMissingModel();
1450
2115
  return filePath;
1451
2116
  }
1452
2117
 
1453
- function metricsFinalizeArchive(targetDir, changeName) {
2118
+ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1454
2119
  const filePath = join(targetDir, 'metrics.json');
1455
- if (!existsSync(filePath)) return null;
1456
2120
  const nowIso = new Date().toISOString();
1457
2121
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2122
+ const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
2123
+ const collected = opts.collect === true
2124
+ ? runCollectSpend(metrics, windowStart, nowIso)
2125
+ : { sources: [] };
2126
+ const reported = opts.reported || emptyMetricsFields();
2127
+ const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2128
+ const session = {
2129
+ startedAt: nowIso,
2130
+ endedAt: nowIso,
2131
+ durationMs: null,
2132
+ role: 'Archiver',
2133
+ phase: phaseForRole('Archiver'),
2134
+ runtime: opts.runtime || 'local',
2135
+ agentId: opts.agentId || 'none',
2136
+ model: resolvedModel || null,
2137
+ platform: opts.platform || null,
2138
+ tasks: opts.tasks || null,
2139
+ sources: [],
2140
+ inputTokens: null,
2141
+ outputTokens: null,
2142
+ totalTokens: null,
2143
+ costUsd: null,
2144
+ ampCredits: null,
2145
+ spendSource: 'unreported',
2146
+ };
2147
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2148
+ metrics.sessions.push(session);
1458
2149
  metrics.archivedAt = nowIso;
1459
2150
  metrics.pending = null;
1460
2151
  metrics.updatedAt = nowIso;
1461
2152
  recomputeMetricsAggregates(metrics);
1462
2153
  saveMetricsFile(filePath, metrics);
2154
+ if (opts.collect === true) metricsBackfillFile(filePath, changeName);
2155
+ const latest = loadMetricsFile(filePath, changeName, nowIso);
2156
+ const last = (latest.sessions || []).at(-1);
2157
+ if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
2158
+ if (!last || last.model == null) warnMissingModel();
2159
+ if (latest.spend.costUsd === null) warnMissingUsd();
1463
2160
  return filePath;
1464
2161
  }
1465
2162
 
@@ -1482,6 +2179,92 @@ function formatMetricsCost(value) {
1482
2179
  return value == null ? '—' : `$${Number(value).toFixed(2)}`;
1483
2180
  }
1484
2181
 
2182
+ function sessionSpendSourceLabel(session) {
2183
+ const raw = session && session.spendSource;
2184
+ if (raw == null || String(raw).trim() === '') return 'unreported';
2185
+ return String(raw);
2186
+ }
2187
+
2188
+ function renderMetricsSummary(metrics) {
2189
+ const lines = [];
2190
+ const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
2191
+ const unreported = sessions.filter((session) => sessionSpendSourceLabel(session) === 'unreported').length;
2192
+ lines.push(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
2193
+ lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
2194
+ lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
2195
+ lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
2196
+ lines.push(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
2197
+ lines.push(`unreported: ${unreported}`);
2198
+ if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
2199
+ if (metrics.pending) {
2200
+ lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
2201
+ }
2202
+
2203
+ const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
2204
+ const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
2205
+ if (phaseKeys.length) {
2206
+ lines.push('');
2207
+ lines.push('phase sessions time tokens cost roles models');
2208
+ for (const key of phaseKeys) {
2209
+ const phase = metrics.phases[key];
2210
+ lines.push([
2211
+ key.padEnd(10),
2212
+ String(phase.sessions).padEnd(9),
2213
+ formatMetricsDuration(phase.durationMs).padEnd(9),
2214
+ formatMetricsNumber(phase.totalTokens).padEnd(9),
2215
+ formatMetricsCost(phase.costUsd).padEnd(9),
2216
+ (phase.agents.join(', ') || '—').padEnd(20),
2217
+ phase.models.join(', ') || '—',
2218
+ ].join(' '));
2219
+ }
2220
+ }
2221
+
2222
+ const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
2223
+ lines.push('');
2224
+ lines.push('by platform:');
2225
+ lines.push('platform tokens cost credits source');
2226
+ for (const key of ['cursor', 'claude', 'amp']) {
2227
+ const row = byPlatform[key] || emptyPlatformSpend();
2228
+ lines.push([
2229
+ key.padEnd(10),
2230
+ formatMetricsNumber(row.totalTokens).padEnd(9),
2231
+ formatMetricsCost(row.costUsd).padEnd(9),
2232
+ formatMetricsNumber(row.ampCredits).padEnd(9),
2233
+ row.source || 'none',
2234
+ ].join(' '));
2235
+ }
2236
+
2237
+ lines.push('');
2238
+ lines.push('by model:');
2239
+ lines.push('model platform tokens cost credits');
2240
+ const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
2241
+ if (!byModel.length) {
2242
+ lines.push('— — — — —');
2243
+ } else {
2244
+ for (const row of byModel) {
2245
+ lines.push([
2246
+ String(row.model || '—').padEnd(20),
2247
+ String(row.platform || '—').padEnd(10),
2248
+ formatMetricsNumber(row.totalTokens).padEnd(9),
2249
+ formatMetricsCost(row.costUsd).padEnd(9),
2250
+ formatMetricsNumber(row.ampCredits),
2251
+ ].join(' '));
2252
+ }
2253
+ }
2254
+
2255
+ if (sessions.length) {
2256
+ lines.push('');
2257
+ lines.push('recent sessions:');
2258
+ for (const session of sessions.slice(-5)) {
2259
+ const spendLabel = session.totalTokens != null || session.costUsd != null
2260
+ ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
2261
+ : '';
2262
+ lines.push(`- ${session.endedAt} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''} (${sessionSpendSourceLabel(session)})${spendLabel}`);
2263
+ }
2264
+ }
2265
+ return lines;
2266
+ }
2267
+
1485
2268
  function resolveMetricsFile(projectDir, changeName) {
1486
2269
  const activePath = metricsFilePath(projectDir, changeName);
1487
2270
  if (existsSync(activePath)) return { filePath: activePath, archived: false };
@@ -2352,6 +3135,9 @@ program
2352
3135
  refreshMemoryManagedFiles(projectDir);
2353
3136
  ensureMemoryMcpEntry(projectDir);
2354
3137
 
3138
+ log.title('Configuring Cursor spend hook');
3139
+ reportCursorSpendHook(projectDir, log);
3140
+
2355
3141
  if (opts.hooks) {
2356
3142
  log.title('Installing pre-commit gate');
2357
3143
  const hookResult = runHooksSetup(projectDir);
@@ -2414,6 +3200,8 @@ program
2414
3200
  log.title('Configuring Memory MCP');
2415
3201
  refreshMemoryManagedFiles(projectDir);
2416
3202
  ensureMemoryMcpEntry(projectDir);
3203
+ log.title('Configuring Cursor spend hook');
3204
+ reportCursorSpendHook(projectDir, log);
2417
3205
  mergeGitignore(projectDir, GITIGNORE_LINES);
2418
3206
 
2419
3207
  log.ok(`Updated to v${KIT_VERSION}`);
@@ -2476,6 +3264,9 @@ program
2476
3264
  log.title('Configuring Memory MCP');
2477
3265
  ensureMemoryMcpEntry(projectDir);
2478
3266
 
3267
+ log.title('Configuring Cursor spend hook');
3268
+ reportCursorSpendHook(projectDir, log);
3269
+
2479
3270
  mergeGitignore(projectDir, GITIGNORE_LINES);
2480
3271
 
2481
3272
  log.ok('Sync complete');
@@ -2512,6 +3303,7 @@ program
2512
3303
  }
2513
3304
 
2514
3305
  printMcpHealth(projectDir);
3306
+ printSpendHealth(projectDir);
2515
3307
  printSkillHealth(projectDir);
2516
3308
  });
2517
3309
 
@@ -2640,6 +3432,13 @@ program
2640
3432
  .option('--sync', 'merge delta specs into openspec/specs/ before archiving')
2641
3433
  .option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
2642
3434
  .option('--force', 'confirm archiving without merge when delta specs exist', false)
3435
+ .option('--model <name>', 'LLM product id recorded on the Archiver session')
3436
+ .option('--platform <platform>', 'Session platform: cursor | claude | amp')
3437
+ .option('--input-tokens <n>', 'Input tokens spent in the Archiver session')
3438
+ .option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
3439
+ .option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
3440
+ .option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
3441
+ .option('--collect', 'Additionally collect local spend adapters', false)
2643
3442
  .action((name, opts) => {
2644
3443
  const projectDir = process.cwd();
2645
3444
  const fail = (msg) => {
@@ -2650,6 +3449,15 @@ program
2650
3449
 
2651
3450
  if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
2652
3451
 
3452
+ const platformResult = resolvePlatform(opts, process.env);
3453
+ if (platformResult.error) {
3454
+ log.err(platformResult.error);
3455
+ process.exitCode = 1;
3456
+ return;
3457
+ }
3458
+ if (platformResult.warn) console.error(platformResult.warn);
3459
+ const resolvedModel = resolveModel(opts, process.env);
3460
+
2653
3461
  let status;
2654
3462
  try {
2655
3463
  const out = execSync(`npx openspec status --change ${name} --json`, {
@@ -2750,6 +3558,11 @@ program
2750
3558
  if (existsSync(archivedHandoffPath)) {
2751
3559
  priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
2752
3560
  }
3561
+ const reported = priorFields.metrics || emptyMetricsFields();
3562
+ const archivePlatform = resolvePlatform(opts, process.env, reported);
3563
+ const archiveModel = resolveModel(opts, process.env, reported);
3564
+ if (archivePlatform.warn) console.error(archivePlatform.warn);
3565
+ printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
2753
3566
  const runtimeResult = resolveRuntime({}, process.env, priorFields);
2754
3567
  const progress = parseTasksProgress(targetDir);
2755
3568
  const fields = {
@@ -2766,6 +3579,7 @@ program
2766
3579
  constraints: 'Pipeline complete — no next session.',
2767
3580
  runtime: runtimeResult.value || 'local',
2768
3581
  agentId: resolveAgentId({}, process.env, priorFields),
3582
+ metrics: reported,
2769
3583
  status: 'archived',
2770
3584
  tasks: progress ? `${progress.done}/${progress.total}` : '',
2771
3585
  review: parseReviewVerdict(targetDir) || '',
@@ -2773,7 +3587,19 @@ program
2773
3587
  };
2774
3588
  writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
2775
3589
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
2776
- const metricsPath = metricsFinalizeArchive(targetDir, name);
3590
+ const metricsPath = metricsFinalizeArchive(targetDir, name, {
3591
+ model: archiveModel,
3592
+ platform: archivePlatform.value || null,
3593
+ runtime: fields.runtime,
3594
+ agentId: fields.agentId,
3595
+ tasks: fields.tasks,
3596
+ collect: opts.collect === true,
3597
+ inputTokens: opts.inputTokens,
3598
+ outputTokens: opts.outputTokens,
3599
+ totalTokens: opts.totalTokens,
3600
+ costUsd: opts.costUsd,
3601
+ reported,
3602
+ });
2777
3603
 
2778
3604
  console.log(`change: ${name}`);
2779
3605
  console.log(`schema: ${status.schemaName || 'unknown'}`);
@@ -2781,7 +3607,11 @@ program
2781
3607
  console.log(`sync: ${syncStatus}`);
2782
3608
  console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
2783
3609
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
2784
- if (metricsPath) console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
3610
+ console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
3611
+ try {
3612
+ const archivedMetrics = loadMetricsFile(metricsPath, name, new Date().toISOString());
3613
+ for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
3614
+ } catch {}
2785
3615
  log.ok(`archived ${name}`);
2786
3616
  });
2787
3617
 
@@ -2982,12 +3812,14 @@ program
2982
3812
  .option('--agent-id <id>', 'Cloud agent identifier')
2983
3813
  .option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
2984
3814
  .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)')
3815
+ .option('--model <name>', 'LLM product id recorded in metrics.json')
3816
+ .option('--platform <platform>', 'Session platform: cursor | claude | amp')
2986
3817
  .option('--input-tokens <n>', 'Input tokens spent in this session')
2987
3818
  .option('--output-tokens <n>', 'Output tokens spent in this session')
2988
3819
  .option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
2989
3820
  .option('--cost-usd <usd>', 'Cost of this session in USD')
2990
3821
  .option('--no-metrics', 'Skip recording this session into metrics.json')
3822
+ .option('--collect', 'Additionally collect local spend adapters', false)
2991
3823
  .action((changeName, opts) => {
2992
3824
  const projectDir = process.cwd();
2993
3825
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -3115,6 +3947,17 @@ program
3115
3947
  return;
3116
3948
  }
3117
3949
 
3950
+ const reported = fields.metrics || emptyMetricsFields();
3951
+ const platformResult = resolvePlatform(opts, process.env, reported);
3952
+ if (platformResult.error) {
3953
+ log.err(platformResult.error);
3954
+ process.exitCode = 1;
3955
+ return;
3956
+ }
3957
+ if (platformResult.warn) console.error(platformResult.warn);
3958
+ printMetricsSectionWarnings(reported, Boolean(platformResult.warn));
3959
+ const resolvedModel = resolveModel(opts, process.env, reported);
3960
+
3118
3961
  const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
3119
3962
  fields.prompt = prompt;
3120
3963
  writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
@@ -3127,11 +3970,14 @@ program
3127
3970
  if (opts.metrics !== false) {
3128
3971
  const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
3129
3972
  startedAt: opts.startedAt,
3130
- model: opts.model,
3973
+ model: resolvedModel,
3974
+ platform: platformResult.value || null,
3131
3975
  inputTokens: opts.inputTokens,
3132
3976
  outputTokens: opts.outputTokens,
3133
3977
  totalTokens: opts.totalTokens,
3134
3978
  costUsd: opts.costUsd,
3979
+ collect: opts.collect === true,
3980
+ reported,
3135
3981
  });
3136
3982
  console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
3137
3983
  }
@@ -3144,11 +3990,13 @@ program
3144
3990
 
3145
3991
  program
3146
3992
  .command('metrics [change-name]')
3147
- .description('Show recorded session metrics for a change: time per phase, tokens, cost, agents, models')
3993
+ .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
3148
3994
  .option('--json', 'Print raw metrics.json', false)
3995
+ .option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
3149
3996
  .action((changeName, opts) => {
3150
3997
  const projectDir = process.cwd();
3151
3998
  let name = changeName;
3999
+ let collectedAlready = false;
3152
4000
  if (!name) {
3153
4001
  const resolved = resolveHandoffChange(projectDir, changeName);
3154
4002
  if (!resolved) {
@@ -3157,11 +4005,37 @@ program
3157
4005
  return;
3158
4006
  }
3159
4007
  if (resolved.ambiguous) {
3160
- log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
4008
+ if (opts.collect) {
4009
+ let added = 0;
4010
+ for (const change of resolved.ambiguous) {
4011
+ const result = metricsBackfillLastSession(projectDir, change);
4012
+ added += result.added || 0;
4013
+ }
4014
+ collectedAlready = true;
4015
+ if (!opts.json) log.ok(`collect: ${added} new source(s) across ${resolved.ambiguous.length} changes`);
4016
+ if (opts.json) {
4017
+ process.stdout.write(`${JSON.stringify({ collected: added, changes: resolved.ambiguous }, null, 2)}\n`);
4018
+ return;
4019
+ }
4020
+ name = resolved.ambiguous[0];
4021
+ } else {
4022
+ log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
4023
+ process.exitCode = 1;
4024
+ return;
4025
+ }
4026
+ } else {
4027
+ name = resolved;
4028
+ }
4029
+ }
4030
+
4031
+ if (opts.collect && name && !collectedAlready) {
4032
+ const result = metricsBackfillLastSession(projectDir, name);
4033
+ if (result.missing) {
4034
+ log.err(`No metrics.json for ${name}`);
3161
4035
  process.exitCode = 1;
3162
4036
  return;
3163
4037
  }
3164
- name = resolved;
4038
+ if (!opts.json) log.ok(`collect: ${result.added} new source(s) on last session`);
3165
4039
  }
3166
4040
 
3167
4041
  const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
@@ -3181,43 +4055,7 @@ program
3181
4055
 
3182
4056
  log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
3183
4057
  console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
3184
- console.log(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
3185
- console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
3186
- console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
3187
- console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
3188
- console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
3189
- if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
3190
- if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
3191
-
3192
- const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
3193
- const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
3194
- if (phaseKeys.length) {
3195
- console.log('');
3196
- console.log('phase sessions time tokens cost agents');
3197
- for (const key of phaseKeys) {
3198
- const phase = metrics.phases[key];
3199
- const cols = [
3200
- key.padEnd(10),
3201
- String(phase.sessions).padEnd(9),
3202
- formatMetricsDuration(phase.durationMs).padEnd(9),
3203
- formatMetricsNumber(phase.totalTokens).padEnd(9),
3204
- formatMetricsCost(phase.costUsd).padEnd(9),
3205
- phase.agents.join(', ') || '—',
3206
- ];
3207
- console.log(cols.join(' '));
3208
- }
3209
- }
3210
-
3211
- if (metrics.sessions.length) {
3212
- console.log('');
3213
- console.log('recent sessions:');
3214
- for (const session of metrics.sessions.slice(-5)) {
3215
- const spendLabel = session.totalTokens != null || session.costUsd != null
3216
- ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
3217
- : '';
3218
- console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
3219
- }
3220
- }
4058
+ for (const line of renderMetricsSummary(metrics)) console.log(line);
3221
4059
  });
3222
4060
 
3223
4061
  program.parse();