agent-orchestrator-kit 0.8.0 → 0.10.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,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.0] - 2026-08-31
8
+
9
+ ### Added
10
+ - **`costUsdEstimated` first-class** in `metrics.json` (`spend`, platforms, models, sessions, sources). Cursor writes a labeled estimate whenever tokens exist: grok-4.5/4.6 keep xAI API rates (`costSource: "api-estimate"`); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: "api-estimate-fallback"`). This is **not** a Cursor invoice and is never mixed into billed `costUsd`. Self-report `cost_usd` stays billed and is not copied into `costUsdEstimated`. Amp without a `Cost:` line leaves `costUsd: null` (tokens are not converted to USD; credits stay separate). Archived `metrics.json` files are not backfilled.
11
+
12
+ ## [0.9.0] - 2026-08-31
13
+
14
+ ### Added
15
+ - **UTC timestamps in `metrics.json`** — `createdAt` / `updatedAt` / `archivedAt` / session times / source `at` are stored as ISO-8601 UTC (`2026-08-31T07:08:17.563Z`). Broken Amp stamps like `2026-08-31T07:08:17.563464.000Z` are parsed and normalized to UTC. Human `metrics` output still prints Kyiv wall time (`31.08.2026 10:08:17 (Київ +03:00)`).
16
+ - **Amp `threads usage`** — when the locked client is Amp, persist also runs `amp threads usage <id> --details` (fail-open) and writes the billed `costUsd`, per-model costs, and `agentMode` (`low` / `medium` / `high` / `ultra`) from `amp threads export`. Mode is never stored as `session.model`. Placeholder `amp-default` yields to the real LLM id from sources.
17
+ - **Cursor API-equivalent estimate** — hook tokens for `cursor-grok-4.6` / `4.5` (including `-fast`, 200k long-context cliff, cache reads when present) fill `costUsdEstimated` with `costSource: "api-estimate"`. This is **not** the Cursor invoice and is never mixed into billed `costUsd`.
18
+ - **Archive auto-collect** — `archive <name>` detects the current client (`resolveRestoreClient`) and records Archiver spend from that adapter (Cursor hook / Amp export+usage / Claude JSONL) in the window after the last session. Leftover apply `## Metrics` numbers are ignored when they match the previous session. `--collect` still runs all three adapters.
19
+
20
+ ### Changed
21
+ - `metrics` / archive summary cost line shows `$1.30 billed + ~$8.98 est.` when both exist.
22
+
7
23
  ## [0.8.0] - 2026-08-31
8
24
 
9
25
  ### Added
@@ -241,6 +257,8 @@ All notable changes to this project will be documented in this file.
241
257
  ### Added
242
258
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
243
259
 
260
+ [0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
261
+ [0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
244
262
  [0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.0
245
263
  [0.7.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.6.0...v0.7.0
246
264
  [0.6.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.5.0...v0.6.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 (locked Amp/Cursor client + `amp threads export` in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; 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+):**
77
+ **🔄 Already have the kit installed? Upgrade to latest (Cursor fallback estimate + first-class `costUsdEstimated` in v0.10.0; UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; 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
@@ -600,7 +600,7 @@ Archive is a **deterministic CLI**, not an agent workflow:
600
600
  npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force] [--collect]
601
601
  ```
602
602
 
603
- It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, and printing the same human metrics summary as `metrics <name>`. Fill `## Metrics` in the change `handoff.md` before archive so the Archiver session can resolve platform / model / spend. `--collect` is optional. The `/opsx:archive` command is a thin wrapper that calls this CLI; the `spec-archiver` subagent remains only as a fallback when the CLI is unavailable.
603
+ It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, appending an Archiver session, and printing the same human metrics summary as `metrics <name>`. Archive collects the locked client (Cursor hook / Amp threads / Claude JSONL) in the window after the last session — the same flow as persist. `--collect` still runs every adapter. Unique `## Metrics` in the change `handoff.md` still counts as Archiver self-report; leftover apply numbers that match the previous session are ignored. The `/opsx:archive` command is a thin wrapper that calls this CLI; the `spec-archiver` subagent remains only as a fallback when the CLI is unavailable.
604
604
 
605
605
  ## Configuration
606
606
 
@@ -804,11 +804,11 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
804
804
  - **`## Metrics` self-report** — Session Exit fills `handoff.md` with `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source` (`unknown` when missing). Persist reads that section; `metrics.json` is the source of truth for what landed. CLI flags do not rewrite the section.
805
805
  - **`session.model`** — `--model` → `## Metrics: model` → `AOK_MODEL` → collected sources (only with `--collect`) → `null` (stderr warning). Never a Closed role.
806
806
  - **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
807
- - **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 spend from flags → self-report → optional `--collect` adapters. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Never guess. Never invent USD from Amp credits. Never Cursor SDK / npm sqlite / a pricing table. No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
808
- - **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session from the archived `## Metrics` (or archive flags), prints the same human summary as `metrics <name>`, and collects adapters only with `--collect`.
807
+ - **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 spend from flags → self-report → adapters. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (`Cost: $N` only; missing line → `costUsd: null`), not from converting credits. Cursor always writes a labeled `costUsdEstimated` when tokens exist: grok-4.6/4.5 use xAI API rates (`costSource: api-estimate`, including `-fast` and the 200k cliff); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: api-estimate-fallback`). That estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
808
+ - **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session, collects the locked client (Cursor hook / Amp export+usage / Claude JSONL) in `[last session.endedAt, now]`, and prints the same human summary as `metrics <name>`. `--collect` still runs all three adapters. Leftover apply `## Metrics` that repeats the previous session is ignored so those tokens are not counted twice.
809
809
  - **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
810
- - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` (`AOK_AMP_BIN`) plus local `threads/*.json`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
811
- - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL and Amp disk threads remain fallbacks; Amp web/CLI spend is taken from `amp threads export`.
810
+ - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
811
+ - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
812
812
 
813
813
  Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
814
814
 
@@ -833,7 +833,7 @@ npx agent-orchestrator-kit metrics add-thing --json # raw metrics.json (wor
833
833
  npx agent-orchestrator-kit archive add-thing --sync # finalize + the same tables as metrics
834
834
  ```
835
835
 
836
- Recording is on by default and never a persist/archive/`gate-check` gate; opt out per persist with `--no-metrics`. Adapters run only with `--collect`. Flags (`--model`, `--platform`, `--input-tokens`, …) override session totals in `metrics.json` and do not rewrite the `## Metrics` section.
836
+ Recording is on by default and never a persist/archive/`gate-check` gate; opt out per persist with `--no-metrics`. Persist and archive collect the locked client without `--collect`; `--collect` runs every adapter. Flags (`--model`, `--platform`, `--input-tokens`, …) override session totals in `metrics.json` and do not rewrite the `## Metrics` section.
837
837
 
838
838
  ### Skill inventory
839
839
 
@@ -1003,6 +1003,19 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1003
1003
 
1004
1004
  ## Changelog
1005
1005
 
1006
+ ### 0.10.0
1007
+ - `costUsdEstimated` is first-class on `metrics.json`; Cursor writes a labeled estimate whenever tokens exist — grok via xAI API (`costSource: api-estimate`), other models via versioned fallback $3/1M in + $15/1M out (or $3.50/1M when only `totalTokens`, `costSource: api-estimate-fallback`). The estimate is **not** an invoice and is never mixed into billed `costUsd`. Amp without a `Cost:` line leaves `costUsd: null`; self-report `cost_usd` stays billed.
1008
+
1009
+ ### 0.9.0
1010
+ - **UTC timestamps** in `metrics.json` (`…Z`; broken Amp stamps parsed and normalized)
1011
+ - Amp `threads usage --details` billed `$` + `agentMode` (never stored as `session.model`)
1012
+ - Cursor `costUsdEstimated` from xAI API rates for grok-4.6/4.5 — labeled, not mixed into billed `costUsd`
1013
+ - **Archive auto-collect** — Archiver session picks up the locked client after the last session; leftover apply `## Metrics` is not double-counted
1014
+
1015
+ ### 0.8.0
1016
+ - Locked session client on `--restore`; persist collects only that client without `--collect`
1017
+ - Amp CLI `threads export` + `threads list` when Amp runs over a pipe
1018
+
1006
1019
  ### 0.7.0
1007
1020
  - **`## Metrics` self-report** — persist reads the session section in `handoff.md`; `metrics.json` is the source of truth; CLI flags do not rewrite the section
1008
1021
  - **BREAKING** — `--no-collect` removed; `handoff` and `archive` collect local adapters only with opt-in `--collect`
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from 'commander';
3
3
  import pc from 'picocolors';
4
- import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, writeFileSync, rmSync, renameSync, chmodSync } from 'fs';
4
+ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, writeFileSync, rmSync, renameSync, chmodSync, realpathSync } from 'fs';
5
5
  import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
8
  import { collectSpend } from './spend-collect.js';
9
9
  import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
10
+ import {
11
+ earlierTimestamp,
12
+ formatKyivDisplay,
13
+ isoOrNull,
14
+ laterTimestamp,
15
+ nowUtcIso,
16
+ parseFlexibleIso,
17
+ } from './metrics-time.js';
10
18
 
11
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
20
  const KIT_ROOT = join(__dirname, '..');
@@ -1123,12 +1131,22 @@ function firstNonNull(...values) {
1123
1131
  return null;
1124
1132
  }
1125
1133
 
1134
+ function isPlaceholderModel(value) {
1135
+ const normalized = String(value || '').trim().toLowerCase();
1136
+ return !normalized || ['unknown', 'none', 'n/a', '-', '—', 'null', 'amp-default'].includes(normalized);
1137
+ }
1138
+
1126
1139
  function resolveModel(opts, env, reported) {
1127
- const flag = opts && opts.model != null ? String(opts.model).trim() : '';
1140
+ const pick = (value) => {
1141
+ const text = value == null ? '' : String(value).trim();
1142
+ if (!text || isPlaceholderModel(text)) return '';
1143
+ return text;
1144
+ };
1145
+ const flag = pick(opts && opts.model);
1128
1146
  if (flag) return flag;
1129
- const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
1147
+ const fromReport = pick(reported && reported.model);
1130
1148
  if (fromReport) return fromReport;
1131
- const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
1149
+ const fromEnv = pick(env && env.AOK_MODEL);
1132
1150
  if (fromEnv) return fromEnv;
1133
1151
  return null;
1134
1152
  }
@@ -1594,14 +1612,14 @@ function readHandoffFields(projectDir, changeName) {
1594
1612
  }
1595
1613
 
1596
1614
  const METRICS_VERSION = 1;
1597
- const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
1615
+ const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1598
1616
 
1599
1617
  function metricsFilePath(projectDir, changeName) {
1600
1618
  return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1601
1619
  }
1602
1620
 
1603
1621
  function emptySpendTotals() {
1604
- return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
1622
+ return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
1605
1623
  }
1606
1624
 
1607
1625
  function emptyPlatformSpend(source = 'none') {
@@ -1611,6 +1629,7 @@ function emptyPlatformSpend(source = 'none') {
1611
1629
  totalTokens: null,
1612
1630
  costUsd: null,
1613
1631
  ampCredits: null,
1632
+ costUsdEstimated: null,
1614
1633
  source,
1615
1634
  };
1616
1635
  }
@@ -1678,7 +1697,9 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1678
1697
 
1679
1698
  function saveMetricsFile(filePath, metrics) {
1680
1699
  mkdirSync(dirname(filePath), { recursive: true });
1681
- writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
1700
+ const out = { ...metrics };
1701
+ delete out.timezone;
1702
+ writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
1682
1703
  }
1683
1704
 
1684
1705
  function numOrNull(value) {
@@ -1703,12 +1724,6 @@ function phaseForRole(role) {
1703
1724
  return 'other';
1704
1725
  }
1705
1726
 
1706
- function isoOrNull(value) {
1707
- if (!value) return null;
1708
- const ms = Date.parse(String(value));
1709
- return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
1710
- }
1711
-
1712
1727
  function sessionFieldOrSources(session, key) {
1713
1728
  if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
1714
1729
  let sum = null;
@@ -1721,7 +1736,7 @@ function sessionFieldOrSources(session, key) {
1721
1736
  function lastSessionEndedAt(metrics) {
1722
1737
  let last = null;
1723
1738
  for (const session of metrics.sessions || []) {
1724
- if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
1739
+ if (session.endedAt) last = last == null ? session.endedAt : laterTimestamp(last, session.endedAt);
1725
1740
  }
1726
1741
  return last;
1727
1742
  }
@@ -1740,6 +1755,33 @@ function existingSourceIdSet(metrics) {
1740
1755
  return ids;
1741
1756
  }
1742
1757
 
1758
+ function lastNonArchiverSession(sessions) {
1759
+ const list = Array.isArray(sessions) ? sessions : [];
1760
+ for (let i = list.length - 1; i >= 0; i -= 1) {
1761
+ if (list[i] && list[i].role !== 'Archiver') return list[i];
1762
+ }
1763
+ return null;
1764
+ }
1765
+
1766
+ function dropStaleArchiveSelfReport(reported, sessions) {
1767
+ const base = reported && typeof reported === 'object' ? { ...reported } : emptyMetricsFields();
1768
+ const last = lastNonArchiverSession(sessions);
1769
+ if (!last) return base;
1770
+ const sameInput = numOrNull(base.inputTokens) != null && numOrNull(base.inputTokens) === numOrNull(last.inputTokens);
1771
+ const sameOutput = numOrNull(base.outputTokens) === numOrNull(last.outputTokens);
1772
+ if (sameInput && sameOutput) {
1773
+ base.inputTokens = null;
1774
+ base.outputTokens = null;
1775
+ base.totalTokens = null;
1776
+ base.costUsd = null;
1777
+ base.ampCredits = null;
1778
+ if (!base.spendSource || base.spendSource === 'self-report') base.spendSource = null;
1779
+ }
1780
+ if (base.model && last.model && base.model === last.model) base.model = null;
1781
+ if (base.platform && last.platform && base.platform === last.platform) base.platform = null;
1782
+ return base;
1783
+ }
1784
+
1743
1785
  function uniqueSourceModels(sources) {
1744
1786
  const seen = [];
1745
1787
  for (const src of sources || []) {
@@ -1798,11 +1840,35 @@ function sourceAmpCredits(sources) {
1798
1840
  return sum;
1799
1841
  }
1800
1842
 
1801
- function resolveSessionSpend(opts, reported, sources) {
1843
+ function sourceEstimatedUsd(sources) {
1844
+ let sum = null;
1845
+ for (const src of sources || []) {
1846
+ sum = addNullable(sum, numOrNull(src.costUsdEstimated));
1847
+ }
1848
+ return sum;
1849
+ }
1850
+
1851
+ function ampThreadTotals(threads) {
1852
+ const list = Array.isArray(threads) ? threads : [];
1853
+ let costUsd = null;
1854
+ let agentMode = null;
1855
+ const models = [];
1856
+ for (const thread of list) {
1857
+ costUsd = addNullable(costUsd, numOrNull(thread && thread.costUsd));
1858
+ if (!agentMode && thread && thread.agentMode) agentMode = String(thread.agentMode);
1859
+ for (const row of (thread && thread.models) || []) {
1860
+ if (row && row.model && !models.includes(row.model)) models.push(row.model);
1861
+ }
1862
+ }
1863
+ return { costUsd, agentMode, models };
1864
+ }
1865
+
1866
+ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1802
1867
  const flags = opts || {};
1803
1868
  const self = reported || emptyMetricsFields();
1804
1869
  const fromSources = sessionTotalsFromSources(sources || []);
1805
1870
  const sourceCredits = sourceAmpCredits(sources || []);
1871
+ const fromAmp = ampThreadTotals(extra.ampThreads);
1806
1872
  const flagInput = numOrNull(flags.inputTokens);
1807
1873
  const flagOutput = numOrNull(flags.outputTokens);
1808
1874
  const flagTotal = numOrNull(flags.totalTokens);
@@ -1814,12 +1880,14 @@ function resolveSessionSpend(opts, reported, sources) {
1814
1880
  if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1815
1881
  totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1816
1882
  }
1817
- const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
1883
+ const costUsd = firstNonNull(flagCost, self.costUsd, fromAmp.costUsd, fromSources.costUsd);
1818
1884
  const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
1885
+ const costUsdEstimated = sourceEstimatedUsd(sources || []);
1819
1886
  let spendSource = 'unreported';
1820
1887
  if (self.spendSource) spendSource = String(self.spendSource);
1821
1888
  else if (hasSpendOverride(flags)) spendSource = 'flag';
1822
1889
  else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
1890
+ else if (fromAmp.costUsd != null) spendSource = 'amp-usage';
1823
1891
  else if (
1824
1892
  fromSources.inputTokens != null
1825
1893
  || fromSources.outputTokens != null
@@ -1829,7 +1897,7 @@ function resolveSessionSpend(opts, reported, sources) {
1829
1897
  ) {
1830
1898
  spendSource = 'adapter';
1831
1899
  }
1832
- return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
1900
+ return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, costUsdEstimated, spendSource, agentMode: fromAmp.agentMode };
1833
1901
  }
1834
1902
 
1835
1903
  function sessionTotalsFromFlags(opts) {
@@ -1874,25 +1942,44 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1874
1942
  ampThreadId: extra.ampThreadId,
1875
1943
  ampCli: extra.ampCli === true,
1876
1944
  exportAmpThread: extra.exportAmpThread,
1945
+ usageAmpThread: extra.usageAmpThread,
1877
1946
  });
1878
1947
  } catch {
1879
1948
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
1880
1949
  }
1881
1950
  }
1882
1951
 
1883
- function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
1952
+ function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
1884
1953
  session.sources = sources || [];
1885
1954
  const uniqueModels = uniqueSourceModels(session.sources);
1886
- if (!session.model) session.model = resolvedModel || primaryModelFromSources(session.sources) || null;
1955
+ const fromAmp = ampThreadTotals(extra.ampThreads);
1956
+ for (const model of fromAmp.models) {
1957
+ if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
1958
+ }
1959
+ if (!session.model || isPlaceholderModel(session.model)) {
1960
+ session.model = (resolvedModel && !isPlaceholderModel(resolvedModel) ? resolvedModel : null)
1961
+ || primaryModelFromSources(session.sources)
1962
+ || fromAmp.models[0]
1963
+ || null;
1964
+ }
1887
1965
  if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
1888
1966
  if (uniqueModels.length > 1) session.models = uniqueModels;
1889
- const spend = resolveSessionSpend(opts, reported, session.sources);
1967
+ const spend = resolveSessionSpend(opts, reported, session.sources, extra);
1890
1968
  session.inputTokens = spend.inputTokens;
1891
1969
  session.outputTokens = spend.outputTokens;
1892
1970
  session.totalTokens = spend.totalTokens;
1893
1971
  session.costUsd = spend.costUsd;
1894
1972
  session.ampCredits = spend.ampCredits;
1973
+ session.costUsdEstimated = spend.costUsdEstimated;
1895
1974
  session.spendSource = spend.spendSource;
1975
+ if (spend.agentMode) session.agentMode = spend.agentMode;
1976
+ const usageModels = [];
1977
+ for (const thread of extra.ampThreads || []) {
1978
+ for (const row of thread.models || []) {
1979
+ if (row && row.model) usageModels.push(row);
1980
+ }
1981
+ }
1982
+ if (usageModels.length) session.usageModels = usageModels;
1896
1983
  }
1897
1984
 
1898
1985
  function sessionTotalsLookOverridden(session) {
@@ -1913,7 +2000,7 @@ function metricsBackfillLastSession(projectDir, changeName) {
1913
2000
  }
1914
2001
 
1915
2002
  function metricsBackfillFile(filePath, changeName) {
1916
- const nowIso = new Date().toISOString();
2003
+ const nowIso = nowUtcIso();
1917
2004
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
1918
2005
  const sessions = metrics.sessions || [];
1919
2006
  if (!sessions.length) return { filePath, added: 0, empty: true };
@@ -1940,7 +2027,7 @@ function metricsBackfillFile(filePath, changeName) {
1940
2027
  applyCollectedSessionFields(last, merged, last.model, {}, {
1941
2028
  model: last.model,
1942
2029
  platform: last.platform,
1943
- });
2030
+ }, { ampThreads: collected.ampThreads });
1944
2031
  }
1945
2032
  metrics.updatedAt = nowIso;
1946
2033
  recomputeMetricsAggregates(metrics);
@@ -1962,11 +2049,12 @@ function spendTuple(obj) {
1962
2049
  totalTokens: numOrNull(obj && obj.totalTokens),
1963
2050
  costUsd: numOrNull(obj && obj.costUsd),
1964
2051
  ampCredits: numOrNull(obj && obj.ampCredits),
2052
+ costUsdEstimated: numOrNull(obj && obj.costUsdEstimated),
1965
2053
  };
1966
2054
  }
1967
2055
 
1968
2056
  function spendTuplesMatch(a, b) {
1969
- return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].every((key) => {
2057
+ return ['inputTokens', 'outputTokens', 'totalTokens'].every((key) => {
1970
2058
  const left = a[key];
1971
2059
  const right = b[key];
1972
2060
  if (left == null && right == null) return true;
@@ -1980,6 +2068,7 @@ function addSpendNums(target, nums) {
1980
2068
  target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
1981
2069
  target.costUsd = addNullable(target.costUsd, nums.costUsd);
1982
2070
  target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
2071
+ target.costUsdEstimated = addNullable(target.costUsdEstimated, nums.costUsdEstimated);
1983
2072
  }
1984
2073
 
1985
2074
  function recomputeSpendMaps(metrics) {
@@ -1997,6 +2086,7 @@ function recomputeSpendMaps(metrics) {
1997
2086
  totalTokens: null,
1998
2087
  costUsd: null,
1999
2088
  ampCredits: null,
2089
+ costUsdEstimated: null,
2000
2090
  };
2001
2091
  addSpendNums(row, nums);
2002
2092
  byModel.set(key, row);
@@ -2006,7 +2096,7 @@ function recomputeSpendMaps(metrics) {
2006
2096
  const nums = spendTuple(src);
2007
2097
  if (src.platform && byPlatform[src.platform]) {
2008
2098
  addSpendNums(byPlatform[src.platform], nums);
2009
- const label = adapterSourceName(src.platform);
2099
+ const label = adapterSourceName(src.platform, src.via);
2010
2100
  if (label) byPlatform[src.platform].source = label;
2011
2101
  }
2012
2102
  addModelRow(src.model, src.platform, nums);
@@ -2021,6 +2111,19 @@ function recomputeSpendMaps(metrics) {
2021
2111
 
2022
2112
  if (sourcesMatchSession) {
2023
2113
  for (const src of sources) addSourceRow(src);
2114
+ if (session.costUsd != null && sourceTotals.costUsd == null && session.platform && byPlatform[session.platform]) {
2115
+ byPlatform[session.platform].costUsd = addNullable(byPlatform[session.platform].costUsd, session.costUsd);
2116
+ }
2117
+ for (const row of session.usageModels || []) {
2118
+ addModelRow(row.model, session.platform || 'amp', {
2119
+ inputTokens: null,
2120
+ outputTokens: null,
2121
+ totalTokens: null,
2122
+ costUsd: numOrNull(row.costUsd),
2123
+ ampCredits: null,
2124
+ costUsdEstimated: null,
2125
+ });
2126
+ }
2024
2127
  continue;
2025
2128
  }
2026
2129
 
@@ -2044,8 +2147,8 @@ function recomputeMetricsAggregates(metrics) {
2044
2147
  totals.sessions += 1;
2045
2148
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
2046
2149
  totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
2047
- if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
2048
- if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
2150
+ if (session.startedAt) firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2151
+ if (session.endedAt) lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
2049
2152
  const key = session.phase || 'other';
2050
2153
  const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
2051
2154
  phase.sessions += 1;
@@ -2065,7 +2168,7 @@ function recomputeMetricsAggregates(metrics) {
2065
2168
  phases[key] = phase;
2066
2169
  }
2067
2170
  if (firstStart && lastEnd) {
2068
- totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
2171
+ totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
2069
2172
  }
2070
2173
  metrics.phases = phases;
2071
2174
  metrics.totals = totals;
@@ -2075,7 +2178,7 @@ function recomputeMetricsAggregates(metrics) {
2075
2178
 
2076
2179
  function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2077
2180
  const filePath = metricsFilePath(projectDir, changeName);
2078
- const nowIso = new Date().toISOString();
2181
+ const nowIso = nowUtcIso();
2079
2182
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2080
2183
  metrics.pending = {
2081
2184
  startedAt: nowIso,
@@ -2091,10 +2194,14 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2091
2194
 
2092
2195
  function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2093
2196
  const filePath = metricsFilePath(projectDir, fields.changeName);
2094
- const nowIso = new Date().toISOString();
2197
+ const nowIso = nowUtcIso();
2095
2198
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
2096
- const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
2097
- const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
2199
+ const startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2200
+ const startedMs = parseFlexibleIso(startedAt);
2201
+ const endedMs = parseFlexibleIso(nowIso);
2202
+ const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
2203
+ ? Math.max(0, endedMs - startedMs)
2204
+ : null;
2098
2205
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2099
2206
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2100
2207
  const windowStart = collectWindowStart(metrics);
@@ -2128,9 +2235,10 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2128
2235
  totalTokens: null,
2129
2236
  costUsd: null,
2130
2237
  ampCredits: null,
2238
+ costUsdEstimated: null,
2131
2239
  spendSource: 'unreported',
2132
2240
  };
2133
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2241
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2134
2242
  metrics.sessions.push(session);
2135
2243
  metrics.pending = null;
2136
2244
  metrics.updatedAt = nowIso;
@@ -2146,21 +2254,22 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2146
2254
 
2147
2255
  function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2148
2256
  const filePath = join(targetDir, 'metrics.json');
2149
- const nowIso = new Date().toISOString();
2257
+ const nowIso = nowUtcIso();
2150
2258
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2151
2259
  const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
2152
2260
  const collectAll = opts.collect === true;
2153
2261
  const platform = opts.platform || null;
2154
2262
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2155
2263
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2264
+ const ampThreadId = opts.ampThreadId || ampThreadIdFromEnv(process.env) || null;
2156
2265
  const collected = shouldCollect
2157
2266
  ? runCollectSpend(metrics, windowStart, nowIso, {
2158
2267
  platforms,
2159
- ampThreadId: opts.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2268
+ ampThreadId,
2160
2269
  ampCli: collectAll || platform === 'amp',
2161
2270
  })
2162
2271
  : { sources: [] };
2163
- const reported = opts.reported || emptyMetricsFields();
2272
+ const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
2164
2273
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2165
2274
  const session = {
2166
2275
  startedAt: nowIso,
@@ -2171,7 +2280,8 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2171
2280
  runtime: opts.runtime || 'local',
2172
2281
  agentId: opts.agentId || 'none',
2173
2282
  model: resolvedModel || null,
2174
- platform: opts.platform || null,
2283
+ platform,
2284
+ threadId: ampThreadId || null,
2175
2285
  tasks: opts.tasks || null,
2176
2286
  sources: [],
2177
2287
  inputTokens: null,
@@ -2179,9 +2289,10 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2179
2289
  totalTokens: null,
2180
2290
  costUsd: null,
2181
2291
  ampCredits: null,
2292
+ costUsdEstimated: null,
2182
2293
  spendSource: 'unreported',
2183
2294
  };
2184
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2295
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2185
2296
  metrics.sessions.push(session);
2186
2297
  metrics.archivedAt = nowIso;
2187
2298
  metrics.pending = null;
@@ -2216,6 +2327,17 @@ function formatMetricsCost(value) {
2216
2327
  return value == null ? '—' : `$${Number(value).toFixed(2)}`;
2217
2328
  }
2218
2329
 
2330
+ function formatMetricsCostLine(spend) {
2331
+ const billed = spend && spend.costUsd;
2332
+ const estimated = spend && spend.costUsdEstimated;
2333
+ if (billed == null && estimated == null) return '—';
2334
+ if (billed != null && estimated != null) {
2335
+ return `${formatMetricsCost(billed)} billed + ~${formatMetricsCost(estimated)} est.`;
2336
+ }
2337
+ if (billed != null) return formatMetricsCost(billed);
2338
+ return `~${formatMetricsCost(estimated)} est.`;
2339
+ }
2340
+
2219
2341
  function sessionSpendSourceLabel(session) {
2220
2342
  const raw = session && session.spendSource;
2221
2343
  if (raw == null || String(raw).trim() === '') return 'unreported';
@@ -2230,9 +2352,9 @@ function renderMetricsSummary(metrics) {
2230
2352
  lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
2231
2353
  lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
2232
2354
  lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
2233
- lines.push(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
2355
+ lines.push(`cost: ${formatMetricsCostLine(metrics.spend)}`);
2234
2356
  lines.push(`unreported: ${unreported}`);
2235
- if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
2357
+ if (metrics.archivedAt) lines.push(`archived: ${formatKyivDisplay(metrics.archivedAt)}`);
2236
2358
  if (metrics.pending) {
2237
2359
  const pendingClient = metrics.pending.platform
2238
2360
  ? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
@@ -2252,7 +2374,7 @@ function renderMetricsSummary(metrics) {
2252
2374
  String(phase.sessions).padEnd(9),
2253
2375
  formatMetricsDuration(phase.durationMs).padEnd(9),
2254
2376
  formatMetricsNumber(phase.totalTokens).padEnd(9),
2255
- formatMetricsCost(phase.costUsd).padEnd(9),
2377
+ formatMetricsCostLine(phase).padEnd(9),
2256
2378
  (phase.agents.join(', ') || '—').padEnd(20),
2257
2379
  phase.models.join(', ') || '—',
2258
2380
  ].join(' '));
@@ -2268,7 +2390,7 @@ function renderMetricsSummary(metrics) {
2268
2390
  lines.push([
2269
2391
  key.padEnd(10),
2270
2392
  formatMetricsNumber(row.totalTokens).padEnd(9),
2271
- formatMetricsCost(row.costUsd).padEnd(9),
2393
+ formatMetricsCostLine(row).padEnd(9),
2272
2394
  formatMetricsNumber(row.ampCredits).padEnd(9),
2273
2395
  row.source || 'none',
2274
2396
  ].join(' '));
@@ -2286,7 +2408,7 @@ function renderMetricsSummary(metrics) {
2286
2408
  String(row.model || '—').padEnd(20),
2287
2409
  String(row.platform || '—').padEnd(10),
2288
2410
  formatMetricsNumber(row.totalTokens).padEnd(9),
2289
- formatMetricsCost(row.costUsd).padEnd(9),
2411
+ formatMetricsCostLine(row).padEnd(9),
2290
2412
  formatMetricsNumber(row.ampCredits),
2291
2413
  ].join(' '));
2292
2414
  }
@@ -2296,10 +2418,11 @@ function renderMetricsSummary(metrics) {
2296
2418
  lines.push('');
2297
2419
  lines.push('recent sessions:');
2298
2420
  for (const session of sessions.slice(-5)) {
2299
- const spendLabel = session.totalTokens != null || session.costUsd != null
2300
- ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
2421
+ const spendLabel = session.totalTokens != null || session.costUsd != null || session.costUsdEstimated != null
2422
+ ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCostLine(session)}`
2301
2423
  : '';
2302
- 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}`);
2424
+ const modeLabel = session.agentMode ? ` mode:${session.agentMode}` : '';
2425
+ lines.push(`- ${formatKyivDisplay(session.endedAt)} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${modeLabel} (${sessionSpendSourceLabel(session)})${spendLabel}`);
2303
2426
  }
2304
2427
  }
2305
2428
  return lines;
@@ -3478,7 +3601,7 @@ program
3478
3601
  .option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
3479
3602
  .option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
3480
3603
  .option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
3481
- .option('--collect', 'Additionally collect local spend adapters', false)
3604
+ .option('--collect', 'Collect all spend adapters (default: locked Cursor/Amp/Claude client only)', false)
3482
3605
  .action((name, opts) => {
3483
3606
  const projectDir = process.cwd();
3484
3607
  const fail = (msg) => {
@@ -3598,11 +3721,25 @@ program
3598
3721
  if (existsSync(archivedHandoffPath)) {
3599
3722
  priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
3600
3723
  }
3601
- const reported = priorFields.metrics || emptyMetricsFields();
3602
- const archivePlatform = resolvePlatform(opts, process.env, reported);
3724
+ const metricsPreview = loadMetricsFile(join(targetDir, 'metrics.json'), name, nowUtcIso());
3725
+ const reported = dropStaleArchiveSelfReport(priorFields.metrics, metricsPreview.sessions);
3726
+ const client = resolveRestoreClient({
3727
+ env: process.env,
3728
+ cwd: projectDir,
3729
+ homedir: process.env.HOME,
3730
+ platform: opts.platform,
3731
+ });
3732
+ const archivePlatform = resolvePlatform(opts, process.env, reported, {
3733
+ platform: client.platform,
3734
+ threadId: client.threadId,
3735
+ });
3603
3736
  const archiveModel = resolveModel(opts, process.env, reported);
3604
3737
  if (archivePlatform.warn) console.error(archivePlatform.warn);
3605
3738
  printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
3739
+ const clientLabel = archivePlatform.value || client.platform
3740
+ ? `${archivePlatform.value || client.platform}${client.threadId ? ` ${client.threadId}` : ''}${client.source ? ` (${client.source})` : ''}`
3741
+ : 'unknown — pass --platform or run archive from Cursor/Amp/Claude';
3742
+ console.error(`metrics: archive client ${clientLabel}`);
3606
3743
  const runtimeResult = resolveRuntime({}, process.env, priorFields);
3607
3744
  const progress = parseTasksProgress(targetDir);
3608
3745
  const fields = {
@@ -3629,7 +3766,8 @@ program
3629
3766
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3630
3767
  const metricsPath = metricsFinalizeArchive(targetDir, name, {
3631
3768
  model: archiveModel,
3632
- platform: archivePlatform.value || null,
3769
+ platform: archivePlatform.value || client.platform || null,
3770
+ ampThreadId: client.threadId || ampThreadIdFromEnv(process.env) || null,
3633
3771
  runtime: fields.runtime,
3634
3772
  agentId: fields.agentId,
3635
3773
  tasks: fields.tasks,
@@ -3649,7 +3787,7 @@ program
3649
3787
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
3650
3788
  console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
3651
3789
  try {
3652
- const archivedMetrics = loadMetricsFile(metricsPath, name, new Date().toISOString());
3790
+ const archivedMetrics = loadMetricsFile(metricsPath, name, nowUtcIso());
3653
3791
  for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
3654
3792
  } catch {}
3655
3793
  log.ok(`archived ${name}`);
@@ -3998,7 +4136,7 @@ program
3998
4136
  }
3999
4137
 
4000
4138
  const reported = fields.metrics || emptyMetricsFields();
4001
- const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, new Date().toISOString());
4139
+ const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, nowUtcIso());
4002
4140
  const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
4003
4141
  if (platformResult.error) {
4004
4142
  log.err(platformResult.error);
@@ -4099,7 +4237,7 @@ program
4099
4237
  return;
4100
4238
  }
4101
4239
 
4102
- const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
4240
+ const metrics = loadMetricsFile(filePath, name, nowUtcIso());
4103
4241
  if (opts.json) {
4104
4242
  process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
4105
4243
  return;
@@ -4110,4 +4248,18 @@ program
4110
4248
  for (const line of renderMetricsSummary(metrics)) console.log(line);
4111
4249
  });
4112
4250
 
4113
- program.parse();
4251
+ function isDirectCliRun() {
4252
+ const entry = process.argv[1];
4253
+ if (!entry) return false;
4254
+ try {
4255
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
4256
+ } catch {
4257
+ return resolve(entry) === fileURLToPath(import.meta.url);
4258
+ }
4259
+ }
4260
+
4261
+ if (isDirectCliRun()) {
4262
+ program.parse();
4263
+ }
4264
+
4265
+ export { formatMetricsCostLine, resolveSessionSpend };
@@ -0,0 +1,63 @@
1
+ function numOrNull(value) {
2
+ if (value == null || value === '') return null;
3
+ const n = Number(String(value).replace(/,/g, ''));
4
+ return Number.isFinite(n) ? n : null;
5
+ }
6
+
7
+ export function parseAmpUsageDetails(text) {
8
+ const raw = String(text || '');
9
+ const costMatch = /(?:^|\n)Cost:\s*\$([0-9]+(?:\.[0-9]+)?)/.exec(raw);
10
+ const totalMatch = /Total tokens:\s*([\d,]+)/i.exec(raw);
11
+ const inputMatch = /Input tokens:\s*([\d,]+)(?:\s*\(([\d,]+)\s*cache reads\))?/i.exec(raw);
12
+ const outputMatch = /Output tokens:\s*([\d,]+)/i.exec(raw);
13
+ const models = [];
14
+ const tableStart = raw.search(/##\s*Models\b/i);
15
+ if (tableStart >= 0) {
16
+ const section = raw.slice(tableStart);
17
+ const rowRe = /^\|\s*(?!Model\b|[-: ]+)([^|]+?)\s*\|\s*([\d,]+)\s*\|\s*([\d,]+)\s*\|\s*([\d,]+)\s*\|\s*\$([0-9]+(?:\.[0-9]+)?)\s*\|/gim;
18
+ let row;
19
+ while ((row = rowRe.exec(section))) {
20
+ models.push({
21
+ model: row[1].trim(),
22
+ requests: numOrNull(row[2]),
23
+ inputTokens: numOrNull(row[3]),
24
+ outputTokens: numOrNull(row[4]),
25
+ costUsd: numOrNull(row[5]),
26
+ });
27
+ }
28
+ }
29
+ return {
30
+ costUsd: costMatch ? numOrNull(costMatch[1]) : null,
31
+ totalTokens: totalMatch ? numOrNull(totalMatch[1]) : null,
32
+ inputTokens: inputMatch ? numOrNull(inputMatch[1]) : null,
33
+ cacheReadTokens: inputMatch && inputMatch[2] ? numOrNull(inputMatch[2]) : null,
34
+ outputTokens: outputMatch ? numOrNull(outputMatch[1]) : null,
35
+ models,
36
+ };
37
+ }
38
+
39
+ export function ampAgentMode(thread) {
40
+ if (!thread || typeof thread !== 'object') return null;
41
+ const direct = thread.agentMode || (thread.meta && thread.meta.agentMode);
42
+ if (direct == null || String(direct).trim() === '') return null;
43
+ return String(direct).trim();
44
+ }
45
+
46
+ function compactModel(value) {
47
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '').replace(/(\d)p(\d)/g, '$1$2');
48
+ }
49
+
50
+ export function matchAmpUsageModel(displayName, sourceModels) {
51
+ const wanted = compactModel(displayName);
52
+ if (!wanted) return displayName;
53
+ let best = null;
54
+ for (const id of sourceModels || []) {
55
+ const compact = compactModel(id);
56
+ if (!compact) continue;
57
+ if (compact === wanted || compact.includes(wanted) || wanted.includes(compact.replace(/^accountsfireworkmodels/, ''))) {
58
+ best = id;
59
+ break;
60
+ }
61
+ }
62
+ return best || displayName;
63
+ }
@@ -0,0 +1,76 @@
1
+ function numOrNull(value) {
2
+ if (value == null || value === '') return null;
3
+ const n = Number(value);
4
+ return Number.isFinite(n) ? n : null;
5
+ }
6
+
7
+ const GROK_46 = {
8
+ inputPerM: 2,
9
+ cachedPerM: 0.5,
10
+ outputPerM: 6,
11
+ longInputPerM: 4,
12
+ longCachedPerM: 1,
13
+ longOutputPerM: 12,
14
+ longAt: 200000,
15
+ };
16
+
17
+ const FALLBACK_INPUT_PER_M = 3;
18
+ const FALLBACK_OUTPUT_PER_M = 15;
19
+ const FALLBACK_TOTAL_PER_M = 3.5;
20
+
21
+ function ratesForModel(model) {
22
+ const id = String(model || '').toLowerCase();
23
+ if (!id) return null;
24
+ let rates = null;
25
+ if (id.includes('grok-4.6') || id.includes('grok-4-6')) rates = { ...GROK_46 };
26
+ else if (id.includes('grok-4.5') || id.includes('grok-4-5')) {
27
+ rates = { ...GROK_46, cachedPerM: 0.3, longCachedPerM: 0.6 };
28
+ } else {
29
+ return null;
30
+ }
31
+ if (id.includes('fast')) {
32
+ for (const key of ['inputPerM', 'cachedPerM', 'outputPerM', 'longInputPerM', 'longCachedPerM', 'longOutputPerM']) {
33
+ rates[key] *= 2;
34
+ }
35
+ }
36
+ return rates;
37
+ }
38
+
39
+ export function estimateCursorCostUsd({ model, inputTokens, outputTokens, cacheReadTokens, totalTokens } = {}) {
40
+ const rates = ratesForModel(model);
41
+ if (rates) {
42
+ const input = numOrNull(inputTokens);
43
+ const output = numOrNull(outputTokens) ?? 0;
44
+ if (input == null && output == 0) return null;
45
+ const totalInput = input ?? 0;
46
+ const cached = Math.min(numOrNull(cacheReadTokens) ?? 0, totalInput);
47
+ const fresh = Math.max(0, totalInput - cached);
48
+ const long = totalInput >= rates.longAt;
49
+ const inputRate = long ? rates.longInputPerM : rates.inputPerM;
50
+ const cachedRate = long ? rates.longCachedPerM : rates.cachedPerM;
51
+ const outputRate = long ? rates.longOutputPerM : rates.outputPerM;
52
+ const usd = (fresh * inputRate + cached * cachedRate + output * outputRate) / 1e6;
53
+ return Math.round(usd * 10000) / 10000;
54
+ }
55
+ const input = numOrNull(inputTokens);
56
+ const output = numOrNull(outputTokens);
57
+ if (input != null || output != null) {
58
+ const usd = ((input ?? 0) * FALLBACK_INPUT_PER_M + (output ?? 0) * FALLBACK_OUTPUT_PER_M) / 1e6;
59
+ return Math.round(usd * 10000) / 10000;
60
+ }
61
+ const total = numOrNull(totalTokens);
62
+ if (total != null) {
63
+ const usd = total * FALLBACK_TOTAL_PER_M / 1e6;
64
+ return Math.round(usd * 10000) / 10000;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export function describeCursorCostEstimate(args) {
70
+ const usd = estimateCursorCostUsd(args);
71
+ if (usd == null) return null;
72
+ return {
73
+ usd,
74
+ costSource: ratesForModel(args && args.model) != null ? 'api-estimate' : 'api-estimate-fallback',
75
+ };
76
+ }
@@ -0,0 +1,99 @@
1
+ const DISPLAY_TIMEZONE = 'Europe/Kyiv';
2
+
3
+ function pad2(value) {
4
+ return String(value).padStart(2, '0');
5
+ }
6
+
7
+ export function parseFlexibleIso(value) {
8
+ if (value == null || value === '') return NaN;
9
+ if (typeof value === 'number' && Number.isFinite(value)) {
10
+ return value < 1e12 ? value * 1000 : value;
11
+ }
12
+ let raw = String(value).trim();
13
+ if (!raw) return NaN;
14
+ raw = raw.replace(/^(\d{4}-\d{2}-\d{2})[ ]+(\d{2}:)/, '$1T$2');
15
+ raw = raw.replace(/(\.\d{3})\d*\.000(?=Z$|[+-]\d{2}:?\d{2}$)/i, '$1');
16
+ raw = raw.replace(/(\.\d{3})\d+(?=Z$|[+-]\d{2}:?\d{2}$)/i, '$1');
17
+ raw = raw.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
18
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(raw)) raw += 'Z';
19
+ const ms = Date.parse(raw);
20
+ return Number.isFinite(ms) ? ms : NaN;
21
+ }
22
+
23
+ export function formatUtcIso(value) {
24
+ const ms = parseFlexibleIso(value);
25
+ if (!Number.isFinite(ms)) return null;
26
+ return new Date(ms).toISOString();
27
+ }
28
+
29
+ export function nowUtcIso(now = Date.now()) {
30
+ return formatUtcIso(now);
31
+ }
32
+
33
+ function kyivOffset(ms) {
34
+ const d = new Date(ms);
35
+ const fmt = new Intl.DateTimeFormat('en-US', {
36
+ timeZone: DISPLAY_TIMEZONE,
37
+ timeZoneName: 'longOffset',
38
+ hour: '2-digit',
39
+ });
40
+ const name = fmt.formatToParts(d).find((part) => part.type === 'timeZoneName')?.value || '';
41
+ const match = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(name);
42
+ if (match) {
43
+ return `${match[1]}${pad2(match[2])}:${match[3] || '00'}`;
44
+ }
45
+ const utc = new Date(d.toLocaleString('en-US', { timeZone: 'UTC' })).getTime();
46
+ const kyiv = new Date(d.toLocaleString('en-US', { timeZone: DISPLAY_TIMEZONE })).getTime();
47
+ const minutes = Math.round((kyiv - utc) / 60000);
48
+ const sign = minutes >= 0 ? '+' : '-';
49
+ const abs = Math.abs(minutes);
50
+ return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
51
+ }
52
+
53
+ function formatKyivIso(value) {
54
+ const ms = parseFlexibleIso(value);
55
+ if (!Number.isFinite(ms)) return null;
56
+ const d = new Date(ms);
57
+ const parts = Object.fromEntries(
58
+ new Intl.DateTimeFormat('en-US', {
59
+ timeZone: DISPLAY_TIMEZONE,
60
+ year: 'numeric',
61
+ month: '2-digit',
62
+ day: '2-digit',
63
+ hour: '2-digit',
64
+ minute: '2-digit',
65
+ second: '2-digit',
66
+ hourCycle: 'h23',
67
+ }).formatToParts(d).map((part) => [part.type, part.value]),
68
+ );
69
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${String(ms % 1000).padStart(3, '0')}${kyivOffset(ms)}`;
70
+ }
71
+
72
+ export function formatKyivDisplay(value) {
73
+ const iso = formatKyivIso(value);
74
+ if (!iso) return '—';
75
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([+-]\d{2}:\d{2})$/.exec(iso);
76
+ if (!match) return iso;
77
+ return `${match[3]}.${match[2]}.${match[1]} ${match[4]}:${match[5]}:${match[6]} (Київ ${match[7]})`;
78
+ }
79
+
80
+ export function isoOrNull(value) {
81
+ if (value == null || value === '') return null;
82
+ return formatUtcIso(value);
83
+ }
84
+
85
+ export function laterTimestamp(a, b) {
86
+ const left = parseFlexibleIso(a);
87
+ const right = parseFlexibleIso(b);
88
+ if (!Number.isFinite(left)) return b;
89
+ if (!Number.isFinite(right)) return a;
90
+ return left >= right ? a : b;
91
+ }
92
+
93
+ export function earlierTimestamp(a, b) {
94
+ const left = parseFlexibleIso(a);
95
+ const right = parseFlexibleIso(b);
96
+ if (!Number.isFinite(left)) return b;
97
+ if (!Number.isFinite(right)) return a;
98
+ return left <= right ? a : b;
99
+ }
@@ -3,6 +3,9 @@ import { join, basename } from 'path';
3
3
  import { homedir as osHomedir } from 'os';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { listRecentAmpThreadIds } from './session-client.js';
6
+ import { formatUtcIso, parseFlexibleIso } from './metrics-time.js';
7
+ import { describeCursorCostEstimate } from './cursor-cost-estimate.js';
8
+ import { ampAgentMode, matchAmpUsageModel, parseAmpUsageDetails } from './amp-usage.js';
6
9
 
7
10
  const PLATFORMS = ['cursor', 'claude', 'amp'];
8
11
 
@@ -24,6 +27,7 @@ function emptyPlatform(source = 'none') {
24
27
  totalTokens: null,
25
28
  costUsd: null,
26
29
  ampCredits: null,
30
+ costUsdEstimated: null,
27
31
  source,
28
32
  };
29
33
  }
@@ -59,11 +63,7 @@ function pathsEqual(a, b) {
59
63
  }
60
64
 
61
65
  function parseTime(value) {
62
- if (value == null || value === '') return NaN;
63
- if (typeof value === 'number' && Number.isFinite(value)) {
64
- return value < 1e12 ? value * 1000 : value;
65
- }
66
- return Date.parse(String(value));
66
+ return parseFlexibleIso(value);
67
67
  }
68
68
 
69
69
  function inWindow(timestamp, windowStart, windowEnd) {
@@ -80,12 +80,25 @@ function inWindow(timestamp, windowStart, windowEnd) {
80
80
  return true;
81
81
  }
82
82
 
83
- function sourceRecord({ id, platform, model, inputTokens, outputTokens, costUsd, ampCredits, at }) {
83
+ function sourceRecord({
84
+ id,
85
+ platform,
86
+ model,
87
+ inputTokens,
88
+ outputTokens,
89
+ costUsd,
90
+ ampCredits,
91
+ at,
92
+ cacheReadTokens,
93
+ costUsdEstimated,
94
+ costSource,
95
+ agentMode,
96
+ }) {
84
97
  const input = numOrNull(inputTokens);
85
98
  const output = numOrNull(outputTokens);
86
99
  let total = null;
87
100
  if (input != null || output != null) total = (input ?? 0) + (output ?? 0);
88
- return {
101
+ const record = {
89
102
  id: String(id),
90
103
  platform,
91
104
  model: model == null || model === '' ? null : String(model),
@@ -94,8 +107,15 @@ function sourceRecord({ id, platform, model, inputTokens, outputTokens, costUsd,
94
107
  totalTokens: total,
95
108
  costUsd: numOrNull(costUsd),
96
109
  ampCredits: numOrNull(ampCredits),
97
- at: at == null ? null : String(at),
110
+ at: at == null || at === '' ? null : (formatUtcIso(at) || String(at)),
98
111
  };
112
+ const cache = numOrNull(cacheReadTokens);
113
+ if (cache != null) record.cacheReadTokens = cache;
114
+ const estimated = numOrNull(costUsdEstimated);
115
+ if (estimated != null) record.costUsdEstimated = estimated;
116
+ if (costSource) record.costSource = String(costSource);
117
+ if (agentMode) record.agentMode = String(agentMode);
118
+ return record;
99
119
  }
100
120
 
101
121
  function claudeInputTokens(usage) {
@@ -334,6 +354,7 @@ export function sourcesFromAmpThread(thread, ctx, fileName = '', via = null) {
334
354
  costUsd: null,
335
355
  ampCredits: null,
336
356
  at: usage.timestamp,
357
+ agentMode: ampAgentMode(thread),
337
358
  });
338
359
  if (via) record.via = via;
339
360
  sources.push(record);
@@ -395,6 +416,39 @@ export function exportAmpThread(threadId, options = {}) {
395
416
  }
396
417
  }
397
418
 
419
+ export function fetchAmpThreadUsage(threadId, options = {}) {
420
+ const id = threadId == null ? '' : String(threadId).trim();
421
+ if (!id) return null;
422
+ if (typeof options.usageAmpThread === 'function') {
423
+ try {
424
+ const injected = options.usageAmpThread(id);
425
+ if (injected == null) return null;
426
+ if (typeof injected === 'string') return parseAmpUsageDetails(injected);
427
+ if (typeof injected === 'object') {
428
+ if (injected.text && injected.costUsd == null) return { ...parseAmpUsageDetails(injected.text), ...injected };
429
+ return injected;
430
+ }
431
+ return null;
432
+ } catch {
433
+ return null;
434
+ }
435
+ }
436
+ if (typeof options.exportAmpThread === 'function') return null;
437
+ const bin = options.ampBin || (options.env && options.env.AOK_AMP_BIN) || 'amp';
438
+ if (bin !== 'amp' && !existsSync(bin)) return null;
439
+ try {
440
+ const out = execFileSync(bin, ['threads', 'usage', id, '--details'], {
441
+ encoding: 'utf-8',
442
+ timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 25000,
443
+ env: options.env || process.env,
444
+ stdio: ['ignore', 'pipe', 'pipe'],
445
+ });
446
+ return parseAmpUsageDetails(out);
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+
398
452
  function collectAmpCli(ctx) {
399
453
  const { env, notes, ampThreadId } = ctx;
400
454
  const ids = [];
@@ -408,17 +462,41 @@ function collectAmpCli(ctx) {
408
462
  for (const id of listRecentAmpThreadIds(ctx)) push(id);
409
463
  }
410
464
  const sources = [];
465
+ const threads = [];
411
466
  for (const id of ids) {
412
467
  const thread = exportAmpThread(id, ctx);
413
468
  if (!thread) {
414
469
  notes.push(`amp: export failed for ${id}`);
415
470
  continue;
416
471
  }
472
+ const agentMode = ampAgentMode(thread);
417
473
  const extracted = sourcesFromAmpThread(thread, ctx, `${id}.json`, 'amp-cli');
418
474
  if (!extracted.length) notes.push(`amp: export ${id} had no matching usage`);
419
475
  sources.push(...extracted);
476
+ const usage = fetchAmpThreadUsage(id, ctx);
477
+ if (!usage) notes.push(`amp: usage failed for ${id}`);
478
+ const sourceModels = extracted.map((src) => src.model).filter(Boolean);
479
+ const usageModels = (usage && Array.isArray(usage.models) ? usage.models : []).map((row) => ({
480
+ ...row,
481
+ model: matchAmpUsageModel(row.model, sourceModels),
482
+ }));
483
+ if (usage && usage.costUsd != null) {
484
+ for (const src of extracted) {
485
+ src.costSource = 'amp-usage';
486
+ }
487
+ }
488
+ threads.push({
489
+ id,
490
+ agentMode,
491
+ costUsd: usage ? numOrNull(usage.costUsd) : null,
492
+ inputTokens: usage ? numOrNull(usage.inputTokens) : null,
493
+ outputTokens: usage ? numOrNull(usage.outputTokens) : null,
494
+ totalTokens: usage ? numOrNull(usage.totalTokens) : null,
495
+ cacheReadTokens: usage ? numOrNull(usage.cacheReadTokens) : null,
496
+ models: usageModels,
497
+ });
420
498
  }
421
- return sources;
499
+ return { sources, threads };
422
500
  }
423
501
 
424
502
  export const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
@@ -455,15 +533,26 @@ function collectCursor({ cwd, windowStart, windowEnd, existing, notes }) {
455
533
  const inputTokens = numOrNull(row.inputTokens);
456
534
  const outputTokens = numOrNull(row.outputTokens);
457
535
  if (inputTokens == null && outputTokens == null) continue;
536
+ const model = row.model || row.modelId;
537
+ const cacheReadTokens = numOrNull(row.cacheReadTokens);
538
+ const described = describeCursorCostEstimate({
539
+ model,
540
+ inputTokens,
541
+ outputTokens,
542
+ cacheReadTokens,
543
+ });
458
544
  const record = sourceRecord({
459
545
  id,
460
546
  platform: 'cursor',
461
- model: row.model || row.modelId,
547
+ model,
462
548
  inputTokens,
463
549
  outputTokens,
464
550
  costUsd: null,
465
551
  ampCredits: null,
466
552
  at: row.at,
553
+ cacheReadTokens,
554
+ costUsdEstimated: described?.usd ?? null,
555
+ costSource: described?.costSource ?? null,
467
556
  });
468
557
  const previous = bestById.get(id);
469
558
  if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
@@ -485,6 +574,7 @@ function aggregate(sources) {
485
574
  bucket.totalTokens = addNullable(bucket.totalTokens, src.totalTokens);
486
575
  bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
487
576
  bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
577
+ bucket.costUsdEstimated = addNullable(bucket.costUsdEstimated, src.costUsdEstimated);
488
578
  if (platform === 'claude') bucket.source = 'claude-jsonl';
489
579
  else if (platform === 'amp') bucket.source = src.via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
490
580
  else bucket.source = 'cursor-hook';
@@ -500,12 +590,14 @@ function aggregate(sources) {
500
590
  totalTokens: null,
501
591
  costUsd: null,
502
592
  ampCredits: null,
593
+ costUsdEstimated: null,
503
594
  };
504
595
  row.inputTokens = addNullable(row.inputTokens, src.inputTokens);
505
596
  row.outputTokens = addNullable(row.outputTokens, src.outputTokens);
506
597
  row.totalTokens = addNullable(row.totalTokens, src.totalTokens);
507
598
  row.costUsd = addNullable(row.costUsd, src.costUsd);
508
599
  row.ampCredits = addNullable(row.ampCredits, src.ampCredits);
600
+ row.costUsdEstimated = addNullable(row.costUsdEstimated, src.costUsdEstimated);
509
601
  byModel.set(key, row);
510
602
  }
511
603
  }
@@ -541,10 +633,12 @@ export function collectSpend(options = {}) {
541
633
  ampThreadId: options.ampThreadId,
542
634
  exportAmpThread: options.exportAmpThread,
543
635
  listAmpThreads: options.listAmpThreads,
636
+ usageAmpThread: options.usageAmpThread,
544
637
  ampBin: options.ampBin,
545
638
  timeoutMs: options.timeoutMs,
546
639
  };
547
640
  let sources = [];
641
+ const ampThreads = [];
548
642
  if (run('claude')) {
549
643
  try {
550
644
  sources = sources.concat(collectClaude(ctx));
@@ -555,7 +649,9 @@ export function collectSpend(options = {}) {
555
649
  if (run('amp')) {
556
650
  if (options.ampCli === true || typeof options.exportAmpThread === 'function') {
557
651
  try {
558
- sources = sources.concat(collectAmpCli(ctx));
652
+ const cli = collectAmpCli(ctx);
653
+ sources = sources.concat(cli.sources || []);
654
+ if (Array.isArray(cli.threads)) ampThreads.push(...cli.threads);
559
655
  } catch {
560
656
  notes.push('amp: cli export failed');
561
657
  }
@@ -574,5 +670,22 @@ export function collectSpend(options = {}) {
574
670
  }
575
671
  }
576
672
  const { byPlatform, byModel } = aggregate(sources);
577
- return { sources, byPlatform, byModel, notes };
673
+ applyAmpThreadSpend(byPlatform, byModel, ampThreads);
674
+ return { sources, byPlatform, byModel, notes, ampThreads };
675
+ }
676
+
677
+ function applyAmpThreadSpend(byPlatform, byModel, threads) {
678
+ if (!threads || !threads.length) return;
679
+ let cost = null;
680
+ for (const thread of threads) {
681
+ cost = addNullable(cost, numOrNull(thread.costUsd));
682
+ for (const row of thread.models || []) {
683
+ if (!row.model) continue;
684
+ const key = `${row.model}::amp`;
685
+ const existing = byModel.find((item) => `${item.model}::${item.platform || ''}` === key)
686
+ || byModel.find((item) => item.platform === 'amp' && item.model === row.model);
687
+ if (existing && row.costUsd != null) existing.costUsd = addNullable(existing.costUsd, row.costUsd);
688
+ }
689
+ }
690
+ if (cost != null) byPlatform.amp.costUsd = addNullable(byPlatform.amp.costUsd, cost);
578
691
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -21,7 +21,7 @@ Archive is one CLI call, no phase subagents.
21
21
  npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
22
22
  ```
23
23
 
24
- Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session) and prints the change-wide metrics summary. Collect runs only with `--collect`; if `spend.costUsd` is `null` — stderr warning, not a gate.
24
+ Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session), collects the locked client into that session (Cursor hook / Amp usage / Claude JSONL; `--collect` = all adapters), and prints the change-wide metrics summary. If `spend.costUsd` is `null` — stderr warning, not a gate.
25
25
 
26
26
  4. **Show stdout as-is.** On exit ≠ 0, report the gate from stderr and stop — no manual merge/move.
27
27
 
@@ -9,7 +9,7 @@ Workflow:
9
9
 
10
10
  1. Read `.agents/orchestrator.yaml`, the complete change, review verdict, task state, and verification/merge evidence supplied by the conductor.
11
11
  2. Refuse to archive unless required review is approved, all tasks are complete, and the configured merge/CI gate is satisfied.
12
- 3. Fill `## Metrics` in the change `handoff.md` (Archiver self-report: platform, model, tokens, cost_usd, amp_credits, spend_source; use `unknown` when missing) before running archive.
12
+ 3. Fill `## Metrics` in the change `handoff.md` only when reporting Archiver-specific numbers (use `unknown` when missing). Do not copy the previous apply session. The CLI auto-collects the locked client into the Archiver session.
13
13
  4. Run `npx agent-orchestrator-kit archive <name>` so delta requirements are merged into main specs, the change moves to the dated archive path, and stdout prints the change-wide metrics summary (by phase / by platform / by model).
14
14
  5. Run strict validation after the move and report the resulting archive path and modified main specs.
15
15