agent-orchestrator-kit 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +44 -18
- package/bin/agent-orchestrator.js +567 -175
- package/bin/spend-collect.js +76 -4
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +1 -1
- package/templates/.agents/rules/session-handoff.mdc +8 -7
- package/templates/.agents/skills/agent-orchestration/SKILL.md +16 -6
- package/templates/.agents/subagents/session-handoff.md +6 -5
- package/templates/.agents/subagents/spec-archiver.md +3 -2
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +285 -0
- package/templates/scripts/cursor-spend-hook.cjs +1 -1
|
@@ -87,9 +87,12 @@ const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
|
|
|
87
87
|
const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
|
|
88
88
|
const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
|
|
89
89
|
const CURSOR_SPEND_HOOK_REL = join('scripts', 'cursor-spend-hook.cjs');
|
|
90
|
+
const CURSOR_SPEND_COLLECT_REL = join('scripts', 'cursor-spend-collect.cjs');
|
|
90
91
|
const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
|
|
91
92
|
const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
|
|
92
|
-
const
|
|
93
|
+
const CURSOR_SPEND_COLLECT_COMMAND = 'node scripts/cursor-spend-collect.cjs';
|
|
94
|
+
const CURSOR_SPEND_HOOK_EVENTS = ['stop', 'subagentStop', 'afterAgentResponse'];
|
|
95
|
+
const CURSOR_SPEND_COLLECT_EVENTS = ['sessionEnd'];
|
|
93
96
|
const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
94
97
|
const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
|
|
95
98
|
const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
|
|
@@ -159,6 +162,7 @@ const HANDOFF_SECTIONS = [
|
|
|
159
162
|
'Subagents to spawn',
|
|
160
163
|
'Constraints',
|
|
161
164
|
'Runtime',
|
|
165
|
+
'Metrics',
|
|
162
166
|
'Prompt',
|
|
163
167
|
];
|
|
164
168
|
const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
|
|
@@ -368,6 +372,12 @@ function refreshOptionalMcpManagedFiles(projectDir) {
|
|
|
368
372
|
refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
|
|
369
373
|
}
|
|
370
374
|
|
|
375
|
+
function hookEventHasCommand(hooks, event, needle) {
|
|
376
|
+
const entries = hooks[event];
|
|
377
|
+
return Array.isArray(entries)
|
|
378
|
+
&& entries.some((entry) => entry && String(entry.command || '').includes(needle));
|
|
379
|
+
}
|
|
380
|
+
|
|
371
381
|
function cursorSpendHookEntryOk(projectDir) {
|
|
372
382
|
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
373
383
|
if (!existsSync(hooksPath)) return false;
|
|
@@ -379,24 +389,39 @@ function cursorSpendHookEntryOk(projectDir) {
|
|
|
379
389
|
}
|
|
380
390
|
const hooks = config && typeof config === 'object' ? config.hooks : null;
|
|
381
391
|
if (!hooks || typeof hooks !== 'object') return false;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
&& entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
386
|
-
});
|
|
392
|
+
const writesOk = CURSOR_SPEND_HOOK_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-hook.cjs'));
|
|
393
|
+
const collectOk = CURSOR_SPEND_COLLECT_EVENTS.every((event) => hookEventHasCommand(hooks, event, 'cursor-spend-collect.cjs'));
|
|
394
|
+
return writesOk && collectOk;
|
|
387
395
|
}
|
|
388
396
|
|
|
389
397
|
// Mandatory spend capture: every kit project must record Cursor token usage
|
|
390
398
|
// locally so handoff/archive can collect real spend without manual flags.
|
|
399
|
+
function ensureManagedScript(projectDir, rel) {
|
|
400
|
+
const src = join(KIT_ROOT, 'templates', rel);
|
|
401
|
+
const dest = join(projectDir, rel);
|
|
402
|
+
if (!existsSync(src)) return false;
|
|
403
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
404
|
+
copyFileSync(src, dest);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mergeHookCommands(config, events, command, needle) {
|
|
409
|
+
let changed = false;
|
|
410
|
+
for (const event of events) {
|
|
411
|
+
const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
|
|
412
|
+
if (!entries.some((entry) => entry && String(entry.command || '').includes(needle))) {
|
|
413
|
+
entries.push({ command });
|
|
414
|
+
config.hooks[event] = entries;
|
|
415
|
+
changed = true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return changed;
|
|
419
|
+
}
|
|
420
|
+
|
|
391
421
|
function ensureCursorSpendHook(projectDir) {
|
|
392
422
|
const result = { script: false, hooksJson: false, error: null };
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (existsSync(src)) {
|
|
396
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
397
|
-
copyFileSync(src, dest);
|
|
398
|
-
result.script = true;
|
|
399
|
-
}
|
|
423
|
+
if (ensureManagedScript(projectDir, CURSOR_SPEND_HOOK_REL)) result.script = true;
|
|
424
|
+
ensureManagedScript(projectDir, CURSOR_SPEND_COLLECT_REL);
|
|
400
425
|
|
|
401
426
|
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
402
427
|
let config = { version: 1, hooks: {} };
|
|
@@ -412,15 +437,8 @@ function ensureCursorSpendHook(projectDir) {
|
|
|
412
437
|
if (config.version == null) config.version = 1;
|
|
413
438
|
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
414
439
|
let changed = !existsSync(hooksPath);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const present = entries.some((entry) => entry && String(entry.command || '').includes('cursor-spend-hook.cjs'));
|
|
418
|
-
if (!present) {
|
|
419
|
-
entries.push({ command: CURSOR_SPEND_HOOK_COMMAND });
|
|
420
|
-
config.hooks[event] = entries;
|
|
421
|
-
changed = true;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
440
|
+
changed = mergeHookCommands(config, CURSOR_SPEND_HOOK_EVENTS, CURSOR_SPEND_HOOK_COMMAND, 'cursor-spend-hook.cjs') || changed;
|
|
441
|
+
changed = mergeHookCommands(config, CURSOR_SPEND_COLLECT_EVENTS, CURSOR_SPEND_COLLECT_COMMAND, 'cursor-spend-collect.cjs') || changed;
|
|
424
442
|
if (changed) {
|
|
425
443
|
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
426
444
|
writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
@@ -436,7 +454,7 @@ function reportCursorSpendHook(projectDir, emit) {
|
|
|
436
454
|
return result;
|
|
437
455
|
}
|
|
438
456
|
if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
|
|
439
|
-
if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop
|
|
457
|
+
if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop / subagentStop / afterAgentResponse + sessionEnd collect)`);
|
|
440
458
|
return result;
|
|
441
459
|
}
|
|
442
460
|
|
|
@@ -457,7 +475,7 @@ function printSpendHealth(projectDir) {
|
|
|
457
475
|
const records = countCursorUsageRecords(projectDir);
|
|
458
476
|
const cursorState = scriptOk && entryOk
|
|
459
477
|
? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
|
|
460
|
-
: '
|
|
478
|
+
: 'optional — not configured (init/update/sync/mcp-setup)';
|
|
461
479
|
console.log(` cursor ${cursorState}`);
|
|
462
480
|
const home = process.env.HOME || '';
|
|
463
481
|
const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
|
|
@@ -1005,16 +1023,135 @@ function applyRuntimeToFields(fields, opts, env) {
|
|
|
1005
1023
|
}
|
|
1006
1024
|
|
|
1007
1025
|
const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
|
|
1026
|
+
const METRICS_NULL_TOKENS = new Set(['unknown', 'none', 'n/a', 'na', '-', '—', '–', 'null']);
|
|
1027
|
+
|
|
1028
|
+
function emptyMetricsFields(warnings = []) {
|
|
1029
|
+
return {
|
|
1030
|
+
platform: null,
|
|
1031
|
+
model: null,
|
|
1032
|
+
inputTokens: null,
|
|
1033
|
+
outputTokens: null,
|
|
1034
|
+
totalTokens: null,
|
|
1035
|
+
costUsd: null,
|
|
1036
|
+
ampCredits: null,
|
|
1037
|
+
spendSource: null,
|
|
1038
|
+
warnings,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function normalizeMetricsBlank(value) {
|
|
1043
|
+
const raw = value == null ? '' : String(value).trim();
|
|
1044
|
+
if (!raw) return null;
|
|
1045
|
+
const lower = raw.toLowerCase();
|
|
1046
|
+
if (METRICS_NULL_TOKENS.has(lower) || METRICS_NULL_TOKENS.has(raw)) return null;
|
|
1047
|
+
return raw;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function parseMetricsNumber(raw, key, warnings) {
|
|
1051
|
+
const normalized = normalizeMetricsBlank(raw);
|
|
1052
|
+
if (normalized == null) return null;
|
|
1053
|
+
const cleaned = normalized.replace(/[$,\s]/g, '');
|
|
1054
|
+
const n = Number(cleaned);
|
|
1055
|
+
if (!Number.isFinite(n)) {
|
|
1056
|
+
warnings.push(`metrics: unparsable ${key} in ## Metrics: ${raw}`);
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
return n;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function parseMetricsSection(body) {
|
|
1063
|
+
const warnings = [];
|
|
1064
|
+
const values = {};
|
|
1065
|
+
const present = new Set();
|
|
1066
|
+
for (const line of String(body || '').split(/\r?\n/)) {
|
|
1067
|
+
const match = line.match(/^\s*[-*]\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/);
|
|
1068
|
+
if (!match) continue;
|
|
1069
|
+
const key = match[1].toLowerCase();
|
|
1070
|
+
values[key] = match[2].trim();
|
|
1071
|
+
present.add(key);
|
|
1072
|
+
}
|
|
1073
|
+
const result = emptyMetricsFields(warnings);
|
|
1074
|
+
const platformRaw = normalizeMetricsBlank(values.platform);
|
|
1075
|
+
if (platformRaw) {
|
|
1076
|
+
const lower = platformRaw.toLowerCase();
|
|
1077
|
+
if (VALID_PLATFORMS.has(lower)) result.platform = lower;
|
|
1078
|
+
else {
|
|
1079
|
+
result.platform = null;
|
|
1080
|
+
warnings.push(`metrics: invalid platform in ## Metrics: ${platformRaw}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
result.model = normalizeMetricsBlank(values.model);
|
|
1084
|
+
result.inputTokens = parseMetricsNumber(values.input_tokens, 'input_tokens', warnings);
|
|
1085
|
+
result.outputTokens = parseMetricsNumber(values.output_tokens, 'output_tokens', warnings);
|
|
1086
|
+
result.totalTokens = present.has('total_tokens')
|
|
1087
|
+
? parseMetricsNumber(values.total_tokens, 'total_tokens', warnings)
|
|
1088
|
+
: null;
|
|
1089
|
+
result.costUsd = parseMetricsNumber(values.cost_usd, 'cost_usd', warnings);
|
|
1090
|
+
result.ampCredits = parseMetricsNumber(values.amp_credits, 'amp_credits', warnings);
|
|
1091
|
+
result.spendSource = normalizeMetricsBlank(values.spend_source);
|
|
1092
|
+
return result;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function formatMetricsField(value) {
|
|
1096
|
+
return value == null || value === '' ? 'unknown' : String(value);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function renderMetricsSection(metrics) {
|
|
1100
|
+
const m = metrics || emptyMetricsFields();
|
|
1101
|
+
return `## Metrics
|
|
1102
|
+
- platform: ${formatMetricsField(m.platform)}
|
|
1103
|
+
- model: ${formatMetricsField(m.model)}
|
|
1104
|
+
- input_tokens: ${formatMetricsField(m.inputTokens)}
|
|
1105
|
+
- output_tokens: ${formatMetricsField(m.outputTokens)}
|
|
1106
|
+
- cost_usd: ${formatMetricsField(m.costUsd)}
|
|
1107
|
+
- amp_credits: ${formatMetricsField(m.ampCredits)}
|
|
1108
|
+
- spend_source: ${formatMetricsField(m.spendSource)}`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function printMetricsSectionWarnings(metrics, platformAlreadyWarned) {
|
|
1112
|
+
for (const warning of (metrics && metrics.warnings) || []) {
|
|
1113
|
+
if (platformAlreadyWarned && /invalid platform/i.test(warning)) continue;
|
|
1114
|
+
console.error(warning);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1008
1117
|
|
|
1009
|
-
function
|
|
1118
|
+
function firstNonNull(...values) {
|
|
1119
|
+
for (const value of values) {
|
|
1120
|
+
if (value != null) return value;
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function resolveModel(opts, env, reported) {
|
|
1010
1126
|
const flag = opts && opts.model != null ? String(opts.model).trim() : '';
|
|
1011
1127
|
if (flag) return flag;
|
|
1128
|
+
const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
|
|
1129
|
+
if (fromReport) return fromReport;
|
|
1012
1130
|
const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
|
|
1013
1131
|
if (fromEnv) return fromEnv;
|
|
1014
1132
|
return null;
|
|
1015
1133
|
}
|
|
1016
1134
|
|
|
1017
|
-
function
|
|
1135
|
+
function envFlagOn(value) {
|
|
1136
|
+
if (value == null) return false;
|
|
1137
|
+
const normalized = String(value).trim().toLowerCase();
|
|
1138
|
+
return normalized !== '' && normalized !== '0' && normalized !== 'false';
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function inferPlatformFromHost(env) {
|
|
1142
|
+
if (!env) return null;
|
|
1143
|
+
if (env.AMP_CURRENT_THREAD != null && String(env.AMP_CURRENT_THREAD).trim()) return 'amp';
|
|
1144
|
+
if (env.AMP_THREAD_ID != null && String(env.AMP_THREAD_ID).trim()) return 'amp';
|
|
1145
|
+
if (envFlagOn(env.CURSOR_AGENT) || (env.CURSOR_CONVERSATION_ID != null && String(env.CURSOR_CONVERSATION_ID).trim())) {
|
|
1146
|
+
return 'cursor';
|
|
1147
|
+
}
|
|
1148
|
+
if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || (env.CLAUDE_CODE_ENTRYPOINT != null && String(env.CLAUDE_CODE_ENTRYPOINT).trim())) {
|
|
1149
|
+
return 'claude';
|
|
1150
|
+
}
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function resolvePlatform(opts, env, reported) {
|
|
1018
1155
|
const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
|
|
1019
1156
|
if (flag) {
|
|
1020
1157
|
const lower = flag.toLowerCase();
|
|
@@ -1023,13 +1160,19 @@ function resolvePlatform(opts, env) {
|
|
|
1023
1160
|
}
|
|
1024
1161
|
return { value: lower };
|
|
1025
1162
|
}
|
|
1163
|
+
const fromReport = reported && reported.platform != null ? String(reported.platform).trim() : '';
|
|
1164
|
+
if (fromReport) {
|
|
1165
|
+
const lower = fromReport.toLowerCase();
|
|
1166
|
+
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1167
|
+
return { value: null, warn: 'invalid platform in ## Metrics (use cursor, claude, or amp)' };
|
|
1168
|
+
}
|
|
1026
1169
|
const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
|
|
1027
1170
|
if (fromEnv) {
|
|
1028
1171
|
const lower = fromEnv.toLowerCase();
|
|
1029
1172
|
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1030
1173
|
return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
|
|
1031
1174
|
}
|
|
1032
|
-
return { value:
|
|
1175
|
+
return { value: inferPlatformFromHost(env) };
|
|
1033
1176
|
}
|
|
1034
1177
|
|
|
1035
1178
|
function warnMissingModel() {
|
|
@@ -1040,6 +1183,12 @@ function warnMissingUsd() {
|
|
|
1040
1183
|
console.error('metrics: spend.costUsd is null — USD spend was not collected');
|
|
1041
1184
|
}
|
|
1042
1185
|
|
|
1186
|
+
function warnUnreportedSelfReport() {
|
|
1187
|
+
console.error(
|
|
1188
|
+
'metrics: session spend is unreported — fill ## Metrics in handoff.md with platform, model, input_tokens, output_tokens, cost_usd, amp_credits, spend_source (use unknown when a value is missing)',
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1043
1192
|
function gitTry(projectDir, command) {
|
|
1044
1193
|
try {
|
|
1045
1194
|
const stdout = execSync(command, {
|
|
@@ -1140,7 +1289,9 @@ ${fields.constraints}
|
|
|
1140
1289
|
|
|
1141
1290
|
## Runtime
|
|
1142
1291
|
- runtime: ${runtime}
|
|
1143
|
-
- agent_id: ${agentId}
|
|
1292
|
+
- agent_id: ${agentId}
|
|
1293
|
+
|
|
1294
|
+
${renderMetricsSection(fields.metrics)}${prompt}
|
|
1144
1295
|
`;
|
|
1145
1296
|
}
|
|
1146
1297
|
|
|
@@ -1161,6 +1312,7 @@ function fieldsFromSections(changeName, sections, extra = {}) {
|
|
|
1161
1312
|
constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
|
|
1162
1313
|
runtime: extra.runtime || runtimeParsed.runtime,
|
|
1163
1314
|
agentId: extra.agentId || runtimeParsed.agentId,
|
|
1315
|
+
metrics: extra.metrics || parseMetricsSection(sectionOr(sections, 'Metrics', '')),
|
|
1164
1316
|
status: extra.status || '',
|
|
1165
1317
|
tasks: extra.tasks || '',
|
|
1166
1318
|
review: extra.review || '',
|
|
@@ -1569,6 +1721,10 @@ function lastSessionEndedAt(metrics) {
|
|
|
1569
1721
|
return last;
|
|
1570
1722
|
}
|
|
1571
1723
|
|
|
1724
|
+
function collectWindowStart(metrics) {
|
|
1725
|
+
return lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1572
1728
|
function existingSourceIdSet(metrics) {
|
|
1573
1729
|
const ids = new Set();
|
|
1574
1730
|
for (const session of metrics.sessions || []) {
|
|
@@ -1587,9 +1743,8 @@ function uniqueSourceModels(sources) {
|
|
|
1587
1743
|
return seen;
|
|
1588
1744
|
}
|
|
1589
1745
|
|
|
1590
|
-
function
|
|
1591
|
-
|
|
1592
|
-
const ranked = [...sources].sort((a, b) => {
|
|
1746
|
+
function rankSources(sources) {
|
|
1747
|
+
return [...sources].sort((a, b) => {
|
|
1593
1748
|
const ta = a.totalTokens ?? 0;
|
|
1594
1749
|
const tb = b.totalTokens ?? 0;
|
|
1595
1750
|
if (tb !== ta) return tb - ta;
|
|
@@ -1597,12 +1752,79 @@ function primaryModelFromSources(sources) {
|
|
|
1597
1752
|
if (platformCmp !== 0) return platformCmp;
|
|
1598
1753
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
1599
1754
|
});
|
|
1600
|
-
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function primaryModelFromSources(sources) {
|
|
1758
|
+
if (!sources || !sources.length) return null;
|
|
1759
|
+
const top = rankSources(sources)[0];
|
|
1760
|
+
const model = top && top.model;
|
|
1601
1761
|
return model == null || model === '' ? null : String(model);
|
|
1602
1762
|
}
|
|
1603
1763
|
|
|
1764
|
+
function primaryPlatformFromSources(sources) {
|
|
1765
|
+
if (!sources || !sources.length) return null;
|
|
1766
|
+
const platform = rankSources(sources)[0] && rankSources(sources)[0].platform;
|
|
1767
|
+
return platform && VALID_PLATFORMS.has(platform) ? platform : null;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1604
1770
|
function hasSpendOverride(opts) {
|
|
1605
|
-
return
|
|
1771
|
+
return Boolean(
|
|
1772
|
+
opts
|
|
1773
|
+
&& (opts.inputTokens != null || opts.outputTokens != null || opts.totalTokens != null || opts.costUsd != null || opts.ampCredits != null),
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function reportedHasSpendNumbers(reported) {
|
|
1778
|
+
if (!reported) return false;
|
|
1779
|
+
return (
|
|
1780
|
+
reported.inputTokens != null
|
|
1781
|
+
|| reported.outputTokens != null
|
|
1782
|
+
|| reported.totalTokens != null
|
|
1783
|
+
|| reported.costUsd != null
|
|
1784
|
+
|| reported.ampCredits != null
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function sourceAmpCredits(sources) {
|
|
1789
|
+
let sum = null;
|
|
1790
|
+
for (const src of sources || []) {
|
|
1791
|
+
sum = addNullable(sum, numOrNull(src.ampCredits));
|
|
1792
|
+
}
|
|
1793
|
+
return sum;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function resolveSessionSpend(opts, reported, sources) {
|
|
1797
|
+
const flags = opts || {};
|
|
1798
|
+
const self = reported || emptyMetricsFields();
|
|
1799
|
+
const fromSources = sessionTotalsFromSources(sources || []);
|
|
1800
|
+
const sourceCredits = sourceAmpCredits(sources || []);
|
|
1801
|
+
const flagInput = numOrNull(flags.inputTokens);
|
|
1802
|
+
const flagOutput = numOrNull(flags.outputTokens);
|
|
1803
|
+
const flagTotal = numOrNull(flags.totalTokens);
|
|
1804
|
+
const flagCost = numOrNull(flags.costUsd);
|
|
1805
|
+
const flagCredits = numOrNull(flags.ampCredits);
|
|
1806
|
+
const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
|
|
1807
|
+
const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
|
|
1808
|
+
let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
|
|
1809
|
+
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1810
|
+
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1811
|
+
}
|
|
1812
|
+
const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
|
|
1813
|
+
const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
|
|
1814
|
+
let spendSource = 'unreported';
|
|
1815
|
+
if (self.spendSource) spendSource = String(self.spendSource);
|
|
1816
|
+
else if (hasSpendOverride(flags)) spendSource = 'flag';
|
|
1817
|
+
else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
|
|
1818
|
+
else if (
|
|
1819
|
+
fromSources.inputTokens != null
|
|
1820
|
+
|| fromSources.outputTokens != null
|
|
1821
|
+
|| fromSources.totalTokens != null
|
|
1822
|
+
|| fromSources.costUsd != null
|
|
1823
|
+
|| sourceCredits != null
|
|
1824
|
+
) {
|
|
1825
|
+
spendSource = 'adapter';
|
|
1826
|
+
}
|
|
1827
|
+
return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
|
|
1606
1828
|
}
|
|
1607
1829
|
|
|
1608
1830
|
function sessionTotalsFromFlags(opts) {
|
|
@@ -1649,54 +1871,151 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
|
1649
1871
|
}
|
|
1650
1872
|
}
|
|
1651
1873
|
|
|
1652
|
-
function applyCollectedSessionFields(session, sources, resolvedModel, opts) {
|
|
1653
|
-
session.sources = sources;
|
|
1654
|
-
const uniqueModels = uniqueSourceModels(sources);
|
|
1655
|
-
session.model = primaryModelFromSources(sources) ||
|
|
1874
|
+
function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
|
|
1875
|
+
session.sources = sources || [];
|
|
1876
|
+
const uniqueModels = uniqueSourceModels(session.sources);
|
|
1877
|
+
if (!session.model) session.model = resolvedModel || primaryModelFromSources(session.sources) || null;
|
|
1878
|
+
if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
|
|
1656
1879
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
1657
|
-
const
|
|
1658
|
-
session.inputTokens =
|
|
1659
|
-
session.outputTokens =
|
|
1660
|
-
session.totalTokens =
|
|
1661
|
-
session.costUsd =
|
|
1880
|
+
const spend = resolveSessionSpend(opts, reported, session.sources);
|
|
1881
|
+
session.inputTokens = spend.inputTokens;
|
|
1882
|
+
session.outputTokens = spend.outputTokens;
|
|
1883
|
+
session.totalTokens = spend.totalTokens;
|
|
1884
|
+
session.costUsd = spend.costUsd;
|
|
1885
|
+
session.ampCredits = spend.ampCredits;
|
|
1886
|
+
session.spendSource = spend.spendSource;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function sessionTotalsLookOverridden(session) {
|
|
1890
|
+
const fromSources = sessionTotalsFromSources(session.sources || []);
|
|
1891
|
+
return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
|
|
1892
|
+
const sessionVal = numOrNull(session[key]);
|
|
1893
|
+
const sourceVal = numOrNull(fromSources[key]);
|
|
1894
|
+
if (sessionVal == null) return false;
|
|
1895
|
+
if (sourceVal == null) return true;
|
|
1896
|
+
return sessionVal !== sourceVal;
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function metricsBackfillLastSession(projectDir, changeName) {
|
|
1901
|
+
const resolved = resolveMetricsFile(projectDir, changeName);
|
|
1902
|
+
if (resolved.missing) return { filePath: resolved.filePath, added: 0, missing: true };
|
|
1903
|
+
return metricsBackfillFile(resolved.filePath, changeName);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function metricsBackfillFile(filePath, changeName) {
|
|
1907
|
+
const nowIso = new Date().toISOString();
|
|
1908
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1909
|
+
const sessions = metrics.sessions || [];
|
|
1910
|
+
if (!sessions.length) return { filePath, added: 0, empty: true };
|
|
1911
|
+
const last = sessions[sessions.length - 1];
|
|
1912
|
+
const windowStart = last.startedAt || collectWindowStart(metrics);
|
|
1913
|
+
const collected = runCollectSpend(metrics, windowStart, nowIso);
|
|
1914
|
+
const incoming = collected.sources || [];
|
|
1915
|
+
if (!incoming.length) return { filePath, added: 0 };
|
|
1916
|
+
const overridden = sessionTotalsLookOverridden(last);
|
|
1917
|
+
const merged = [...(last.sources || []), ...incoming];
|
|
1918
|
+
const keepReportedTotals = overridden
|
|
1919
|
+
|| last.spendSource === 'flag'
|
|
1920
|
+
|| last.spendSource === 'self-report'
|
|
1921
|
+
|| (last.spendSource && last.spendSource !== 'adapter' && last.spendSource !== 'unreported');
|
|
1922
|
+
if (keepReportedTotals) {
|
|
1923
|
+
last.sources = merged;
|
|
1924
|
+
const uniqueModels = uniqueSourceModels(merged);
|
|
1925
|
+
if (uniqueModels.length > 1) last.models = uniqueModels;
|
|
1926
|
+
} else {
|
|
1927
|
+
applyCollectedSessionFields(last, merged, last.model, {}, {
|
|
1928
|
+
model: last.model,
|
|
1929
|
+
platform: last.platform,
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
metrics.updatedAt = nowIso;
|
|
1933
|
+
recomputeMetricsAggregates(metrics);
|
|
1934
|
+
saveMetricsFile(filePath, metrics);
|
|
1935
|
+
return { filePath, added: incoming.length };
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function adapterSourceName(platform) {
|
|
1939
|
+
if (platform === 'claude') return 'claude-jsonl';
|
|
1940
|
+
if (platform === 'amp') return 'amp-thread';
|
|
1941
|
+
if (platform === 'cursor') return 'cursor-hook';
|
|
1942
|
+
return null;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function spendTuple(obj) {
|
|
1946
|
+
return {
|
|
1947
|
+
inputTokens: numOrNull(obj && obj.inputTokens),
|
|
1948
|
+
outputTokens: numOrNull(obj && obj.outputTokens),
|
|
1949
|
+
totalTokens: numOrNull(obj && obj.totalTokens),
|
|
1950
|
+
costUsd: numOrNull(obj && obj.costUsd),
|
|
1951
|
+
ampCredits: numOrNull(obj && obj.ampCredits),
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function spendTuplesMatch(a, b) {
|
|
1956
|
+
return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].every((key) => {
|
|
1957
|
+
const left = a[key];
|
|
1958
|
+
const right = b[key];
|
|
1959
|
+
if (left == null && right == null) return true;
|
|
1960
|
+
return left === right;
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
function addSpendNums(target, nums) {
|
|
1965
|
+
target.inputTokens = addNullable(target.inputTokens, nums.inputTokens);
|
|
1966
|
+
target.outputTokens = addNullable(target.outputTokens, nums.outputTokens);
|
|
1967
|
+
target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
|
|
1968
|
+
target.costUsd = addNullable(target.costUsd, nums.costUsd);
|
|
1969
|
+
target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
|
|
1662
1970
|
}
|
|
1663
1971
|
|
|
1664
1972
|
function recomputeSpendMaps(metrics) {
|
|
1665
1973
|
const byPlatform = defaultSpendByPlatform();
|
|
1666
1974
|
const byModel = new Map();
|
|
1975
|
+
|
|
1976
|
+
function addModelRow(model, platform, nums) {
|
|
1977
|
+
if (!model) return;
|
|
1978
|
+
const key = `${model}::${platform || ''}`;
|
|
1979
|
+
const row = byModel.get(key) || {
|
|
1980
|
+
model,
|
|
1981
|
+
platform: platform || null,
|
|
1982
|
+
inputTokens: null,
|
|
1983
|
+
outputTokens: null,
|
|
1984
|
+
totalTokens: null,
|
|
1985
|
+
costUsd: null,
|
|
1986
|
+
ampCredits: null,
|
|
1987
|
+
};
|
|
1988
|
+
addSpendNums(row, nums);
|
|
1989
|
+
byModel.set(key, row);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function addSourceRow(src) {
|
|
1993
|
+
const nums = spendTuple(src);
|
|
1994
|
+
if (src.platform && byPlatform[src.platform]) {
|
|
1995
|
+
addSpendNums(byPlatform[src.platform], nums);
|
|
1996
|
+
const label = adapterSourceName(src.platform);
|
|
1997
|
+
if (label) byPlatform[src.platform].source = label;
|
|
1998
|
+
}
|
|
1999
|
+
addModelRow(src.model, src.platform, nums);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1667
2002
|
for (const session of metrics.sessions || []) {
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
1678
|
-
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
1679
|
-
else if (platform === 'cursor') bucket.source = 'cursor-hook';
|
|
1680
|
-
}
|
|
1681
|
-
if (src.model) {
|
|
1682
|
-
const key = `${src.model}::${src.platform || ''}`;
|
|
1683
|
-
const row = byModel.get(key) || {
|
|
1684
|
-
model: src.model,
|
|
1685
|
-
platform: src.platform || null,
|
|
1686
|
-
inputTokens: null,
|
|
1687
|
-
outputTokens: null,
|
|
1688
|
-
totalTokens: null,
|
|
1689
|
-
costUsd: null,
|
|
1690
|
-
ampCredits: null,
|
|
1691
|
-
};
|
|
1692
|
-
row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
|
|
1693
|
-
row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
|
|
1694
|
-
row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
|
|
1695
|
-
row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
|
|
1696
|
-
row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
|
|
1697
|
-
byModel.set(key, row);
|
|
1698
|
-
}
|
|
2003
|
+
const sessionNums = spendTuple(session);
|
|
2004
|
+
const sourceTotals = sessionTotalsFromSources(session.sources || []);
|
|
2005
|
+
sourceTotals.ampCredits = sourceAmpCredits(session.sources || []);
|
|
2006
|
+
const sources = session.sources || [];
|
|
2007
|
+
const sourcesMatchSession = sources.length > 0 && spendTuplesMatch(sessionNums, sourceTotals);
|
|
2008
|
+
|
|
2009
|
+
if (sourcesMatchSession) {
|
|
2010
|
+
for (const src of sources) addSourceRow(src);
|
|
2011
|
+
continue;
|
|
1699
2012
|
}
|
|
2013
|
+
|
|
2014
|
+
if (session.platform && byPlatform[session.platform]) {
|
|
2015
|
+
addSpendNums(byPlatform[session.platform], sessionNums);
|
|
2016
|
+
}
|
|
2017
|
+
addModelRow(session.model, session.platform, sessionNums);
|
|
2018
|
+
for (const src of sources) addSourceRow(src);
|
|
1700
2019
|
}
|
|
1701
2020
|
metrics.spendByPlatform = byPlatform;
|
|
1702
2021
|
metrics.spendByModel = [...byModel.values()];
|
|
@@ -1757,11 +2076,12 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1757
2076
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
1758
2077
|
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1759
2078
|
const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
|
|
1760
|
-
const
|
|
1761
|
-
const
|
|
1762
|
-
const
|
|
1763
|
-
|
|
1764
|
-
|
|
2079
|
+
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
2080
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2081
|
+
const windowStart = collectWindowStart(metrics);
|
|
2082
|
+
const collected = opts.collect === true
|
|
2083
|
+
? runCollectSpend(metrics, windowStart, nowIso)
|
|
2084
|
+
: { sources: [] };
|
|
1765
2085
|
const session = {
|
|
1766
2086
|
startedAt,
|
|
1767
2087
|
endedAt: nowIso,
|
|
@@ -1778,14 +2098,20 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1778
2098
|
outputTokens: null,
|
|
1779
2099
|
totalTokens: null,
|
|
1780
2100
|
costUsd: null,
|
|
2101
|
+
ampCredits: null,
|
|
2102
|
+
spendSource: 'unreported',
|
|
1781
2103
|
};
|
|
1782
|
-
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts);
|
|
2104
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
1783
2105
|
metrics.sessions.push(session);
|
|
1784
2106
|
metrics.pending = null;
|
|
1785
2107
|
metrics.updatedAt = nowIso;
|
|
1786
2108
|
recomputeMetricsAggregates(metrics);
|
|
1787
|
-
if (session.model == null) warnMissingModel();
|
|
1788
2109
|
saveMetricsFile(filePath, metrics);
|
|
2110
|
+
if (opts.collect === true) metricsBackfillFile(filePath, fields.changeName);
|
|
2111
|
+
const latest = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
2112
|
+
const last = (latest.sessions || []).at(-1);
|
|
2113
|
+
if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
|
|
2114
|
+
if (!last || last.model == null) warnMissingModel();
|
|
1789
2115
|
return filePath;
|
|
1790
2116
|
}
|
|
1791
2117
|
|
|
@@ -1794,10 +2120,11 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
1794
2120
|
const nowIso = new Date().toISOString();
|
|
1795
2121
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1796
2122
|
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1797
|
-
const collected = opts.collect ===
|
|
1798
|
-
?
|
|
1799
|
-
:
|
|
1800
|
-
const
|
|
2123
|
+
const collected = opts.collect === true
|
|
2124
|
+
? runCollectSpend(metrics, windowStart, nowIso)
|
|
2125
|
+
: { sources: [] };
|
|
2126
|
+
const reported = opts.reported || emptyMetricsFields();
|
|
2127
|
+
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
1801
2128
|
const session = {
|
|
1802
2129
|
startedAt: nowIso,
|
|
1803
2130
|
endedAt: nowIso,
|
|
@@ -1814,16 +2141,22 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
1814
2141
|
outputTokens: null,
|
|
1815
2142
|
totalTokens: null,
|
|
1816
2143
|
costUsd: null,
|
|
2144
|
+
ampCredits: null,
|
|
2145
|
+
spendSource: 'unreported',
|
|
1817
2146
|
};
|
|
1818
|
-
applyCollectedSessionFields(session, collected.sources || [], resolvedModel,
|
|
2147
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
1819
2148
|
metrics.sessions.push(session);
|
|
1820
2149
|
metrics.archivedAt = nowIso;
|
|
1821
2150
|
metrics.pending = null;
|
|
1822
2151
|
metrics.updatedAt = nowIso;
|
|
1823
2152
|
recomputeMetricsAggregates(metrics);
|
|
1824
|
-
if (session.model == null) warnMissingModel();
|
|
1825
|
-
if (metrics.spend.costUsd === null) warnMissingUsd();
|
|
1826
2153
|
saveMetricsFile(filePath, metrics);
|
|
2154
|
+
if (opts.collect === true) metricsBackfillFile(filePath, changeName);
|
|
2155
|
+
const latest = loadMetricsFile(filePath, changeName, nowIso);
|
|
2156
|
+
const last = (latest.sessions || []).at(-1);
|
|
2157
|
+
if (last && last.spendSource === 'unreported') warnUnreportedSelfReport();
|
|
2158
|
+
if (!last || last.model == null) warnMissingModel();
|
|
2159
|
+
if (latest.spend.costUsd === null) warnMissingUsd();
|
|
1827
2160
|
return filePath;
|
|
1828
2161
|
}
|
|
1829
2162
|
|
|
@@ -1846,6 +2179,92 @@ function formatMetricsCost(value) {
|
|
|
1846
2179
|
return value == null ? '—' : `$${Number(value).toFixed(2)}`;
|
|
1847
2180
|
}
|
|
1848
2181
|
|
|
2182
|
+
function sessionSpendSourceLabel(session) {
|
|
2183
|
+
const raw = session && session.spendSource;
|
|
2184
|
+
if (raw == null || String(raw).trim() === '') return 'unreported';
|
|
2185
|
+
return String(raw);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function renderMetricsSummary(metrics) {
|
|
2189
|
+
const lines = [];
|
|
2190
|
+
const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
|
|
2191
|
+
const unreported = sessions.filter((session) => sessionSpendSourceLabel(session) === 'unreported').length;
|
|
2192
|
+
lines.push(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
|
|
2193
|
+
lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
2194
|
+
lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
2195
|
+
lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
2196
|
+
lines.push(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
|
|
2197
|
+
lines.push(`unreported: ${unreported}`);
|
|
2198
|
+
if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
|
|
2199
|
+
if (metrics.pending) {
|
|
2200
|
+
lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
2204
|
+
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
2205
|
+
if (phaseKeys.length) {
|
|
2206
|
+
lines.push('');
|
|
2207
|
+
lines.push('phase sessions time tokens cost roles models');
|
|
2208
|
+
for (const key of phaseKeys) {
|
|
2209
|
+
const phase = metrics.phases[key];
|
|
2210
|
+
lines.push([
|
|
2211
|
+
key.padEnd(10),
|
|
2212
|
+
String(phase.sessions).padEnd(9),
|
|
2213
|
+
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
2214
|
+
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
2215
|
+
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
2216
|
+
(phase.agents.join(', ') || '—').padEnd(20),
|
|
2217
|
+
phase.models.join(', ') || '—',
|
|
2218
|
+
].join(' '));
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
|
|
2223
|
+
lines.push('');
|
|
2224
|
+
lines.push('by platform:');
|
|
2225
|
+
lines.push('platform tokens cost credits source');
|
|
2226
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
2227
|
+
const row = byPlatform[key] || emptyPlatformSpend();
|
|
2228
|
+
lines.push([
|
|
2229
|
+
key.padEnd(10),
|
|
2230
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
2231
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
2232
|
+
formatMetricsNumber(row.ampCredits).padEnd(9),
|
|
2233
|
+
row.source || 'none',
|
|
2234
|
+
].join(' '));
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
lines.push('');
|
|
2238
|
+
lines.push('by model:');
|
|
2239
|
+
lines.push('model platform tokens cost credits');
|
|
2240
|
+
const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
|
|
2241
|
+
if (!byModel.length) {
|
|
2242
|
+
lines.push('— — — — —');
|
|
2243
|
+
} else {
|
|
2244
|
+
for (const row of byModel) {
|
|
2245
|
+
lines.push([
|
|
2246
|
+
String(row.model || '—').padEnd(20),
|
|
2247
|
+
String(row.platform || '—').padEnd(10),
|
|
2248
|
+
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
2249
|
+
formatMetricsCost(row.costUsd).padEnd(9),
|
|
2250
|
+
formatMetricsNumber(row.ampCredits),
|
|
2251
|
+
].join(' '));
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if (sessions.length) {
|
|
2256
|
+
lines.push('');
|
|
2257
|
+
lines.push('recent sessions:');
|
|
2258
|
+
for (const session of sessions.slice(-5)) {
|
|
2259
|
+
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
2260
|
+
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
|
|
2261
|
+
: '';
|
|
2262
|
+
lines.push(`- ${session.endedAt} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''} (${sessionSpendSourceLabel(session)})${spendLabel}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return lines;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
1849
2268
|
function resolveMetricsFile(projectDir, changeName) {
|
|
1850
2269
|
const activePath = metricsFilePath(projectDir, changeName);
|
|
1851
2270
|
if (existsSync(activePath)) return { filePath: activePath, archived: false };
|
|
@@ -3015,7 +3434,11 @@ program
|
|
|
3015
3434
|
.option('--force', 'confirm archiving without merge when delta specs exist', false)
|
|
3016
3435
|
.option('--model <name>', 'LLM product id recorded on the Archiver session')
|
|
3017
3436
|
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
3018
|
-
.option('--
|
|
3437
|
+
.option('--input-tokens <n>', 'Input tokens spent in the Archiver session')
|
|
3438
|
+
.option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
|
|
3439
|
+
.option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
|
|
3440
|
+
.option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
|
|
3441
|
+
.option('--collect', 'Additionally collect local spend adapters', false)
|
|
3019
3442
|
.action((name, opts) => {
|
|
3020
3443
|
const projectDir = process.cwd();
|
|
3021
3444
|
const fail = (msg) => {
|
|
@@ -3135,6 +3558,11 @@ program
|
|
|
3135
3558
|
if (existsSync(archivedHandoffPath)) {
|
|
3136
3559
|
priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
|
|
3137
3560
|
}
|
|
3561
|
+
const reported = priorFields.metrics || emptyMetricsFields();
|
|
3562
|
+
const archivePlatform = resolvePlatform(opts, process.env, reported);
|
|
3563
|
+
const archiveModel = resolveModel(opts, process.env, reported);
|
|
3564
|
+
if (archivePlatform.warn) console.error(archivePlatform.warn);
|
|
3565
|
+
printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
|
|
3138
3566
|
const runtimeResult = resolveRuntime({}, process.env, priorFields);
|
|
3139
3567
|
const progress = parseTasksProgress(targetDir);
|
|
3140
3568
|
const fields = {
|
|
@@ -3151,6 +3579,7 @@ program
|
|
|
3151
3579
|
constraints: 'Pipeline complete — no next session.',
|
|
3152
3580
|
runtime: runtimeResult.value || 'local',
|
|
3153
3581
|
agentId: resolveAgentId({}, process.env, priorFields),
|
|
3582
|
+
metrics: reported,
|
|
3154
3583
|
status: 'archived',
|
|
3155
3584
|
tasks: progress ? `${progress.done}/${progress.total}` : '',
|
|
3156
3585
|
review: parseReviewVerdict(targetDir) || '',
|
|
@@ -3159,12 +3588,17 @@ program
|
|
|
3159
3588
|
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
3160
3589
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
3161
3590
|
const metricsPath = metricsFinalizeArchive(targetDir, name, {
|
|
3162
|
-
model:
|
|
3163
|
-
platform:
|
|
3591
|
+
model: archiveModel,
|
|
3592
|
+
platform: archivePlatform.value || null,
|
|
3164
3593
|
runtime: fields.runtime,
|
|
3165
3594
|
agentId: fields.agentId,
|
|
3166
3595
|
tasks: fields.tasks,
|
|
3167
|
-
collect: opts.collect
|
|
3596
|
+
collect: opts.collect === true,
|
|
3597
|
+
inputTokens: opts.inputTokens,
|
|
3598
|
+
outputTokens: opts.outputTokens,
|
|
3599
|
+
totalTokens: opts.totalTokens,
|
|
3600
|
+
costUsd: opts.costUsd,
|
|
3601
|
+
reported,
|
|
3168
3602
|
});
|
|
3169
3603
|
|
|
3170
3604
|
console.log(`change: ${name}`);
|
|
@@ -3174,6 +3608,10 @@ program
|
|
|
3174
3608
|
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
3175
3609
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
3176
3610
|
console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
|
|
3611
|
+
try {
|
|
3612
|
+
const archivedMetrics = loadMetricsFile(metricsPath, name, new Date().toISOString());
|
|
3613
|
+
for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
|
|
3614
|
+
} catch {}
|
|
3177
3615
|
log.ok(`archived ${name}`);
|
|
3178
3616
|
});
|
|
3179
3617
|
|
|
@@ -3381,7 +3819,7 @@ program
|
|
|
3381
3819
|
.option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
|
|
3382
3820
|
.option('--cost-usd <usd>', 'Cost of this session in USD')
|
|
3383
3821
|
.option('--no-metrics', 'Skip recording this session into metrics.json')
|
|
3384
|
-
.option('--
|
|
3822
|
+
.option('--collect', 'Additionally collect local spend adapters', false)
|
|
3385
3823
|
.action((changeName, opts) => {
|
|
3386
3824
|
const projectDir = process.cwd();
|
|
3387
3825
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -3442,9 +3880,6 @@ program
|
|
|
3442
3880
|
const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
|
|
3443
3881
|
log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
|
|
3444
3882
|
}
|
|
3445
|
-
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3446
|
-
if (spendHook.error) log.warn(`Cursor spend hook: ${spendHook.error}`);
|
|
3447
|
-
else if (spendHook.hooksJson) log.ok('Cursor spend hook installed — restart Cursor once to activate it');
|
|
3448
3883
|
return;
|
|
3449
3884
|
}
|
|
3450
3885
|
|
|
@@ -3512,14 +3947,16 @@ program
|
|
|
3512
3947
|
return;
|
|
3513
3948
|
}
|
|
3514
3949
|
|
|
3515
|
-
const
|
|
3950
|
+
const reported = fields.metrics || emptyMetricsFields();
|
|
3951
|
+
const platformResult = resolvePlatform(opts, process.env, reported);
|
|
3516
3952
|
if (platformResult.error) {
|
|
3517
3953
|
log.err(platformResult.error);
|
|
3518
3954
|
process.exitCode = 1;
|
|
3519
3955
|
return;
|
|
3520
3956
|
}
|
|
3521
3957
|
if (platformResult.warn) console.error(platformResult.warn);
|
|
3522
|
-
|
|
3958
|
+
printMetricsSectionWarnings(reported, Boolean(platformResult.warn));
|
|
3959
|
+
const resolvedModel = resolveModel(opts, process.env, reported);
|
|
3523
3960
|
|
|
3524
3961
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
3525
3962
|
fields.prompt = prompt;
|
|
@@ -3530,10 +3967,6 @@ program
|
|
|
3530
3967
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
3531
3968
|
console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
|
|
3532
3969
|
|
|
3533
|
-
const spendHook = ensureCursorSpendHook(projectDir);
|
|
3534
|
-
if (spendHook.error) console.error(pc.yellow(' !'), `Cursor spend hook: ${spendHook.error}`);
|
|
3535
|
-
else if (spendHook.hooksJson) console.error(pc.green(' ✓'), 'Cursor spend hook installed — restart Cursor once to activate it');
|
|
3536
|
-
|
|
3537
3970
|
if (opts.metrics !== false) {
|
|
3538
3971
|
const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
|
|
3539
3972
|
startedAt: opts.startedAt,
|
|
@@ -3543,7 +3976,8 @@ program
|
|
|
3543
3976
|
outputTokens: opts.outputTokens,
|
|
3544
3977
|
totalTokens: opts.totalTokens,
|
|
3545
3978
|
costUsd: opts.costUsd,
|
|
3546
|
-
collect: opts.collect
|
|
3979
|
+
collect: opts.collect === true,
|
|
3980
|
+
reported,
|
|
3547
3981
|
});
|
|
3548
3982
|
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
3549
3983
|
}
|
|
@@ -3558,9 +3992,11 @@ program
|
|
|
3558
3992
|
.command('metrics [change-name]')
|
|
3559
3993
|
.description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
|
|
3560
3994
|
.option('--json', 'Print raw metrics.json', false)
|
|
3995
|
+
.option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
|
|
3561
3996
|
.action((changeName, opts) => {
|
|
3562
3997
|
const projectDir = process.cwd();
|
|
3563
3998
|
let name = changeName;
|
|
3999
|
+
let collectedAlready = false;
|
|
3564
4000
|
if (!name) {
|
|
3565
4001
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
3566
4002
|
if (!resolved) {
|
|
@@ -3569,11 +4005,37 @@ program
|
|
|
3569
4005
|
return;
|
|
3570
4006
|
}
|
|
3571
4007
|
if (resolved.ambiguous) {
|
|
3572
|
-
|
|
4008
|
+
if (opts.collect) {
|
|
4009
|
+
let added = 0;
|
|
4010
|
+
for (const change of resolved.ambiguous) {
|
|
4011
|
+
const result = metricsBackfillLastSession(projectDir, change);
|
|
4012
|
+
added += result.added || 0;
|
|
4013
|
+
}
|
|
4014
|
+
collectedAlready = true;
|
|
4015
|
+
if (!opts.json) log.ok(`collect: ${added} new source(s) across ${resolved.ambiguous.length} changes`);
|
|
4016
|
+
if (opts.json) {
|
|
4017
|
+
process.stdout.write(`${JSON.stringify({ collected: added, changes: resolved.ambiguous }, null, 2)}\n`);
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
name = resolved.ambiguous[0];
|
|
4021
|
+
} else {
|
|
4022
|
+
log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
|
|
4023
|
+
process.exitCode = 1;
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
} else {
|
|
4027
|
+
name = resolved;
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
if (opts.collect && name && !collectedAlready) {
|
|
4032
|
+
const result = metricsBackfillLastSession(projectDir, name);
|
|
4033
|
+
if (result.missing) {
|
|
4034
|
+
log.err(`No metrics.json for ${name}`);
|
|
3573
4035
|
process.exitCode = 1;
|
|
3574
4036
|
return;
|
|
3575
4037
|
}
|
|
3576
|
-
|
|
4038
|
+
if (!opts.json) log.ok(`collect: ${result.added} new source(s) on last session`);
|
|
3577
4039
|
}
|
|
3578
4040
|
|
|
3579
4041
|
const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
|
|
@@ -3593,77 +4055,7 @@ program
|
|
|
3593
4055
|
|
|
3594
4056
|
log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
|
|
3595
4057
|
console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3596
|
-
|
|
3597
|
-
console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
3598
|
-
console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
3599
|
-
console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
3600
|
-
console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
|
|
3601
|
-
if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
|
|
3602
|
-
if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
|
|
3603
|
-
|
|
3604
|
-
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
3605
|
-
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
3606
|
-
if (phaseKeys.length) {
|
|
3607
|
-
console.log('');
|
|
3608
|
-
console.log('phase sessions time tokens cost roles models');
|
|
3609
|
-
for (const key of phaseKeys) {
|
|
3610
|
-
const phase = metrics.phases[key];
|
|
3611
|
-
const cols = [
|
|
3612
|
-
key.padEnd(10),
|
|
3613
|
-
String(phase.sessions).padEnd(9),
|
|
3614
|
-
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
3615
|
-
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
3616
|
-
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
3617
|
-
(phase.agents.join(', ') || '—').padEnd(20),
|
|
3618
|
-
phase.models.join(', ') || '—',
|
|
3619
|
-
];
|
|
3620
|
-
console.log(cols.join(' '));
|
|
3621
|
-
}
|
|
3622
|
-
}
|
|
3623
|
-
|
|
3624
|
-
const byPlatform = metrics.spendByPlatform || defaultSpendByPlatform();
|
|
3625
|
-
console.log('');
|
|
3626
|
-
console.log('by platform:');
|
|
3627
|
-
console.log('platform tokens cost credits source');
|
|
3628
|
-
for (const key of ['cursor', 'claude', 'amp']) {
|
|
3629
|
-
const row = byPlatform[key] || emptyPlatformSpend();
|
|
3630
|
-
console.log([
|
|
3631
|
-
key.padEnd(10),
|
|
3632
|
-
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3633
|
-
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3634
|
-
formatMetricsNumber(row.ampCredits).padEnd(9),
|
|
3635
|
-
row.source || 'none',
|
|
3636
|
-
].join(' '));
|
|
3637
|
-
}
|
|
3638
|
-
|
|
3639
|
-
console.log('');
|
|
3640
|
-
console.log('by model:');
|
|
3641
|
-
console.log('model platform tokens cost credits');
|
|
3642
|
-
const byModel = Array.isArray(metrics.spendByModel) ? metrics.spendByModel : [];
|
|
3643
|
-
if (!byModel.length) {
|
|
3644
|
-
console.log('— — — — —');
|
|
3645
|
-
} else {
|
|
3646
|
-
for (const row of byModel) {
|
|
3647
|
-
console.log([
|
|
3648
|
-
String(row.model || '—').padEnd(20),
|
|
3649
|
-
String(row.platform || '—').padEnd(10),
|
|
3650
|
-
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
3651
|
-
formatMetricsCost(row.costUsd).padEnd(9),
|
|
3652
|
-
formatMetricsNumber(row.ampCredits),
|
|
3653
|
-
].join(' '));
|
|
3654
|
-
}
|
|
3655
|
-
}
|
|
3656
|
-
|
|
3657
|
-
if (metrics.sessions.length) {
|
|
3658
|
-
console.log('');
|
|
3659
|
-
console.log('recent sessions:');
|
|
3660
|
-
for (const session of metrics.sessions.slice(-5)) {
|
|
3661
|
-
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
3662
|
-
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
|
|
3663
|
-
: '';
|
|
3664
|
-
console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
4058
|
+
for (const line of renderMetricsSummary(metrics)) console.log(line);
|
|
3667
4059
|
});
|
|
3668
4060
|
|
|
3669
4061
|
program.parse();
|