agent-orchestrator-kit 0.10.0 β†’ 0.11.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,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.0] - 2026-09-02
8
+
9
+ ### Changed
10
+ - **Session metrics attribution.** Persist collect is `[pending.startedAt, endedAt]`; a late hook goes to leftover of that session, not the next persist. Archive collect is `[pending.startedAt, now]` plus leftover of the previous session; `sessionEnd` reads the newest `openspec/changes/archive/*-<name>/metrics.json`. Source product id wins when sources have a model; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none. Cursor collect filters by `conversationId` when a filter id is present. Canonical Closed role token is stored in `session.role`. Placeholder `unknown` plus `spend_source: self-report` does not freeze leftover totals.
11
+
12
+ ### Fixed
13
+ - **Cursor `sessionEnd` collect writes `costUsdEstimated`.** `scripts/cursor-spend-collect.cjs` no longer copies tokens without an estimate: leftover hook rows keep `cacheReadTokens`, grok uses the xAI table (`costSource: api-estimate`, including `-fast` and the 200k cliff), other models use the `$3/$15` fallback, and aggregates (`spend`, platforms, models, phases) keep `costUsdEstimated`. Leftover rows after `pending.startedAt` stay off the last closed session. Persist / restore / `metrics --collect` also backfill estimates onto legacy cursor sources that already had tokens but `costUsdEstimated: null`. Amp credits stay self-report only; Amp billed USD still comes only from `amp threads usage` `Cost: $N`.
14
+ - **Cursor `stop` + `afterAgentResponse` no longer double-count a turn.** When Cursor omits `generation_id`, the spend hook used `Date.now()` and wrote two ids for the same tokens. The hook now uses a stable fallback id; persist and `sessionEnd` collect also collapse rows that share model + input/output/cache, prefer `stop`, skip leftover after `endedAt + 120s` when no session is pending, and resync adapter session totals from the remaining sources.
15
+
7
16
  ## [0.10.0] - 2026-08-31
8
17
 
9
18
  ### Added
@@ -257,6 +266,7 @@ All notable changes to this project will be documented in this file.
257
266
  ### Added
258
267
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
259
268
 
269
+ [0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
260
270
  [0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
261
271
  [0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
262
272
  [0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.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 (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+):**
77
+ **πŸ”„ Already have the kit installed? Upgrade to latest (session attribution + Cursor estimate/dedup in v0.11.0; 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, 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.
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 `[pending.startedAt, now]` plus leftover of the previous session β€” the same leftover-then-collect 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
 
@@ -802,10 +802,13 @@ Persist with `runtime: cloud` prints those four steps on stderr; stdout stays th
802
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
803
 
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
- - **`session.model`** β€” `--model` β†’ `## Metrics: model` β†’ `AOK_MODEL` β†’ collected sources (only with `--collect`) β†’ `null` (stderr warning). Never a Closed role.
805
+ - **`session.model`** β€” source product id wins when any collected source has a model; `--model` / `## Metrics: model` / `AOK_MODEL` apply only when sources have no model. 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 β†’ 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.
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. Persist collect window is `[pending.startedAt, endedAt]`; a late hook after persist goes to leftover of that session, not the next persist. `--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 `[pending.startedAt, now]` plus leftover of the previous session, 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
+ - **`sessionEnd` leftover** β€” `scripts/cursor-spend-collect.cjs` also reads the newest `openspec/changes/archive/*-<name>/metrics.json` when the active change folder is gone, so a late hook after archive still attaches.
810
+ - **Cursor `conversationId`** β€” restore writes `CURSOR_CONVERSATION_ID` into `pending.threadId`; Cursor collect skips rows whose `conversationId` does not match when a filter id is present.
811
+ - **Canonical role** β€” `session.role` stores the first known token (`Explorer`, `Architect`, `Spec Reviewer`, `Implementer`, `Archiver`, `Design Intake`); Closed role in `handoff.md` MAY keep a sentence after `β€”`.
809
812
  - **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
813
  - **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
814
  - **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).
@@ -1003,6 +1006,11 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1003
1006
 
1004
1007
  ## Changelog
1005
1008
 
1009
+ ### 0.11.0
1010
+ - Session metrics attribution: persist collect is `[pending.startedAt, endedAt]`; a late hook stays leftover of that session; archive is `[pending.startedAt, now]` plus leftover of the previous session
1011
+ - Source product id wins for `session.model`; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none
1012
+ - Cursor `sessionEnd` writes `costUsdEstimated`; `stop` + `afterAgentResponse` no longer double-count a turn
1013
+
1006
1014
  ### 0.10.0
1007
1015
  - `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
1016
 
@@ -5,7 +5,7 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
5
5
  import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
- import { collectSpend } from './spend-collect.js';
8
+ import { collectSpend, enrichMetricsCursorEstimates } from './spend-collect.js';
9
9
  import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
10
10
  import {
11
11
  earlierTimestamp,
@@ -1613,6 +1613,7 @@ function readHandoffFields(projectDir, changeName) {
1613
1613
 
1614
1614
  const METRICS_VERSION = 1;
1615
1615
  const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1616
+ const LEFTOVER_GRACE_MS = 120000;
1616
1617
 
1617
1618
  function metricsFilePath(projectDir, changeName) {
1618
1619
  return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
@@ -1713,12 +1714,29 @@ function addNullable(a, b) {
1713
1714
  return (a ?? 0) + (b ?? 0);
1714
1715
  }
1715
1716
 
1717
+ function canonicalRole(role) {
1718
+ const raw = String(role || '').trim();
1719
+ if (!raw) return '';
1720
+ const segment = raw.split(/[β€”,]/)[0].trim();
1721
+ const known = (text) => {
1722
+ const value = String(text || '').trim();
1723
+ if (/^spec\s+reviewer\b/i.test(value)) return 'Spec Reviewer';
1724
+ if (/^design\s+intake\b/i.test(value)) return 'Design Intake';
1725
+ if (/^explorer\b/i.test(value)) return 'Explorer';
1726
+ if (/^architect\b/i.test(value)) return 'Architect';
1727
+ if (/^implementer\b/i.test(value)) return 'Implementer';
1728
+ if (/^archiver\b/i.test(value)) return 'Archiver';
1729
+ return '';
1730
+ };
1731
+ return known(segment) || known(raw) || segment;
1732
+ }
1733
+
1716
1734
  function phaseForRole(role) {
1717
1735
  const value = String(role || '').toLowerCase();
1718
1736
  if (/explor/.test(value)) return 'explore';
1737
+ if (/architect|propose/.test(value)) return 'spec';
1719
1738
  if (/review/.test(value)) return 'review';
1720
1739
  if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
1721
- if (/architect|propose/.test(value)) return 'spec';
1722
1740
  if (/design/.test(value)) return 'design';
1723
1741
  if (/archiv/.test(value)) return 'archive';
1724
1742
  return 'other';
@@ -1741,8 +1759,46 @@ function lastSessionEndedAt(metrics) {
1741
1759
  return last;
1742
1760
  }
1743
1761
 
1744
- function collectWindowStart(metrics) {
1745
- return lastSessionEndedAt(metrics) || metrics.createdAt;
1762
+ function collectWindowStart(metrics, extra) {
1763
+ return (extra && extra.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1764
+ }
1765
+
1766
+ function leftoverGraceEnd(endedAt) {
1767
+ const ms = parseFlexibleIso(endedAt);
1768
+ if (!Number.isFinite(ms)) return null;
1769
+ return new Date(ms + LEFTOVER_GRACE_MS).toISOString();
1770
+ }
1771
+
1772
+ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1773
+ const t = parseFlexibleIso(at);
1774
+ if (!Number.isFinite(t)) return false;
1775
+ const start = parseFlexibleIso(windowStart);
1776
+ if (Number.isFinite(start) && t < start) return false;
1777
+ const end = parseFlexibleIso(leftoverEnd);
1778
+ if (Number.isFinite(end)) {
1779
+ if (exclusiveEnd) {
1780
+ if (t >= end) return false;
1781
+ } else if (t > end) return false;
1782
+ }
1783
+ return true;
1784
+ }
1785
+
1786
+ function sessionSpendIsFrozen(session) {
1787
+ if (!session) return false;
1788
+ if (session.spendSource === 'flag') return true;
1789
+ if (!reportedHasSpendNumbers(session)) return false;
1790
+ if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
1791
+ return true;
1792
+ }
1793
+
1794
+ function existingSourceRecords(metrics) {
1795
+ const out = [];
1796
+ for (const session of metrics.sessions || []) {
1797
+ for (const src of session.sources || []) {
1798
+ if (src) out.push(src);
1799
+ }
1800
+ }
1801
+ return out;
1746
1802
  }
1747
1803
 
1748
1804
  function existingSourceIdSet(metrics) {
@@ -1802,8 +1858,9 @@ function rankSources(sources) {
1802
1858
  }
1803
1859
 
1804
1860
  function primaryModelFromSources(sources) {
1805
- if (!sources || !sources.length) return null;
1806
- const top = rankSources(sources)[0];
1861
+ const withModel = (sources || []).filter((src) => src && src.model);
1862
+ if (!withModel.length) return null;
1863
+ const top = rankSources(withModel)[0];
1807
1864
  const model = top && top.model;
1808
1865
  return model == null || model === '' ? null : String(model);
1809
1866
  }
@@ -1884,7 +1941,7 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1884
1941
  const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
1885
1942
  const costUsdEstimated = sourceEstimatedUsd(sources || []);
1886
1943
  let spendSource = 'unreported';
1887
- if (self.spendSource) spendSource = String(self.spendSource);
1944
+ if (self.spendSource && reportedHasSpendNumbers(self)) spendSource = String(self.spendSource);
1888
1945
  else if (hasSpendOverride(flags)) spendSource = 'flag';
1889
1946
  else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
1890
1947
  else if (fromAmp.costUsd != null) spendSource = 'amp-usage';
@@ -1936,11 +1993,13 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1936
1993
  windowStart,
1937
1994
  windowEnd,
1938
1995
  existingSourceIds: existingSourceIdSet(metrics),
1996
+ existingSources: existingSourceRecords(metrics),
1939
1997
  env: process.env,
1940
1998
  homedir: process.env.HOME,
1941
1999
  platforms: extra.platforms,
1942
2000
  ampThreadId: extra.ampThreadId,
1943
2001
  ampCli: extra.ampCli === true,
2002
+ cursorConversationId: extra.cursorConversationId,
1944
2003
  exportAmpThread: extra.exportAmpThread,
1945
2004
  usageAmpThread: extra.usageAmpThread,
1946
2005
  });
@@ -1949,6 +2008,37 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1949
2008
  }
1950
2009
  }
1951
2010
 
2011
+ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2012
+ if (!session || !session.endedAt || !leftoverEnd) return 0;
2013
+ const exclusiveEnd = extra.exclusiveEnd === true;
2014
+ const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
2015
+ platforms: extra.platforms,
2016
+ ampThreadId: session.threadId || extra.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2017
+ ampCli: extra.ampCli === true || session.platform === 'amp',
2018
+ cursorConversationId: extra.cursorConversationId
2019
+ || (session.platform === 'cursor' ? session.threadId : undefined),
2020
+ });
2021
+ const incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2022
+ src.at,
2023
+ session.endedAt,
2024
+ leftoverEnd,
2025
+ exclusiveEnd,
2026
+ ));
2027
+ if (!incoming.length) return 0;
2028
+ const merged = [...(session.sources || []), ...incoming];
2029
+ if (sessionSpendIsFrozen(session)) {
2030
+ session.sources = merged;
2031
+ const uniqueModels = uniqueSourceModels(merged);
2032
+ if (uniqueModels.length > 1) session.models = uniqueModels;
2033
+ } else {
2034
+ applyCollectedSessionFields(session, merged, session.model, {}, {
2035
+ model: session.model,
2036
+ platform: session.platform,
2037
+ }, { ampThreads: collected.ampThreads });
2038
+ }
2039
+ return incoming.length;
2040
+ }
2041
+
1952
2042
  function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
1953
2043
  session.sources = sources || [];
1954
2044
  const uniqueModels = uniqueSourceModels(session.sources);
@@ -1956,14 +2046,16 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
1956
2046
  for (const model of fromAmp.models) {
1957
2047
  if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
1958
2048
  }
1959
- if (!session.model || isPlaceholderModel(session.model)) {
2049
+ if (uniqueModels.length > 1) session.models = uniqueModels;
2050
+ const sourceModel = primaryModelFromSources(session.sources);
2051
+ if (sourceModel) {
2052
+ session.model = sourceModel;
2053
+ } else {
1960
2054
  session.model = (resolvedModel && !isPlaceholderModel(resolvedModel) ? resolvedModel : null)
1961
- || primaryModelFromSources(session.sources)
1962
2055
  || fromAmp.models[0]
1963
2056
  || null;
1964
2057
  }
1965
2058
  if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
1966
- if (uniqueModels.length > 1) session.models = uniqueModels;
1967
2059
  const spend = resolveSessionSpend(opts, reported, session.sources, extra);
1968
2060
  session.inputTokens = spend.inputTokens;
1969
2061
  session.outputTokens = spend.outputTokens;
@@ -2005,21 +2097,17 @@ function metricsBackfillFile(filePath, changeName) {
2005
2097
  const sessions = metrics.sessions || [];
2006
2098
  if (!sessions.length) return { filePath, added: 0, empty: true };
2007
2099
  const last = sessions[sessions.length - 1];
2008
- const windowStart = last.startedAt || collectWindowStart(metrics);
2100
+ const windowStart = last.startedAt || last.endedAt || metrics.createdAt;
2009
2101
  const lastPlatform = last && last.platform;
2010
2102
  const collected = runCollectSpend(metrics, windowStart, nowIso, {
2011
2103
  ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
2012
2104
  ampCli: lastPlatform === 'amp',
2105
+ cursorConversationId: lastPlatform === 'cursor' ? last.threadId : undefined,
2013
2106
  });
2014
2107
  const incoming = collected.sources || [];
2015
2108
  if (!incoming.length) return { filePath, added: 0 };
2016
- const overridden = sessionTotalsLookOverridden(last);
2017
2109
  const merged = [...(last.sources || []), ...incoming];
2018
- const keepReportedTotals = overridden
2019
- || last.spendSource === 'flag'
2020
- || last.spendSource === 'self-report'
2021
- || (last.spendSource && last.spendSource !== 'adapter' && last.spendSource !== 'unreported');
2022
- if (keepReportedTotals) {
2110
+ if (sessionSpendIsFrozen(last)) {
2023
2111
  last.sources = merged;
2024
2112
  const uniqueModels = uniqueSourceModels(merged);
2025
2113
  if (uniqueModels.length > 1) last.models = uniqueModels;
@@ -2103,41 +2191,23 @@ function recomputeSpendMaps(metrics) {
2103
2191
  }
2104
2192
 
2105
2193
  for (const session of metrics.sessions || []) {
2106
- const sessionNums = spendTuple(session);
2107
- const sourceTotals = sessionTotalsFromSources(session.sources || []);
2108
- sourceTotals.ampCredits = sourceAmpCredits(session.sources || []);
2109
2194
  const sources = session.sources || [];
2110
- const sourcesMatchSession = sources.length > 0 && spendTuplesMatch(sessionNums, sourceTotals);
2111
-
2112
- if (sourcesMatchSession) {
2195
+ if (sources.length > 0) {
2113
2196
  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
- }
2127
2197
  continue;
2128
2198
  }
2129
-
2199
+ const sessionNums = spendTuple(session);
2130
2200
  if (session.platform && byPlatform[session.platform]) {
2131
2201
  addSpendNums(byPlatform[session.platform], sessionNums);
2132
2202
  }
2133
2203
  addModelRow(session.model, session.platform, sessionNums);
2134
- for (const src of sources) addSourceRow(src);
2135
2204
  }
2136
2205
  metrics.spendByPlatform = byPlatform;
2137
2206
  metrics.spendByModel = [...byModel.values()];
2138
2207
  }
2139
2208
 
2140
2209
  function recomputeMetricsAggregates(metrics) {
2210
+ enrichMetricsCursorEstimates(metrics, process.cwd());
2141
2211
  const phases = {};
2142
2212
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
2143
2213
  const spend = emptySpendTotals();
@@ -2154,7 +2224,14 @@ function recomputeMetricsAggregates(metrics) {
2154
2224
  phase.sessions += 1;
2155
2225
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
2156
2226
  for (const spendKey of METRICS_SPEND_KEYS) {
2157
- const value = sessionFieldOrSources(session, spendKey);
2227
+ let value = null;
2228
+ if ((session.sources || []).length > 0) {
2229
+ for (const src of session.sources) {
2230
+ value = addNullable(value, numOrNull(src[spendKey]));
2231
+ }
2232
+ } else {
2233
+ value = sessionFieldOrSources(session, spendKey);
2234
+ }
2158
2235
  phase[spendKey] = addNullable(phase[spendKey], value);
2159
2236
  spend[spendKey] = addNullable(spend[spendKey], value);
2160
2237
  }
@@ -2188,6 +2265,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2188
2265
  clientSource: client.source || null,
2189
2266
  };
2190
2267
  metrics.updatedAt = nowIso;
2268
+ recomputeMetricsAggregates(metrics);
2191
2269
  saveMetricsFile(filePath, metrics);
2192
2270
  return filePath;
2193
2271
  }
@@ -2196,33 +2274,43 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2196
2274
  const filePath = metricsFilePath(projectDir, fields.changeName);
2197
2275
  const nowIso = nowUtcIso();
2198
2276
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
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;
2277
+ const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
2278
+ const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
2279
+ ? metrics.pending.startedAt
2280
+ : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2281
+ attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2282
+ exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2283
+ });
2284
+ let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2205
2285
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2206
2286
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2207
- const windowStart = collectWindowStart(metrics);
2287
+ const windowStart = collectWindowStart(metrics, { startedAt });
2208
2288
  const pending = metrics.pending || {};
2209
2289
  const platform = opts.platform || pending.platform || null;
2210
2290
  const collectAll = opts.collect === true;
2211
2291
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2212
2292
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2293
+ const endedAt = nowIso;
2213
2294
  const collected = shouldCollect
2214
- ? runCollectSpend(metrics, windowStart, nowIso, {
2295
+ ? runCollectSpend(metrics, windowStart, endedAt, {
2215
2296
  platforms,
2216
2297
  ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2217
2298
  ampCli: collectAll || platform === 'amp',
2299
+ cursorConversationId: platform === 'cursor' ? pending.threadId : undefined,
2218
2300
  })
2219
2301
  : { sources: [] };
2302
+ const startedMs = parseFlexibleIso(startedAt);
2303
+ const endedMs = parseFlexibleIso(endedAt);
2304
+ let durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
2305
+ ? Math.max(0, endedMs - startedMs)
2306
+ : null;
2307
+ const closedRole = canonicalRole(fields.closedRole) || fields.closedRole || '';
2220
2308
  const session = {
2221
2309
  startedAt,
2222
- endedAt: nowIso,
2310
+ endedAt,
2223
2311
  durationMs,
2224
- role: fields.closedRole || '',
2225
- phase: phaseForRole(fields.closedRole),
2312
+ role: closedRole,
2313
+ phase: phaseForRole(closedRole),
2226
2314
  runtime: fields.runtime || 'local',
2227
2315
  agentId: fields.agentId || 'none',
2228
2316
  model: resolvedModel || null,
@@ -2239,6 +2327,22 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2239
2327
  spendSource: 'unreported',
2240
2328
  };
2241
2329
  applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2330
+ if (!session.startedAt) {
2331
+ let earliest = null;
2332
+ for (const src of session.sources || []) {
2333
+ const at = isoOrNull(src.at);
2334
+ if (!at) continue;
2335
+ earliest = earliest == null ? at : earlierTimestamp(earliest, at);
2336
+ }
2337
+ if (earliest) {
2338
+ session.startedAt = earliest;
2339
+ const fromMs = parseFlexibleIso(session.startedAt);
2340
+ const toMs = parseFlexibleIso(session.endedAt);
2341
+ session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
2342
+ ? Math.max(0, toMs - fromMs)
2343
+ : null;
2344
+ }
2345
+ }
2242
2346
  metrics.sessions.push(session);
2243
2347
  metrics.pending = null;
2244
2348
  metrics.updatedAt = nowIso;
@@ -2256,25 +2360,33 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2256
2360
  const filePath = join(targetDir, 'metrics.json');
2257
2361
  const nowIso = nowUtcIso();
2258
2362
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2259
- const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
2363
+ const pending = metrics.pending || {};
2364
+ const startedAt = isoOrNull(pending.startedAt) || nowIso;
2365
+ const endedAt = nowIso;
2366
+ const startedMs = parseFlexibleIso(startedAt);
2367
+ const endedMs = parseFlexibleIso(endedAt);
2368
+ const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
2369
+ ? Math.max(0, endedMs - startedMs)
2370
+ : 0;
2260
2371
  const collectAll = opts.collect === true;
2261
- const platform = opts.platform || null;
2372
+ const platform = opts.platform || pending.platform || null;
2262
2373
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2263
2374
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2264
- const ampThreadId = opts.ampThreadId || ampThreadIdFromEnv(process.env) || null;
2375
+ const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
2265
2376
  const collected = shouldCollect
2266
- ? runCollectSpend(metrics, windowStart, nowIso, {
2377
+ ? runCollectSpend(metrics, startedAt, endedAt, {
2267
2378
  platforms,
2268
2379
  ampThreadId,
2269
2380
  ampCli: collectAll || platform === 'amp',
2381
+ cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
2270
2382
  })
2271
2383
  : { sources: [] };
2272
2384
  const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
2273
2385
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2274
2386
  const session = {
2275
- startedAt: nowIso,
2276
- endedAt: nowIso,
2277
- durationMs: null,
2387
+ startedAt,
2388
+ endedAt,
2389
+ durationMs,
2278
2390
  role: 'Archiver',
2279
2391
  phase: phaseForRole('Archiver'),
2280
2392
  runtime: opts.runtime || 'local',
@@ -2308,6 +2420,29 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2308
2420
  return filePath;
2309
2421
  }
2310
2422
 
2423
+ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2424
+ const filePath = join(changeRoot, 'metrics.json');
2425
+ if (!existsSync(filePath)) return;
2426
+ const nowIso = nowUtcIso();
2427
+ const metrics = loadMetricsFile(filePath, changeName, nowIso);
2428
+ if (metrics.pending == null) {
2429
+ metrics.pending = {
2430
+ startedAt: nowIso,
2431
+ role: 'Archiver',
2432
+ platform: client.platform || null,
2433
+ threadId: client.threadId || null,
2434
+ clientSource: client.source || null,
2435
+ };
2436
+ }
2437
+ const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2438
+ attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
2439
+ exclusiveEnd: true,
2440
+ });
2441
+ metrics.updatedAt = nowIso;
2442
+ recomputeMetricsAggregates(metrics);
2443
+ saveMetricsFile(filePath, metrics);
2444
+ }
2445
+
2311
2446
  function formatMetricsDuration(durationMs) {
2312
2447
  if (durationMs == null || !Number.isFinite(durationMs)) return 'β€”';
2313
2448
  const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
@@ -3691,7 +3826,13 @@ program
3691
3826
  }
3692
3827
  }
3693
3828
 
3694
- // Move change into the dated archive
3829
+ const archiveClient = resolveRestoreClient({
3830
+ env: process.env,
3831
+ cwd: projectDir,
3832
+ homedir: process.env.HOME,
3833
+ platform: opts.platform,
3834
+ });
3835
+ metricsPrepareArchiveStart(changeRoot, name, archiveClient);
3695
3836
  mkdirSync(dirname(targetDir), { recursive: true });
3696
3837
  renameSync(changeRoot, targetDir);
3697
3838
 
@@ -4262,4 +4403,4 @@ if (isDirectCliRun()) {
4262
4403
  program.parse();
4263
4404
  }
4264
4405
 
4265
- export { formatMetricsCostLine, resolveSessionSpend };
4406
+ export { formatMetricsCostLine, resolveSessionSpend, canonicalRole, phaseForRole };
@@ -146,7 +146,7 @@ export function detectSessionClient(options = {}) {
146
146
  }
147
147
 
148
148
  if (envFlagOn(env.CURSOR_AGENT) || trim(env.CURSOR_CONVERSATION_ID)) {
149
- return { platform: 'cursor', threadId: null, source: 'cursor-env' };
149
+ return { platform: 'cursor', threadId: trim(env.CURSOR_CONVERSATION_ID) || null, source: 'cursor-env' };
150
150
  }
151
151
  if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || trim(env.CLAUDE_CODE_ENTRYPOINT)) {
152
152
  return { platform: 'claude', threadId: null, source: 'claude-env' };