agent-orchestrator-kit 0.5.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 +33 -0
- package/README.md +50 -15
- package/bin/agent-orchestrator.js +899 -61
- package/bin/spend-collect.js +486 -0
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +7 -7
- 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 +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,14 @@ 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_SPEND_COLLECT_REL = join('scripts', 'cursor-spend-collect.cjs');
|
|
91
|
+
const CURSOR_HOOKS_JSON_REL = join('.cursor', 'hooks.json');
|
|
92
|
+
const CURSOR_SPEND_HOOK_COMMAND = 'node scripts/cursor-spend-hook.cjs';
|
|
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'];
|
|
96
|
+
const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
87
97
|
const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
|
|
88
98
|
const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
|
|
89
99
|
const OPTIONAL_MCP_SEED_STRIP = ['github', 'gitlab', 'browser'];
|
|
@@ -152,6 +162,7 @@ const HANDOFF_SECTIONS = [
|
|
|
152
162
|
'Subagents to spawn',
|
|
153
163
|
'Constraints',
|
|
154
164
|
'Runtime',
|
|
165
|
+
'Metrics',
|
|
155
166
|
'Prompt',
|
|
156
167
|
];
|
|
157
168
|
const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
|
|
@@ -361,6 +372,122 @@ function refreshOptionalMcpManagedFiles(projectDir) {
|
|
|
361
372
|
refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
|
|
362
373
|
}
|
|
363
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
|
+
|
|
381
|
+
function cursorSpendHookEntryOk(projectDir) {
|
|
382
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
383
|
+
if (!existsSync(hooksPath)) return false;
|
|
384
|
+
let config;
|
|
385
|
+
try {
|
|
386
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
387
|
+
} catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
const hooks = config && typeof config === 'object' ? config.hooks : null;
|
|
391
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
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;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Mandatory spend capture: every kit project must record Cursor token usage
|
|
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
|
+
|
|
421
|
+
function ensureCursorSpendHook(projectDir) {
|
|
422
|
+
const result = { script: false, hooksJson: false, error: null };
|
|
423
|
+
if (ensureManagedScript(projectDir, CURSOR_SPEND_HOOK_REL)) result.script = true;
|
|
424
|
+
ensureManagedScript(projectDir, CURSOR_SPEND_COLLECT_REL);
|
|
425
|
+
|
|
426
|
+
const hooksPath = join(projectDir, CURSOR_HOOKS_JSON_REL);
|
|
427
|
+
let config = { version: 1, hooks: {} };
|
|
428
|
+
if (existsSync(hooksPath)) {
|
|
429
|
+
try {
|
|
430
|
+
config = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
431
|
+
} catch {
|
|
432
|
+
result.error = `${CURSOR_HOOKS_JSON_REL} is not valid JSON — fix it, then re-run any kit command`;
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (!config || typeof config !== 'object') config = { version: 1, hooks: {} };
|
|
437
|
+
if (config.version == null) config.version = 1;
|
|
438
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
439
|
+
let changed = !existsSync(hooksPath);
|
|
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;
|
|
442
|
+
if (changed) {
|
|
443
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
444
|
+
writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
445
|
+
result.hooksJson = true;
|
|
446
|
+
}
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function reportCursorSpendHook(projectDir, emit) {
|
|
451
|
+
const result = ensureCursorSpendHook(projectDir);
|
|
452
|
+
if (result.error) {
|
|
453
|
+
emit.warn(`Cursor spend hook: ${result.error}`);
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
if (result.script) emit.ok(CURSOR_SPEND_HOOK_REL);
|
|
457
|
+
if (result.hooksJson) emit.ok(`${CURSOR_HOOKS_JSON_REL} (stop / subagentStop / afterAgentResponse + sessionEnd collect)`);
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function countCursorUsageRecords(projectDir) {
|
|
462
|
+
const filePath = join(projectDir, CURSOR_USAGE_FILE_REL);
|
|
463
|
+
if (!existsSync(filePath)) return null;
|
|
464
|
+
try {
|
|
465
|
+
return readFileSync(filePath, 'utf-8').split('\n').filter((line) => line.trim()).length;
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function printSpendHealth(projectDir) {
|
|
472
|
+
console.log(pc.bold('\nSpend capture'));
|
|
473
|
+
const scriptOk = existsSync(join(projectDir, CURSOR_SPEND_HOOK_REL));
|
|
474
|
+
const entryOk = cursorSpendHookEntryOk(projectDir);
|
|
475
|
+
const records = countCursorUsageRecords(projectDir);
|
|
476
|
+
const cursorState = scriptOk && entryOk
|
|
477
|
+
? `ok${records != null ? ` (${records} records)` : ' (no turns recorded yet)'}`
|
|
478
|
+
: 'optional — not configured (init/update/sync/mcp-setup)';
|
|
479
|
+
console.log(` cursor ${cursorState}`);
|
|
480
|
+
const home = process.env.HOME || '';
|
|
481
|
+
const claudeOk = home && existsSync(join(home, '.claude', 'projects'));
|
|
482
|
+
console.log(` claude ${claudeOk ? 'ok (~/.claude/projects)' : 'no local Claude data'}`);
|
|
483
|
+
const ampDir = process.env.AMP_DATA_DIR && String(process.env.AMP_DATA_DIR).trim()
|
|
484
|
+
? String(process.env.AMP_DATA_DIR).trim()
|
|
485
|
+
: join(home, '.local', 'share', 'amp');
|
|
486
|
+
const ampOk = existsSync(join(ampDir, 'threads'));
|
|
487
|
+
console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
|
|
488
|
+
console.log('');
|
|
489
|
+
}
|
|
490
|
+
|
|
364
491
|
function parseGitRemoteHostname(url) {
|
|
365
492
|
const raw = String(url || '').trim();
|
|
366
493
|
if (!raw) return '';
|
|
@@ -698,6 +825,7 @@ function runMcpSetup(projectDir, { vcs = '', browser = true } = {}) {
|
|
|
698
825
|
refreshFigmaManagedFiles(projectDir);
|
|
699
826
|
refreshMemoryManagedFiles(projectDir);
|
|
700
827
|
refreshOptionalMcpManagedFiles(projectDir);
|
|
828
|
+
reportCursorSpendHook(projectDir, log);
|
|
701
829
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
702
830
|
|
|
703
831
|
const selected = resolveMcpSetupVcs(projectDir, vcs);
|
|
@@ -894,6 +1022,173 @@ function applyRuntimeToFields(fields, opts, env) {
|
|
|
894
1022
|
return true;
|
|
895
1023
|
}
|
|
896
1024
|
|
|
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
|
+
}
|
|
1117
|
+
|
|
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) {
|
|
1126
|
+
const flag = opts && opts.model != null ? String(opts.model).trim() : '';
|
|
1127
|
+
if (flag) return flag;
|
|
1128
|
+
const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
|
|
1129
|
+
if (fromReport) return fromReport;
|
|
1130
|
+
const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
|
|
1131
|
+
if (fromEnv) return fromEnv;
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
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) {
|
|
1155
|
+
const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
|
|
1156
|
+
if (flag) {
|
|
1157
|
+
const lower = flag.toLowerCase();
|
|
1158
|
+
if (!VALID_PLATFORMS.has(lower)) {
|
|
1159
|
+
return { error: 'invalid --platform (use cursor, claude, or amp)' };
|
|
1160
|
+
}
|
|
1161
|
+
return { value: lower };
|
|
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
|
+
}
|
|
1169
|
+
const fromEnv = env && env.AOK_PLATFORM != null ? String(env.AOK_PLATFORM).trim() : '';
|
|
1170
|
+
if (fromEnv) {
|
|
1171
|
+
const lower = fromEnv.toLowerCase();
|
|
1172
|
+
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1173
|
+
return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
|
|
1174
|
+
}
|
|
1175
|
+
return { value: inferPlatformFromHost(env) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function warnMissingModel() {
|
|
1179
|
+
console.error('metrics: session.model is null — pass --model <llm-product-id> or set AOK_MODEL');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function warnMissingUsd() {
|
|
1183
|
+
console.error('metrics: spend.costUsd is null — USD spend was not collected');
|
|
1184
|
+
}
|
|
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
|
+
|
|
897
1192
|
function gitTry(projectDir, command) {
|
|
898
1193
|
try {
|
|
899
1194
|
const stdout = execSync(command, {
|
|
@@ -994,7 +1289,9 @@ ${fields.constraints}
|
|
|
994
1289
|
|
|
995
1290
|
## Runtime
|
|
996
1291
|
- runtime: ${runtime}
|
|
997
|
-
- agent_id: ${agentId}
|
|
1292
|
+
- agent_id: ${agentId}
|
|
1293
|
+
|
|
1294
|
+
${renderMetricsSection(fields.metrics)}${prompt}
|
|
998
1295
|
`;
|
|
999
1296
|
}
|
|
1000
1297
|
|
|
@@ -1015,6 +1312,7 @@ function fieldsFromSections(changeName, sections, extra = {}) {
|
|
|
1015
1312
|
constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
|
|
1016
1313
|
runtime: extra.runtime || runtimeParsed.runtime,
|
|
1017
1314
|
agentId: extra.agentId || runtimeParsed.agentId,
|
|
1315
|
+
metrics: extra.metrics || parseMetricsSection(sectionOr(sections, 'Metrics', '')),
|
|
1018
1316
|
status: extra.status || '',
|
|
1019
1317
|
tasks: extra.tasks || '',
|
|
1020
1318
|
review: extra.review || '',
|
|
@@ -1301,6 +1599,35 @@ function emptySpendTotals() {
|
|
|
1301
1599
|
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
1302
1600
|
}
|
|
1303
1601
|
|
|
1602
|
+
function emptyPlatformSpend(source = 'none') {
|
|
1603
|
+
return {
|
|
1604
|
+
inputTokens: null,
|
|
1605
|
+
outputTokens: null,
|
|
1606
|
+
totalTokens: null,
|
|
1607
|
+
costUsd: null,
|
|
1608
|
+
ampCredits: null,
|
|
1609
|
+
source,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function defaultSpendByPlatform() {
|
|
1614
|
+
return {
|
|
1615
|
+
cursor: emptyPlatformSpend(),
|
|
1616
|
+
claude: emptyPlatformSpend(),
|
|
1617
|
+
amp: emptyPlatformSpend(),
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function mergeSpendByPlatform(raw) {
|
|
1622
|
+
const base = defaultSpendByPlatform();
|
|
1623
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
|
|
1624
|
+
for (const key of ['cursor', 'claude', 'amp']) {
|
|
1625
|
+
const row = raw[key] && typeof raw[key] === 'object' && !Array.isArray(raw[key]) ? raw[key] : {};
|
|
1626
|
+
base[key] = { ...base[key], ...row };
|
|
1627
|
+
}
|
|
1628
|
+
return base;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1304
1631
|
function defaultMetrics(changeName, nowIso) {
|
|
1305
1632
|
return {
|
|
1306
1633
|
version: METRICS_VERSION,
|
|
@@ -1309,6 +1636,8 @@ function defaultMetrics(changeName, nowIso) {
|
|
|
1309
1636
|
updatedAt: nowIso,
|
|
1310
1637
|
archivedAt: null,
|
|
1311
1638
|
spend: emptySpendTotals(),
|
|
1639
|
+
spendByPlatform: defaultSpendByPlatform(),
|
|
1640
|
+
spendByModel: [],
|
|
1312
1641
|
totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
|
|
1313
1642
|
phases: {},
|
|
1314
1643
|
sessions: [],
|
|
@@ -1334,6 +1663,8 @@ function loadMetricsFile(filePath, changeName, nowIso) {
|
|
|
1334
1663
|
version: METRICS_VERSION,
|
|
1335
1664
|
change: changeName,
|
|
1336
1665
|
spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
|
|
1666
|
+
spendByPlatform: mergeSpendByPlatform(parsed.spendByPlatform),
|
|
1667
|
+
spendByModel: Array.isArray(parsed.spendByModel) ? parsed.spendByModel : [],
|
|
1337
1668
|
totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
|
|
1338
1669
|
phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
|
|
1339
1670
|
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
@@ -1373,6 +1704,323 @@ function isoOrNull(value) {
|
|
|
1373
1704
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
1374
1705
|
}
|
|
1375
1706
|
|
|
1707
|
+
function sessionFieldOrSources(session, key) {
|
|
1708
|
+
if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
|
|
1709
|
+
let sum = null;
|
|
1710
|
+
for (const src of session.sources || []) {
|
|
1711
|
+
sum = addNullable(sum, numOrNull(src[key]));
|
|
1712
|
+
}
|
|
1713
|
+
return sum;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function lastSessionEndedAt(metrics) {
|
|
1717
|
+
let last = null;
|
|
1718
|
+
for (const session of metrics.sessions || []) {
|
|
1719
|
+
if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
|
|
1720
|
+
}
|
|
1721
|
+
return last;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function collectWindowStart(metrics) {
|
|
1725
|
+
return lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function existingSourceIdSet(metrics) {
|
|
1729
|
+
const ids = new Set();
|
|
1730
|
+
for (const session of metrics.sessions || []) {
|
|
1731
|
+
for (const src of session.sources || []) {
|
|
1732
|
+
if (src && src.id != null && src.id !== '') ids.add(String(src.id));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return ids;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function uniqueSourceModels(sources) {
|
|
1739
|
+
const seen = [];
|
|
1740
|
+
for (const src of sources || []) {
|
|
1741
|
+
if (src.model && !seen.includes(src.model)) seen.push(src.model);
|
|
1742
|
+
}
|
|
1743
|
+
return seen;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function rankSources(sources) {
|
|
1747
|
+
return [...sources].sort((a, b) => {
|
|
1748
|
+
const ta = a.totalTokens ?? 0;
|
|
1749
|
+
const tb = b.totalTokens ?? 0;
|
|
1750
|
+
if (tb !== ta) return tb - ta;
|
|
1751
|
+
const platformCmp = String(a.platform || '').localeCompare(String(b.platform || ''));
|
|
1752
|
+
if (platformCmp !== 0) return platformCmp;
|
|
1753
|
+
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
1754
|
+
});
|
|
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;
|
|
1761
|
+
return model == null || model === '' ? null : String(model);
|
|
1762
|
+
}
|
|
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
|
+
|
|
1770
|
+
function hasSpendOverride(opts) {
|
|
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 };
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function sessionTotalsFromFlags(opts) {
|
|
1831
|
+
const inputTokens = numOrNull(opts.inputTokens);
|
|
1832
|
+
const outputTokens = numOrNull(opts.outputTokens);
|
|
1833
|
+
let totalTokens = numOrNull(opts.totalTokens);
|
|
1834
|
+
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1835
|
+
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1836
|
+
}
|
|
1837
|
+
return {
|
|
1838
|
+
inputTokens,
|
|
1839
|
+
outputTokens,
|
|
1840
|
+
totalTokens,
|
|
1841
|
+
costUsd: numOrNull(opts.costUsd),
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function sessionTotalsFromSources(sources) {
|
|
1846
|
+
let inputTokens = null;
|
|
1847
|
+
let outputTokens = null;
|
|
1848
|
+
let totalTokens = null;
|
|
1849
|
+
let costUsd = null;
|
|
1850
|
+
for (const src of sources || []) {
|
|
1851
|
+
inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
|
|
1852
|
+
outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
|
|
1853
|
+
totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
|
|
1854
|
+
if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
|
|
1855
|
+
}
|
|
1856
|
+
return { inputTokens, outputTokens, totalTokens, costUsd };
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
1860
|
+
try {
|
|
1861
|
+
return collectSpend({
|
|
1862
|
+
cwd: process.cwd(),
|
|
1863
|
+
windowStart,
|
|
1864
|
+
windowEnd,
|
|
1865
|
+
existingSourceIds: existingSourceIdSet(metrics),
|
|
1866
|
+
env: process.env,
|
|
1867
|
+
homedir: process.env.HOME,
|
|
1868
|
+
});
|
|
1869
|
+
} catch {
|
|
1870
|
+
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
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;
|
|
1879
|
+
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
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);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function recomputeSpendMaps(metrics) {
|
|
1973
|
+
const byPlatform = defaultSpendByPlatform();
|
|
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
|
+
|
|
2002
|
+
for (const session of metrics.sessions || []) {
|
|
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;
|
|
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);
|
|
2019
|
+
}
|
|
2020
|
+
metrics.spendByPlatform = byPlatform;
|
|
2021
|
+
metrics.spendByModel = [...byModel.values()];
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1376
2024
|
function recomputeMetricsAggregates(metrics) {
|
|
1377
2025
|
const phases = {};
|
|
1378
2026
|
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
@@ -1390,12 +2038,17 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
1390
2038
|
phase.sessions += 1;
|
|
1391
2039
|
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
1392
2040
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
1393
|
-
const value =
|
|
2041
|
+
const value = sessionFieldOrSources(session, spendKey);
|
|
1394
2042
|
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
1395
2043
|
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
1396
2044
|
}
|
|
1397
2045
|
if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
|
|
1398
2046
|
if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
|
|
2047
|
+
if (Array.isArray(session.models)) {
|
|
2048
|
+
for (const model of session.models) {
|
|
2049
|
+
if (model && !phase.models.includes(model)) phase.models.push(model);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
1399
2052
|
phases[key] = phase;
|
|
1400
2053
|
}
|
|
1401
2054
|
if (firstStart && lastEnd) {
|
|
@@ -1404,6 +2057,7 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
1404
2057
|
metrics.phases = phases;
|
|
1405
2058
|
metrics.totals = totals;
|
|
1406
2059
|
metrics.spend = spend;
|
|
2060
|
+
recomputeSpendMaps(metrics);
|
|
1407
2061
|
}
|
|
1408
2062
|
|
|
1409
2063
|
function metricsRecordSessionStart(projectDir, changeName, role) {
|
|
@@ -1422,13 +2076,13 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1422
2076
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
1423
2077
|
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1424
2078
|
const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
|
|
1425
|
-
const
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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: [] };
|
|
2085
|
+
const session = {
|
|
1432
2086
|
startedAt,
|
|
1433
2087
|
endedAt: nowIso,
|
|
1434
2088
|
durationMs,
|
|
@@ -1436,30 +2090,73 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
1436
2090
|
phase: phaseForRole(fields.closedRole),
|
|
1437
2091
|
runtime: fields.runtime || 'local',
|
|
1438
2092
|
agentId: fields.agentId || 'none',
|
|
1439
|
-
model:
|
|
2093
|
+
model: resolvedModel || null,
|
|
2094
|
+
platform: opts.platform || null,
|
|
1440
2095
|
tasks: fields.tasks || null,
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
2096
|
+
sources: [],
|
|
2097
|
+
inputTokens: null,
|
|
2098
|
+
outputTokens: null,
|
|
2099
|
+
totalTokens: null,
|
|
2100
|
+
costUsd: null,
|
|
2101
|
+
ampCredits: null,
|
|
2102
|
+
spendSource: 'unreported',
|
|
2103
|
+
};
|
|
2104
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
2105
|
+
metrics.sessions.push(session);
|
|
1446
2106
|
metrics.pending = null;
|
|
1447
2107
|
metrics.updatedAt = nowIso;
|
|
1448
2108
|
recomputeMetricsAggregates(metrics);
|
|
1449
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();
|
|
1450
2115
|
return filePath;
|
|
1451
2116
|
}
|
|
1452
2117
|
|
|
1453
|
-
function metricsFinalizeArchive(targetDir, changeName) {
|
|
2118
|
+
function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
1454
2119
|
const filePath = join(targetDir, 'metrics.json');
|
|
1455
|
-
if (!existsSync(filePath)) return null;
|
|
1456
2120
|
const nowIso = new Date().toISOString();
|
|
1457
2121
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2122
|
+
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
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;
|
|
2128
|
+
const session = {
|
|
2129
|
+
startedAt: nowIso,
|
|
2130
|
+
endedAt: nowIso,
|
|
2131
|
+
durationMs: null,
|
|
2132
|
+
role: 'Archiver',
|
|
2133
|
+
phase: phaseForRole('Archiver'),
|
|
2134
|
+
runtime: opts.runtime || 'local',
|
|
2135
|
+
agentId: opts.agentId || 'none',
|
|
2136
|
+
model: resolvedModel || null,
|
|
2137
|
+
platform: opts.platform || null,
|
|
2138
|
+
tasks: opts.tasks || null,
|
|
2139
|
+
sources: [],
|
|
2140
|
+
inputTokens: null,
|
|
2141
|
+
outputTokens: null,
|
|
2142
|
+
totalTokens: null,
|
|
2143
|
+
costUsd: null,
|
|
2144
|
+
ampCredits: null,
|
|
2145
|
+
spendSource: 'unreported',
|
|
2146
|
+
};
|
|
2147
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
2148
|
+
metrics.sessions.push(session);
|
|
1458
2149
|
metrics.archivedAt = nowIso;
|
|
1459
2150
|
metrics.pending = null;
|
|
1460
2151
|
metrics.updatedAt = nowIso;
|
|
1461
2152
|
recomputeMetricsAggregates(metrics);
|
|
1462
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();
|
|
1463
2160
|
return filePath;
|
|
1464
2161
|
}
|
|
1465
2162
|
|
|
@@ -1482,6 +2179,92 @@ function formatMetricsCost(value) {
|
|
|
1482
2179
|
return value == null ? '—' : `$${Number(value).toFixed(2)}`;
|
|
1483
2180
|
}
|
|
1484
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
|
+
|
|
1485
2268
|
function resolveMetricsFile(projectDir, changeName) {
|
|
1486
2269
|
const activePath = metricsFilePath(projectDir, changeName);
|
|
1487
2270
|
if (existsSync(activePath)) return { filePath: activePath, archived: false };
|
|
@@ -2352,6 +3135,9 @@ program
|
|
|
2352
3135
|
refreshMemoryManagedFiles(projectDir);
|
|
2353
3136
|
ensureMemoryMcpEntry(projectDir);
|
|
2354
3137
|
|
|
3138
|
+
log.title('Configuring Cursor spend hook');
|
|
3139
|
+
reportCursorSpendHook(projectDir, log);
|
|
3140
|
+
|
|
2355
3141
|
if (opts.hooks) {
|
|
2356
3142
|
log.title('Installing pre-commit gate');
|
|
2357
3143
|
const hookResult = runHooksSetup(projectDir);
|
|
@@ -2414,6 +3200,8 @@ program
|
|
|
2414
3200
|
log.title('Configuring Memory MCP');
|
|
2415
3201
|
refreshMemoryManagedFiles(projectDir);
|
|
2416
3202
|
ensureMemoryMcpEntry(projectDir);
|
|
3203
|
+
log.title('Configuring Cursor spend hook');
|
|
3204
|
+
reportCursorSpendHook(projectDir, log);
|
|
2417
3205
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2418
3206
|
|
|
2419
3207
|
log.ok(`Updated to v${KIT_VERSION}`);
|
|
@@ -2476,6 +3264,9 @@ program
|
|
|
2476
3264
|
log.title('Configuring Memory MCP');
|
|
2477
3265
|
ensureMemoryMcpEntry(projectDir);
|
|
2478
3266
|
|
|
3267
|
+
log.title('Configuring Cursor spend hook');
|
|
3268
|
+
reportCursorSpendHook(projectDir, log);
|
|
3269
|
+
|
|
2479
3270
|
mergeGitignore(projectDir, GITIGNORE_LINES);
|
|
2480
3271
|
|
|
2481
3272
|
log.ok('Sync complete');
|
|
@@ -2512,6 +3303,7 @@ program
|
|
|
2512
3303
|
}
|
|
2513
3304
|
|
|
2514
3305
|
printMcpHealth(projectDir);
|
|
3306
|
+
printSpendHealth(projectDir);
|
|
2515
3307
|
printSkillHealth(projectDir);
|
|
2516
3308
|
});
|
|
2517
3309
|
|
|
@@ -2640,6 +3432,13 @@ program
|
|
|
2640
3432
|
.option('--sync', 'merge delta specs into openspec/specs/ before archiving')
|
|
2641
3433
|
.option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
|
|
2642
3434
|
.option('--force', 'confirm archiving without merge when delta specs exist', false)
|
|
3435
|
+
.option('--model <name>', 'LLM product id recorded on the Archiver session')
|
|
3436
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
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)
|
|
2643
3442
|
.action((name, opts) => {
|
|
2644
3443
|
const projectDir = process.cwd();
|
|
2645
3444
|
const fail = (msg) => {
|
|
@@ -2650,6 +3449,15 @@ program
|
|
|
2650
3449
|
|
|
2651
3450
|
if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
|
|
2652
3451
|
|
|
3452
|
+
const platformResult = resolvePlatform(opts, process.env);
|
|
3453
|
+
if (platformResult.error) {
|
|
3454
|
+
log.err(platformResult.error);
|
|
3455
|
+
process.exitCode = 1;
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3459
|
+
const resolvedModel = resolveModel(opts, process.env);
|
|
3460
|
+
|
|
2653
3461
|
let status;
|
|
2654
3462
|
try {
|
|
2655
3463
|
const out = execSync(`npx openspec status --change ${name} --json`, {
|
|
@@ -2750,6 +3558,11 @@ program
|
|
|
2750
3558
|
if (existsSync(archivedHandoffPath)) {
|
|
2751
3559
|
priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
|
|
2752
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));
|
|
2753
3566
|
const runtimeResult = resolveRuntime({}, process.env, priorFields);
|
|
2754
3567
|
const progress = parseTasksProgress(targetDir);
|
|
2755
3568
|
const fields = {
|
|
@@ -2766,6 +3579,7 @@ program
|
|
|
2766
3579
|
constraints: 'Pipeline complete — no next session.',
|
|
2767
3580
|
runtime: runtimeResult.value || 'local',
|
|
2768
3581
|
agentId: resolveAgentId({}, process.env, priorFields),
|
|
3582
|
+
metrics: reported,
|
|
2769
3583
|
status: 'archived',
|
|
2770
3584
|
tasks: progress ? `${progress.done}/${progress.total}` : '',
|
|
2771
3585
|
review: parseReviewVerdict(targetDir) || '',
|
|
@@ -2773,7 +3587,19 @@ program
|
|
|
2773
3587
|
};
|
|
2774
3588
|
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
2775
3589
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2776
|
-
const metricsPath = metricsFinalizeArchive(targetDir, name
|
|
3590
|
+
const metricsPath = metricsFinalizeArchive(targetDir, name, {
|
|
3591
|
+
model: archiveModel,
|
|
3592
|
+
platform: archivePlatform.value || null,
|
|
3593
|
+
runtime: fields.runtime,
|
|
3594
|
+
agentId: fields.agentId,
|
|
3595
|
+
tasks: fields.tasks,
|
|
3596
|
+
collect: opts.collect === true,
|
|
3597
|
+
inputTokens: opts.inputTokens,
|
|
3598
|
+
outputTokens: opts.outputTokens,
|
|
3599
|
+
totalTokens: opts.totalTokens,
|
|
3600
|
+
costUsd: opts.costUsd,
|
|
3601
|
+
reported,
|
|
3602
|
+
});
|
|
2777
3603
|
|
|
2778
3604
|
console.log(`change: ${name}`);
|
|
2779
3605
|
console.log(`schema: ${status.schemaName || 'unknown'}`);
|
|
@@ -2781,7 +3607,11 @@ program
|
|
|
2781
3607
|
console.log(`sync: ${syncStatus}`);
|
|
2782
3608
|
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
2783
3609
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
2784
|
-
|
|
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 {}
|
|
2785
3615
|
log.ok(`archived ${name}`);
|
|
2786
3616
|
});
|
|
2787
3617
|
|
|
@@ -2982,12 +3812,14 @@ program
|
|
|
2982
3812
|
.option('--agent-id <id>', 'Cloud agent identifier')
|
|
2983
3813
|
.option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
|
|
2984
3814
|
.option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
|
|
2985
|
-
.option('--model <name>', '
|
|
3815
|
+
.option('--model <name>', 'LLM product id recorded in metrics.json')
|
|
3816
|
+
.option('--platform <platform>', 'Session platform: cursor | claude | amp')
|
|
2986
3817
|
.option('--input-tokens <n>', 'Input tokens spent in this session')
|
|
2987
3818
|
.option('--output-tokens <n>', 'Output tokens spent in this session')
|
|
2988
3819
|
.option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
|
|
2989
3820
|
.option('--cost-usd <usd>', 'Cost of this session in USD')
|
|
2990
3821
|
.option('--no-metrics', 'Skip recording this session into metrics.json')
|
|
3822
|
+
.option('--collect', 'Additionally collect local spend adapters', false)
|
|
2991
3823
|
.action((changeName, opts) => {
|
|
2992
3824
|
const projectDir = process.cwd();
|
|
2993
3825
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -3115,6 +3947,17 @@ program
|
|
|
3115
3947
|
return;
|
|
3116
3948
|
}
|
|
3117
3949
|
|
|
3950
|
+
const reported = fields.metrics || emptyMetricsFields();
|
|
3951
|
+
const platformResult = resolvePlatform(opts, process.env, reported);
|
|
3952
|
+
if (platformResult.error) {
|
|
3953
|
+
log.err(platformResult.error);
|
|
3954
|
+
process.exitCode = 1;
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
if (platformResult.warn) console.error(platformResult.warn);
|
|
3958
|
+
printMetricsSectionWarnings(reported, Boolean(platformResult.warn));
|
|
3959
|
+
const resolvedModel = resolveModel(opts, process.env, reported);
|
|
3960
|
+
|
|
3118
3961
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
3119
3962
|
fields.prompt = prompt;
|
|
3120
3963
|
writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
@@ -3127,11 +3970,14 @@ program
|
|
|
3127
3970
|
if (opts.metrics !== false) {
|
|
3128
3971
|
const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
|
|
3129
3972
|
startedAt: opts.startedAt,
|
|
3130
|
-
model:
|
|
3973
|
+
model: resolvedModel,
|
|
3974
|
+
platform: platformResult.value || null,
|
|
3131
3975
|
inputTokens: opts.inputTokens,
|
|
3132
3976
|
outputTokens: opts.outputTokens,
|
|
3133
3977
|
totalTokens: opts.totalTokens,
|
|
3134
3978
|
costUsd: opts.costUsd,
|
|
3979
|
+
collect: opts.collect === true,
|
|
3980
|
+
reported,
|
|
3135
3981
|
});
|
|
3136
3982
|
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
3137
3983
|
}
|
|
@@ -3144,11 +3990,13 @@ program
|
|
|
3144
3990
|
|
|
3145
3991
|
program
|
|
3146
3992
|
.command('metrics [change-name]')
|
|
3147
|
-
.description('Show recorded session metrics for a change: time per phase, tokens, cost,
|
|
3993
|
+
.description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
|
|
3148
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)
|
|
3149
3996
|
.action((changeName, opts) => {
|
|
3150
3997
|
const projectDir = process.cwd();
|
|
3151
3998
|
let name = changeName;
|
|
3999
|
+
let collectedAlready = false;
|
|
3152
4000
|
if (!name) {
|
|
3153
4001
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
3154
4002
|
if (!resolved) {
|
|
@@ -3157,11 +4005,37 @@ program
|
|
|
3157
4005
|
return;
|
|
3158
4006
|
}
|
|
3159
4007
|
if (resolved.ambiguous) {
|
|
3160
|
-
|
|
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}`);
|
|
3161
4035
|
process.exitCode = 1;
|
|
3162
4036
|
return;
|
|
3163
4037
|
}
|
|
3164
|
-
|
|
4038
|
+
if (!opts.json) log.ok(`collect: ${result.added} new source(s) on last session`);
|
|
3165
4039
|
}
|
|
3166
4040
|
|
|
3167
4041
|
const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
|
|
@@ -3181,43 +4055,7 @@ program
|
|
|
3181
4055
|
|
|
3182
4056
|
log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
|
|
3183
4057
|
console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3184
|
-
|
|
3185
|
-
console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
3186
|
-
console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
3187
|
-
console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
3188
|
-
console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
|
|
3189
|
-
if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
|
|
3190
|
-
if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
|
|
3191
|
-
|
|
3192
|
-
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
3193
|
-
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
3194
|
-
if (phaseKeys.length) {
|
|
3195
|
-
console.log('');
|
|
3196
|
-
console.log('phase sessions time tokens cost agents');
|
|
3197
|
-
for (const key of phaseKeys) {
|
|
3198
|
-
const phase = metrics.phases[key];
|
|
3199
|
-
const cols = [
|
|
3200
|
-
key.padEnd(10),
|
|
3201
|
-
String(phase.sessions).padEnd(9),
|
|
3202
|
-
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
3203
|
-
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
3204
|
-
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
3205
|
-
phase.agents.join(', ') || '—',
|
|
3206
|
-
];
|
|
3207
|
-
console.log(cols.join(' '));
|
|
3208
|
-
}
|
|
3209
|
-
}
|
|
3210
|
-
|
|
3211
|
-
if (metrics.sessions.length) {
|
|
3212
|
-
console.log('');
|
|
3213
|
-
console.log('recent sessions:');
|
|
3214
|
-
for (const session of metrics.sessions.slice(-5)) {
|
|
3215
|
-
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
3216
|
-
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
|
|
3217
|
-
: '';
|
|
3218
|
-
console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
|
|
3219
|
-
}
|
|
3220
|
-
}
|
|
4058
|
+
for (const line of renderMetricsSummary(metrics)) console.log(line);
|
|
3221
4059
|
});
|
|
3222
4060
|
|
|
3223
4061
|
program.parse();
|