agent-orchestrator-kit 0.12.0 → 0.13.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,11 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.0] - 2026-09-04
8
+
9
+ ### Fixed
10
+ - **Amp Cost-once rollup, restore `amp-session-last`, leftover thread-scope.** After persist/recompute, Amp `Cost: $N` is added once **per qualifying session** into `spend.costUsd`, `spendByPlatform.amp.costUsd`, and `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last` (Amp parent without tty still uses `amp threads list`). Leftover without `--collect` scopes to `last.platform` and the Amp thread id / `T-…` prefix; an explicit `ampThreadId` does not add the env thread. Leftover after `amp-usage` resyncs tokens from sources and keeps billed Cost; `usageModels` stay unique to that thread; `pending.role` is the canonical token.
11
+
7
12
  ## [0.12.0] - 2026-09-02
8
13
 
9
14
  ### Fixed
@@ -271,6 +276,7 @@ All notable changes to this project will be documented in this file.
271
276
  ### Added
272
277
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
273
278
 
279
+ [0.13.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.12.0...v0.13.0
274
280
  [0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
275
281
  [0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
276
282
  [0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.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 leftover race + multi-root + per-phase clock in v0.12.0; 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+):**
77
+ **🔄 Already have the kit installed? Upgrade to latest (Amp Cost-once rollup + `amp-session-last` + leftover thread-scope in v0.13.0; Cursor leftover race + multi-root + per-phase clock in v0.12.0; 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
@@ -804,13 +804,14 @@ 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`** — 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. 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`.
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. After persist/recompute, that Cost is a **Cost-once rollup**: once **per qualifying session** into `spend.costUsd` / `spendByPlatform.amp.costUsd` / `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. 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
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
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. `stop` / `afterAgentResponse` run the same leftover after a successful jsonl append. When `last.threadId` is non-empty, leftover keeps only rows whose `conversationId` matches that id (empty/`null` threadId stays time-only). In a multi-root window leftover walks every candidate that has `openspec/changes`, reading that root’s jsonl. Aggregates write `costUsdEstimated` with 4 decimal places. Each `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` from that phase’s sessions; `durationMs` stays the work-time sum and does not clone `totals.leadTimeMs`.
810
+ - **Leftover without `--collect`** — Scopes to `last.platform` and, for Amp, leftover **thread-scope**: the session thread id or the `T-…` prefix from `sources[].id` (`T-apply:8` → `T-apply`). An explicit `ampThreadId` does not also add the env thread. After `amp-usage`, leftover **token resync** sums tokens from all sources and keeps billed Cost (`spendSource: amp-usage`). `usageModels` stay unique to that thread.
810
811
  - **Cursor `conversationId`** — restore writes `CURSOR_CONVERSATION_ID` into `pending.threadId`; Cursor collect and sessionEnd leftover skip rows whose `conversationId` does not match when a filter id is present (`last.threadId` on leftover).
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 `—`.
812
+ - **Canonical role** — `session.role` and `pending.role` store the first known token (`Explorer`, `Architect`, `Spec Reviewer`, `Implementer`, `Archiver`, `Design Intake`); Closed role in `handoff.md` MAY keep a sentence after `—`.
812
813
  - **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.
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.
814
+ - **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`. When env and Amp parent do not win, restore locks a fresh `session.json` `lastThreadId` as `amp-session-last`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
814
815
  - **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`. After a successful `stop` / `afterAgentResponse` append the hook runs leftover (fail-open, no stdout). Hook and collect resolve the consumer in a multi-root window (not the first cwd with `.agents` or `openspec`). 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).
815
816
 
816
817
  Aggregates are recomputed on every write: per-phase totals (`startedAt`, `endedAt`, `leadTimeMs` from that phase’s sessions, `durationMs` = sum of session work time — not `totals.leadTimeMs` and not `endedAt − startedAt`, tokens, `costUsd`, `costUsdEstimated` to 4 decimals, `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.
@@ -1006,6 +1007,11 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1006
1007
 
1007
1008
  ## Changelog
1008
1009
 
1010
+ ### 0.13.0
1011
+ - Amp Cost-once rollup: `Cost: $N` once per qualifying session into `spend` / `spendByPlatform.amp` / `phases` (not ×N sources; several sessions accumulate)
1012
+ - Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last`
1013
+ - Leftover without `--collect` scopes to `last.platform` and Amp thread / `T-…` prefix; leftover after `amp-usage` resyncs tokens and keeps billed Cost
1014
+
1009
1015
  ### 0.12.0
1010
1016
  - Cursor leftover after `stop` / `afterAgentResponse` (fail-open, idempotent `sessionEnd`); leftover filters `last.threadId` when it is set
1011
1017
  - Multi-root hook/collect resolve the consumer window; leftover walks every candidate with `openspec/changes`
@@ -1788,12 +1788,64 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1788
1788
  return true;
1789
1789
  }
1790
1790
 
1791
+ function uniqueAmpSourceThreadPrefix(sources) {
1792
+ const prefixes = [];
1793
+ for (const src of sources || []) {
1794
+ const id = src && src.id != null ? String(src.id) : '';
1795
+ if (!id.startsWith('T-')) continue;
1796
+ const prefix = id.split(':')[0];
1797
+ if (!prefix) continue;
1798
+ if (!prefixes.includes(prefix)) prefixes.push(prefix);
1799
+ }
1800
+ return prefixes.length === 1 ? prefixes[0] : null;
1801
+ }
1802
+
1803
+ function leftoverAmpThreadId(session) {
1804
+ if (!session) return null;
1805
+ if (session.threadId) return String(session.threadId);
1806
+ return uniqueAmpSourceThreadPrefix(session.sources);
1807
+ }
1808
+
1809
+ function sessionAmpThreadKeys(session) {
1810
+ const keys = new Set();
1811
+ if (session && session.threadId) keys.add(String(session.threadId));
1812
+ for (const src of (session && session.sources) || []) {
1813
+ const id = src && src.id != null ? String(src.id) : '';
1814
+ if (!id.startsWith('T-')) continue;
1815
+ const prefix = id.split(':')[0];
1816
+ if (prefix) keys.add(prefix);
1817
+ }
1818
+ return keys;
1819
+ }
1820
+
1821
+ function usageModelTokenCount(row) {
1822
+ const total = numOrNull(row && row.totalTokens);
1823
+ if (total != null) return total;
1824
+ return (numOrNull(row && row.inputTokens) ?? 0) + (numOrNull(row && row.outputTokens) ?? 0);
1825
+ }
1826
+
1827
+ function usageModelsForSession(session, ampThreads) {
1828
+ const allowed = sessionAmpThreadKeys(session);
1829
+ const byModel = new Map();
1830
+ for (const thread of ampThreads || []) {
1831
+ const id = thread && thread.id != null ? String(thread.id) : '';
1832
+ if (!allowed.has(id)) continue;
1833
+ for (const row of (thread && thread.models) || []) {
1834
+ if (!row || !row.model) continue;
1835
+ const key = String(row.model);
1836
+ const prev = byModel.get(key);
1837
+ if (!prev || usageModelTokenCount(row) >= usageModelTokenCount(prev)) {
1838
+ byModel.set(key, row);
1839
+ }
1840
+ }
1841
+ }
1842
+ return [...byModel.values()];
1843
+ }
1844
+
1791
1845
  function sessionSpendIsFrozen(session) {
1792
1846
  if (!session) return false;
1793
1847
  if (session.spendSource === 'flag') return true;
1794
- if (!reportedHasSpendNumbers(session)) return false;
1795
- if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
1796
- return true;
1848
+ return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
1797
1849
  }
1798
1850
 
1799
1851
  function existingSourceRecords(metrics) {
@@ -1994,41 +2046,90 @@ function sessionTotalsFromSources(sources) {
1994
2046
  function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1995
2047
  try {
1996
2048
  return collectSpend({
1997
- cwd: process.cwd(),
2049
+ cwd: extra.cwd || process.cwd(),
1998
2050
  windowStart,
1999
2051
  windowEnd,
2000
2052
  existingSourceIds: existingSourceIdSet(metrics),
2001
2053
  existingSources: existingSourceRecords(metrics),
2002
- env: process.env,
2003
- homedir: process.env.HOME,
2054
+ env: extra.env || process.env,
2055
+ homedir: extra.homedir || process.env.HOME,
2004
2056
  platforms: extra.platforms,
2005
2057
  ampThreadId: extra.ampThreadId,
2006
2058
  ampCli: extra.ampCli === true,
2007
2059
  cursorConversationId: extra.cursorConversationId,
2008
2060
  exportAmpThread: extra.exportAmpThread,
2009
2061
  usageAmpThread: extra.usageAmpThread,
2062
+ listRecentAmpThreads: extra.listRecentAmpThreads,
2063
+ listAmpThreads: extra.listAmpThreads,
2010
2064
  });
2011
2065
  } catch {
2012
2066
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2013
2067
  }
2014
2068
  }
2015
2069
 
2070
+ function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
2071
+ if (collectAll) return undefined;
2072
+ if (session.platform === 'amp' || session.platform === 'cursor' || session.platform === 'claude') {
2073
+ if (session.platform === 'amp' && !ampThreadId) return [];
2074
+ return [session.platform];
2075
+ }
2076
+ if (session.platform == null && ampThreadId) return ['amp'];
2077
+ return [];
2078
+ }
2079
+
2080
+ function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
2081
+ const fromSources = sessionTotalsFromSources(session.sources || []);
2082
+ session.inputTokens = fromSources.inputTokens;
2083
+ session.outputTokens = fromSources.outputTokens;
2084
+ let totalTokens = fromSources.totalTokens;
2085
+ if (totalTokens == null && (fromSources.inputTokens != null || fromSources.outputTokens != null)) {
2086
+ totalTokens = (fromSources.inputTokens ?? 0) + (fromSources.outputTokens ?? 0);
2087
+ }
2088
+ session.totalTokens = totalTokens;
2089
+ if (fromSources.costUsd == null && priorCost != null) {
2090
+ session.costUsd = priorCost;
2091
+ }
2092
+ if (priorSource === 'amp-usage' && numOrNull(session.costUsd) != null) {
2093
+ session.spendSource = 'amp-usage';
2094
+ }
2095
+ }
2096
+
2016
2097
  function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2017
2098
  if (!session || !session.endedAt || !leftoverEnd) return 0;
2018
2099
  const exclusiveEnd = extra.exclusiveEnd === true;
2100
+ const collectAll = extra.collect === true;
2101
+ const ampThreadId = leftoverAmpThreadId(session);
2102
+ const platforms = leftoverCollectPlatforms(session, collectAll, ampThreadId);
2103
+ if (!collectAll && (!Array.isArray(platforms) || platforms.length === 0)) return 0;
2104
+ const collectAmp = collectAll || (Array.isArray(platforms) && platforms.includes('amp'));
2105
+ const collectCursor = collectAll || (Array.isArray(platforms) && platforms.includes('cursor'));
2019
2106
  const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
2020
- platforms: extra.platforms,
2021
- ampThreadId: session.threadId || extra.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2022
- ampCli: extra.ampCli === true || session.platform === 'amp',
2107
+ platforms,
2108
+ ...(ampThreadId ? { ampThreadId } : {}),
2109
+ listRecentAmpThreads: false,
2110
+ ampCli: extra.ampCli === true || collectAmp,
2023
2111
  cursorConversationId: extra.cursorConversationId
2024
- || (session.platform === 'cursor' ? session.threadId : undefined),
2112
+ || (collectCursor && session.platform === 'cursor' ? session.threadId : undefined),
2113
+ exportAmpThread: extra.exportAmpThread,
2114
+ usageAmpThread: extra.usageAmpThread,
2115
+ listAmpThreads: extra.listAmpThreads,
2116
+ env: extra.env,
2117
+ cwd: extra.cwd,
2118
+ homedir: extra.homedir,
2025
2119
  });
2026
- const incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2120
+ let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2027
2121
  src.at,
2028
2122
  session.endedAt,
2029
2123
  leftoverEnd,
2030
2124
  exclusiveEnd,
2031
2125
  ));
2126
+ if (ampThreadId) {
2127
+ const prefix = `${ampThreadId}:`;
2128
+ incoming = incoming.filter((src) => {
2129
+ if (src && src.platform === 'amp') return String(src.id || '').startsWith(prefix);
2130
+ return true;
2131
+ });
2132
+ }
2032
2133
  if (!incoming.length) return 0;
2033
2134
  const merged = [...(session.sources || []), ...incoming];
2034
2135
  if (sessionSpendIsFrozen(session)) {
@@ -2036,10 +2137,13 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2036
2137
  const uniqueModels = uniqueSourceModels(merged);
2037
2138
  if (uniqueModels.length > 1) session.models = uniqueModels;
2038
2139
  } else {
2140
+ const priorCost = numOrNull(session.costUsd);
2141
+ const priorSource = session.spendSource;
2039
2142
  applyCollectedSessionFields(session, merged, session.model, {}, {
2040
2143
  model: session.model,
2041
2144
  platform: session.platform,
2042
- }, { ampThreads: collected.ampThreads });
2145
+ }, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
2146
+ resyncLeftoverSessionSpend(session, priorCost, priorSource);
2043
2147
  }
2044
2148
  return incoming.length;
2045
2149
  }
@@ -2070,13 +2174,10 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2070
2174
  session.costUsdEstimated = spend.costUsdEstimated;
2071
2175
  session.spendSource = spend.spendSource;
2072
2176
  if (spend.agentMode) session.agentMode = spend.agentMode;
2073
- const usageModels = [];
2074
- for (const thread of extra.ampThreads || []) {
2075
- for (const row of thread.models || []) {
2076
- if (row && row.model) usageModels.push(row);
2077
- }
2177
+ const usageModels = usageModelsForSession(session, extra.ampThreads);
2178
+ if (usageModels.length || Array.isArray(session.usageModels)) {
2179
+ session.usageModels = usageModels;
2078
2180
  }
2079
- if (usageModels.length) session.usageModels = usageModels;
2080
2181
  }
2081
2182
 
2082
2183
  function sessionTotalsLookOverridden(session) {
@@ -2199,6 +2300,14 @@ function recomputeSpendMaps(metrics) {
2199
2300
  const sources = session.sources || [];
2200
2301
  if (sources.length > 0) {
2201
2302
  for (const src of sources) addSourceRow(src);
2303
+ let sourceCost = null;
2304
+ for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2305
+ if (sourceCost == null && session.platform && byPlatform[session.platform]) {
2306
+ byPlatform[session.platform].costUsd = addNullable(
2307
+ byPlatform[session.platform].costUsd,
2308
+ numOrNull(session.costUsd),
2309
+ );
2310
+ }
2202
2311
  continue;
2203
2312
  }
2204
2313
  const sessionNums = spendTuple(session);
@@ -2258,11 +2367,18 @@ function recomputeMetricsAggregates(metrics) {
2258
2367
  ? session.endedAt
2259
2368
  : laterTimestamp(phase.endedAt, session.endedAt);
2260
2369
  }
2370
+ const sources = session.sources || [];
2371
+ let sourceCost = null;
2372
+ for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2261
2373
  for (const spendKey of METRICS_SPEND_KEYS) {
2262
2374
  let value = null;
2263
- if ((session.sources || []).length > 0) {
2264
- for (const src of session.sources) {
2265
- value = addNullable(value, numOrNull(src[spendKey]));
2375
+ if (sources.length > 0) {
2376
+ if (spendKey === 'costUsd') {
2377
+ value = sourceCost == null ? numOrNull(session.costUsd) : sourceCost;
2378
+ } else {
2379
+ for (const src of sources) {
2380
+ value = addNullable(value, numOrNull(src[spendKey]));
2381
+ }
2266
2382
  }
2267
2383
  } else {
2268
2384
  value = sessionFieldOrSources(session, spendKey);
@@ -2303,7 +2419,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2303
2419
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2304
2420
  metrics.pending = {
2305
2421
  startedAt: nowIso,
2306
- role: role || '',
2422
+ role: canonicalRole(role) || role || '',
2307
2423
  platform: client.platform || null,
2308
2424
  threadId: client.threadId || null,
2309
2425
  clientSource: client.source || null,
@@ -2324,6 +2440,7 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2324
2440
  : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2325
2441
  attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2326
2442
  exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2443
+ collect: opts.collect === true,
2327
2444
  });
2328
2445
  let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2329
2446
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
@@ -2464,7 +2581,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2464
2581
  return filePath;
2465
2582
  }
2466
2583
 
2467
- function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2584
+ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
2468
2585
  const filePath = join(changeRoot, 'metrics.json');
2469
2586
  if (!existsSync(filePath)) return;
2470
2587
  const nowIso = nowUtcIso();
@@ -2481,6 +2598,7 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2481
2598
  const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2482
2599
  attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
2483
2600
  exclusiveEnd: true,
2601
+ collect: extra.collect === true,
2484
2602
  });
2485
2603
  metrics.updatedAt = nowIso;
2486
2604
  recomputeMetricsAggregates(metrics);
@@ -3876,7 +3994,7 @@ program
3876
3994
  homedir: process.env.HOME,
3877
3995
  platform: opts.platform,
3878
3996
  });
3879
- metricsPrepareArchiveStart(changeRoot, name, archiveClient);
3997
+ metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
3880
3998
  mkdirSync(dirname(targetDir), { recursive: true });
3881
3999
  renameSync(changeRoot, targetDir);
3882
4000
 
@@ -4447,4 +4565,13 @@ if (isDirectCliRun()) {
4447
4565
  program.parse();
4448
4566
  }
4449
4567
 
4450
- export { formatMetricsCostLine, resolveSessionSpend, canonicalRole, phaseForRole };
4568
+ export {
4569
+ formatMetricsCostLine,
4570
+ resolveSessionSpend,
4571
+ canonicalRole,
4572
+ phaseForRole,
4573
+ recomputeMetricsAggregates,
4574
+ sessionSpendIsFrozen,
4575
+ attachLeftoverSources,
4576
+ runCollectSpend,
4577
+ };
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readlinkSync } from 'fs';
1
+ import { existsSync, readFileSync, readlinkSync, statSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir as osHomedir } from 'os';
4
4
  import { execFileSync } from 'child_process';
@@ -114,28 +114,68 @@ function isFreshTimestamp(value, now, maxAgeMs) {
114
114
  return now - n >= 0 && now - n <= maxAgeMs;
115
115
  }
116
116
 
117
+ function isSessionFileFresh(updatedAt, filePath, now, maxAgeMs) {
118
+ if (updatedAt != null && updatedAt !== '') {
119
+ return isFreshTimestamp(updatedAt, now, maxAgeMs);
120
+ }
121
+ try {
122
+ return isFreshTimestamp(statSync(filePath).mtimeMs, now, maxAgeMs);
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function emptyAmpSessionHint(filePath, fileFresh, updatedAt) {
129
+ return {
130
+ threadId: '',
131
+ source: '',
132
+ lastThreadId: '',
133
+ filePath: filePath || '',
134
+ fileFresh: Boolean(fileFresh),
135
+ updatedAt: updatedAt == null ? '' : updatedAt,
136
+ };
137
+ }
138
+
117
139
  export function readAmpSessionHint(options = {}) {
118
140
  const env = options.env || {};
119
141
  const homedir = options.homedir || env.HOME;
120
142
  const now = options.now != null ? Number(options.now) : Date.now();
121
143
  const maxAgeMs = options.maxAgeMs != null ? Number(options.maxAgeMs) : AMP_TTY_MAX_AGE_MS;
122
144
  const filePath = join(ampDataRoot(env, homedir), 'session.json');
123
- if (!existsSync(filePath)) return { threadId: '', source: '' };
145
+ if (!existsSync(filePath)) return emptyAmpSessionHint(filePath, false, '');
124
146
  let data;
125
147
  try {
126
148
  data = JSON.parse(readFileSync(filePath, 'utf-8'));
127
149
  } catch {
128
- return { threadId: '', source: '' };
150
+ return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
151
+ }
152
+ if (!data || typeof data !== 'object') {
153
+ return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
129
154
  }
130
- if (!data || typeof data !== 'object') return { threadId: '', source: '', lastThreadId: '' };
131
155
  const lastThreadId = trim(data.lastThreadId);
156
+ const updatedAt = data.updatedAt;
157
+ const fileFresh = isSessionFileFresh(updatedAt, filePath, now, maxAgeMs);
132
158
  const rawTty = options.ttyKey != null ? options.ttyKey : currentTtyKey(env, options.readlink);
133
159
  const ttyKey = isUsableTtyPath(rawTty) ? (String(rawTty).startsWith('tty:') ? rawTty : `tty:${rawTty}`) : '';
134
160
  const byTty = data.lastThreadByTerminal && ttyKey ? data.lastThreadByTerminal[ttyKey] : null;
135
161
  if (byTty && trim(byTty.lastThreadId) && isFreshTimestamp(byTty.updatedAt, now, maxAgeMs)) {
136
- return { threadId: trim(byTty.lastThreadId), source: 'amp-session-tty', lastThreadId };
162
+ return {
163
+ threadId: trim(byTty.lastThreadId),
164
+ source: 'amp-session-tty',
165
+ lastThreadId,
166
+ filePath,
167
+ fileFresh,
168
+ updatedAt,
169
+ };
137
170
  }
138
- return { threadId: '', source: '', lastThreadId };
171
+ return {
172
+ threadId: '',
173
+ source: '',
174
+ lastThreadId,
175
+ filePath,
176
+ fileFresh,
177
+ updatedAt,
178
+ };
139
179
  }
140
180
 
141
181
  export function detectSessionClient(options = {}) {
@@ -168,6 +208,15 @@ export function detectSessionClient(options = {}) {
168
208
  if (hint.threadId) {
169
209
  return { platform: 'amp', threadId: hint.threadId, source: hint.source };
170
210
  }
211
+ if (hint.lastThreadId && hint.fileFresh) {
212
+ return { platform: 'amp', threadId: hint.lastThreadId, source: 'amp-session-last' };
213
+ }
214
+ if (hint.fileFresh) {
215
+ const listed = listRecentAmpThreadIds(options);
216
+ if (listed[0]) {
217
+ return { platform: 'amp', threadId: listed[0], source: 'amp-session-list' };
218
+ }
219
+ }
171
220
 
172
221
  return { platform: null, threadId: null, source: 'none' };
173
222
  }
@@ -504,8 +504,10 @@ function collectAmpCli(ctx) {
504
504
  if (id && !ids.includes(id)) ids.push(id);
505
505
  };
506
506
  push(ampThreadId);
507
- push(ampCurrentThreadId(env));
508
- if (!ids.length) {
507
+ if (ctx.listRecentAmpThreads !== false) {
508
+ push(ampCurrentThreadId(env));
509
+ }
510
+ if (!ids.length && ctx.listRecentAmpThreads !== false) {
509
511
  for (const id of listRecentAmpThreadIds(ctx)) push(id);
510
512
  }
511
513
  const sources = [];
@@ -825,6 +827,7 @@ export function collectSpend(options = {}) {
825
827
  homedir,
826
828
  notes,
827
829
  ampThreadId: options.ampThreadId,
830
+ listRecentAmpThreads: options.listRecentAmpThreads,
828
831
  cursorConversationId: options.cursorConversationId,
829
832
  exportAmpThread: options.exportAmpThread,
830
833
  listAmpThreads: options.listAmpThreads,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -508,7 +508,9 @@ function recompute(metrics) {
508
508
  }
509
509
  phases[key] = phase;
510
510
 
511
+ let sessionSourceCostUsd = null;
511
512
  for (const src of session.sources || []) {
513
+ sessionSourceCostUsd = addNullable(sessionSourceCostUsd, numOrNull(src.costUsd));
512
514
  const platform = src.platform;
513
515
  if (platform && byPlatform[platform]) {
514
516
  const bucket = byPlatform[platform];
@@ -543,6 +545,11 @@ function recompute(metrics) {
543
545
  byModel.set(modelKey, row);
544
546
  }
545
547
  }
548
+ const sessionCostUsd = numOrNull(session.costUsd);
549
+ if (sessionSourceCostUsd == null && sessionCostUsd != null) {
550
+ const billedBucket = session.platform && byPlatform[session.platform];
551
+ if (billedBucket) billedBucket.costUsd = addNullable(billedBucket.costUsd, sessionCostUsd);
552
+ }
546
553
  }
547
554
 
548
555
  if (firstStart && lastEnd) {