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.
- package/CHANGELOG.md +26 -0
- package/README.md +24 -4
- package/bin/agent-orchestrator.js +469 -136
- package/bin/claude-cost-estimate.js +51 -0
- package/bin/spend-collect.js +76 -13
- package/package.json +2 -1
- package/profiles/generic/orchestrator.yaml +3 -0
- package/profiles/mvp/orchestrator.yaml +3 -0
- package/profiles/node/orchestrator.yaml +3 -0
- package/profiles/vue3/orchestrator.yaml +3 -0
- package/templates/.agents/commands/opsx-propose.md +6 -0
- package/templates/.agents/commands/opsx-review.md +35 -11
- package/templates/.agents/rules/session-handoff.mdc +2 -2
- package/templates/.agents/skills/agent-orchestration/SKILL.md +5 -4
- package/templates/.agents/skills/openspec-propose/SKILL.md +6 -0
- package/templates/.agents/subagents/openspec-guide.md +3 -2
- package/templates/.agents/subagents/session-handoff.md +2 -1
- package/templates/.agents/subagents/spec-architect.md +6 -1
- package/templates/.agents/subagents/spec-archiver.md +2 -1
- package/templates/.agents/subagents/spec-reviewer.md +5 -1
- package/templates/AGENTS.md +1 -1
- package/templates/orchestrator.yaml +3 -0
- package/templates/scripts/cursor-spend-collect.cjs +129 -67
- package/templates/scripts/sync-local-agent-skills.sh +27 -0
- package/templates/.cursor/memory.json +0 -11
|
@@ -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
|
|
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
|
|
972
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1982
|
+
function uniqueAmpSourceThreadPrefix(sourceIds) {
|
|
1792
1983
|
const prefixes = [];
|
|
1793
|
-
for (const
|
|
1794
|
-
const 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.
|
|
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
|
|
1813
|
-
const 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
|
|
1852
|
-
const out =
|
|
2042
|
+
function existingSourceTotals(metrics) {
|
|
2043
|
+
const out = {};
|
|
1853
2044
|
for (const session of metrics.sessions || []) {
|
|
1854
|
-
for (const
|
|
1855
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2396
|
+
const beforeIds = new Set(session.sourceIds || []);
|
|
2135
2397
|
if (sessionSpendIsFrozen(session)) {
|
|
2136
|
-
|
|
2137
|
-
|
|
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,
|
|
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
|
-
|
|
2153
|
-
const
|
|
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(
|
|
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(
|
|
2168
|
-
const spend = resolveSessionSpend(opts, reported,
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
2743
|
+
ampThreadId,
|
|
2459
2744
|
ampCli: collectAll || platform === 'amp',
|
|
2460
|
-
cursorConversationId:
|
|
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:
|
|
2766
|
+
threadId: cursorThreadId || ampThreadId || pending.threadId || null,
|
|
2480
2767
|
tasks: fields.tasks || null,
|
|
2481
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
2599
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
3831
|
-
: gitDiffTouchesGlob(projectDir, opts.base,
|
|
4135
|
+
? gitStagedTouchesGlob(projectDir, srcGlob)
|
|
4136
|
+
: gitDiffTouchesGlob(projectDir, opts.base, srcGlob);
|
|
3832
4137
|
if (touchesSrc === false) {
|
|
3833
|
-
log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${
|
|
4138
|
+
log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${srcGlob} — nothing to gate`);
|
|
3834
4139
|
return;
|
|
3835
4140
|
}
|
|
3836
4141
|
if (touchesSrc === null) {
|
|
3837
|
-
|
|
3838
|
-
|
|
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(`${
|
|
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
|
};
|