agent-orchestrator-kit 0.4.0 → 0.5.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 CHANGED
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-08-29
8
+
9
+ ### Added
10
+ - **Change metrics** — git-tracked `openspec/changes/<name>/metrics.json`: `handoff --restore` records the session start (`pending` marker), `handoff <name>` closes the session (duration, closed role → phase `explore|design|spec|review|apply|archive`, runtime, tasks snapshot, optional `--model` / `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd`, `--started-at` when restore was skipped, `--no-metrics` to opt out), `archive <name>` sets `archivedAt`; per-phase and total aggregates (durations, tokens, cost, agents, models, `leadTimeMs`) are recomputed on every write and unreported values stay null-honest
11
+ - **`npx agent-orchestrator-kit metrics [change-name] [--json]`** — human summary (phases table, spend, recent sessions) or raw JSON; resolves archived changes under `openspec/changes/archive/*-<name>` too
12
+
7
13
  ## [0.4.0] - 2026-08-28
8
14
 
9
15
  ### Added
@@ -193,6 +199,7 @@ All notable changes to this project will be documented in this file.
193
199
  ### Added
194
200
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
195
201
 
202
+ [0.5.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.4.0...v0.5.0
196
203
  [0.4.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.3.0...v0.4.0
197
204
  [0.3.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.2.0...v0.3.0
198
205
  [0.2.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.1.14...v0.2.0
package/README.md CHANGED
@@ -74,7 +74,7 @@ npx agent-orchestrator-kit@latest init --profile generic --ci gitlab --spec-veri
74
74
 
75
75
  See [Installation](#installation) for profile/CI options.
76
76
 
77
- **🔄 Already have the kit installed? Upgrade to latest (factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
77
+ **🔄 Already have the kit installed? Upgrade to latest (change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
78
78
 
79
79
  ```bash
80
80
  npx agent-orchestrator-kit@latest update
@@ -191,7 +191,7 @@ your-project/
191
191
  | OpenSpec skills | All 7 skills for `/opsx:*` workflow |
192
192
  | IDE sync | Cursor + Claude Code sync script (`--delete` semantics — removes stale skills/subagents) |
193
193
  | Subagents | 12 exclusive routes: guide/setup/session-handoff, explore/design/propose/review/archive stage agents, and apply implementation/test/code-review agents — native in Cursor + Claude Code, isolated Amp `subagent-*` wrappers |
194
- | CLI gates | `npx agent-orchestrator-kit status` / `gate-check` / `archive` / `handoff` / `memory-setup` — deterministic review-gate, archive, and session-handoff (always via `npx`; see `cli-via-npm.mdc`) |
194
+ | CLI gates | `npx agent-orchestrator-kit status` / `gate-check` / `archive` / `handoff` / `metrics` / `memory-setup` — deterministic review-gate, archive, session-handoff, and change metrics (always via `npx`; see `cli-via-npm.mdc`) |
195
195
  | CI | `agent-verify.yml` — GitHub (default) or GitLab fragment + `prebuild` hook, both run `gate-check` |
196
196
  | AI Spec Verifier | `spec-verify.yml` + verifier scripts — GitLab or GitHub, opt-in (`--spec-verify`) |
197
197
  | MCP templates | Memory MCP for Cursor and Amp |
@@ -797,6 +797,24 @@ npx agent-orchestrator-kit handoff <name> --cloud-check # require exit 0
797
797
 
798
798
  Persist with `runtime: cloud` prints those four steps on stderr; stdout stays the pure `/opsx:` next-thread prompt. Local persist is unchanged.
799
799
 
800
+ ### Change metrics
801
+
802
+ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` — the data source for planning the next feature: how long each phase took, how many sessions it needed, what it cost.
803
+
804
+ - **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
805
+ - **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and — when the agent passes them — `--model`, `--input-tokens`, `--output-tokens`, `--total-tokens`, `--cost-usd`. No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
806
+ - **Archive** — `archive <name>` sets `archivedAt` and clears any pending marker; the file moves to the archive folder with the change.
807
+
808
+ Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, agents, models) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end) and `spend` (token/cost sums). Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`.
809
+
810
+ ```bash
811
+ npx agent-orchestrator-kit handoff add-thing --input-tokens 12000 --output-tokens 3000 --cost-usd 0.42 --model claude-sonnet
812
+ npx agent-orchestrator-kit metrics add-thing # human summary: phases, tokens, cost, agents
813
+ npx agent-orchestrator-kit metrics add-thing --json # raw metrics.json (works for archived changes too)
814
+ ```
815
+
816
+ Recording is on by default and never blocks persist; opt out per session with `--no-metrics`.
817
+
800
818
  ### Skill inventory
801
819
 
802
820
  `.agents/orchestrator.yaml` carries a machine-readable `skills:` section (`kit` / `stack` / `external`) instead of hardcoded skill names in the CLI:
@@ -900,10 +918,21 @@ npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
900
918
 
901
919
  npx agent-orchestrator-kit handoff [change-name] [options]
902
920
  --restore Print the restore briefing instead of persisting
921
+ (also records the session start into metrics.json)
903
922
  --runtime <value> local | cloud (invalid values exit non-zero)
904
923
  --agent-id <id> Cloud agent identifier (default: none)
905
924
  --cloud-check Verify change artifacts are committed and pushed
906
925
  (cloud: non-zero on failure; local: warning, exit 0)
926
+ --started-at <iso> Session start override when --restore was not run
927
+ --model <name> Model used in this session (metrics.json)
928
+ --input-tokens <n> / --output-tokens <n> / --total-tokens <n>
929
+ Token spend for this session (total defaults to in+out)
930
+ --cost-usd <usd> Session cost in USD
931
+ --no-metrics Skip recording this session into metrics.json
932
+
933
+ npx agent-orchestrator-kit metrics [change-name] [--json]
934
+ Show recorded session metrics for a change (active or archived):
935
+ time per phase, sessions, tokens, cost, agents, models, lead time
907
936
  ```
908
937
 
909
938
  ## Directory Reference
@@ -936,7 +965,7 @@ CLAUDE.md # Committed — synced to .claude/CLAUDE.md
936
965
  openspec/ # Committed — spec-driven workflow
937
966
  config.yaml # Project context for AI
938
967
  specs/ # Source of truth after archive
939
- changes/ # Active work; <name>/handoff.md indexes session state
968
+ changes/ # Active work; <name>/handoff.md + metrics.json index session state
940
969
  ```
941
970
 
942
971
  ## Roadmap
@@ -950,6 +979,11 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
950
979
 
951
980
  ## Changelog
952
981
 
982
+ ### 0.5.0
983
+ - **Change metrics** — git-tracked `openspec/changes/<name>/metrics.json` (session start on `handoff --restore`, close on persist)
984
+ - **`metrics` CLI** — human summary or `--json`; phases, tokens, cost, agents, models, lead time
985
+ - Optional persist spend flags: `--model`, `--input-tokens`, `--output-tokens`, `--total-tokens`, `--cost-usd`, `--started-at`, `--no-metrics`
986
+
953
987
  ### 0.4.0
954
988
  - Factory phases 1–3: `hooks-setup` / `gate-check --staged`, `mcp-setup` (GitHub / GitLab / browser), MCP and Skill health in `status`
955
989
  - Git-canonical append-only `decisions.md`; Memory `Decision:*` is a file→Memory mirror
@@ -1290,6 +1290,215 @@ function readHandoffFields(projectDir, changeName) {
1290
1290
  return { filePath, fields: fieldsFromSections(changeName, sections) };
1291
1291
  }
1292
1292
 
1293
+ const METRICS_VERSION = 1;
1294
+ const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
1295
+
1296
+ function metricsFilePath(projectDir, changeName) {
1297
+ return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1298
+ }
1299
+
1300
+ function emptySpendTotals() {
1301
+ return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
1302
+ }
1303
+
1304
+ function defaultMetrics(changeName, nowIso) {
1305
+ return {
1306
+ version: METRICS_VERSION,
1307
+ change: changeName,
1308
+ createdAt: nowIso,
1309
+ updatedAt: nowIso,
1310
+ archivedAt: null,
1311
+ spend: emptySpendTotals(),
1312
+ totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
1313
+ phases: {},
1314
+ sessions: [],
1315
+ pending: null,
1316
+ };
1317
+ }
1318
+
1319
+ function loadMetricsFile(filePath, changeName, nowIso) {
1320
+ if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
1321
+ let parsed;
1322
+ try {
1323
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
1324
+ } catch {
1325
+ return defaultMetrics(changeName, nowIso);
1326
+ }
1327
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1328
+ return defaultMetrics(changeName, nowIso);
1329
+ }
1330
+ const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
1331
+ return {
1332
+ ...base,
1333
+ ...parsed,
1334
+ version: METRICS_VERSION,
1335
+ change: changeName,
1336
+ spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
1337
+ totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
1338
+ phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
1339
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
1340
+ };
1341
+ }
1342
+
1343
+ function saveMetricsFile(filePath, metrics) {
1344
+ mkdirSync(dirname(filePath), { recursive: true });
1345
+ writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
1346
+ }
1347
+
1348
+ function numOrNull(value) {
1349
+ if (value == null || value === '') return null;
1350
+ const n = Number(value);
1351
+ return Number.isFinite(n) ? n : null;
1352
+ }
1353
+
1354
+ function addNullable(a, b) {
1355
+ if (a == null && b == null) return null;
1356
+ return (a ?? 0) + (b ?? 0);
1357
+ }
1358
+
1359
+ function phaseForRole(role) {
1360
+ const value = String(role || '').toLowerCase();
1361
+ if (/explor/.test(value)) return 'explore';
1362
+ if (/review/.test(value)) return 'review';
1363
+ if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
1364
+ if (/architect|propose/.test(value)) return 'spec';
1365
+ if (/design/.test(value)) return 'design';
1366
+ if (/archiv/.test(value)) return 'archive';
1367
+ return 'other';
1368
+ }
1369
+
1370
+ function isoOrNull(value) {
1371
+ if (!value) return null;
1372
+ const ms = Date.parse(String(value));
1373
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
1374
+ }
1375
+
1376
+ function recomputeMetricsAggregates(metrics) {
1377
+ const phases = {};
1378
+ const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
1379
+ const spend = emptySpendTotals();
1380
+ let firstStart = null;
1381
+ let lastEnd = null;
1382
+ for (const session of metrics.sessions) {
1383
+ totals.sessions += 1;
1384
+ if (session.runtime === 'cloud') totals.cloudSessions += 1;
1385
+ totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
1386
+ if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
1387
+ if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
1388
+ const key = session.phase || 'other';
1389
+ const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
1390
+ phase.sessions += 1;
1391
+ phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
1392
+ for (const spendKey of METRICS_SPEND_KEYS) {
1393
+ const value = numOrNull(session[spendKey]);
1394
+ phase[spendKey] = addNullable(phase[spendKey], value);
1395
+ spend[spendKey] = addNullable(spend[spendKey], value);
1396
+ }
1397
+ if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
1398
+ if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
1399
+ phases[key] = phase;
1400
+ }
1401
+ if (firstStart && lastEnd) {
1402
+ totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
1403
+ }
1404
+ metrics.phases = phases;
1405
+ metrics.totals = totals;
1406
+ metrics.spend = spend;
1407
+ }
1408
+
1409
+ function metricsRecordSessionStart(projectDir, changeName, role) {
1410
+ const filePath = metricsFilePath(projectDir, changeName);
1411
+ const nowIso = new Date().toISOString();
1412
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
1413
+ metrics.pending = { startedAt: nowIso, role: role || '' };
1414
+ metrics.updatedAt = nowIso;
1415
+ saveMetricsFile(filePath, metrics);
1416
+ return filePath;
1417
+ }
1418
+
1419
+ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
1420
+ const filePath = metricsFilePath(projectDir, fields.changeName);
1421
+ const nowIso = new Date().toISOString();
1422
+ const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
1423
+ const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1424
+ const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
1425
+ const inputTokens = numOrNull(opts.inputTokens);
1426
+ const outputTokens = numOrNull(opts.outputTokens);
1427
+ let totalTokens = numOrNull(opts.totalTokens);
1428
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1429
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1430
+ }
1431
+ metrics.sessions.push({
1432
+ startedAt,
1433
+ endedAt: nowIso,
1434
+ durationMs,
1435
+ role: fields.closedRole || '',
1436
+ phase: phaseForRole(fields.closedRole),
1437
+ runtime: fields.runtime || 'local',
1438
+ agentId: fields.agentId || 'none',
1439
+ model: opts.model || null,
1440
+ tasks: fields.tasks || null,
1441
+ inputTokens,
1442
+ outputTokens,
1443
+ totalTokens,
1444
+ costUsd: numOrNull(opts.costUsd),
1445
+ });
1446
+ metrics.pending = null;
1447
+ metrics.updatedAt = nowIso;
1448
+ recomputeMetricsAggregates(metrics);
1449
+ saveMetricsFile(filePath, metrics);
1450
+ return filePath;
1451
+ }
1452
+
1453
+ function metricsFinalizeArchive(targetDir, changeName) {
1454
+ const filePath = join(targetDir, 'metrics.json');
1455
+ if (!existsSync(filePath)) return null;
1456
+ const nowIso = new Date().toISOString();
1457
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
1458
+ metrics.archivedAt = nowIso;
1459
+ metrics.pending = null;
1460
+ metrics.updatedAt = nowIso;
1461
+ recomputeMetricsAggregates(metrics);
1462
+ saveMetricsFile(filePath, metrics);
1463
+ return filePath;
1464
+ }
1465
+
1466
+ function formatMetricsDuration(durationMs) {
1467
+ if (durationMs == null || !Number.isFinite(durationMs)) return '—';
1468
+ const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
1469
+ const hours = Math.floor(totalSeconds / 3600);
1470
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1471
+ const seconds = totalSeconds % 60;
1472
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1473
+ if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1474
+ return `${seconds}s`;
1475
+ }
1476
+
1477
+ function formatMetricsNumber(value) {
1478
+ return value == null ? '—' : String(value);
1479
+ }
1480
+
1481
+ function formatMetricsCost(value) {
1482
+ return value == null ? '—' : `$${Number(value).toFixed(2)}`;
1483
+ }
1484
+
1485
+ function resolveMetricsFile(projectDir, changeName) {
1486
+ const activePath = metricsFilePath(projectDir, changeName);
1487
+ if (existsSync(activePath)) return { filePath: activePath, archived: false };
1488
+ const archiveDir = join(projectDir, 'openspec', 'changes', 'archive');
1489
+ if (existsSync(archiveDir)) {
1490
+ const folders = readdirSync(archiveDir)
1491
+ .filter((name) => name === changeName || name.endsWith(`-${changeName}`))
1492
+ .sort()
1493
+ .reverse();
1494
+ for (const folder of folders) {
1495
+ const archivedPath = join(archiveDir, folder, 'metrics.json');
1496
+ if (existsSync(archivedPath)) return { filePath: archivedPath, archived: true };
1497
+ }
1498
+ }
1499
+ return { filePath: activePath, archived: false, missing: true };
1500
+ }
1501
+
1293
1502
  function parseFigmaUrl(url) {
1294
1503
  try {
1295
1504
  const parsed = new URL(url);
@@ -2564,6 +2773,7 @@ program
2564
2773
  };
2565
2774
  writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
2566
2775
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
2776
+ const metricsPath = metricsFinalizeArchive(targetDir, name);
2567
2777
 
2568
2778
  console.log(`change: ${name}`);
2569
2779
  console.log(`schema: ${status.schemaName || 'unknown'}`);
@@ -2571,6 +2781,7 @@ program
2571
2781
  console.log(`sync: ${syncStatus}`);
2572
2782
  console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
2573
2783
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
2784
+ if (metricsPath) console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
2574
2785
  log.ok(`archived ${name}`);
2575
2786
  });
2576
2787
 
@@ -2770,6 +2981,13 @@ program
2770
2981
  .option('--runtime <runtime>', 'Session runtime: local | cloud')
2771
2982
  .option('--agent-id <id>', 'Cloud agent identifier')
2772
2983
  .option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
2984
+ .option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
2985
+ .option('--model <name>', 'Model used in this session (recorded in metrics.json)')
2986
+ .option('--input-tokens <n>', 'Input tokens spent in this session')
2987
+ .option('--output-tokens <n>', 'Output tokens spent in this session')
2988
+ .option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
2989
+ .option('--cost-usd <usd>', 'Cost of this session in USD')
2990
+ .option('--no-metrics', 'Skip recording this session into metrics.json')
2773
2991
  .action((changeName, opts) => {
2774
2992
  const projectDir = process.cwd();
2775
2993
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -2826,6 +3044,10 @@ program
2826
3044
  } else {
2827
3045
  log.warn(`Memory JSON empty or missing at ${memoryPath}`);
2828
3046
  }
3047
+ if (opts.metrics !== false && existsSync(changeDir)) {
3048
+ const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
3049
+ log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
3050
+ }
2829
3051
  return;
2830
3052
  }
2831
3053
 
@@ -2902,10 +3124,100 @@ program
2902
3124
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
2903
3125
  console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
2904
3126
 
3127
+ if (opts.metrics !== false) {
3128
+ const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
3129
+ startedAt: opts.startedAt,
3130
+ model: opts.model,
3131
+ inputTokens: opts.inputTokens,
3132
+ outputTokens: opts.outputTokens,
3133
+ totalTokens: opts.totalTokens,
3134
+ costUsd: opts.costUsd,
3135
+ });
3136
+ console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
3137
+ }
3138
+
2905
3139
  if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
2906
3140
 
2907
3141
  console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
2908
3142
  process.stdout.write(`${prompt}\n`);
2909
3143
  });
2910
3144
 
3145
+ program
3146
+ .command('metrics [change-name]')
3147
+ .description('Show recorded session metrics for a change: time per phase, tokens, cost, agents, models')
3148
+ .option('--json', 'Print raw metrics.json', false)
3149
+ .action((changeName, opts) => {
3150
+ const projectDir = process.cwd();
3151
+ let name = changeName;
3152
+ if (!name) {
3153
+ const resolved = resolveHandoffChange(projectDir, changeName);
3154
+ if (!resolved) {
3155
+ log.err('No active change found. Pass a name: npx agent-orchestrator-kit metrics <name>');
3156
+ process.exitCode = 1;
3157
+ return;
3158
+ }
3159
+ if (resolved.ambiguous) {
3160
+ log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
3161
+ process.exitCode = 1;
3162
+ return;
3163
+ }
3164
+ name = resolved;
3165
+ }
3166
+
3167
+ const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
3168
+ if (missing) {
3169
+ log.err(`No metrics.json for ${name}`);
3170
+ log.info(`Expected: ${filePath.replace(`${projectDir}/`, '')}`);
3171
+ log.info('Metrics are recorded by: handoff --restore (session start) and handoff <name> (session end)');
3172
+ process.exitCode = 1;
3173
+ return;
3174
+ }
3175
+
3176
+ const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
3177
+ if (opts.json) {
3178
+ process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
3179
+ return;
3180
+ }
3181
+
3182
+ log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
3183
+ console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
3184
+ console.log(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
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
+ }
3221
+ });
3222
+
2911
3223
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -20,7 +20,7 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
20
20
 
21
21
  ## Session Exit (order)
22
22
  1. The parent writes `openspec/changes/<name>/handoff.md` itself: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime.
23
- 2. `npx agent-orchestrator-kit handoff <name>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, prints the expanded prompt on stdout). `decisions.md` is the git canon of change decisions; Memory `Decision:*` is a file→Memory mirror only. Cloud sessions pass `--runtime cloud` (or set `AOK_RUNTIME=cloud` / `AOK_AGENT_ID` in the cloud-agent environment).
23
+ 2. `npx agent-orchestrator-kit handoff <name>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, records the session into `openspec/changes/<name>/metrics.json`, prints the expanded prompt on stdout). `decisions.md` is the git canon of change decisions; Memory `Decision:*` is a file→Memory mirror only. Cloud sessions pass `--runtime cloud` (or set `AOK_RUNTIME=cloud` / `AOK_AGENT_ID` in the cloud-agent environment). When session spend is known, pass it to persist: `--model <name> --input-tokens <n> --output-tokens <n> --cost-usd <usd>` — metrics power planning; never guess values you do not know.
24
24
  3. Spawn `session-handoff` in persist mode ONLY if step 2 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
25
25
  4. Memory MCP is an optional mirror: if tools are available, update `Change:<name>`, `Handoff:<name>`, `Decision:*` in one call; unavailability never blocks closing.
26
26
  5. Paste CLI stdout as one fenced block. First line `/opsx:…`. Body uses `project.agent_language`. Self-contained (Done/Decisions/Blocked/spawn/HARD STOP). No banner.