agent-orchestrator-kit 0.6.0 → 0.8.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.
@@ -6,6 +6,7 @@ import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
8
  import { collectSpend } from './spend-collect.js';
9
+ import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const KIT_ROOT = join(__dirname, '..');
@@ -87,9 +88,12 @@ const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
87
88
  const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
88
89
  const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
89
90
  const CURSOR_SPEND_HOOK_REL = join('scripts', 'cursor-spend-hook.cjs');
91
+ const CURSOR_SPEND_COLLECT_REL = join('scripts', 'cursor-spend-collect.cjs');
90
92
  const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
91
93
  const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
92
- const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop'];
94
+ const CURSOR_SPEND_COLLECT_COMMAND = 'node scripts/cursor-spend-collect.cjs';
95
+ const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop', 'afterAgentResponse'];
96
+ const CURSOR_SPEND_COLLECT_EVENTS = ['sessionEnd'];
93
97
  const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
94
98
  const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
95
99
  const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
@@ -159,6 +163,7 @@ const HANDOFF_SECTIONS = [
159
163
  'Subagents to spawn',
160
164
  'Constraints',
161
165
  'Runtime',
166
+ 'Metrics',
162
167
  'Prompt',
163
168
  ];
164
169
  const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
@@ -368,6 +373,12 @@ function refreshOptionalMcpManagedFiles(projectDir) {
368
373
  refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
369
374
  }
370
375
 
376
+ function hookEventHasCommand(hooks, event, needle) {
377
+ const entries = hooks[event];
378
+ return Array.isArray(entries)
379
+ && entries.some((entry) => entry && String(entry.command || '').includes(needle));
380
+ }
381
+
371
382
  function cursorSpendHookEntryOk(projectDir) {
372
383
  const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
373
384
  if (!existsSync(hooksPath)) return false;
@@ -379,24 +390,39 @@ function cursorSpendHookEntryOk(projectDir) {
379
390
  }
380
391
  const hooks = config && typeof config === 'object' ? config.hooks : null;
381
392
  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
- });
393
+ const writesOk = CURSOR_SPEND_HOOK_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-hook.cjs'));
394
+ const collectOk = CURSOR_SPEND_COLLECT_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-collect.cjs'));
395
+ return writesOk && collectOk;
387
396
  }
388
397
 
389
398
  // Mandatory spend capture: every kit project must record Cursor token usage
390
399
  // locally so handoff/archive can collect real spend without manual flags.
400
+ function ensureManagedScript(projectDir, rel) {
401
+ const src = join(KIT_ROOT, 'templates', rel);
402
+ const dest = join(projectDir, rel);
403
+ if (!existsSync(src)) return false;
404
+ mkdirSync(dirname(dest), { recursive: true });
405
+ copyFileSync(src, dest);
406
+ return true;
407
+ }
408
+
409
+ function mergeHookCommands(config, events, command, needle) {
410
+ let changed = false;
411
+ for (const event of events) {
412
+ const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
413
+ if (!entries.some((entry) => entry && String(entry.command || '').includes(needle))) {
414
+ entries.push({ command });
415
+ config.hooks[event] = entries;
416
+ changed = true;
417
+ }
418
+ }
419
+ return changed;
420
+ }
421
+
391
422
  function ensureCursorSpendHook(projectDir) {
392
423
  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
- }
424
+ if (ensureManagedScript(projectDir, CURSOR_SPEND_HOOK_REL)) result.script = true;
425
+ ensureManagedScript(projectDir, CURSOR_SPEND_COLLECT_REL);
400
426
 
401
427
  const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
402
428
  let config = { version: 1, hooks: {} };
@@ -412,15 +438,8 @@ function ensureCursorSpendHook(projectDir) {
412
438
  if (config.version == null) config.version = 1;
413
439
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
414
440
  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
- }
441
+ changed = mergeHookCommands(config, CURSOR_SPEND_HOOK_EVENTS, CURSOR_SPEND_HOOK_COMMAND, 'cursor-spend-hook.cjs') || changed;
442
+ changed = mergeHookCommands(config, CURSOR_SPEND_COLLECT_EVENTS, CURSOR_SPEND_COLLECT_COMMAND, 'cursor-spend-collect.cjs') || changed;
424
443
  if (changed) {
425
444
  mkdirSync(dirname(hooksPath), { recursive: true });
426
445
  writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
@@ -436,7 +455,7 @@ function reportCursorSpendHook(projectDir, emit) {
436
455
  return result;
437
456
  }
438
457
  if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
439
- if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop + subagentStop spend hook)`);
458
+ if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop / subagentStop / afterAgentResponse + sessionEnd collect)`);
440
459
  return result;
441
460
  }
442
461
 
@@ -457,7 +476,7 @@ function printSpendHealth(projectDir) {
457
476
  const records = countCursorUsageRecords(projectDir);
458
477
  const cursorState = scriptOk && entryOk
459
478
  ? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
460
- : 'not configured run npx agent-orchestrator-kit update';
479
+ : 'optionalnot configured (init/update/sync/mcp-setup)';
461
480
  console.log(` cursor ${cursorState}`);
462
481
  const home = process.env.HOME || '';
463
482
  const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
@@ -466,7 +485,7 @@ function printSpendHealth(projectDir) {
466
485
  ? String(process.env.AMP_DATA_DIR).trim()
467
486
  : join(home, '.local', 'share', 'amp');
468
487
  const ampOk = existsSync(join(ampDir, 'threads'));
469
- console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
488
+ console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}; locked client + amp threads export`);
470
489
  console.log('');
471
490
  }
472
491
 
@@ -1005,16 +1024,135 @@ function applyRuntimeToFields(fields, opts, env) {
1005
1024
  }
1006
1025
 
1007
1026
  const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
1027
+ const METRICS_NULL_TOKENS = new Set(['unknown', 'none', 'n/a', 'na', '-', '—', '–', 'null']);
1028
+
1029
+ function emptyMetricsFields(warnings = []) {
1030
+ return {
1031
+ platform: null,
1032
+ model: null,
1033
+ inputTokens: null,
1034
+ outputTokens: null,
1035
+ totalTokens: null,
1036
+ costUsd: null,
1037
+ ampCredits: null,
1038
+ spendSource: null,
1039
+ warnings,
1040
+ };
1041
+ }
1042
+
1043
+ function normalizeMetricsBlank(value) {
1044
+ const raw = value == null ? '' : String(value).trim();
1045
+ if (!raw) return null;
1046
+ const lower = raw.toLowerCase();
1047
+ if (METRICS_NULL_TOKENS.has(lower) || METRICS_NULL_TOKENS.has(raw)) return null;
1048
+ return raw;
1049
+ }
1050
+
1051
+ function parseMetricsNumber(raw, key, warnings) {
1052
+ const normalized = normalizeMetricsBlank(raw);
1053
+ if (normalized == null) return null;
1054
+ const cleaned = normalized.replace(/[$,\s]/g, '');
1055
+ const n = Number(cleaned);
1056
+ if (!Number.isFinite(n)) {
1057
+ warnings.push(`metrics: unparsable ${key} in ## Metrics: ${raw}`);
1058
+ return null;
1059
+ }
1060
+ return n;
1061
+ }
1062
+
1063
+ function parseMetricsSection(body) {
1064
+ const warnings = [];
1065
+ const values = {};
1066
+ const present = new Set();
1067
+ for (const line of String(body || '').split(/\r?\n/)) {
1068
+ const match = line.match(/^\s*[-*]\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/);
1069
+ if (!match) continue;
1070
+ const key = match[1].toLowerCase();
1071
+ values[key] = match[2].trim();
1072
+ present.add(key);
1073
+ }
1074
+ const result = emptyMetricsFields(warnings);
1075
+ const platformRaw = normalizeMetricsBlank(values.platform);
1076
+ if (platformRaw) {
1077
+ const lower = platformRaw.toLowerCase();
1078
+ if (VALID_PLATFORMS.has(lower)) result.platform = lower;
1079
+ else {
1080
+ result.platform = null;
1081
+ warnings.push(`metrics: invalid platform in ## Metrics: ${platformRaw}`);
1082
+ }
1083
+ }
1084
+ result.model = normalizeMetricsBlank(values.model);
1085
+ result.inputTokens = parseMetricsNumber(values.input_tokens, 'input_tokens', warnings);
1086
+ result.outputTokens = parseMetricsNumber(values.output_tokens, 'output_tokens', warnings);
1087
+ result.totalTokens = present.has('total_tokens')
1088
+ ? parseMetricsNumber(values.total_tokens, 'total_tokens', warnings)
1089
+ : null;
1090
+ result.costUsd = parseMetricsNumber(values.cost_usd, 'cost_usd', warnings);
1091
+ result.ampCredits = parseMetricsNumber(values.amp_credits, 'amp_credits', warnings);
1092
+ result.spendSource = normalizeMetricsBlank(values.spend_source);
1093
+ return result;
1094
+ }
1095
+
1096
+ function formatMetricsField(value) {
1097
+ return value == null || value === '' ? 'unknown' : String(value);
1098
+ }
1008
1099
 
1009
- function resolveModel(opts, env) {
1100
+ function renderMetricsSection(metrics) {
1101
+ const m = metrics || emptyMetricsFields();
1102
+ return `## Metrics
1103
+ - platform: ${formatMetricsField(m.platform)}
1104
+ - model: ${formatMetricsField(m.model)}
1105
+ - input_tokens: ${formatMetricsField(m.inputTokens)}
1106
+ - output_tokens: ${formatMetricsField(m.outputTokens)}
1107
+ - cost_usd: ${formatMetricsField(m.costUsd)}
1108
+ - amp_credits: ${formatMetricsField(m.ampCredits)}
1109
+ - spend_source: ${formatMetricsField(m.spendSource)}`;
1110
+ }
1111
+
1112
+ function printMetricsSectionWarnings(metrics, platformAlreadyWarned) {
1113
+ for (const warning of (metrics && metrics.warnings) || []) {
1114
+ if (platformAlreadyWarned && /invalid platform/i.test(warning)) continue;
1115
+ console.error(warning);
1116
+ }
1117
+ }
1118
+
1119
+ function firstNonNull(...values) {
1120
+ for (const value of values) {
1121
+ if (value != null) return value;
1122
+ }
1123
+ return null;
1124
+ }
1125
+
1126
+ function resolveModel(opts, env, reported) {
1010
1127
  const flag = opts && opts.model != null ? String(opts.model).trim() : '';
1011
1128
  if (flag) return flag;
1129
+ const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
1130
+ if (fromReport) return fromReport;
1012
1131
  const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
1013
1132
  if (fromEnv) return fromEnv;
1014
1133
  return null;
1015
1134
  }
1016
1135
 
1017
- function resolvePlatform(opts, env) {
1136
+ function envFlagOn(value) {
1137
+ if (value == null) return false;
1138
+ const normalized = String(value).trim().toLowerCase();
1139
+ return normalized !== '' && normalized !== '0' && normalized !== 'false';
1140
+ }
1141
+
1142
+ function inferPlatformFromHost(env) {
1143
+ if (!env) return null;
1144
+ if (env.AMP_CURRENT_THREAD != null && String(env.AMP_CURRENT_THREAD).trim()) return 'amp';
1145
+ if (env.AMP_THREAD_ID != null && String(env.AMP_THREAD_ID).trim()) return 'amp';
1146
+ if (envFlagOn(env.CURSOR_AGENT) || (env.CURSOR_CONVERSATION_ID != null && String(env.CURSOR_CONVERSATION_ID).trim())) {
1147
+ return 'cursor';
1148
+ }
1149
+ if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || (env.CLAUDE_CODE_ENTRYPOINT != null && String(env.CLAUDE_CODE_ENTRYPOINT).trim())) {
1150
+ return 'claude';
1151
+ }
1152
+ return null;
1153
+ }
1154
+
1155
+ function resolvePlatform(opts, env, reported, pending) {
1018
1156
  const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
1019
1157
  if (flag) {
1020
1158
  const lower = flag.toLowerCase();
@@ -1023,13 +1161,23 @@ function resolvePlatform(opts, env) {
1023
1161
  }
1024
1162
  return { value: lower };
1025
1163
  }
1164
+ const fromReport = reported && reported.platform != null ? String(reported.platform).trim() : '';
1165
+ if (fromReport) {
1166
+ const lower = fromReport.toLowerCase();
1167
+ if (VALID_PLATFORMS.has(lower)) return { value: lower };
1168
+ return { value: null, warn: 'invalid platform in ## Metrics (use cursor, claude, or amp)' };
1169
+ }
1026
1170
  const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
1027
1171
  if (fromEnv) {
1028
1172
  const lower = fromEnv.toLowerCase();
1029
1173
  if (VALID_PLATFORMS.has(lower)) return { value: lower };
1030
1174
  return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
1031
1175
  }
1032
- return { value: null };
1176
+ const pendingPlatform = pending && pending.platform != null ? String(pending.platform).trim() : '';
1177
+ if (pendingPlatform && VALID_PLATFORMS.has(pendingPlatform)) {
1178
+ return { value: pendingPlatform };
1179
+ }
1180
+ return { value: inferPlatformFromHost(env) };
1033
1181
  }
1034
1182
 
1035
1183
  function warnMissingModel() {
@@ -1040,6 +1188,12 @@ function warnMissingUsd() {
1040
1188
  console.error('metrics: spend.costUsd is null — USD spend was not collected');
1041
1189
  }
1042
1190
 
1191
+ function warnUnreportedSelfReport() {
1192
+ console.error(
1193
+ '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)',
1194
+ );
1195
+ }
1196
+
1043
1197
  function gitTry(projectDir, command) {
1044
1198
  try {
1045
1199
  const stdout = execSync(command, {
@@ -1140,7 +1294,9 @@ ${fields.constraints}
1140
1294
 
1141
1295
  ## Runtime
1142
1296
  - runtime: ${runtime}
1143
- - agent_id: ${agentId}${prompt}
1297
+ - agent_id: ${agentId}
1298
+
1299
+ ${renderMetricsSection(fields.metrics)}${prompt}
1144
1300
  `;
1145
1301
  }
1146
1302
 
@@ -1161,6 +1317,7 @@ function fieldsFromSections(changeName, sections, extra = {}) {
1161
1317
  constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
1162
1318
  runtime: extra.runtime || runtimeParsed.runtime,
1163
1319
  agentId: extra.agentId || runtimeParsed.agentId,
1320
+ metrics: extra.metrics || parseMetricsSection(sectionOr(sections, 'Metrics', '')),
1164
1321
  status: extra.status || '',
1165
1322
  tasks: extra.tasks || '',
1166
1323
  review: extra.review || '',
@@ -1569,6 +1726,10 @@ function lastSessionEndedAt(metrics) {
1569
1726
  return last;
1570
1727
  }
1571
1728
 
1729
+ function collectWindowStart(metrics) {
1730
+ return lastSessionEndedAt(metrics) || metrics.createdAt;
1731
+ }
1732
+
1572
1733
  function existingSourceIdSet(metrics) {
1573
1734
  const ids = new Set();
1574
1735
  for (const session of metrics.sessions || []) {
@@ -1587,9 +1748,8 @@ function uniqueSourceModels(sources) {
1587
1748
  return seen;
1588
1749
  }
1589
1750
 
1590
- function primaryModelFromSources(sources) {
1591
- if (!sources || !sources.length) return null;
1592
- const ranked = [...sources].sort((a, b) => {
1751
+ function rankSources(sources) {
1752
+ return [...sources].sort((a, b) => {
1593
1753
  const ta = a.totalTokens ?? 0;
1594
1754
  const tb = b.totalTokens ?? 0;
1595
1755
  if (tb !== ta) return tb - ta;
@@ -1597,12 +1757,79 @@ function primaryModelFromSources(sources) {
1597
1757
  if (platformCmp !== 0) return platformCmp;
1598
1758
  return String(a.id || '').localeCompare(String(b.id || ''));
1599
1759
  });
1600
- const model = ranked[0] && ranked[0].model;
1760
+ }
1761
+
1762
+ function primaryModelFromSources(sources) {
1763
+ if (!sources || !sources.length) return null;
1764
+ const top = rankSources(sources)[0];
1765
+ const model = top && top.model;
1601
1766
  return model == null || model === '' ? null : String(model);
1602
1767
  }
1603
1768
 
1769
+ function primaryPlatformFromSources(sources) {
1770
+ if (!sources || !sources.length) return null;
1771
+ const platform = rankSources(sources)[0] && rankSources(sources)[0].platform;
1772
+ return platform && VALID_PLATFORMS.has(platform) ? platform : null;
1773
+ }
1774
+
1604
1775
  function hasSpendOverride(opts) {
1605
- return opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null;
1776
+ return Boolean(
1777
+ opts
1778
+ && (opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null || opts.ampCredits != null),
1779
+ );
1780
+ }
1781
+
1782
+ function reportedHasSpendNumbers(reported) {
1783
+ if (!reported) return false;
1784
+ return (
1785
+ reported.inputTokens != null
1786
+ || reported.outputTokens != null
1787
+ || reported.totalTokens != null
1788
+ || reported.costUsd != null
1789
+ || reported.ampCredits != null
1790
+ );
1791
+ }
1792
+
1793
+ function sourceAmpCredits(sources) {
1794
+ let sum = null;
1795
+ for (const src of sources || []) {
1796
+ sum = addNullable(sum, numOrNull(src.ampCredits));
1797
+ }
1798
+ return sum;
1799
+ }
1800
+
1801
+ function resolveSessionSpend(opts, reported, sources) {
1802
+ const flags = opts || {};
1803
+ const self = reported || emptyMetricsFields();
1804
+ const fromSources = sessionTotalsFromSources(sources || []);
1805
+ const sourceCredits = sourceAmpCredits(sources || []);
1806
+ const flagInput = numOrNull(flags.inputTokens);
1807
+ const flagOutput = numOrNull(flags.outputTokens);
1808
+ const flagTotal = numOrNull(flags.totalTokens);
1809
+ const flagCost = numOrNull(flags.costUsd);
1810
+ const flagCredits = numOrNull(flags.ampCredits);
1811
+ const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
1812
+ const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
1813
+ let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
1814
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1815
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1816
+ }
1817
+ const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
1818
+ const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
1819
+ let spendSource = 'unreported';
1820
+ if (self.spendSource) spendSource = String(self.spendSource);
1821
+ else if (hasSpendOverride(flags)) spendSource = 'flag';
1822
+ else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
1823
+ else if (
1824
+ fromSources.inputTokens != null
1825
+ || fromSources.outputTokens != null
1826
+ || fromSources.totalTokens != null
1827
+ || fromSources.costUsd != null
1828
+ || sourceCredits != null
1829
+ ) {
1830
+ spendSource = 'adapter';
1831
+ }
1832
+ return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
1606
1833
  }
1607
1834
 
1608
1835
  function sessionTotalsFromFlags(opts) {
@@ -1634,7 +1861,7 @@ function sessionTotalsFromSources(sources) {
1634
1861
  return { inputTokens, outputTokens, totalTokens, costUsd };
1635
1862
  }
1636
1863
 
1637
- function runCollectSpend(metrics, windowStart, windowEnd) {
1864
+ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1638
1865
  try {
1639
1866
  return collectSpend({
1640
1867
  cwd: process.cwd(),
@@ -1643,60 +1870,165 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
1643
1870
  existingSourceIds: existingSourceIdSet(metrics),
1644
1871
  env: process.env,
1645
1872
  homedir: process.env.HOME,
1873
+ platforms: extra.platforms,
1874
+ ampThreadId: extra.ampThreadId,
1875
+ ampCli: extra.ampCli === true,
1876
+ exportAmpThread: extra.exportAmpThread,
1646
1877
  });
1647
1878
  } catch {
1648
1879
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
1649
1880
  }
1650
1881
  }
1651
1882
 
1652
- function applyCollectedSessionFields(session, sources, resolvedModel, opts) {
1653
- session.sources = sources;
1654
- const uniqueModels = uniqueSourceModels(sources);
1655
- session.model = primaryModelFromSources(sources) || resolvedModel || null;
1883
+ function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
1884
+ session.sources = sources || [];
1885
+ const uniqueModels = uniqueSourceModels(session.sources);
1886
+ if (!session.model) session.model = resolvedModel || primaryModelFromSources(session.sources) || null;
1887
+ if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
1656
1888
  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;
1889
+ const spend = resolveSessionSpend(opts, reported, session.sources);
1890
+ session.inputTokens = spend.inputTokens;
1891
+ session.outputTokens = spend.outputTokens;
1892
+ session.totalTokens = spend.totalTokens;
1893
+ session.costUsd = spend.costUsd;
1894
+ session.ampCredits = spend.ampCredits;
1895
+ session.spendSource = spend.spendSource;
1896
+ }
1897
+
1898
+ function sessionTotalsLookOverridden(session) {
1899
+ const fromSources = sessionTotalsFromSources(session.sources || []);
1900
+ return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
1901
+ const sessionVal = numOrNull(session[key]);
1902
+ const sourceVal = numOrNull(fromSources[key]);
1903
+ if (sessionVal == null) return false;
1904
+ if (sourceVal == null) return true;
1905
+ return sessionVal !== sourceVal;
1906
+ });
1907
+ }
1908
+
1909
+ function metricsBackfillLastSession(projectDir, changeName) {
1910
+ const resolved = resolveMetricsFile(projectDir, changeName);
1911
+ if (resolved.missing) return { filePath: resolved.filePath, added: 0, missing: true };
1912
+ return metricsBackfillFile(resolved.filePath, changeName);
1913
+ }
1914
+
1915
+ function metricsBackfillFile(filePath, changeName) {
1916
+ const nowIso = new Date().toISOString();
1917
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
1918
+ const sessions = metrics.sessions || [];
1919
+ if (!sessions.length) return { filePath, added: 0, empty: true };
1920
+ const last = sessions[sessions.length - 1];
1921
+ const windowStart = last.startedAt || collectWindowStart(metrics);
1922
+ const lastPlatform = last && last.platform;
1923
+ const collected = runCollectSpend(metrics, windowStart, nowIso, {
1924
+ ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
1925
+ ampCli: lastPlatform === 'amp',
1926
+ });
1927
+ const incoming = collected.sources || [];
1928
+ if (!incoming.length) return { filePath, added: 0 };
1929
+ const overridden = sessionTotalsLookOverridden(last);
1930
+ const merged = [...(last.sources || []), ...incoming];
1931
+ const keepReportedTotals = overridden
1932
+ || last.spendSource === 'flag'
1933
+ || last.spendSource === 'self-report'
1934
+ || (last.spendSource && last.spendSource !== 'adapter' && last.spendSource !== 'unreported');
1935
+ if (keepReportedTotals) {
1936
+ last.sources = merged;
1937
+ const uniqueModels = uniqueSourceModels(merged);
1938
+ if (uniqueModels.length > 1) last.models = uniqueModels;
1939
+ } else {
1940
+ applyCollectedSessionFields(last, merged, last.model, {}, {
1941
+ model: last.model,
1942
+ platform: last.platform,
1943
+ });
1944
+ }
1945
+ metrics.updatedAt = nowIso;
1946
+ recomputeMetricsAggregates(metrics);
1947
+ saveMetricsFile(filePath, metrics);
1948
+ return { filePath, added: incoming.length };
1949
+ }
1950
+
1951
+ function adapterSourceName(platform, via) {
1952
+ if (platform === 'claude') return 'claude-jsonl';
1953
+ if (platform === 'amp') return via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
1954
+ if (platform === 'cursor') return 'cursor-hook';
1955
+ return null;
1956
+ }
1957
+
1958
+ function spendTuple(obj) {
1959
+ return {
1960
+ inputTokens: numOrNull(obj && obj.inputTokens),
1961
+ outputTokens: numOrNull(obj && obj.outputTokens),
1962
+ totalTokens: numOrNull(obj && obj.totalTokens),
1963
+ costUsd: numOrNull(obj && obj.costUsd),
1964
+ ampCredits: numOrNull(obj && obj.ampCredits),
1965
+ };
1966
+ }
1967
+
1968
+ function spendTuplesMatch(a, b) {
1969
+ return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].every((key) => {
1970
+ const left = a[key];
1971
+ const right = b[key];
1972
+ if (left == null && right == null) return true;
1973
+ return left === right;
1974
+ });
1975
+ }
1976
+
1977
+ function addSpendNums(target, nums) {
1978
+ target.inputTokens = addNullable(target.inputTokens, nums.inputTokens);
1979
+ target.outputTokens = addNullable(target.outputTokens, nums.outputTokens);
1980
+ target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
1981
+ target.costUsd = addNullable(target.costUsd, nums.costUsd);
1982
+ target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
1662
1983
  }
1663
1984
 
1664
1985
  function recomputeSpendMaps(metrics) {
1665
1986
  const byPlatform = defaultSpendByPlatform();
1666
1987
  const byModel = new Map();
1988
+
1989
+ function addModelRow(model, platform, nums) {
1990
+ if (!model) return;
1991
+ const key = `${model}::${platform || ''}`;
1992
+ const row = byModel.get(key) || {
1993
+ model,
1994
+ platform: platform || null,
1995
+ inputTokens: null,
1996
+ outputTokens: null,
1997
+ totalTokens: null,
1998
+ costUsd: null,
1999
+ ampCredits: null,
2000
+ };
2001
+ addSpendNums(row, nums);
2002
+ byModel.set(key, row);
2003
+ }
2004
+
2005
+ function addSourceRow(src) {
2006
+ const nums = spendTuple(src);
2007
+ if (src.platform && byPlatform[src.platform]) {
2008
+ addSpendNums(byPlatform[src.platform], nums);
2009
+ const label = adapterSourceName(src.platform);
2010
+ if (label) byPlatform[src.platform].source = label;
2011
+ }
2012
+ addModelRow(src.model, src.platform, nums);
2013
+ }
2014
+
1667
2015
  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
- }
2016
+ const sessionNums = spendTuple(session);
2017
+ const sourceTotals = sessionTotalsFromSources(session.sources || []);
2018
+ sourceTotals.ampCredits = sourceAmpCredits(session.sources || []);
2019
+ const sources = session.sources || [];
2020
+ const sourcesMatchSession = sources.length > 0 && spendTuplesMatch(sessionNums, sourceTotals);
2021
+
2022
+ if (sourcesMatchSession) {
2023
+ for (const src of sources) addSourceRow(src);
2024
+ continue;
2025
+ }
2026
+
2027
+ if (session.platform && byPlatform[session.platform]) {
2028
+ addSpendNums(byPlatform[session.platform], sessionNums);
1699
2029
  }
2030
+ addModelRow(session.model, session.platform, sessionNums);
2031
+ for (const src of sources) addSourceRow(src);
1700
2032
  }
1701
2033
  metrics.spendByPlatform = byPlatform;
1702
2034
  metrics.spendByModel = [...byModel.values()];
@@ -1741,11 +2073,17 @@ function recomputeMetricsAggregates(metrics) {
1741
2073
  recomputeSpendMaps(metrics);
1742
2074
  }
1743
2075
 
1744
- function metricsRecordSessionStart(projectDir, changeName, role) {
2076
+ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
1745
2077
  const filePath = metricsFilePath(projectDir, changeName);
1746
2078
  const nowIso = new Date().toISOString();
1747
2079
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
1748
- metrics.pending = { startedAt: nowIso, role: role || '' };
2080
+ metrics.pending = {
2081
+ startedAt: nowIso,
2082
+ role: role || '',
2083
+ platform: client.platform || null,
2084
+ threadId: client.threadId || null,
2085
+ clientSource: client.source || null,
2086
+ };
1749
2087
  metrics.updatedAt = nowIso;
1750
2088
  saveMetricsFile(filePath, metrics);
1751
2089
  return filePath;
@@ -1757,11 +2095,21 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1757
2095
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
1758
2096
  const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1759
2097
  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);
2098
+ const reported = opts.reported || fields.metrics || emptyMetricsFields();
2099
+ const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2100
+ const windowStart = collectWindowStart(metrics);
2101
+ const pending = metrics.pending || {};
2102
+ const platform = opts.platform || pending.platform || null;
2103
+ const collectAll = opts.collect === true;
2104
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2105
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2106
+ const collected = shouldCollect
2107
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2108
+ platforms,
2109
+ ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2110
+ ampCli: collectAll || platform === 'amp',
2111
+ })
2112
+ : { sources: [] };
1765
2113
  const session = {
1766
2114
  startedAt,
1767
2115
  endedAt: nowIso,
@@ -1771,21 +2119,28 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1771
2119
  runtime: fields.runtime || 'local',
1772
2120
  agentId: fields.agentId || 'none',
1773
2121
  model: resolvedModel || null,
1774
- platform: opts.platform || null,
2122
+ platform: opts.platform || pending.platform || null,
2123
+ threadId: opts.ampThreadId || pending.threadId || null,
1775
2124
  tasks: fields.tasks || null,
1776
2125
  sources: [],
1777
2126
  inputTokens: null,
1778
2127
  outputTokens: null,
1779
2128
  totalTokens: null,
1780
2129
  costUsd: null,
2130
+ ampCredits: null,
2131
+ spendSource: 'unreported',
1781
2132
  };
1782
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts);
2133
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
1783
2134
  metrics.sessions.push(session);
1784
2135
  metrics.pending = null;
1785
2136
  metrics.updatedAt = nowIso;
1786
2137
  recomputeMetricsAggregates(metrics);
1787
- if (session.model == null) warnMissingModel();
1788
2138
  saveMetricsFile(filePath, metrics);
2139
+ if (opts.collect === true) metricsBackfillFile(filePath, fields.changeName);
2140
+ const latest = loadMetricsFile(filePath, fields.changeName, nowIso);
2141
+ const last = (latest.sessions || []).at(-1);
2142
+ if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
2143
+ if (!last || last.model == null) warnMissingModel();
1789
2144
  return filePath;
1790
2145
  }
1791
2146
 
@@ -1794,10 +2149,19 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1794
2149
  const nowIso = new Date().toISOString();
1795
2150
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
1796
2151
  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;
2152
+ const collectAll = opts.collect === true;
2153
+ const platform = opts.platform || null;
2154
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2155
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2156
+ const collected = shouldCollect
2157
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2158
+ platforms,
2159
+ ampThreadId: opts.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2160
+ ampCli: collectAll || platform === 'amp',
2161
+ })
2162
+ : { sources: [] };
2163
+ const reported = opts.reported || emptyMetricsFields();
2164
+ const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
1801
2165
  const session = {
1802
2166
  startedAt: nowIso,
1803
2167
  endedAt: nowIso,
@@ -1814,16 +2178,22 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
1814
2178
  outputTokens: null,
1815
2179
  totalTokens: null,
1816
2180
  costUsd: null,
2181
+ ampCredits: null,
2182
+ spendSource: 'unreported',
1817
2183
  };
1818
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, {});
2184
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
1819
2185
  metrics.sessions.push(session);
1820
2186
  metrics.archivedAt = nowIso;
1821
2187
  metrics.pending = null;
1822
2188
  metrics.updatedAt = nowIso;
1823
2189
  recomputeMetricsAggregates(metrics);
1824
- if (session.model == null) warnMissingModel();
1825
- if (metrics.spend.costUsd === null) warnMissingUsd();
1826
2190
  saveMetricsFile(filePath, metrics);
2191
+ if (opts.collect === true) metricsBackfillFile(filePath, changeName);
2192
+ const latest = loadMetricsFile(filePath, changeName, nowIso);
2193
+ const last = (latest.sessions || []).at(-1);
2194
+ if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
2195
+ if (!last || last.model == null) warnMissingModel();
2196
+ if (latest.spend.costUsd === null) warnMissingUsd();
1827
2197
  return filePath;
1828
2198
  }
1829
2199
 
@@ -1846,6 +2216,95 @@ function formatMetricsCost(value) {
1846
2216
  return value == null ? '—' : `$${Number(value).toFixed(2)}`;
1847
2217
  }
1848
2218
 
2219
+ function sessionSpendSourceLabel(session) {
2220
+ const raw = session && session.spendSource;
2221
+ if (raw == null || String(raw).trim() === '') return 'unreported';
2222
+ return String(raw);
2223
+ }
2224
+
2225
+ function renderMetricsSummary(metrics) {
2226
+ const lines = [];
2227
+ const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
2228
+ const unreported = sessions.filter((session) => sessionSpendSourceLabel(session) === 'unreported').length;
2229
+ lines.push(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
2230
+ lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
2231
+ lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
2232
+ lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
2233
+ lines.push(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
2234
+ lines.push(`unreported: ${unreported}`);
2235
+ if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
2236
+ if (metrics.pending) {
2237
+ const pendingClient = metrics.pending.platform
2238
+ ? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
2239
+ : '';
2240
+ lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'}${pendingClient})`);
2241
+ }
2242
+
2243
+ const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
2244
+ const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
2245
+ if (phaseKeys.length) {
2246
+ lines.push('');
2247
+ lines.push('phase sessions time tokens cost roles models');
2248
+ for (const key of phaseKeys) {
2249
+ const phase = metrics.phases[key];
2250
+ lines.push([
2251
+ key.padEnd(10),
2252
+ String(phase.sessions).padEnd(9),
2253
+ formatMetricsDuration(phase.durationMs).padEnd(9),
2254
+ formatMetricsNumber(phase.totalTokens).padEnd(9),
2255
+ formatMetricsCost(phase.costUsd).padEnd(9),
2256
+ (phase.agents.join(', ') || '—').padEnd(20),
2257
+ phase.models.join(', ') || '—',
2258
+ ].join(' '));
2259
+ }
2260
+ }
2261
+
2262
+ const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
2263
+ lines.push('');
2264
+ lines.push('by platform:');
2265
+ lines.push('platform tokens cost credits source');
2266
+ for (const key of ['cursor', 'claude', 'amp']) {
2267
+ const row = byPlatform[key] || emptyPlatformSpend();
2268
+ lines.push([
2269
+ key.padEnd(10),
2270
+ formatMetricsNumber(row.totalTokens).padEnd(9),
2271
+ formatMetricsCost(row.costUsd).padEnd(9),
2272
+ formatMetricsNumber(row.ampCredits).padEnd(9),
2273
+ row.source || 'none',
2274
+ ].join(' '));
2275
+ }
2276
+
2277
+ lines.push('');
2278
+ lines.push('by model:');
2279
+ lines.push('model platform tokens cost credits');
2280
+ const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
2281
+ if (!byModel.length) {
2282
+ lines.push('— — — — —');
2283
+ } else {
2284
+ for (const row of byModel) {
2285
+ lines.push([
2286
+ String(row.model || '—').padEnd(20),
2287
+ String(row.platform || '—').padEnd(10),
2288
+ formatMetricsNumber(row.totalTokens).padEnd(9),
2289
+ formatMetricsCost(row.costUsd).padEnd(9),
2290
+ formatMetricsNumber(row.ampCredits),
2291
+ ].join(' '));
2292
+ }
2293
+ }
2294
+
2295
+ if (sessions.length) {
2296
+ lines.push('');
2297
+ lines.push('recent sessions:');
2298
+ for (const session of sessions.slice(-5)) {
2299
+ const spendLabel = session.totalTokens != null || session.costUsd != null
2300
+ ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
2301
+ : '';
2302
+ 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}`);
2303
+ }
2304
+ }
2305
+ return lines;
2306
+ }
2307
+
1849
2308
  function resolveMetricsFile(projectDir, changeName) {
1850
2309
  const activePath = metricsFilePath(projectDir, changeName);
1851
2310
  if (existsSync(activePath)) return { filePath: activePath, archived: false };
@@ -3015,7 +3474,11 @@ program
3015
3474
  .option('--force', 'confirm archiving without merge when delta specs exist', false)
3016
3475
  .option('--model <name>', 'LLM product id recorded on the Archiver session')
3017
3476
  .option('--platform <platform>', 'Session platform: cursor | claude | amp')
3018
- .option('--no-collect', 'Skip local spend adapters when finalizing metrics')
3477
+ .option('--input-tokens <n>', 'Input tokens spent in the Archiver session')
3478
+ .option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
3479
+ .option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
3480
+ .option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
3481
+ .option('--collect', 'Additionally collect local spend adapters', false)
3019
3482
  .action((name, opts) => {
3020
3483
  const projectDir = process.cwd();
3021
3484
  const fail = (msg) => {
@@ -3135,6 +3598,11 @@ program
3135
3598
  if (existsSync(archivedHandoffPath)) {
3136
3599
  priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
3137
3600
  }
3601
+ const reported = priorFields.metrics || emptyMetricsFields();
3602
+ const archivePlatform = resolvePlatform(opts, process.env, reported);
3603
+ const archiveModel = resolveModel(opts, process.env, reported);
3604
+ if (archivePlatform.warn) console.error(archivePlatform.warn);
3605
+ printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
3138
3606
  const runtimeResult = resolveRuntime({}, process.env, priorFields);
3139
3607
  const progress = parseTasksProgress(targetDir);
3140
3608
  const fields = {
@@ -3151,6 +3619,7 @@ program
3151
3619
  constraints: 'Pipeline complete — no next session.',
3152
3620
  runtime: runtimeResult.value || 'local',
3153
3621
  agentId: resolveAgentId({}, process.env, priorFields),
3622
+ metrics: reported,
3154
3623
  status: 'archived',
3155
3624
  tasks: progress ? `${progress.done}/${progress.total}` : '',
3156
3625
  review: parseReviewVerdict(targetDir) || '',
@@ -3159,12 +3628,17 @@ program
3159
3628
  writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
3160
3629
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3161
3630
  const metricsPath = metricsFinalizeArchive(targetDir, name, {
3162
- model: resolvedModel,
3163
- platform: platformResult.value || null,
3631
+ model: archiveModel,
3632
+ platform: archivePlatform.value || null,
3164
3633
  runtime: fields.runtime,
3165
3634
  agentId: fields.agentId,
3166
3635
  tasks: fields.tasks,
3167
- collect: opts.collect !== false,
3636
+ collect: opts.collect === true,
3637
+ inputTokens: opts.inputTokens,
3638
+ outputTokens: opts.outputTokens,
3639
+ totalTokens: opts.totalTokens,
3640
+ costUsd: opts.costUsd,
3641
+ reported,
3168
3642
  });
3169
3643
 
3170
3644
  console.log(`change: ${name}`);
@@ -3174,6 +3648,10 @@ program
3174
3648
  console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
3175
3649
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
3176
3650
  console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
3651
+ try {
3652
+ const archivedMetrics = loadMetricsFile(metricsPath, name, new Date().toISOString());
3653
+ for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
3654
+ } catch {}
3177
3655
  log.ok(`archived ${name}`);
3178
3656
  });
3179
3657
 
@@ -3381,7 +3859,7 @@ program
3381
3859
  .option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
3382
3860
  .option('--cost-usd <usd>', 'Cost of this session in USD')
3383
3861
  .option('--no-metrics', 'Skip recording this session into metrics.json')
3384
- .option('--no-collect', 'Skip local spend adapters for this persist')
3862
+ .option('--collect', 'Additionally collect local spend adapters', false)
3385
3863
  .action((changeName, opts) => {
3386
3864
  const projectDir = process.cwd();
3387
3865
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -3439,12 +3917,19 @@ program
3439
3917
  log.warn(`Memory JSON empty or missing at ${memoryPath}`);
3440
3918
  }
3441
3919
  if (opts.metrics !== false && existsSync(changeDir)) {
3442
- const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
3920
+ const client = resolveRestoreClient({
3921
+ env: process.env,
3922
+ cwd: projectDir,
3923
+ homedir: process.env.HOME,
3924
+ platform: opts.platform,
3925
+ });
3926
+ const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '', client);
3927
+ const clientLabel = client.platform
3928
+ ? `${client.platform}${client.threadId ? ` ${client.threadId}` : ''} (${client.source})`
3929
+ : 'unknown — pass --platform or fill ## Metrics';
3443
3930
  log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
3931
+ log.info(`metrics: client ${clientLabel}`);
3444
3932
  }
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
3933
  return;
3449
3934
  }
3450
3935
 
@@ -3512,14 +3997,17 @@ program
3512
3997
  return;
3513
3998
  }
3514
3999
 
3515
- const platformResult = resolvePlatform(opts, process.env);
4000
+ const reported = fields.metrics || emptyMetricsFields();
4001
+ const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, new Date().toISOString());
4002
+ const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
3516
4003
  if (platformResult.error) {
3517
4004
  log.err(platformResult.error);
3518
4005
  process.exitCode = 1;
3519
4006
  return;
3520
4007
  }
3521
4008
  if (platformResult.warn) console.error(platformResult.warn);
3522
- const resolvedModel = resolveModel(opts, process.env);
4009
+ printMetricsSectionWarnings(reported, Boolean(platformResult.warn));
4010
+ const resolvedModel = resolveModel(opts, process.env, reported);
3523
4011
 
3524
4012
  const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
3525
4013
  fields.prompt = prompt;
@@ -3530,10 +4018,6 @@ program
3530
4018
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3531
4019
  console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
3532
4020
 
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
4021
  if (opts.metrics !== false) {
3538
4022
  const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
3539
4023
  startedAt: opts.startedAt,
@@ -3543,7 +4027,9 @@ program
3543
4027
  outputTokens: opts.outputTokens,
3544
4028
  totalTokens: opts.totalTokens,
3545
4029
  costUsd: opts.costUsd,
3546
- collect: opts.collect !== false,
4030
+ collect: opts.collect === true,
4031
+ ampThreadId: (metricsPreview.pending && metricsPreview.pending.threadId) || ampThreadIdFromEnv(process.env) || null,
4032
+ reported,
3547
4033
  });
3548
4034
  console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
3549
4035
  }
@@ -3558,9 +4044,11 @@ program
3558
4044
  .command('metrics [change-name]')
3559
4045
  .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
3560
4046
  .option('--json', 'Print raw metrics.json', false)
4047
+ .option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
3561
4048
  .action((changeName, opts) => {
3562
4049
  const projectDir = process.cwd();
3563
4050
  let name = changeName;
4051
+ let collectedAlready = false;
3564
4052
  if (!name) {
3565
4053
  const resolved = resolveHandoffChange(projectDir, changeName);
3566
4054
  if (!resolved) {
@@ -3569,11 +4057,37 @@ program
3569
4057
  return;
3570
4058
  }
3571
4059
  if (resolved.ambiguous) {
3572
- log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
4060
+ if (opts.collect) {
4061
+ let added = 0;
4062
+ for (const change of resolved.ambiguous) {
4063
+ const result = metricsBackfillLastSession(projectDir, change);
4064
+ added += result.added || 0;
4065
+ }
4066
+ collectedAlready = true;
4067
+ if (!opts.json) log.ok(`collect: ${added} new source(s) across ${resolved.ambiguous.length} changes`);
4068
+ if (opts.json) {
4069
+ process.stdout.write(`${JSON.stringify({ collected: added, changes: resolved.ambiguous }, null, 2)}\n`);
4070
+ return;
4071
+ }
4072
+ name = resolved.ambiguous[0];
4073
+ } else {
4074
+ log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
4075
+ process.exitCode = 1;
4076
+ return;
4077
+ }
4078
+ } else {
4079
+ name = resolved;
4080
+ }
4081
+ }
4082
+
4083
+ if (opts.collect && name && !collectedAlready) {
4084
+ const result = metricsBackfillLastSession(projectDir, name);
4085
+ if (result.missing) {
4086
+ log.err(`No metrics.json for ${name}`);
3573
4087
  process.exitCode = 1;
3574
4088
  return;
3575
4089
  }
3576
- name = resolved;
4090
+ if (!opts.json) log.ok(`collect: ${result.added} new source(s) on last session`);
3577
4091
  }
3578
4092
 
3579
4093
  const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
@@ -3593,77 +4107,7 @@ program
3593
4107
 
3594
4108
  log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
3595
4109
  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
- }
4110
+ for (const line of renderMetricsSummary(metrics)) console.log(line);
3667
4111
  });
3668
4112
 
3669
4113
  program.parse();