agent-orchestrator-kit 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
5
5
  import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
- import { collectSpend, enrichMetricsCursorEstimates } from './spend-collect.js';
8
+ import { collectSpend } from './spend-collect.js';
9
+ import { describeClaudeCostEstimate } from './claude-cost-estimate.js';
9
10
  import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
10
11
  import {
11
12
  earlierTimestamp,
@@ -219,6 +220,47 @@ function copyDir(src, dest, opts = {}) {
219
220
  }
220
221
  }
221
222
 
223
+ // `.agents/commands/opsx-<phase>.md` is the source of truth for the /opsx:*
224
+ // role commands. Cursor reads `.cursor/commands/<file>.md` as `/<file>`, so it
225
+ // gets a flat copy. Claude Code namespaces by subdirectory —
226
+ // `.claude/commands/opsx/<phase>.md` is what makes the documented
227
+ // `/opsx:<phase>` actually exist there.
228
+ function syncCommands(projectDir, ideDir, { namespaced }) {
229
+ const src = join(projectDir, '.agents', 'commands');
230
+ const dest = join(projectDir, ideDir, 'commands');
231
+ if (!existsSync(src)) return;
232
+
233
+ const files = readdirSync(src).filter((f) => f.endsWith('.md'));
234
+ const written = new Set();
235
+ mkdirSync(dest, { recursive: true });
236
+
237
+ for (const file of files) {
238
+ const match = namespaced && file.match(/^([a-z0-9]+)-(.+\.md)$/i);
239
+ const rel = match ? join(match[1], match[2]) : file;
240
+ const destPath = join(dest, rel);
241
+ mkdirSync(dirname(destPath), { recursive: true });
242
+ copyFileSync(join(src, file), destPath);
243
+ written.add(rel);
244
+ log.ok(destPath.replace(process.cwd() + '/', ''));
245
+ }
246
+
247
+ // Drop commands a kit `update` removed, mirroring the skills/subagents sync.
248
+ const walk = (dir, prefix = '') => {
249
+ for (const entry of readdirSync(dir)) {
250
+ const full = join(dir, entry);
251
+ const rel = prefix ? join(prefix, entry) : entry;
252
+ if (statSync(full).isDirectory()) {
253
+ walk(full, rel);
254
+ if (readdirSync(full).length === 0) rmSync(full, { recursive: true, force: true });
255
+ } else if (!written.has(rel)) {
256
+ rmSync(full, { force: true });
257
+ log.warn(`removed stale: ${join(dest, rel).replace(process.cwd() + '/', '')}`);
258
+ }
259
+ }
260
+ };
261
+ walk(dest);
262
+ }
263
+
222
264
  function gitignoreLines(content) {
223
265
  return content.split('\n').map((l) => l.trim()).filter(Boolean);
224
266
  }
@@ -959,6 +1001,26 @@ function parseHandoffMarkdown(content) {
959
1001
  return sections;
960
1002
  }
961
1003
 
1004
+ // The `## Change` section is a bullet list (`- name: <x>`), but the next-thread
1005
+ // prompt inlines it after its own bullet. Flatten it so the prompt does not
1006
+ // read "- Change: - name: <x>".
1007
+ function inlineChangeLabel(value, fallbackName) {
1008
+ const text = String(value || '').trim();
1009
+ if (!text) return fallbackName;
1010
+ const parts = text
1011
+ .split('\n')
1012
+ .map((line) => line.replace(/^\s*[-*]\s*/, '').trim())
1013
+ .filter(Boolean);
1014
+ if (parts.length === 0) return fallbackName;
1015
+ const named = parts.find((p) => /^name:\s*/i.test(p));
1016
+ if (named) {
1017
+ const rest = parts.filter((p) => p !== named);
1018
+ const label = named.replace(/^name:\s*/i, '').trim() || fallbackName;
1019
+ return rest.length ? `${label} (${rest.join('; ')})` : label;
1020
+ }
1021
+ return parts.join('; ');
1022
+ }
1023
+
962
1024
  function firstLineCommand(value) {
963
1025
  const match = String(value || '').match(/\/opsx:[^\s`]+(?:\s+[^\s`]+)?/);
964
1026
  if (match) return match[0].trim();
@@ -968,8 +1030,14 @@ function firstLineCommand(value) {
968
1030
  function firstSpawnName(value) {
969
1031
  const tick = String(value || '').match(/`([a-z0-9-]+)`/i);
970
1032
  if (tick) return tick[1];
971
- const word = String(value || '').match(/\b([a-z][a-z0-9-]{2,})\b/i);
972
- return word ? word[1] : '';
1033
+ const role = canonicalRole(value);
1034
+ return {
1035
+ Explorer: 'explorer',
1036
+ Architect: 'spec-architect',
1037
+ 'Spec Reviewer': 'spec-reviewer',
1038
+ Implementer: '',
1039
+ Archiver: '',
1040
+ }[role] || '';
973
1041
  }
974
1042
 
975
1043
  function sectionOr(sections, title, fallback = '') {
@@ -1390,7 +1458,7 @@ function buildNextSessionPrompt(fields, agentLanguage) {
1390
1458
 
1391
1459
  ## Повний контекст попередньої сесії (самодостатній — не покладайся лише на Memory)
1392
1460
  - Закрита роль: ${fields.closedRole || 'не вказано'}
1393
- - Зміна: ${fields.change || name}
1461
+ - Зміна: ${inlineChangeLabel(fields.change, name)}
1394
1462
  - Зроблено:
1395
1463
  ${fields.done || 'не вказано'}
1396
1464
  - Рішення:
@@ -1442,7 +1510,7 @@ Do not mix phases. Do not start the following role in this chat until this phase
1442
1510
 
1443
1511
  ## Full previous-session context (self-contained — do not rely on Memory alone)
1444
1512
  - Closed role: ${fields.closedRole || 'not set'}
1445
- - Change: ${fields.change || name}
1513
+ - Change: ${inlineChangeLabel(fields.change, name)}
1446
1514
  - Done:
1447
1515
  ${fields.done || 'not set'}
1448
1516
  - Decisions:
@@ -1474,26 +1542,48 @@ function loadMemoryItems(filePath) {
1474
1542
  if (!existsSync(filePath)) return [];
1475
1543
  const raw = readFileSync(filePath, 'utf-8').trim();
1476
1544
  if (!raw) return [];
1477
- if (raw.startsWith('{')) {
1478
- try {
1479
- const parsed = JSON.parse(raw);
1545
+
1546
+ // Aggregate form is a single JSON document: { entities: [...], relations: [...] }.
1547
+ // Every JSONL line is an object too, so the *shape* decides — not the first
1548
+ // character. Keying off `{` classified every JSONL graph as an aggregate,
1549
+ // parsed it as one document, failed, and returned [] — which made the next
1550
+ // persist overwrite the whole graph with just the current change.
1551
+ try {
1552
+ const parsed = JSON.parse(raw);
1553
+ if (parsed && !Array.isArray(parsed) && (Array.isArray(parsed.entities) || Array.isArray(parsed.relations))) {
1480
1554
  const entities = (parsed.entities || []).map((entity) => ({ type: 'entity', ...entity }));
1481
1555
  const relations = (parsed.relations || []).map((relation) => ({ type: 'relation', ...relation }));
1482
1556
  return [...entities, ...relations];
1557
+ }
1558
+ } catch {
1559
+ // Not a single JSON document — fall through to JSONL.
1560
+ }
1561
+
1562
+ // JSONL: the format @modelcontextprotocol/server-memory reads and writes,
1563
+ // one entity or relation per line. A line we cannot parse is kept verbatim
1564
+ // so a persist never drops memory it failed to understand.
1565
+ const items = [];
1566
+ for (const line of raw.split('\n')) {
1567
+ const trimmed = line.trim();
1568
+ if (!trimmed) continue;
1569
+ let parsed;
1570
+ try {
1571
+ parsed = JSON.parse(trimmed);
1483
1572
  } catch {
1484
- return [];
1573
+ items.push({ __raw: trimmed });
1574
+ continue;
1485
1575
  }
1576
+ if (parsed && typeof parsed === 'object' && parsed.type) items.push(parsed);
1577
+ else items.push({ __raw: trimmed });
1486
1578
  }
1487
- return raw
1488
- .split('\n')
1489
- .map((line) => line.trim())
1490
- .filter(Boolean)
1491
- .map((line) => JSON.parse(line));
1579
+ return items;
1492
1580
  }
1493
1581
 
1494
1582
  function saveMemoryItems(filePath, items) {
1495
1583
  mkdirSync(dirname(filePath), { recursive: true });
1496
- const body = items.map((item) => JSON.stringify(item)).join('\n');
1584
+ const body = items
1585
+ .map((item) => (item && item.__raw !== undefined ? item.__raw : JSON.stringify(item)))
1586
+ .join('\n');
1497
1587
  writeFileSync(filePath, body ? `${body}\n` : '');
1498
1588
  }
1499
1589
 
@@ -1611,7 +1701,7 @@ function readHandoffFields(projectDir, changeName) {
1611
1701
  return { filePath, fields: fieldsFromSections(changeName, sections) };
1612
1702
  }
1613
1703
 
1614
- const METRICS_VERSION = 1;
1704
+ const METRICS_VERSION = 2;
1615
1705
  const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1616
1706
  const LEFTOVER_GRACE_MS = 120000;
1617
1707
 
@@ -1675,6 +1765,92 @@ function defaultMetrics(changeName, nowIso) {
1675
1765
  };
1676
1766
  }
1677
1767
 
1768
+ const VALID_COST_SOURCES = new Set(['api-estimate', 'api-estimate-fallback', 'amp-usage']);
1769
+
1770
+ function compactModelRows(sources) {
1771
+ const byModel = new Map();
1772
+ for (const source of sources || []) {
1773
+ if (!source) continue;
1774
+ const platform = VALID_PLATFORMS.has(source.platform) ? source.platform : null;
1775
+ const model = source.model == null || source.model === '' ? null : String(source.model);
1776
+ const key = `${platform || ''}::${model || ''}`;
1777
+ const row = byModel.get(key) || {
1778
+ model,
1779
+ platform,
1780
+ inputTokens: null,
1781
+ outputTokens: null,
1782
+ totalTokens: null,
1783
+ costUsd: null,
1784
+ costUsdEstimated: null,
1785
+ };
1786
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
1787
+ row[field] = addNullable(row[field], numOrNull(source[field]));
1788
+ }
1789
+ row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(source.costUsdEstimated)));
1790
+ if (VALID_COST_SOURCES.has(source.costSource)) {
1791
+ if (!row.costSource || row.costSource === source.costSource) row.costSource = source.costSource;
1792
+ else delete row.costSource;
1793
+ }
1794
+ byModel.set(key, row);
1795
+ }
1796
+ return [...byModel.values()];
1797
+ }
1798
+
1799
+ function compactSourceTotals(sources) {
1800
+ const sourceIds = [];
1801
+ const sourceTotals = {};
1802
+ for (const source of sources || []) {
1803
+ if (!source || source.id == null || source.id === '') continue;
1804
+ const id = String(source.id);
1805
+ if (!sourceIds.includes(id)) sourceIds.push(id);
1806
+ const total = numOrNull(source.totalTokens);
1807
+ if (total != null) sourceTotals[id] = Math.max(numOrNull(sourceTotals[id]) ?? 0, total);
1808
+ }
1809
+ return { sourceIds, sourceTotals };
1810
+ }
1811
+
1812
+ function normalizeSessionV2(session) {
1813
+ const normalized = session && typeof session === 'object' && !Array.isArray(session) ? { ...session } : {};
1814
+ const legacySources = Array.isArray(normalized.sources) ? normalized.sources : null;
1815
+ if (legacySources) {
1816
+ const bestById = new Map();
1817
+ for (const source of legacySources) {
1818
+ if (!source || source.id == null || source.id === '') continue;
1819
+ const id = String(source.id);
1820
+ const previous = bestById.get(id);
1821
+ if (!previous || (numOrNull(source.totalTokens) ?? 0) >= (numOrNull(previous.totalTokens) ?? 0)) bestById.set(id, source);
1822
+ }
1823
+ const compactSources = [...bestById.values()].map((source) => ({
1824
+ ...source,
1825
+ model: source.model || normalized.model || null,
1826
+ platform: source.platform || normalized.platform || null,
1827
+ }));
1828
+ const compact = compactSourceTotals(compactSources);
1829
+ normalized.sourceIds = compact.sourceIds;
1830
+ normalized.sourceTotals = compact.sourceTotals;
1831
+ normalized.byModel = compactModelRows(compactSources);
1832
+ if (!normalized.platform && compactSources[0] && compactSources[0].platform) normalized.platform = compactSources[0].platform;
1833
+ if (!normalized.model && compactSources[0] && compactSources[0].model) normalized.model = compactSources[0].model;
1834
+ delete normalized.sources;
1835
+ } else {
1836
+ normalized.sourceIds = Array.isArray(normalized.sourceIds)
1837
+ ? [...new Set(normalized.sourceIds.filter((id) => id != null && id !== '').map(String))]
1838
+ : [];
1839
+ normalized.sourceTotals = normalized.sourceTotals && typeof normalized.sourceTotals === 'object' && !Array.isArray(normalized.sourceTotals)
1840
+ ? { ...normalized.sourceTotals }
1841
+ : {};
1842
+ normalized.byModel = Array.isArray(normalized.byModel) ? normalized.byModel.map((row) => ({ ...row })) : [];
1843
+ }
1844
+ return normalized;
1845
+ }
1846
+
1847
+ function normalizeMetricsV2(metrics) {
1848
+ if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) return metrics;
1849
+ metrics.version = METRICS_VERSION;
1850
+ metrics.sessions = Array.isArray(metrics.sessions) ? metrics.sessions.map(normalizeSessionV2) : [];
1851
+ return metrics;
1852
+ }
1853
+
1678
1854
  function loadMetricsFile(filePath, changeName, nowIso) {
1679
1855
  if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
1680
1856
  let parsed;
@@ -1687,7 +1863,7 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1687
1863
  return defaultMetrics(changeName, nowIso);
1688
1864
  }
1689
1865
  const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
1690
- return {
1866
+ return normalizeMetricsV2({
1691
1867
  ...base,
1692
1868
  ...parsed,
1693
1869
  version: METRICS_VERSION,
@@ -1698,12 +1874,12 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1698
1874
  totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
1699
1875
  phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
1700
1876
  sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
1701
- };
1877
+ });
1702
1878
  }
1703
1879
 
1704
1880
  function saveMetricsFile(filePath, metrics) {
1705
1881
  mkdirSync(dirname(filePath), { recursive: true });
1706
- const out = { ...metrics };
1882
+ const out = normalizeMetricsV2({ ...metrics });
1707
1883
  delete out.timezone;
1708
1884
  writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
1709
1885
  }
@@ -1750,7 +1926,7 @@ function phaseForRole(role) {
1750
1926
  function sessionFieldOrSources(session, key) {
1751
1927
  if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
1752
1928
  let sum = null;
1753
- for (const src of session.sources || []) {
1929
+ for (const src of session.byModel || []) {
1754
1930
  sum = addNullable(sum, numOrNull(src[key]));
1755
1931
  }
1756
1932
  return sum;
@@ -1764,8 +1940,14 @@ function lastSessionEndedAt(metrics) {
1764
1940
  return last;
1765
1941
  }
1766
1942
 
1767
- function collectWindowStart(metrics, extra) {
1768
- return (extra && extra.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1943
+ function collectWindowStart(metrics, extra = {}) {
1944
+ const explicit = isoOrNull(extra.explicitStartedAt);
1945
+ const pending = isoOrNull(metrics.pending && metrics.pending.startedAt);
1946
+ const selected = explicit || pending || isoOrNull(extra.lastEndedAt) || isoOrNull(metrics.createdAt);
1947
+ if (!selected) return nowUtcIso();
1948
+ if (!explicit && !pending) return selected;
1949
+ const selectedMs = parseFlexibleIso(selected);
1950
+ return Number.isFinite(selectedMs) ? new Date(selectedMs - LEFTOVER_GRACE_MS).toISOString() : selected;
1769
1951
  }
1770
1952
 
1771
1953
  function leftoverGraceEnd(endedAt) {
@@ -1774,6 +1956,15 @@ function leftoverGraceEnd(endedAt) {
1774
1956
  return new Date(ms + LEFTOVER_GRACE_MS).toISOString();
1775
1957
  }
1776
1958
 
1959
+ function sessionLeftoverEnd(session, pendingStartedAt) {
1960
+ const pending = isoOrNull(pendingStartedAt);
1961
+ const grace = leftoverGraceEnd(session && session.endedAt);
1962
+ if (session && session.threadId) return pending || grace;
1963
+ if (!pending) return grace;
1964
+ if (!grace) return pending;
1965
+ return earlierTimestamp(pending, grace);
1966
+ }
1967
+
1777
1968
  function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1778
1969
  const t = parseFlexibleIso(at);
1779
1970
  if (!Number.isFinite(t)) return false;
@@ -1788,10 +1979,10 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1788
1979
  return true;
1789
1980
  }
1790
1981
 
1791
- function uniqueAmpSourceThreadPrefix(sources) {
1982
+ function uniqueAmpSourceThreadPrefix(sourceIds) {
1792
1983
  const prefixes = [];
1793
- for (const src of sources || []) {
1794
- const id = src && src.id != null ? String(src.id) : '';
1984
+ for (const sourceId of sourceIds || []) {
1985
+ const id = sourceId == null ? '' : String(sourceId);
1795
1986
  if (!id.startsWith('T-')) continue;
1796
1987
  const prefix = id.split(':')[0];
1797
1988
  if (!prefix) continue;
@@ -1803,14 +1994,14 @@ function uniqueAmpSourceThreadPrefix(sources) {
1803
1994
  function leftoverAmpThreadId(session) {
1804
1995
  if (!session) return null;
1805
1996
  if (session.threadId) return String(session.threadId);
1806
- return uniqueAmpSourceThreadPrefix(session.sources);
1997
+ return uniqueAmpSourceThreadPrefix(session.sourceIds);
1807
1998
  }
1808
1999
 
1809
2000
  function sessionAmpThreadKeys(session) {
1810
2001
  const keys = new Set();
1811
2002
  if (session && session.threadId) keys.add(String(session.threadId));
1812
- for (const src of (session && session.sources) || []) {
1813
- const id = src && src.id != null ? String(src.id) : '';
2003
+ for (const sourceId of (session && session.sourceIds) || []) {
2004
+ const id = sourceId == null ? '' : String(sourceId);
1814
2005
  if (!id.startsWith('T-')) continue;
1815
2006
  const prefix = id.split(':')[0];
1816
2007
  if (prefix) keys.add(prefix);
@@ -1848,11 +2039,11 @@ function sessionSpendIsFrozen(session) {
1848
2039
  return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
1849
2040
  }
1850
2041
 
1851
- function existingSourceRecords(metrics) {
1852
- const out = [];
2042
+ function existingSourceTotals(metrics) {
2043
+ const out = {};
1853
2044
  for (const session of metrics.sessions || []) {
1854
- for (const src of session.sources || []) {
1855
- if (src) out.push(src);
2045
+ for (const [id, total] of Object.entries(session.sourceTotals || {})) {
2046
+ out[id] = Math.max(numOrNull(out[id]) ?? 0, numOrNull(total) ?? 0);
1856
2047
  }
1857
2048
  }
1858
2049
  return out;
@@ -1861,13 +2052,15 @@ function existingSourceRecords(metrics) {
1861
2052
  function existingSourceIdSet(metrics) {
1862
2053
  const ids = new Set();
1863
2054
  for (const session of metrics.sessions || []) {
1864
- for (const src of session.sources || []) {
1865
- if (src && src.id != null && src.id !== '') ids.add(String(src.id));
1866
- }
2055
+ for (const id of session.sourceIds || []) ids.add(String(id));
1867
2056
  }
1868
2057
  return ids;
1869
2058
  }
1870
2059
 
2060
+ function existingSessionThreadIds(metrics) {
2061
+ return [...new Set((metrics.sessions || []).map((session) => session && session.threadId).filter(Boolean).map(String))];
2062
+ }
2063
+
1871
2064
  function lastNonArchiverSession(sessions) {
1872
2065
  const list = Array.isArray(sessions) ? sessions : [];
1873
2066
  for (let i = list.length - 1; i >= 0; i -= 1) {
@@ -1903,6 +2096,63 @@ function uniqueSourceModels(sources) {
1903
2096
  return seen;
1904
2097
  }
1905
2098
 
2099
+ function sessionTotalsFromModels(byModel) {
2100
+ const totals = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
2101
+ for (const row of byModel || []) {
2102
+ for (const key of Object.keys(totals)) totals[key] = addNullable(totals[key], numOrNull(row[key]));
2103
+ }
2104
+ totals.costUsdEstimated = roundUsd4(totals.costUsdEstimated);
2105
+ return totals;
2106
+ }
2107
+
2108
+ function mergeModelRows(current, incoming) {
2109
+ const merged = new Map();
2110
+ for (const raw of [...(current || []), ...(incoming || [])]) {
2111
+ if (!raw) continue;
2112
+ const model = raw.model == null || raw.model === '' ? null : String(raw.model);
2113
+ const key = `${raw.platform || ''}::${model || ''}`;
2114
+ const row = merged.get(key) || {
2115
+ model,
2116
+ platform: raw.platform || null,
2117
+ inputTokens: null,
2118
+ outputTokens: null,
2119
+ totalTokens: null,
2120
+ costUsd: null,
2121
+ costUsdEstimated: null,
2122
+ };
2123
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
2124
+ row[field] = addNullable(row[field], numOrNull(raw[field]));
2125
+ }
2126
+ row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(raw.costUsdEstimated)));
2127
+ if (raw.costSource) row.costSource = raw.costSource;
2128
+ merged.set(key, row);
2129
+ }
2130
+ return [...merged.values()];
2131
+ }
2132
+
2133
+ function compactCollectedDelta(session, sources) {
2134
+ const adjusted = [];
2135
+ for (const source of sources || []) {
2136
+ if (!source || source.id == null) continue;
2137
+ const previous = numOrNull(session.sourceTotals && session.sourceTotals[source.id]);
2138
+ const next = numOrNull(source.totalTokens);
2139
+ if (previous == null || next == null) {
2140
+ adjusted.push(source);
2141
+ continue;
2142
+ }
2143
+ const delta = Math.max(0, next - previous);
2144
+ if (delta === 0) continue;
2145
+ const ratio = next > 0 ? delta / next : 0;
2146
+ const copy = { ...source };
2147
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
2148
+ const value = numOrNull(copy[field]);
2149
+ if (value != null) copy[field] = field.includes('Usd') ? roundUsd4(value * ratio) : Math.round(value * ratio);
2150
+ }
2151
+ adjusted.push(copy);
2152
+ }
2153
+ return adjusted;
2154
+ }
2155
+
1906
2156
  function rankSources(sources) {
1907
2157
  return [...sources].sort((a, b) => {
1908
2158
  const ta = a.totalTokens ?? 0;
@@ -1974,7 +2224,15 @@ function ampThreadTotals(threads) {
1974
2224
  if (row && row.model && !models.includes(row.model)) models.push(row.model);
1975
2225
  }
1976
2226
  }
1977
- return { costUsd, agentMode, models };
2227
+ let inputTokens = null;
2228
+ let outputTokens = null;
2229
+ let totalTokens = null;
2230
+ for (const thread of list) {
2231
+ inputTokens = addNullable(inputTokens, numOrNull(thread && thread.inputTokens));
2232
+ outputTokens = addNullable(outputTokens, numOrNull(thread && thread.outputTokens));
2233
+ totalTokens = addNullable(totalTokens, numOrNull(thread && thread.totalTokens));
2234
+ }
2235
+ return { costUsd, agentMode, models, inputTokens, outputTokens, totalTokens };
1978
2236
  }
1979
2237
 
1980
2238
  function resolveSessionSpend(opts, reported, sources, extra = {}) {
@@ -1988,9 +2246,9 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1988
2246
  const flagTotal = numOrNull(flags.totalTokens);
1989
2247
  const flagCost = numOrNull(flags.costUsd);
1990
2248
  const flagCredits = numOrNull(flags.ampCredits);
1991
- const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
1992
- const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
1993
- let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
2249
+ const inputTokens = firstNonNull(flagInput, self.inputTokens, fromAmp.inputTokens, fromSources.inputTokens);
2250
+ const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromAmp.outputTokens, fromSources.outputTokens);
2251
+ let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromAmp.totalTokens, fromSources.totalTokens);
1994
2252
  if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1995
2253
  totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1996
2254
  }
@@ -2050,7 +2308,7 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
2050
2308
  windowStart,
2051
2309
  windowEnd,
2052
2310
  existingSourceIds: existingSourceIdSet(metrics),
2053
- existingSources: existingSourceRecords(metrics),
2311
+ existingSourceTotals: existingSourceTotals(metrics),
2054
2312
  env: extra.env || process.env,
2055
2313
  homedir: extra.homedir || process.env.HOME,
2056
2314
  platforms: extra.platforms,
@@ -2061,9 +2319,12 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
2061
2319
  usageAmpThread: extra.usageAmpThread,
2062
2320
  listRecentAmpThreads: extra.listRecentAmpThreads,
2063
2321
  listAmpThreads: extra.listAmpThreads,
2322
+ collectAll: extra.collectAll === true,
2323
+ existingThreadIds: existingSessionThreadIds(metrics),
2324
+ rebillThreadId: extra.rebillThreadId,
2064
2325
  });
2065
2326
  } catch {
2066
- return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2327
+ return { sources: [], ids: [], totals: {}, byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2067
2328
  }
2068
2329
  }
2069
2330
 
@@ -2078,7 +2339,7 @@ function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
2078
2339
  }
2079
2340
 
2080
2341
  function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
2081
- const fromSources = sessionTotalsFromSources(session.sources || []);
2342
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2082
2343
  session.inputTokens = fromSources.inputTokens;
2083
2344
  session.outputTokens = fromSources.outputTokens;
2084
2345
  let totalTokens = fromSources.totalTokens;
@@ -2116,6 +2377,7 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2116
2377
  env: extra.env,
2117
2378
  cwd: extra.cwd,
2118
2379
  homedir: extra.homedir,
2380
+ rebillThreadId: ampThreadId,
2119
2381
  });
2120
2382
  let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2121
2383
  src.at,
@@ -2131,32 +2393,43 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2131
2393
  });
2132
2394
  }
2133
2395
  if (!incoming.length) return 0;
2134
- const merged = [...(session.sources || []), ...incoming];
2396
+ const beforeIds = new Set(session.sourceIds || []);
2135
2397
  if (sessionSpendIsFrozen(session)) {
2136
- session.sources = merged;
2137
- const uniqueModels = uniqueSourceModels(merged);
2398
+ const delta = compactCollectedDelta(session, incoming);
2399
+ session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
2400
+ session.sourceTotals = { ...(session.sourceTotals || {}) };
2401
+ for (const source of incoming) session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
2402
+ session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
2403
+ const uniqueModels = uniqueSourceModels(session.byModel);
2138
2404
  if (uniqueModels.length > 1) session.models = uniqueModels;
2139
2405
  } else {
2140
2406
  const priorCost = numOrNull(session.costUsd);
2141
2407
  const priorSource = session.spendSource;
2142
- applyCollectedSessionFields(session, merged, session.model, {}, {
2408
+ applyCollectedSessionFields(session, incoming, session.model, {}, {
2143
2409
  model: session.model,
2144
2410
  platform: session.platform,
2145
2411
  }, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
2146
2412
  resyncLeftoverSessionSpend(session, priorCost, priorSource);
2147
2413
  }
2148
- return incoming.length;
2414
+ return incoming.filter((source) => !beforeIds.has(String(source.id))).length;
2149
2415
  }
2150
2416
 
2151
2417
  function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
2152
- session.sources = sources || [];
2153
- const uniqueModels = uniqueSourceModels(session.sources);
2418
+ const incoming = sources || [];
2419
+ const delta = compactCollectedDelta(session, incoming);
2420
+ session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
2421
+ session.sourceTotals = { ...(session.sourceTotals || {}) };
2422
+ for (const source of incoming) {
2423
+ session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
2424
+ }
2425
+ session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
2426
+ const uniqueModels = uniqueSourceModels(session.byModel);
2154
2427
  const fromAmp = ampThreadTotals(extra.ampThreads);
2155
2428
  for (const model of fromAmp.models) {
2156
2429
  if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
2157
2430
  }
2158
2431
  if (uniqueModels.length > 1) session.models = uniqueModels;
2159
- const sourceModel = primaryModelFromSources(session.sources);
2432
+ const sourceModel = primaryModelFromSources(incoming);
2160
2433
  if (sourceModel) {
2161
2434
  session.model = sourceModel;
2162
2435
  } else {
@@ -2164,8 +2437,8 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2164
2437
  || fromAmp.models[0]
2165
2438
  || null;
2166
2439
  }
2167
- if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
2168
- const spend = resolveSessionSpend(opts, reported, session.sources, extra);
2440
+ if (!session.platform) session.platform = primaryPlatformFromSources(incoming) || null;
2441
+ const spend = resolveSessionSpend(opts, reported, incoming, extra);
2169
2442
  session.inputTokens = spend.inputTokens;
2170
2443
  session.outputTokens = spend.outputTokens;
2171
2444
  session.totalTokens = spend.totalTokens;
@@ -2178,10 +2451,42 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2178
2451
  if (usageModels.length || Array.isArray(session.usageModels)) {
2179
2452
  session.usageModels = usageModels;
2180
2453
  }
2454
+ if (session.platform === 'amp' && usageModels.length) {
2455
+ session.byModel = usageModels.map((row) => ({
2456
+ model: row.model,
2457
+ platform: 'amp',
2458
+ inputTokens: numOrNull(row.inputTokens),
2459
+ outputTokens: numOrNull(row.outputTokens),
2460
+ totalTokens: numOrNull(row.totalTokens) ?? ((numOrNull(row.inputTokens) ?? 0) + (numOrNull(row.outputTokens) ?? 0)),
2461
+ costUsd: numOrNull(row.costUsd),
2462
+ costUsdEstimated: null,
2463
+ ...(numOrNull(row.costUsd) != null ? { costSource: 'amp-usage' } : {}),
2464
+ }));
2465
+ }
2466
+ if (session.costUsd == null) {
2467
+ for (const row of session.byModel) {
2468
+ if (row.costUsd != null) continue;
2469
+ // adapters already estimated with the cache-read / cache-write split; do not
2470
+ // overwrite it with the coarse input+output estimate below
2471
+ if (row.costUsdEstimated != null) continue;
2472
+ const described = describeClaudeCostEstimate({
2473
+ model: String(row.model || '').replace(/^Claude\s+/i, 'claude-').replaceAll(' ', '-').toLowerCase(),
2474
+ inputTokens: row.inputTokens,
2475
+ outputTokens: row.outputTokens,
2476
+ });
2477
+ if (described) {
2478
+ row.costUsdEstimated = described.usd;
2479
+ row.costSource = described.costSource;
2480
+ }
2481
+ }
2482
+ session.costUsdEstimated = sessionTotalsFromModels(session.byModel).costUsdEstimated;
2483
+ } else {
2484
+ session.costUsdEstimated = null;
2485
+ }
2181
2486
  }
2182
2487
 
2183
2488
  function sessionTotalsLookOverridden(session) {
2184
- const fromSources = sessionTotalsFromSources(session.sources || []);
2489
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2185
2490
  return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
2186
2491
  const sessionVal = numOrNull(session[key]);
2187
2492
  const sourceVal = numOrNull(fromSources[key]);
@@ -2212,16 +2517,16 @@ function metricsBackfillFile(filePath, changeName) {
2212
2517
  });
2213
2518
  const incoming = collected.sources || [];
2214
2519
  if (!incoming.length) return { filePath, added: 0 };
2215
- const merged = [...(last.sources || []), ...incoming];
2216
2520
  if (sessionSpendIsFrozen(last)) {
2217
- last.sources = merged;
2218
- const uniqueModels = uniqueSourceModels(merged);
2219
- if (uniqueModels.length > 1) last.models = uniqueModels;
2521
+ applyCollectedSessionFields(last, incoming, last.model, {}, last, { ampThreads: collected.ampThreads });
2220
2522
  } else {
2221
- applyCollectedSessionFields(last, merged, last.model, {}, {
2523
+ const priorCost = numOrNull(last.costUsd);
2524
+ const priorSource = last.spendSource;
2525
+ applyCollectedSessionFields(last, incoming, last.model, {}, {
2222
2526
  model: last.model,
2223
2527
  platform: last.platform,
2224
2528
  }, { ampThreads: collected.ampThreads });
2529
+ resyncLeftoverSessionSpend(last, priorCost, priorSource);
2225
2530
  }
2226
2531
  metrics.updatedAt = nowIso;
2227
2532
  recomputeMetricsAggregates(metrics);
@@ -2286,35 +2591,19 @@ function recomputeSpendMaps(metrics) {
2286
2591
  byModel.set(key, row);
2287
2592
  }
2288
2593
 
2289
- function addSourceRow(src) {
2290
- const nums = spendTuple(src);
2291
- if (src.platform && byPlatform[src.platform]) {
2292
- addSpendNums(byPlatform[src.platform], nums);
2293
- const label = adapterSourceName(src.platform, src.via);
2294
- if (label) byPlatform[src.platform].source = label;
2295
- }
2296
- addModelRow(src.model, src.platform, nums);
2297
- }
2298
-
2299
2594
  for (const session of metrics.sessions || []) {
2300
- const sources = session.sources || [];
2301
- if (sources.length > 0) {
2302
- for (const src of sources) addSourceRow(src);
2303
- let sourceCost = null;
2304
- for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2305
- if (sourceCost == null && session.platform && byPlatform[session.platform]) {
2306
- byPlatform[session.platform].costUsd = addNullable(
2307
- byPlatform[session.platform].costUsd,
2308
- numOrNull(session.costUsd),
2309
- );
2310
- }
2311
- continue;
2312
- }
2313
2595
  const sessionNums = spendTuple(session);
2314
2596
  if (session.platform && byPlatform[session.platform]) {
2315
2597
  addSpendNums(byPlatform[session.platform], sessionNums);
2598
+ if ((session.sourceIds || []).length) {
2599
+ byPlatform[session.platform].source = adapterSourceName(session.platform, session.platform === 'amp' ? 'amp-cli' : null);
2600
+ }
2601
+ }
2602
+ if ((session.byModel || []).length) {
2603
+ for (const row of session.byModel) addModelRow(row.model, row.platform || session.platform, spendTuple(row));
2604
+ } else {
2605
+ addModelRow(session.model, session.platform, sessionNums);
2316
2606
  }
2317
- addModelRow(session.model, session.platform, sessionNums);
2318
2607
  }
2319
2608
  for (const key of Object.keys(byPlatform)) {
2320
2609
  byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
@@ -2327,7 +2616,7 @@ function recomputeSpendMaps(metrics) {
2327
2616
  }
2328
2617
 
2329
2618
  function recomputeMetricsAggregates(metrics) {
2330
- enrichMetricsCursorEstimates(metrics, process.cwd());
2619
+ normalizeMetricsV2(metrics);
2331
2620
  const phases = {};
2332
2621
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
2333
2622
  const spend = emptySpendTotals();
@@ -2367,22 +2656,8 @@ function recomputeMetricsAggregates(metrics) {
2367
2656
  ? session.endedAt
2368
2657
  : laterTimestamp(phase.endedAt, session.endedAt);
2369
2658
  }
2370
- const sources = session.sources || [];
2371
- let sourceCost = null;
2372
- for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2373
2659
  for (const spendKey of METRICS_SPEND_KEYS) {
2374
- let value = null;
2375
- if (sources.length > 0) {
2376
- if (spendKey === 'costUsd') {
2377
- value = sourceCost == null ? numOrNull(session.costUsd) : sourceCost;
2378
- } else {
2379
- for (const src of sources) {
2380
- value = addNullable(value, numOrNull(src[spendKey]));
2381
- }
2382
- }
2383
- } else {
2384
- value = sessionFieldOrSources(session, spendKey);
2385
- }
2660
+ const value = sessionFieldOrSources(session, spendKey);
2386
2661
  phase[spendKey] = addNullable(phase[spendKey], value);
2387
2662
  spend[spendKey] = addNullable(spend[spendKey], value);
2388
2663
  }
@@ -2435,19 +2710,29 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2435
2710
  const nowIso = nowUtcIso();
2436
2711
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
2437
2712
  const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
2438
- const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
2439
- ? metrics.pending.startedAt
2440
- : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2713
+ const leftoverEnd = sessionLeftoverEnd(lastClosed, metrics.pending && metrics.pending.startedAt);
2441
2714
  attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2442
2715
  exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2443
2716
  collect: opts.collect === true,
2444
2717
  });
2445
- let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2718
+ const explicitStartedAt = isoOrNull(opts.startedAt);
2719
+ const pendingStartedAt = isoOrNull(metrics.pending && metrics.pending.startedAt);
2720
+ const startedAt = explicitStartedAt || pendingStartedAt || isoOrNull(lastClosed && lastClosed.endedAt) || isoOrNull(metrics.createdAt) || nowIso;
2721
+ if (!explicitStartedAt && !pendingStartedAt) console.error('Warning: persist without restore; collecting from the last closed session boundary.');
2446
2722
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2447
2723
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2448
- const windowStart = collectWindowStart(metrics, { startedAt });
2724
+ const windowStart = collectWindowStart(metrics, {
2725
+ explicitStartedAt,
2726
+ lastEndedAt: lastClosed && lastClosed.endedAt,
2727
+ });
2449
2728
  const pending = metrics.pending || {};
2450
2729
  const platform = opts.platform || pending.platform || null;
2730
+ const cursorThreadId = platform === 'cursor'
2731
+ ? (pending.threadId || String(process.env.CURSOR_CONVERSATION_ID || '').trim() || null)
2732
+ : null;
2733
+ const ampThreadId = platform === 'amp'
2734
+ ? (opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null)
2735
+ : null;
2451
2736
  const collectAll = opts.collect === true;
2452
2737
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2453
2738
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
@@ -2455,9 +2740,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2455
2740
  const collected = shouldCollect
2456
2741
  ? runCollectSpend(metrics, windowStart, endedAt, {
2457
2742
  platforms,
2458
- ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2743
+ ampThreadId,
2459
2744
  ampCli: collectAll || platform === 'amp',
2460
- cursorConversationId: platform === 'cursor' ? pending.threadId : undefined,
2745
+ cursorConversationId: cursorThreadId || undefined,
2746
+ listRecentAmpThreads: false,
2747
+ collectAll,
2461
2748
  })
2462
2749
  : { sources: [] };
2463
2750
  const startedMs = parseFlexibleIso(startedAt);
@@ -2476,9 +2763,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2476
2763
  agentId: fields.agentId || 'none',
2477
2764
  model: resolvedModel || null,
2478
2765
  platform: opts.platform || pending.platform || null,
2479
- threadId: opts.ampThreadId || pending.threadId || null,
2766
+ threadId: cursorThreadId || ampThreadId || pending.threadId || null,
2480
2767
  tasks: fields.tasks || null,
2481
- sources: [],
2768
+ sourceIds: [],
2769
+ sourceTotals: {},
2770
+ byModel: [],
2482
2771
  inputTokens: null,
2483
2772
  outputTokens: null,
2484
2773
  totalTokens: null,
@@ -2488,22 +2777,6 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2488
2777
  spendSource: 'unreported',
2489
2778
  };
2490
2779
  applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2491
- if (!session.startedAt) {
2492
- let earliest = null;
2493
- for (const src of session.sources || []) {
2494
- const at = isoOrNull(src.at);
2495
- if (!at) continue;
2496
- earliest = earliest == null ? at : earlierTimestamp(earliest, at);
2497
- }
2498
- if (earliest) {
2499
- session.startedAt = earliest;
2500
- const fromMs = parseFlexibleIso(session.startedAt);
2501
- const toMs = parseFlexibleIso(session.endedAt);
2502
- session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
2503
- ? Math.max(0, toMs - fromMs)
2504
- : null;
2505
- }
2506
- }
2507
2780
  metrics.sessions.push(session);
2508
2781
  metrics.pending = null;
2509
2782
  metrics.updatedAt = nowIso;
@@ -2534,12 +2807,15 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2534
2807
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2535
2808
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2536
2809
  const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
2810
+ const archiveWindowStart = collectWindowStart(metrics, {});
2537
2811
  const collected = shouldCollect
2538
- ? runCollectSpend(metrics, startedAt, endedAt, {
2812
+ ? runCollectSpend(metrics, archiveWindowStart, endedAt, {
2539
2813
  platforms,
2540
2814
  ampThreadId,
2541
2815
  ampCli: collectAll || platform === 'amp',
2542
2816
  cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
2817
+ listRecentAmpThreads: false,
2818
+ collectAll,
2543
2819
  })
2544
2820
  : { sources: [] };
2545
2821
  const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
@@ -2556,7 +2832,9 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2556
2832
  platform,
2557
2833
  threadId: ampThreadId || null,
2558
2834
  tasks: opts.tasks || null,
2559
- sources: [],
2835
+ sourceIds: [],
2836
+ sourceTotals: {},
2837
+ byModel: [],
2560
2838
  inputTokens: null,
2561
2839
  outputTokens: null,
2562
2840
  totalTokens: null,
@@ -2595,8 +2873,9 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra =
2595
2873
  clientSource: client.source || null,
2596
2874
  };
2597
2875
  }
2598
- const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2599
- attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
2876
+ const previous = lastNonArchiverSession(metrics.sessions);
2877
+ const leftoverEnd = sessionLeftoverEnd(previous, metrics.pending && metrics.pending.startedAt);
2878
+ attachLeftoverSources(metrics, previous, leftoverEnd, {
2600
2879
  exclusiveEnd: true,
2601
2880
  collect: extra.collect === true,
2602
2881
  });
@@ -2862,11 +3141,13 @@ function readPipelineConfig(projectDir) {
2862
3141
  const requireBriefMatch = content.match(/require_design_brief:\s*(true|false)/);
2863
3142
  const maxActiveMatch = content.match(/max_active_changes:\s*(\d+)/);
2864
3143
  const taskContractMatch = content.match(/task_contract:\s*(warn|strict|off)/);
3144
+ const srcGlobMatch = content.match(/src_glob:\s*["']?([^"'\s#]+)["']?/);
2865
3145
  return {
2866
3146
  requireSpecReview: requireReviewMatch ? requireReviewMatch[1] === 'true' : true,
2867
3147
  requireDesignBrief: requireBriefMatch ? requireBriefMatch[1] === 'true' : false,
2868
3148
  maxActiveChanges: maxActiveMatch ? parseInt(maxActiveMatch[1], 10) : null,
2869
3149
  taskContract: taskContractMatch ? taskContractMatch[1] : 'warn',
3150
+ srcGlob: srcGlobMatch ? srcGlobMatch[1] : null,
2870
3151
  };
2871
3152
  }
2872
3153
 
@@ -3698,6 +3979,7 @@ program
3698
3979
  }
3699
3980
  copyDir(join(projectDir, '.agents', 'rules'), join(projectDir, '.cursor', 'rules'), { overwrite: true, delete: true });
3700
3981
  copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.cursor', 'agents'), { overwrite: true, delete: true });
3982
+ syncCommands(projectDir, '.cursor', { namespaced: false });
3701
3983
  }
3702
3984
 
3703
3985
  if (syncClaude) {
@@ -3707,6 +3989,7 @@ program
3707
3989
  rmSync(join(projectDir, '.claude', 'skills', wrapper), { recursive: true, force: true });
3708
3990
  }
3709
3991
  copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.claude', 'agents'), { overwrite: true, delete: true });
3992
+ syncCommands(projectDir, '.claude', { namespaced: true });
3710
3993
 
3711
3994
  const claudeMd = join(projectDir, 'CLAUDE.md');
3712
3995
  const claudeDir = join(projectDir, '.claude');
@@ -3744,6 +4027,13 @@ program
3744
4027
  if (changes.length === 0) {
3745
4028
  log.info('No active changes');
3746
4029
  } else {
4030
+ // Readiness must mirror the gates `archive` actually enforces — reporting
4031
+ // "ready" on task count alone told the conductor to archive a change the
4032
+ // CLI would then refuse.
4033
+ const config = readPipelineConfig(projectDir);
4034
+ const requireReview = config ? config.requireSpecReview : true;
4035
+ const requireBrief = config ? config.requireDesignBrief : false;
4036
+
3747
4037
  for (const name of changes) {
3748
4038
  const changeDir = join(projectDir, 'openspec', 'changes', name);
3749
4039
  const progress = parseTasksProgress(changeDir);
@@ -3751,13 +4041,24 @@ program
3751
4041
  const hasBrief = parseDesignBrief(changeDir);
3752
4042
  const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
3753
4043
  const verdictStr = verdict || 'none';
3754
- const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
4044
+
4045
+ const blockers = [];
4046
+ if (!(progress && progress.total > 0 && progress.done === progress.total)) {
4047
+ blockers.push('tasks incomplete');
4048
+ }
4049
+ if (requireReview && !(verdict && /^APPROVE/i.test(verdict))) {
4050
+ blockers.push(verdict ? `review verdict "${verdict}" (need APPROVE)` : 'no review.md');
4051
+ }
4052
+ if (requireBrief && !hasBrief && !hasDesignOptOut(changeDir)) {
4053
+ blockers.push('no design-brief.md');
4054
+ }
3755
4055
 
3756
4056
  console.log(`\n${pc.bold(name)}`);
3757
4057
  console.log(` tasks: ${progressStr}`);
3758
4058
  console.log(` review: ${verdictStr}`);
3759
4059
  console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
3760
- if (readyToArchive) log.ok('ready to archive');
4060
+ if (blockers.length === 0) log.ok('ready to archive');
4061
+ else log.info(`not ready to archive — ${blockers.join('; ')}`);
3761
4062
  }
3762
4063
  console.log('');
3763
4064
  }
@@ -3770,7 +4071,7 @@ program
3770
4071
  program
3771
4072
  .command('gate-check [change-name]')
3772
4073
  .description('Deterministically check the review gate before apply/merge (exit non-zero if unmet)')
3773
- .option('--src-glob <glob>', 'source path filter used to detect code changes', 'src/')
4074
+ .option('--src-glob <glob>', 'source path filter used to detect code changes (default: pipeline.src_glob, else src/)')
3774
4075
  .option('--base <ref>', 'git ref to diff against', 'HEAD~1')
3775
4076
  .option('--staged', 'check staged files (git diff --cached) instead of --base...HEAD', false)
3776
4077
  .option('--tasks <name>', 'lint task contracts (Files/Do/Done-when) of a change')
@@ -3826,16 +4127,21 @@ program
3826
4127
  return;
3827
4128
  }
3828
4129
 
4130
+ // A repo whose code does not live in src/ used to fall through to
4131
+ // "nothing to gate" on every run, silently disabling the review gate.
4132
+ const srcGlob = opts.srcGlob || config.srcGlob || 'src/';
4133
+
3829
4134
  const touchesSrc = opts.staged
3830
- ? gitStagedTouchesGlob(projectDir, opts.srcGlob)
3831
- : gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
4135
+ ? gitStagedTouchesGlob(projectDir, srcGlob)
4136
+ : gitDiffTouchesGlob(projectDir, opts.base, srcGlob);
3832
4137
  if (touchesSrc === false) {
3833
- log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${opts.srcGlob} — nothing to gate`);
4138
+ log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${srcGlob} — nothing to gate`);
3834
4139
  return;
3835
4140
  }
3836
4141
  if (touchesSrc === null) {
3837
- log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — skipping gate-check`);
3838
- return;
4142
+ // Cannot prove nothing changed (shallow clone, missing base ref, no
4143
+ // commits yet) — verify the gate instead of passing a blocking check.
4144
+ log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — verifying the review gate anyway`);
3839
4145
  }
3840
4146
 
3841
4147
  const changes = listActiveChanges(projectDir);
@@ -3846,7 +4152,7 @@ program
3846
4152
  let target = changeName;
3847
4153
  if (!target) {
3848
4154
  if (changes.length === 0) {
3849
- log.warn(`${opts.srcGlob} changed but no active OpenSpec change found — cannot verify review gate`);
4155
+ log.warn(`${srcGlob} changed but no active OpenSpec change found — cannot verify review gate`);
3850
4156
  return;
3851
4157
  }
3852
4158
  target = changes
@@ -4485,6 +4791,8 @@ program
4485
4791
  .command('metrics [change-name]')
4486
4792
  .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
4487
4793
  .option('--json', 'Print raw metrics.json', false)
4794
+ .option('--summary-json', 'Print compact aggregate JSON for dashboards', false)
4795
+ .option('--migrate', 'Rewrite metrics.json using the current schema without recomputing numbers', false)
4488
4796
  .option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
4489
4797
  .action((changeName, opts) => {
4490
4798
  const projectDir = process.cwd();
@@ -4541,6 +4849,29 @@ program
4541
4849
  }
4542
4850
 
4543
4851
  const metrics = loadMetricsFile(filePath, name, nowUtcIso());
4852
+ if (opts.migrate) {
4853
+ saveMetricsFile(filePath, metrics);
4854
+ if (!opts.json && !opts.summaryJson) {
4855
+ log.ok(`migrated: ${filePath.replace(`${projectDir}/`, '')}`);
4856
+ return;
4857
+ }
4858
+ }
4859
+ if (opts.summaryJson) {
4860
+ recomputeMetricsAggregates(metrics);
4861
+ const summary = {
4862
+ version: metrics.version,
4863
+ change: metrics.change,
4864
+ createdAt: metrics.createdAt,
4865
+ archivedAt: metrics.archivedAt,
4866
+ totals: metrics.totals,
4867
+ phases: metrics.phases,
4868
+ spend: metrics.spend,
4869
+ spendByPlatform: metrics.spendByPlatform,
4870
+ spendByModel: metrics.spendByModel,
4871
+ };
4872
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
4873
+ return;
4874
+ }
4544
4875
  if (opts.json) {
4545
4876
  process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
4546
4877
  return;
@@ -4574,4 +4905,6 @@ export {
4574
4905
  sessionSpendIsFrozen,
4575
4906
  attachLeftoverSources,
4576
4907
  runCollectSpend,
4908
+ normalizeMetricsV2,
4909
+ firstSpawnName,
4577
4910
  };