agent-orchestrator-kit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +46 -3
- package/bin/agent-orchestrator.js +758 -0
- package/bin/spend-collect.js +414 -0
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +7 -7
- package/templates/.agents/rules/session-handoff.mdc +1 -1
- package/templates/.agents/skills/agent-orchestration/SKILL.md +2 -2
- package/templates/.agents/subagents/session-handoff.md +1 -1
- package/templates/scripts/cursor-spend-hook.cjs +74 -0
|
@@ -5,6 +5,7 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
|
|
|
5
5
|
import { join, dirname, basename, resolve } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
+
import { collectSpend } from './spend-collect.js';
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const KIT_ROOT = join(__dirname, '..');
|
|
@@ -69,6 +70,7 @@ const GITIGNORE_LINES = [
|
|
|
69
70
|
'.agents/figma.local.env',
|
|
70
71
|
'.agents/github.local.env',
|
|
71
72
|
'.agents/gitlab.local.env',
|
|
73
|
+
'.agents/spend/',
|
|
72
74
|
];
|
|
73
75
|
|
|
74
76
|
const FIGMA_ENV_REL = join('.agents', 'figma.local.env');
|
|
@@ -84,6 +86,11 @@ const GITLAB_ENV_EXAMPLE_REL = join('.agents', 'gitlab.local.env.example');
|
|
|
84
86
|
const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
|
|
85
87
|
const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
|
|
86
88
|
const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
|
|
89
|
+
const CURSOR_SPEND_HOOK_REL = join('scripts', 'cursor-spend-hook.cjs');
|
|
90
|
+
const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
|
|
91
|
+
const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
|
|
92
|
+
const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop'];
|
|
93
|
+
const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
87
94
|
const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
|
|
88
95
|
const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
|
|
89
96
|
const OPTIONAL_MCP_SEED_STRIP = ['github', 'gitlab', 'browser'];
|
|
@@ -361,6 +368,108 @@ function refreshOptionalMcpManagedFiles(projectDir) {
|
|
|
361
368
|
refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
|
|
362
369
|
}
|
|
363
370
|
|
|
371
|
+
function cursorSpendHookEntryOk(projectDir) {
|
|
372
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
373
|
+
if (!existsSync(hooksPath)) return false;
|
|
374
|
+
let config;
|
|
375
|
+
try {
|
|
376
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
const hooks = config && typeof config === 'object' ? config.hooks : null;
|
|
381
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
382
|
+
return CURSOR_SPEND_HOOK_EVENTS.every((event) => {
|
|
383
|
+
const entries = hooks[event];
|
|
384
|
+
return Array.isArray(entries)
|
|
385
|
+
&& entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Mandatory spend capture: every kit project must record Cursor token usage
|
|
390
|
+
// locally so handoff/archive can collect real spend without manual flags.
|
|
391
|
+
function ensureCursorSpendHook(projectDir) {
|
|
392
|
+
const result = { script: false, hooksJson: false, error: null };
|
|
393
|
+
const src = join(KIT_ROOT, 'templates', CURSOR_SPEND_HOOK_REL);
|
|
394
|
+
const dest = join(projectDir, CURSOR_SPEND_HOOK_REL);
|
|
395
|
+
if (existsSync(src)) {
|
|
396
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
397
|
+
copyFileSync(src, dest);
|
|
398
|
+
result.script = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
402
|
+
let config = { version: 1, hooks: {} };
|
|
403
|
+
if (existsSync(hooksPath)) {
|
|
404
|
+
try {
|
|
405
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
406
|
+
} catch {
|
|
407
|
+
result.error = `${CURSOR_HOOKS_JSON_REL} is not valid JSON — fix it, then re-run any kit command`;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!config || typeof config !== 'object') config = { version: 1, hooks: {} };
|
|
412
|
+
if (config.version == null) config.version = 1;
|
|
413
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
414
|
+
let changed = !existsSync(hooksPath);
|
|
415
|
+
for (const event of CURSOR_SPEND_HOOK_EVENTS) {
|
|
416
|
+
const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
|
|
417
|
+
const present = entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
418
|
+
if (!present) {
|
|
419
|
+
entries.push({ command: CURSOR_SPEND_HOOK_COMMAND });
|
|
420
|
+
config.hooks[event] = entries;
|
|
421
|
+
changed = true;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (changed) {
|
|
425
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
426
|
+
writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
427
|
+
result.hooksJson = true;
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function reportCursorSpendHook(projectDir, emit) {
|
|
433
|
+
const result = ensureCursorSpendHook(projectDir);
|
|
434
|
+
if (result.error) {
|
|
435
|
+
emit.warn(`Cursor spend hook: ${result.error}`);
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
|
|
439
|
+
if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop + subagentStop spend hook)`);
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function countCursorUsageRecords(projectDir) {
|
|
444
|
+
const filePath = join(projectDir, CURSOR_USAGE_FILE_REL);
|
|
445
|
+
if (!existsSync(filePath)) return null;
|
|
446
|
+
try {
|
|
447
|
+
return readFileSync(filePath, 'utf-8').split('\n').filter((line) => line.trim()).length;
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function printSpendHealth(projectDir) {
|
|
454
|
+
console.log(pc.bold('\nSpend capture'));
|
|
455
|
+
const scriptOk = existsSync(join(projectDir, CURSOR_SPEND_HOOK_REL));
|
|
456
|
+
const entryOk = cursorSpendHookEntryOk(projectDir);
|
|
457
|
+
const records = countCursorUsageRecords(projectDir);
|
|
458
|
+
const cursorState = scriptOk && entryOk
|
|
459
|
+
? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
|
|
460
|
+
: 'not configured — run npx agent-orchestrator-kit update';
|
|
461
|
+
console.log(` cursor ${cursorState}`);
|
|
462
|
+
const home = process.env.HOME || '';
|
|
463
|
+
const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
|
|
464
|
+
console.log(` claude ${claudeOk ? 'ok (~/.claude/projects)' : 'no local Claude data'}`);
|
|
465
|
+
const ampDir = process.env.AMP_DATA_DIR && String(process.env.AMP_DATA_DIR).trim()
|
|
466
|
+
? String(process.env.AMP_DATA_DIR).trim()
|
|
467
|
+
: join(home, '.local', 'share', 'amp');
|
|
468
|
+
const ampOk = existsSync(join(ampDir, 'threads'));
|
|
469
|
+
console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
|
|
470
|
+
console.log('');
|
|
471
|
+
}
|
|
472
|
+
|
|
364
473
|
function parseGitRemoteHostname(url) {
|
|
365
474
|
const raw = String(url || '').trim();
|
|
366
475
|
if (!raw) return '';
|
|
@@ -698,6 +807,7 @@ function runMcpSetup(projectDir, { vcs = '', browser = true } = {}) {
|
|
|
698
807
|
refreshFigmaManagedFiles(projectDir);
|
|
699
808
|
refreshMemoryManagedFiles(projectDir);
|
|
700
809
|
refreshOptionalMcpManagedFiles(projectDir);
|
|
810
|
+
reportCursorSpendHook(projectDir, log);
|
|
701
811
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
702
812
|
|
|
703
813
|
const selected = resolveMcpSetupVcs(projectDir, vcs);
|
|
@@ -894,6 +1004,42 @@ function applyRuntimeToFields(fields, opts, env) {
|
|
|
894
1004
|
return true;
|
|
895
1005
|
}
|
|
896
1006
|
|
|
1007
|
+
const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
|
|
1008
|
+
|
|
1009
|
+
function resolveModel(opts, env) {
|
|
1010
|
+
const flag = opts && opts.model != null ? String(opts.model).trim() : '';
|
|
1011
|
+
if (flag) return flag;
|
|
1012
|
+
const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
|
|
1013
|
+
if (fromEnv) return fromEnv;
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function resolvePlatform(opts, env) {
|
|
1018
|
+
const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
|
|
1019
|
+
if (flag) {
|
|
1020
|
+
const lower = flag.toLowerCase();
|
|
1021
|
+
if (!VALID_PLATFORMS.has(lower)) {
|
|
1022
|
+
return { error: 'invalid --platform (use cursor, claude, or amp)' };
|
|
1023
|
+
}
|
|
1024
|
+
return { value: lower };
|
|
1025
|
+
}
|
|
1026
|
+
const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
|
|
1027
|
+
if (fromEnv) {
|
|
1028
|
+
const lower = fromEnv.toLowerCase();
|
|
1029
|
+
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1030
|
+
return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
|
|
1031
|
+
}
|
|
1032
|
+
return { value: null };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function warnMissingModel() {
|
|
1036
|
+
console.error('metrics: session.model is null — pass --model <llm-product-id> or set AOK_MODEL');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function warnMissingUsd() {
|
|
1040
|
+
console.error('metrics: spend.costUsd is null — USD spend was not collected');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
897
1043
|
function gitTry(projectDir, command) {
|
|
898
1044
|
try {
|
|
899
1045
|
const stdout = execSync(command, {
|
|
@@ -1290,6 +1436,433 @@ function readHandoffFields(projectDir, changeName) {
|
|
|
1290
1436
|
return { filePath, fields: fieldsFromSections(changeName, sections) };
|
|
1291
1437
|
}
|
|
1292
1438
|
|
|
1439
|
+
const METRICS_VERSION = 1;
|
|
1440
|
+
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
|
|
1441
|
+
|
|
1442
|
+
function metricsFilePath(projectDir, changeName) {
|
|
1443
|
+
return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function emptySpendTotals() {
|
|
1447
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function emptyPlatformSpend(source = 'none') {
|
|
1451
|
+
return {
|
|
1452
|
+
inputTokens: null,
|
|
1453
|
+
outputTokens: null,
|
|
1454
|
+
totalTokens: null,
|
|
1455
|
+
costUsd: null,
|
|
1456
|
+
ampCredits: null,
|
|
1457
|
+
source,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function defaultSpendByPlatform() {
|
|
1462
|
+
return {
|
|
1463
|
+
cursor: emptyPlatformSpend(),
|
|
1464
|
+
claude: emptyPlatformSpend(),
|
|
1465
|
+
amp: emptyPlatformSpend(),
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function mergeSpendByPlatform(raw) {
|
|
1470
|
+
const base = defaultSpendByPlatform();
|
|
1471
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
|
|
1472
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
1473
|
+
const row = raw[key] && typeof raw[key] === 'object' && !Array.isArray(raw[key]) ? raw[key] : {};
|
|
1474
|
+
base[key] = { ...base[key], ...row };
|
|
1475
|
+
}
|
|
1476
|
+
return base;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function defaultMetrics(changeName, nowIso) {
|
|
1480
|
+
return {
|
|
1481
|
+
version: METRICS_VERSION,
|
|
1482
|
+
change: changeName,
|
|
1483
|
+
createdAt: nowIso,
|
|
1484
|
+
updatedAt: nowIso,
|
|
1485
|
+
archivedAt: null,
|
|
1486
|
+
spend: emptySpendTotals(),
|
|
1487
|
+
spendByPlatform: defaultSpendByPlatform(),
|
|
1488
|
+
spendByModel: [],
|
|
1489
|
+
totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
|
|
1490
|
+
phases: {},
|
|
1491
|
+
sessions: [],
|
|
1492
|
+
pending: null,
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function loadMetricsFile(filePath, changeName, nowIso) {
|
|
1497
|
+
if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
|
|
1498
|
+
let parsed;
|
|
1499
|
+
try {
|
|
1500
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
1501
|
+
} catch {
|
|
1502
|
+
return defaultMetrics(changeName, nowIso);
|
|
1503
|
+
}
|
|
1504
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1505
|
+
return defaultMetrics(changeName, nowIso);
|
|
1506
|
+
}
|
|
1507
|
+
const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
|
|
1508
|
+
return {
|
|
1509
|
+
...base,
|
|
1510
|
+
...parsed,
|
|
1511
|
+
version: METRICS_VERSION,
|
|
1512
|
+
change: changeName,
|
|
1513
|
+
spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
|
|
1514
|
+
spendByPlatform: mergeSpendByPlatform(parsed.spendByPlatform),
|
|
1515
|
+
spendByModel: Array.isArray(parsed.spendByModel) ? parsed.spendByModel : [],
|
|
1516
|
+
totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
|
|
1517
|
+
phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
|
|
1518
|
+
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function saveMetricsFile(filePath, metrics) {
|
|
1523
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1524
|
+
writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function numOrNull(value) {
|
|
1528
|
+
if (value == null || value === '') return null;
|
|
1529
|
+
const n = Number(value);
|
|
1530
|
+
return Number.isFinite(n) ? n : null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function addNullable(a, b) {
|
|
1534
|
+
if (a == null && b == null) return null;
|
|
1535
|
+
return (a ?? 0) + (b ?? 0);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function phaseForRole(role) {
|
|
1539
|
+
const value = String(role || '').toLowerCase();
|
|
1540
|
+
if (/explor/.test(value)) return 'explore';
|
|
1541
|
+
if (/review/.test(value)) return 'review';
|
|
1542
|
+
if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
|
|
1543
|
+
if (/architect|propose/.test(value)) return 'spec';
|
|
1544
|
+
if (/design/.test(value)) return 'design';
|
|
1545
|
+
if (/archiv/.test(value)) return 'archive';
|
|
1546
|
+
return 'other';
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function isoOrNull(value) {
|
|
1550
|
+
if (!value) return null;
|
|
1551
|
+
const ms = Date.parse(String(value));
|
|
1552
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function sessionFieldOrSources(session, key) {
|
|
1556
|
+
if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
|
|
1557
|
+
let sum = null;
|
|
1558
|
+
for (const src of session.sources || []) {
|
|
1559
|
+
sum = addNullable(sum, numOrNull(src[key]));
|
|
1560
|
+
}
|
|
1561
|
+
return sum;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function lastSessionEndedAt(metrics) {
|
|
1565
|
+
let last = null;
|
|
1566
|
+
for (const session of metrics.sessions || []) {
|
|
1567
|
+
if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
|
|
1568
|
+
}
|
|
1569
|
+
return last;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function existingSourceIdSet(metrics) {
|
|
1573
|
+
const ids = new Set();
|
|
1574
|
+
for (const session of metrics.sessions || []) {
|
|
1575
|
+
for (const src of session.sources || []) {
|
|
1576
|
+
if (src && src.id != null && src.id !== '') ids.add(String(src.id));
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return ids;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function uniqueSourceModels(sources) {
|
|
1583
|
+
const seen = [];
|
|
1584
|
+
for (const src of sources || []) {
|
|
1585
|
+
if (src.model && !seen.includes(src.model)) seen.push(src.model);
|
|
1586
|
+
}
|
|
1587
|
+
return seen;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function primaryModelFromSources(sources) {
|
|
1591
|
+
if (!sources || !sources.length) return null;
|
|
1592
|
+
const ranked = [...sources].sort((a, b) => {
|
|
1593
|
+
const ta = a.totalTokens ?? 0;
|
|
1594
|
+
const tb = b.totalTokens ?? 0;
|
|
1595
|
+
if (tb !== ta) return tb - ta;
|
|
1596
|
+
const platformCmp = String(a.platform || '').localeCompare(String(b.platform || ''));
|
|
1597
|
+
if (platformCmp !== 0) return platformCmp;
|
|
1598
|
+
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
1599
|
+
});
|
|
1600
|
+
const model = ranked[0] && ranked[0].model;
|
|
1601
|
+
return model == null || model === '' ? null : String(model);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function hasSpendOverride(opts) {
|
|
1605
|
+
return opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function sessionTotalsFromFlags(opts) {
|
|
1609
|
+
const inputTokens = numOrNull(opts.inputTokens);
|
|
1610
|
+
const outputTokens = numOrNull(opts.outputTokens);
|
|
1611
|
+
let totalTokens = numOrNull(opts.totalTokens);
|
|
1612
|
+
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1613
|
+
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
inputTokens,
|
|
1617
|
+
outputTokens,
|
|
1618
|
+
totalTokens,
|
|
1619
|
+
costUsd: numOrNull(opts.costUsd),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function sessionTotalsFromSources(sources) {
|
|
1624
|
+
let inputTokens = null;
|
|
1625
|
+
let outputTokens = null;
|
|
1626
|
+
let totalTokens = null;
|
|
1627
|
+
let costUsd = null;
|
|
1628
|
+
for (const src of sources || []) {
|
|
1629
|
+
inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
|
|
1630
|
+
outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
|
|
1631
|
+
totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
|
|
1632
|
+
if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
|
|
1633
|
+
}
|
|
1634
|
+
return { inputTokens, outputTokens, totalTokens, costUsd };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
1638
|
+
try {
|
|
1639
|
+
return collectSpend({
|
|
1640
|
+
cwd: process.cwd(),
|
|
1641
|
+
windowStart,
|
|
1642
|
+
windowEnd,
|
|
1643
|
+
existingSourceIds: existingSourceIdSet(metrics),
|
|
1644
|
+
env: process.env,
|
|
1645
|
+
homedir: process.env.HOME,
|
|
1646
|
+
});
|
|
1647
|
+
} catch {
|
|
1648
|
+
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function applyCollectedSessionFields(session, sources, resolvedModel, opts) {
|
|
1653
|
+
session.sources = sources;
|
|
1654
|
+
const uniqueModels = uniqueSourceModels(sources);
|
|
1655
|
+
session.model = primaryModelFromSources(sources) || resolvedModel || null;
|
|
1656
|
+
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
1657
|
+
const totals = hasSpendOverride(opts) ? sessionTotalsFromFlags(opts) : sessionTotalsFromSources(sources);
|
|
1658
|
+
session.inputTokens = totals.inputTokens;
|
|
1659
|
+
session.outputTokens = totals.outputTokens;
|
|
1660
|
+
session.totalTokens = totals.totalTokens;
|
|
1661
|
+
session.costUsd = totals.costUsd;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function recomputeSpendMaps(metrics) {
|
|
1665
|
+
const byPlatform = defaultSpendByPlatform();
|
|
1666
|
+
const byModel = new Map();
|
|
1667
|
+
for (const session of metrics.sessions || []) {
|
|
1668
|
+
for (const src of session.sources || []) {
|
|
1669
|
+
const platform = src.platform;
|
|
1670
|
+
if (platform && byPlatform[platform]) {
|
|
1671
|
+
const bucket = byPlatform[platform];
|
|
1672
|
+
bucket.inputTokens = addNullable(bucket.inputTokens, numOrNull(src.inputTokens));
|
|
1673
|
+
bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
|
|
1674
|
+
bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
|
|
1675
|
+
bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
|
|
1676
|
+
bucket.ampCredits = addNullable(bucket.ampCredits, numOrNull(src.ampCredits));
|
|
1677
|
+
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
1678
|
+
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
1679
|
+
else if (platform === 'cursor') bucket.source = 'cursor-hook';
|
|
1680
|
+
}
|
|
1681
|
+
if (src.model) {
|
|
1682
|
+
const key = `${src.model}::${src.platform || ''}`;
|
|
1683
|
+
const row = byModel.get(key) || {
|
|
1684
|
+
model: src.model,
|
|
1685
|
+
platform: src.platform || null,
|
|
1686
|
+
inputTokens: null,
|
|
1687
|
+
outputTokens: null,
|
|
1688
|
+
totalTokens: null,
|
|
1689
|
+
costUsd: null,
|
|
1690
|
+
ampCredits: null,
|
|
1691
|
+
};
|
|
1692
|
+
row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
|
|
1693
|
+
row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
|
|
1694
|
+
row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
|
|
1695
|
+
row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
|
|
1696
|
+
row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
|
|
1697
|
+
byModel.set(key, row);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
metrics.spendByPlatform = byPlatform;
|
|
1702
|
+
metrics.spendByModel = [...byModel.values()];
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function recomputeMetricsAggregates(metrics) {
|
|
1706
|
+
const phases = {};
|
|
1707
|
+
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
1708
|
+
const spend = emptySpendTotals();
|
|
1709
|
+
let firstStart = null;
|
|
1710
|
+
let lastEnd = null;
|
|
1711
|
+
for (const session of metrics.sessions) {
|
|
1712
|
+
totals.sessions += 1;
|
|
1713
|
+
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
1714
|
+
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
1715
|
+
if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
|
|
1716
|
+
if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
|
|
1717
|
+
const key = session.phase || 'other';
|
|
1718
|
+
const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
|
|
1719
|
+
phase.sessions += 1;
|
|
1720
|
+
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
1721
|
+
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
1722
|
+
const value = sessionFieldOrSources(session, spendKey);
|
|
1723
|
+
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
1724
|
+
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
1725
|
+
}
|
|
1726
|
+
if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
|
|
1727
|
+
if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
|
|
1728
|
+
if (Array.isArray(session.models)) {
|
|
1729
|
+
for (const model of session.models) {
|
|
1730
|
+
if (model && !phase.models.includes(model)) phase.models.push(model);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
phases[key] = phase;
|
|
1734
|
+
}
|
|
1735
|
+
if (firstStart && lastEnd) {
|
|
1736
|
+
totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
|
|
1737
|
+
}
|
|
1738
|
+
metrics.phases = phases;
|
|
1739
|
+
metrics.totals = totals;
|
|
1740
|
+
metrics.spend = spend;
|
|
1741
|
+
recomputeSpendMaps(metrics);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function metricsRecordSessionStart(projectDir, changeName, role) {
|
|
1745
|
+
const filePath = metricsFilePath(projectDir, changeName);
|
|
1746
|
+
const nowIso = new Date().toISOString();
|
|
1747
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1748
|
+
metrics.pending = { startedAt: nowIso, role: role || '' };
|
|
1749
|
+
metrics.updatedAt = nowIso;
|
|
1750
|
+
saveMetricsFile(filePath, metrics);
|
|
1751
|
+
return filePath;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
1755
|
+
const filePath = metricsFilePath(projectDir, fields.changeName);
|
|
1756
|
+
const nowIso = new Date().toISOString();
|
|
1757
|
+
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
1758
|
+
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1759
|
+
const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
|
|
1760
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env) : opts.model;
|
|
1761
|
+
const windowStart = (metrics.pending && metrics.pending.startedAt) || lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1762
|
+
const collected = opts.collect === false
|
|
1763
|
+
? { sources: [] }
|
|
1764
|
+
: runCollectSpend(metrics, windowStart, nowIso);
|
|
1765
|
+
const session = {
|
|
1766
|
+
startedAt,
|
|
1767
|
+
endedAt: nowIso,
|
|
1768
|
+
durationMs,
|
|
1769
|
+
role: fields.closedRole || '',
|
|
1770
|
+
phase: phaseForRole(fields.closedRole),
|
|
1771
|
+
runtime: fields.runtime || 'local',
|
|
1772
|
+
agentId: fields.agentId || 'none',
|
|
1773
|
+
model: resolvedModel || null,
|
|
1774
|
+
platform: opts.platform || null,
|
|
1775
|
+
tasks: fields.tasks || null,
|
|
1776
|
+
sources: [],
|
|
1777
|
+
inputTokens: null,
|
|
1778
|
+
outputTokens: null,
|
|
1779
|
+
totalTokens: null,
|
|
1780
|
+
costUsd: null,
|
|
1781
|
+
};
|
|
1782
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts);
|
|
1783
|
+
metrics.sessions.push(session);
|
|
1784
|
+
metrics.pending = null;
|
|
1785
|
+
metrics.updatedAt = nowIso;
|
|
1786
|
+
recomputeMetricsAggregates(metrics);
|
|
1787
|
+
if (session.model == null) warnMissingModel();
|
|
1788
|
+
saveMetricsFile(filePath, metrics);
|
|
1789
|
+
return filePath;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
1793
|
+
const filePath = join(targetDir, 'metrics.json');
|
|
1794
|
+
const nowIso = new Date().toISOString();
|
|
1795
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1796
|
+
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1797
|
+
const collected = opts.collect === false
|
|
1798
|
+
? { sources: [] }
|
|
1799
|
+
: runCollectSpend(metrics, windowStart, nowIso);
|
|
1800
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env) : opts.model;
|
|
1801
|
+
const session = {
|
|
1802
|
+
startedAt: nowIso,
|
|
1803
|
+
endedAt: nowIso,
|
|
1804
|
+
durationMs: null,
|
|
1805
|
+
role: 'Archiver',
|
|
1806
|
+
phase: phaseForRole('Archiver'),
|
|
1807
|
+
runtime: opts.runtime || 'local',
|
|
1808
|
+
agentId: opts.agentId || 'none',
|
|
1809
|
+
model: resolvedModel || null,
|
|
1810
|
+
platform: opts.platform || null,
|
|
1811
|
+
tasks: opts.tasks || null,
|
|
1812
|
+
sources: [],
|
|
1813
|
+
inputTokens: null,
|
|
1814
|
+
outputTokens: null,
|
|
1815
|
+
totalTokens: null,
|
|
1816
|
+
costUsd: null,
|
|
1817
|
+
};
|
|
1818
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, {});
|
|
1819
|
+
metrics.sessions.push(session);
|
|
1820
|
+
metrics.archivedAt = nowIso;
|
|
1821
|
+
metrics.pending = null;
|
|
1822
|
+
metrics.updatedAt = nowIso;
|
|
1823
|
+
recomputeMetricsAggregates(metrics);
|
|
1824
|
+
if (session.model == null) warnMissingModel();
|
|
1825
|
+
if (metrics.spend.costUsd === null) warnMissingUsd();
|
|
1826
|
+
saveMetricsFile(filePath, metrics);
|
|
1827
|
+
return filePath;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function formatMetricsDuration(durationMs) {
|
|
1831
|
+
if (durationMs == null || !Number.isFinite(durationMs)) return '—';
|
|
1832
|
+
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
1833
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1834
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1835
|
+
const seconds = totalSeconds % 60;
|
|
1836
|
+
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1837
|
+
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
1838
|
+
return `${seconds}s`;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function formatMetricsNumber(value) {
|
|
1842
|
+
return value == null ? '—' : String(value);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function formatMetricsCost(value) {
|
|
1846
|
+
return value == null ? '—' : `$${Number(value).toFixed(2)}`;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function resolveMetricsFile(projectDir, changeName) {
|
|
1850
|
+
const activePath = metricsFilePath(projectDir, changeName);
|
|
1851
|
+
if (existsSync(activePath)) return { filePath: activePath, archived: false };
|
|
1852
|
+
const archiveDir = join(projectDir, 'openspec', 'changes', 'archive');
|
|
1853
|
+
if (existsSync(archiveDir)) {
|
|
1854
|
+
const folders = readdirSync(archiveDir)
|
|
1855
|
+
.filter((name) => name === changeName || name.endsWith(`-${changeName}`))
|
|
1856
|
+
.sort()
|
|
1857
|
+
.reverse();
|
|
1858
|
+
for (const folder of folders) {
|
|
1859
|
+
const archivedPath = join(archiveDir, folder, 'metrics.json');
|
|
1860
|
+
if (existsSync(archivedPath)) return { filePath: archivedPath, archived: true };
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return { filePath: activePath, archived: false, missing: true };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1293
1866
|
function parseFigmaUrl(url) {
|
|
1294
1867
|
try {
|
|
1295
1868
|
const parsed = new URL(url);
|
|
@@ -2143,6 +2716,9 @@ program
|
|
|
2143
2716
|
refreshMemoryManagedFiles(projectDir);
|
|
2144
2717
|
ensureMemoryMcpEntry(projectDir);
|
|
2145
2718
|
|
|
2719
|
+
log.title('Configuring Cursor spend hook');
|
|
2720
|
+
reportCursorSpendHook(projectDir, log);
|
|
2721
|
+
|
|
2146
2722
|
if (opts.hooks) {
|
|
2147
2723
|
log.title('Installing pre-commit gate');
|
|
2148
2724
|
const hookResult = runHooksSetup(projectDir);
|
|
@@ -2205,6 +2781,8 @@ program
|
|
|
2205
2781
|
log.title('Configuring Memory MCP');
|
|
2206
2782
|
refreshMemoryManagedFiles(projectDir);
|
|
2207
2783
|
ensureMemoryMcpEntry(projectDir);
|
|
2784
|
+
log.title('Configuring Cursor spend hook');
|
|
2785
|
+
reportCursorSpendHook(projectDir, log);
|
|
2208
2786
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2209
2787
|
|
|
2210
2788
|
log.ok(`Updated to v${KIT_VERSION}`);
|
|
@@ -2267,6 +2845,9 @@ program
|
|
|
2267
2845
|
log.title('Configuring Memory MCP');
|
|
2268
2846
|
ensureMemoryMcpEntry(projectDir);
|
|
2269
2847
|
|
|
2848
|
+
log.title('Configuring Cursor spend hook');
|
|
2849
|
+
reportCursorSpendHook(projectDir, log);
|
|
2850
|
+
|
|
2270
2851
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2271
2852
|
|
|
2272
2853
|
log.ok('Sync complete');
|
|
@@ -2303,6 +2884,7 @@ program
|
|
|
2303
2884
|
}
|
|
2304
2885
|
|
|
2305
2886
|
printMcpHealth(projectDir);
|
|
2887
|
+
printSpendHealth(projectDir);
|
|
2306
2888
|
printSkillHealth(projectDir);
|
|
2307
2889
|
});
|
|
2308
2890
|
|
|
@@ -2431,6 +3013,9 @@ program
|
|
|
2431
3013
|
.option('--sync', 'merge delta specs into openspec/specs/ before archiving')
|
|
2432
3014
|
.option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
|
|
2433
3015
|
.option('--force', 'confirm archiving without merge when delta specs exist', false)
|
|
3016
|
+
.option('--model <name>', 'LLM product id recorded on the Archiver session')
|
|
3017
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
3018
|
+
.option('--no-collect', 'Skip local spend adapters when finalizing metrics')
|
|
2434
3019
|
.action((name, opts) => {
|
|
2435
3020
|
const projectDir = process.cwd();
|
|
2436
3021
|
const fail = (msg) => {
|
|
@@ -2441,6 +3026,15 @@ program
|
|
|
2441
3026
|
|
|
2442
3027
|
if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
|
|
2443
3028
|
|
|
3029
|
+
const platformResult = resolvePlatform(opts, process.env);
|
|
3030
|
+
if (platformResult.error) {
|
|
3031
|
+
log.err(platformResult.error);
|
|
3032
|
+
process.exitCode = 1;
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3036
|
+
const resolvedModel = resolveModel(opts, process.env);
|
|
3037
|
+
|
|
2444
3038
|
let status;
|
|
2445
3039
|
try {
|
|
2446
3040
|
const out = execSync(`npx openspec status --change ${name} --json`, {
|
|
@@ -2564,6 +3158,14 @@ program
|
|
|
2564
3158
|
};
|
|
2565
3159
|
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
2566
3160
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
3161
|
+
const metricsPath = metricsFinalizeArchive(targetDir, name, {
|
|
3162
|
+
model: resolvedModel,
|
|
3163
|
+
platform: platformResult.value || null,
|
|
3164
|
+
runtime: fields.runtime,
|
|
3165
|
+
agentId: fields.agentId,
|
|
3166
|
+
tasks: fields.tasks,
|
|
3167
|
+
collect: opts.collect !== false,
|
|
3168
|
+
});
|
|
2567
3169
|
|
|
2568
3170
|
console.log(`change: ${name}`);
|
|
2569
3171
|
console.log(`schema: ${status.schemaName || 'unknown'}`);
|
|
@@ -2571,6 +3173,7 @@ program
|
|
|
2571
3173
|
console.log(`sync: ${syncStatus}`);
|
|
2572
3174
|
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
2573
3175
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
3176
|
+
console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
|
|
2574
3177
|
log.ok(`archived ${name}`);
|
|
2575
3178
|
});
|
|
2576
3179
|
|
|
@@ -2770,6 +3373,15 @@ program
|
|
|
2770
3373
|
.option('--runtime <runtime>', 'Session runtime: local | cloud')
|
|
2771
3374
|
.option('--agent-id <id>', 'Cloud agent identifier')
|
|
2772
3375
|
.option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
|
|
3376
|
+
.option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
|
|
3377
|
+
.option('--model <name>', 'LLM product id recorded in metrics.json')
|
|
3378
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
3379
|
+
.option('--input-tokens <n>', 'Input tokens spent in this session')
|
|
3380
|
+
.option('--output-tokens <n>', 'Output tokens spent in this session')
|
|
3381
|
+
.option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
|
|
3382
|
+
.option('--cost-usd <usd>', 'Cost of this session in USD')
|
|
3383
|
+
.option('--no-metrics', 'Skip recording this session into metrics.json')
|
|
3384
|
+
.option('--no-collect', 'Skip local spend adapters for this persist')
|
|
2773
3385
|
.action((changeName, opts) => {
|
|
2774
3386
|
const projectDir = process.cwd();
|
|
2775
3387
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -2826,6 +3438,13 @@ program
|
|
|
2826
3438
|
} else {
|
|
2827
3439
|
log.warn(`Memory JSON empty or missing at ${memoryPath}`);
|
|
2828
3440
|
}
|
|
3441
|
+
if (opts.metrics !== false && existsSync(changeDir)) {
|
|
3442
|
+
const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
|
|
3443
|
+
log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
|
|
3444
|
+
}
|
|
3445
|
+
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3446
|
+
if (spendHook.error) log.warn(`Cursor spend hook: ${spendHook.error}`);
|
|
3447
|
+
else if (spendHook.hooksJson) log.ok('Cursor spend hook installed — restart Cursor once to activate it');
|
|
2829
3448
|
return;
|
|
2830
3449
|
}
|
|
2831
3450
|
|
|
@@ -2893,6 +3512,15 @@ program
|
|
|
2893
3512
|
return;
|
|
2894
3513
|
}
|
|
2895
3514
|
|
|
3515
|
+
const platformResult = resolvePlatform(opts, process.env);
|
|
3516
|
+
if (platformResult.error) {
|
|
3517
|
+
log.err(platformResult.error);
|
|
3518
|
+
process.exitCode = 1;
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3522
|
+
const resolvedModel = resolveModel(opts, process.env);
|
|
3523
|
+
|
|
2896
3524
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
2897
3525
|
fields.prompt = prompt;
|
|
2898
3526
|
writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
@@ -2902,10 +3530,140 @@ program
|
|
|
2902
3530
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2903
3531
|
console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
|
|
2904
3532
|
|
|
3533
|
+
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3534
|
+
if (spendHook.error) console.error(pc.yellow(' !'), `Cursor spend hook: ${spendHook.error}`);
|
|
3535
|
+
else if (spendHook.hooksJson) console.error(pc.green(' ✓'), 'Cursor spend hook installed — restart Cursor once to activate it');
|
|
3536
|
+
|
|
3537
|
+
if (opts.metrics !== false) {
|
|
3538
|
+
const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
|
|
3539
|
+
startedAt: opts.startedAt,
|
|
3540
|
+
model: resolvedModel,
|
|
3541
|
+
platform: platformResult.value || null,
|
|
3542
|
+
inputTokens: opts.inputTokens,
|
|
3543
|
+
outputTokens: opts.outputTokens,
|
|
3544
|
+
totalTokens: opts.totalTokens,
|
|
3545
|
+
costUsd: opts.costUsd,
|
|
3546
|
+
collect: opts.collect !== false,
|
|
3547
|
+
});
|
|
3548
|
+
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
3549
|
+
}
|
|
3550
|
+
|
|
2905
3551
|
if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
|
|
2906
3552
|
|
|
2907
3553
|
console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
|
|
2908
3554
|
process.stdout.write(`${prompt}\n`);
|
|
2909
3555
|
});
|
|
2910
3556
|
|
|
3557
|
+
program
|
|
3558
|
+
.command('metrics [change-name]')
|
|
3559
|
+
.description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
|
|
3560
|
+
.option('--json', 'Print raw metrics.json', false)
|
|
3561
|
+
.action((changeName, opts) => {
|
|
3562
|
+
const projectDir = process.cwd();
|
|
3563
|
+
let name = changeName;
|
|
3564
|
+
if (!name) {
|
|
3565
|
+
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
3566
|
+
if (!resolved) {
|
|
3567
|
+
log.err('No active change found. Pass a name: npx agent-orchestrator-kit metrics <name>');
|
|
3568
|
+
process.exitCode = 1;
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
if (resolved.ambiguous) {
|
|
3572
|
+
log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
|
|
3573
|
+
process.exitCode = 1;
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
name = resolved;
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
|
|
3580
|
+
if (missing) {
|
|
3581
|
+
log.err(`No metrics.json for ${name}`);
|
|
3582
|
+
log.info(`Expected: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3583
|
+
log.info('Metrics are recorded by: handoff --restore (session start) and handoff <name> (session end)');
|
|
3584
|
+
process.exitCode = 1;
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
|
|
3589
|
+
if (opts.json) {
|
|
3590
|
+
process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
|
|
3595
|
+
console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3596
|
+
console.log(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
|
|
3597
|
+
console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
3598
|
+
console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
3599
|
+
console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
3600
|
+
console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
|
|
3601
|
+
if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
|
|
3602
|
+
if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
|
|
3603
|
+
|
|
3604
|
+
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
3605
|
+
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
3606
|
+
if (phaseKeys.length) {
|
|
3607
|
+
console.log('');
|
|
3608
|
+
console.log('phase sessions time tokens cost roles models');
|
|
3609
|
+
for (const key of phaseKeys) {
|
|
3610
|
+
const phase = metrics.phases[key];
|
|
3611
|
+
const cols = [
|
|
3612
|
+
key.padEnd(10),
|
|
3613
|
+
String(phase.sessions).padEnd(9),
|
|
3614
|
+
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
3615
|
+
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
3616
|
+
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
3617
|
+
(phase.agents.join(', ') || '—').padEnd(20),
|
|
3618
|
+
phase.models.join(', ') || '—',
|
|
3619
|
+
];
|
|
3620
|
+
console.log(cols.join(' '));
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
|
|
3625
|
+
console.log('');
|
|
3626
|
+
console.log('by platform:');
|
|
3627
|
+
console.log('platform tokens cost credits source');
|
|
3628
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
3629
|
+
const row = byPlatform[key] || emptyPlatformSpend();
|
|
3630
|
+
console.log([
|
|
3631
|
+
key.padEnd(10),
|
|
3632
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3633
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3634
|
+
formatMetricsNumber(row.ampCredits).padEnd(9),
|
|
3635
|
+
row.source || 'none',
|
|
3636
|
+
].join(' '));
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
console.log('');
|
|
3640
|
+
console.log('by model:');
|
|
3641
|
+
console.log('model platform tokens cost credits');
|
|
3642
|
+
const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
|
|
3643
|
+
if (!byModel.length) {
|
|
3644
|
+
console.log('— — — — —');
|
|
3645
|
+
} else {
|
|
3646
|
+
for (const row of byModel) {
|
|
3647
|
+
console.log([
|
|
3648
|
+
String(row.model || '—').padEnd(20),
|
|
3649
|
+
String(row.platform || '—').padEnd(10),
|
|
3650
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3651
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3652
|
+
formatMetricsNumber(row.ampCredits),
|
|
3653
|
+
].join(' '));
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
if (metrics.sessions.length) {
|
|
3658
|
+
console.log('');
|
|
3659
|
+
console.log('recent sessions:');
|
|
3660
|
+
for (const session of metrics.sessions.slice(-5)) {
|
|
3661
|
+
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
3662
|
+
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
|
|
3663
|
+
: '';
|
|
3664
|
+
console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
});
|
|
3668
|
+
|
|
2911
3669
|
program.parse();
|