agent-orchestrator-kit 0.6.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.
@@ -87,9 +87,12 @@ const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
87
87
  const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
88
88
  const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
89
89
  const CURSOR_SPEND_HOOK_REL = join('scripts', 'cursor-spend-hook.cjs');
90
+ const CURSOR_SPEND_COLLECT_REL = join('scripts', 'cursor-spend-collect.cjs');
90
91
  const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
91
92
  const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
92
- const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop'];
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'];
93
96
  const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
94
97
  const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
95
98
  const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
@@ -159,6 +162,7 @@ const HANDOFF_SECTIONS = [
159
162
  'Subagents to spawn',
160
163
  'Constraints',
161
164
  'Runtime',
165
+ 'Metrics',
162
166
  'Prompt',
163
167
  ];
164
168
  const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
@@ -368,6 +372,12 @@ function refreshOptionalMcpManagedFiles(projectDir) {
368
372
  refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
369
373
  }
370
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
+
371
381
  function cursorSpendHookEntryOk(projectDir) {
372
382
  const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
373
383
  if (!existsSync(hooksPath)) return false;
@@ -379,24 +389,39 @@ function cursorSpendHookEntryOk(projectDir) {
379
389
  }
380
390
  const hooks = config && typeof config === 'object' ? config.hooks : null;
381
391
  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
- });
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;
387
395
  }
388
396
 
389
397
  // Mandatory spend capture: every kit project must record Cursor token usage
390
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
+
391
421
  function ensureCursorSpendHook(projectDir) {
392
422
  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
- }
423
+ if (ensureManagedScript(projectDir, CURSOR_SPEND_HOOK_REL)) result.script = true;
424
+ ensureManagedScript(projectDir, CURSOR_SPEND_COLLECT_REL);
400
425
 
401
426
  const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
402
427
  let config = { version: 1, hooks: {} };
@@ -412,15 +437,8 @@ function ensureCursorSpendHook(projectDir) {
412
437
  if (config.version == null) config.version = 1;
413
438
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
414
439
  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
- }
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;
424
442
  if (changed) {
425
443
  mkdirSync(dirname(hooksPath), { recursive: true });
426
444
  writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
@@ -436,7 +454,7 @@ function reportCursorSpendHook(projectDir, emit) {
436
454
  return result;
437
455
  }
438
456
  if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
439
- if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop + subagentStop spend hook)`);
457
+ if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop / subagentStop / afterAgentResponse + sessionEnd collect)`);
440
458
  return result;
441
459
  }
442
460
 
@@ -457,7 +475,7 @@ function printSpendHealth(projectDir) {
457
475
  const records = countCursorUsageRecords(projectDir);
458
476
  const cursorState = scriptOk && entryOk
459
477
  ? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
460
- : 'not configured run npx agent-orchestrator-kit update';
478
+ : 'optionalnot configured (init/update/sync/mcp-setup)';
461
479
  console.log(` cursor ${cursorState}`);
462
480
  const home = process.env.HOME || '';
463
481
  const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
@@ -1005,16 +1023,135 @@ function applyRuntimeToFields(fields, opts, env) {
1005
1023
  }
1006
1024
 
1007
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
+ }
1008
1117
 
1009
- function resolveModel(opts, env) {
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) {
1010
1126
  const flag = opts && opts.model != null ? String(opts.model).trim() : '';
1011
1127
  if (flag) return flag;
1128
+ const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
1129
+ if (fromReport) return fromReport;
1012
1130
  const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
1013
1131
  if (fromEnv) return fromEnv;
1014
1132
  return null;
1015
1133
  }
1016
1134
 
1017
- function resolvePlatform(opts, env) {
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) {
1018
1155
  const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
1019
1156
  if (flag) {
1020
1157
  const lower = flag.toLowerCase();
@@ -1023,13 +1160,19 @@ function resolvePlatform(opts, env) {
1023
1160
  }
1024
1161
  return { value: lower };
1025
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
+ }
1026
1169
  const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
1027
1170
  if (fromEnv) {
1028
1171
  const lower = fromEnv.toLowerCase();
1029
1172
  if (VALID_PLATFORMS.has(lower)) return { value: lower };
1030
1173
  return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
1031
1174
  }
1032
- return { value: null };
1175
+ return { value: inferPlatformFromHost(env) };
1033
1176
  }
1034
1177
 
1035
1178
  function warnMissingModel() {
@@ -1040,6 +1183,12 @@ function warnMissingUsd() {
1040
1183
  console.error('metrics: spend.costUsd is null — USD spend was not collected');
1041
1184
  }
1042
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
+
1043
1192
  function gitTry(projectDir, command) {
1044
1193
  try {
1045
1194
  const stdout = execSync(command, {
@@ -1140,7 +1289,9 @@ ${fields.constraints}
1140
1289
 
1141
1290
  ## Runtime
1142
1291
  - runtime: ${runtime}
1143
- - agent_id: ${agentId}${prompt}
1292
+ - agent_id: ${agentId}
1293
+
1294
+ ${renderMetricsSection(fields.metrics)}${prompt}
1144
1295
  `;
1145
1296
  }
1146
1297
 
@@ -1161,6 +1312,7 @@ function fieldsFromSections(changeName, sections, extra = {}) {
1161
1312
  constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
1162
1313
  runtime: extra.runtime || runtimeParsed.runtime,
1163
1314
  agentId: extra.agentId || runtimeParsed.agentId,
1315
+ metrics: extra.metrics || parseMetricsSection(sectionOr(sections, 'Metrics', '')),
1164
1316
  status: extra.status || '',
1165
1317
  tasks: extra.tasks || '',
1166
1318
  review: extra.review || '',
@@ -1569,6 +1721,10 @@ function lastSessionEndedAt(metrics) {
1569
1721
  return last;
1570
1722
  }
1571
1723
 
1724
+ function collectWindowStart(metrics) {
1725
+ return lastSessionEndedAt(metrics) || metrics.createdAt;
1726
+ }
1727
+
1572
1728
  function existingSourceIdSet(metrics) {
1573
1729
  const ids = new Set();
1574
1730
  for (const session of metrics.sessions || []) {
@@ -1587,9 +1743,8 @@ function uniqueSourceModels(sources) {
1587
1743
  return seen;
1588
1744
  }
1589
1745
 
1590
- function primaryModelFromSources(sources) {
1591
- if (!sources || !sources.length) return null;
1592
- const ranked = [...sources].sort((a, b) => {
1746
+ function rankSources(sources) {
1747
+ return [...sources].sort((a, b) => {
1593
1748
  const ta = a.totalTokens ?? 0;
1594
1749
  const tb = b.totalTokens ?? 0;
1595
1750
  if (tb !== ta) return tb - ta;
@@ -1597,12 +1752,79 @@ function primaryModelFromSources(sources) {
1597
1752
  if (platformCmp !== 0) return platformCmp;
1598
1753
  return String(a.id || '').localeCompare(String(b.id || ''));
1599
1754
  });
1600
- const model = ranked[0] && ranked[0].model;
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;
1601
1761
  return model == null || model === '' ? null : String(model);
1602
1762
  }
1603
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
+
1604
1770
  function hasSpendOverride(opts) {
1605
- return opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null;
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 };
1606
1828
  }
1607
1829
 
1608
1830
  function sessionTotalsFromFlags(opts) {
@@ -1649,54 +1871,151 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
1649
1871
  }
1650
1872
  }
1651
1873
 
1652
- function applyCollectedSessionFields(session, sources, resolvedModel, opts) {
1653
- session.sources = sources;
1654
- const uniqueModels = uniqueSourceModels(sources);
1655
- session.model = primaryModelFromSources(sources) || resolvedModel || null;
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;
1656
1879
  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;
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);
1662
1970
  }
1663
1971
 
1664
1972
  function recomputeSpendMaps(metrics) {
1665
1973
  const byPlatform = defaultSpendByPlatform();
1666
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
+
1667
2002
  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
- }
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;
1699
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);
1700
2019
  }
1701
2020
  metrics.spendByPlatform = byPlatform;
1702
2021
  metrics.spendByModel = [...byModel.values()];
@@ -1757,11 +2076,12 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1757
2076
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
1758
2077
  const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1759
2078
  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);
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: [] };
1765
2085
  const session = {
1766
2086
  startedAt,
1767
2087
  endedAt: nowIso,
@@ -1778,14 +2098,20 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1778
2098
  outputTokens: null,
1779
2099
  totalTokens: null,
1780
2100
  costUsd: null,
2101
+ ampCredits: null,
2102
+ spendSource: 'unreported',
1781
2103
  };
1782
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts);
2104
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
1783
2105
  metrics.sessions.push(session);
1784
2106
  metrics.pending = null;
1785
2107
  metrics.updatedAt = nowIso;
1786
2108
  recomputeMetricsAggregates(metrics);
1787
- if (session.model == null) warnMissingModel();
1788
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();
1789
2115
  return filePath;
1790
2116
  }
1791
2117
 
@@ -1794,10 +2120,11 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1794
2120
  const nowIso = new Date().toISOString();
1795
2121
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
1796
2122
  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;
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;
1801
2128
  const session = {
1802
2129
  startedAt: nowIso,
1803
2130
  endedAt: nowIso,
@@ -1814,16 +2141,22 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1814
2141
  outputTokens: null,
1815
2142
  totalTokens: null,
1816
2143
  costUsd: null,
2144
+ ampCredits: null,
2145
+ spendSource: 'unreported',
1817
2146
  };
1818
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, {});
2147
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
1819
2148
  metrics.sessions.push(session);
1820
2149
  metrics.archivedAt = nowIso;
1821
2150
  metrics.pending = null;
1822
2151
  metrics.updatedAt = nowIso;
1823
2152
  recomputeMetricsAggregates(metrics);
1824
- if (session.model == null) warnMissingModel();
1825
- if (metrics.spend.costUsd === null) warnMissingUsd();
1826
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();
1827
2160
  return filePath;
1828
2161
  }
1829
2162
 
@@ -1846,6 +2179,92 @@ function formatMetricsCost(value) {
1846
2179
  return value == null ? '—' : `$${Number(value).toFixed(2)}`;
1847
2180
  }
1848
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
+
1849
2268
  function resolveMetricsFile(projectDir, changeName) {
1850
2269
  const activePath = metricsFilePath(projectDir, changeName);
1851
2270
  if (existsSync(activePath)) return { filePath: activePath, archived: false };
@@ -3015,7 +3434,11 @@ program
3015
3434
  .option('--force', 'confirm archiving without merge when delta specs exist', false)
3016
3435
  .option('--model <name>', 'LLM product id recorded on the Archiver session')
3017
3436
  .option('--platform <platform>', 'Session platform: cursor | claude | amp')
3018
- .option('--no-collect', 'Skip local spend adapters when finalizing metrics')
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)
3019
3442
  .action((name, opts) => {
3020
3443
  const projectDir = process.cwd();
3021
3444
  const fail = (msg) => {
@@ -3135,6 +3558,11 @@ program
3135
3558
  if (existsSync(archivedHandoffPath)) {
3136
3559
  priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
3137
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));
3138
3566
  const runtimeResult = resolveRuntime({}, process.env, priorFields);
3139
3567
  const progress = parseTasksProgress(targetDir);
3140
3568
  const fields = {
@@ -3151,6 +3579,7 @@ program
3151
3579
  constraints: 'Pipeline complete — no next session.',
3152
3580
  runtime: runtimeResult.value || 'local',
3153
3581
  agentId: resolveAgentId({}, process.env, priorFields),
3582
+ metrics: reported,
3154
3583
  status: 'archived',
3155
3584
  tasks: progress ? `${progress.done}/${progress.total}` : '',
3156
3585
  review: parseReviewVerdict(targetDir) || '',
@@ -3159,12 +3588,17 @@ program
3159
3588
  writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
3160
3589
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3161
3590
  const metricsPath = metricsFinalizeArchive(targetDir, name, {
3162
- model: resolvedModel,
3163
- platform: platformResult.value || null,
3591
+ model: archiveModel,
3592
+ platform: archivePlatform.value || null,
3164
3593
  runtime: fields.runtime,
3165
3594
  agentId: fields.agentId,
3166
3595
  tasks: fields.tasks,
3167
- collect: opts.collect !== false,
3596
+ collect: opts.collect === true,
3597
+ inputTokens: opts.inputTokens,
3598
+ outputTokens: opts.outputTokens,
3599
+ totalTokens: opts.totalTokens,
3600
+ costUsd: opts.costUsd,
3601
+ reported,
3168
3602
  });
3169
3603
 
3170
3604
  console.log(`change: ${name}`);
@@ -3174,6 +3608,10 @@ program
3174
3608
  console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
3175
3609
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
3176
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 {}
3177
3615
  log.ok(`archived ${name}`);
3178
3616
  });
3179
3617
 
@@ -3381,7 +3819,7 @@ program
3381
3819
  .option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
3382
3820
  .option('--cost-usd <usd>', 'Cost of this session in USD')
3383
3821
  .option('--no-metrics', 'Skip recording this session into metrics.json')
3384
- .option('--no-collect', 'Skip local spend adapters for this persist')
3822
+ .option('--collect', 'Additionally collect local spend adapters', false)
3385
3823
  .action((changeName, opts) => {
3386
3824
  const projectDir = process.cwd();
3387
3825
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -3442,9 +3880,6 @@ program
3442
3880
  const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
3443
3881
  log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
3444
3882
  }
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');
3448
3883
  return;
3449
3884
  }
3450
3885
 
@@ -3512,14 +3947,16 @@ program
3512
3947
  return;
3513
3948
  }
3514
3949
 
3515
- const platformResult = resolvePlatform(opts, process.env);
3950
+ const reported = fields.metrics || emptyMetricsFields();
3951
+ const platformResult = resolvePlatform(opts, process.env, reported);
3516
3952
  if (platformResult.error) {
3517
3953
  log.err(platformResult.error);
3518
3954
  process.exitCode = 1;
3519
3955
  return;
3520
3956
  }
3521
3957
  if (platformResult.warn) console.error(platformResult.warn);
3522
- const resolvedModel = resolveModel(opts, process.env);
3958
+ printMetricsSectionWarnings(reported, Boolean(platformResult.warn));
3959
+ const resolvedModel = resolveModel(opts, process.env, reported);
3523
3960
 
3524
3961
  const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
3525
3962
  fields.prompt = prompt;
@@ -3530,10 +3967,6 @@ program
3530
3967
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3531
3968
  console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
3532
3969
 
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
3970
  if (opts.metrics !== false) {
3538
3971
  const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
3539
3972
  startedAt: opts.startedAt,
@@ -3543,7 +3976,8 @@ program
3543
3976
  outputTokens: opts.outputTokens,
3544
3977
  totalTokens: opts.totalTokens,
3545
3978
  costUsd: opts.costUsd,
3546
- collect: opts.collect !== false,
3979
+ collect: opts.collect === true,
3980
+ reported,
3547
3981
  });
3548
3982
  console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
3549
3983
  }
@@ -3558,9 +3992,11 @@ program
3558
3992
  .command('metrics [change-name]')
3559
3993
  .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
3560
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)
3561
3996
  .action((changeName, opts) => {
3562
3997
  const projectDir = process.cwd();
3563
3998
  let name = changeName;
3999
+ let collectedAlready = false;
3564
4000
  if (!name) {
3565
4001
  const resolved = resolveHandoffChange(projectDir, changeName);
3566
4002
  if (!resolved) {
@@ -3569,11 +4005,37 @@ program
3569
4005
  return;
3570
4006
  }
3571
4007
  if (resolved.ambiguous) {
3572
- 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}`);
3573
4035
  process.exitCode = 1;
3574
4036
  return;
3575
4037
  }
3576
- name = resolved;
4038
+ if (!opts.json) log.ok(`collect: ${result.added} new source(s) on last session`);
3577
4039
  }
3578
4040
 
3579
4041
  const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
@@ -3593,77 +4055,7 @@ program
3593
4055
 
3594
4056
  log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
3595
4057
  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
- }
4058
+ for (const line of renderMetricsSummary(metrics)) console.log(line);
3667
4059
  });
3668
4060
 
3669
4061
  program.parse();