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 +10 -0
- package/README.md +13 -5
- package/bin/agent-orchestrator.js +200 -59
- package/bin/session-client.js +1 -1
- package/bin/spend-collect.js +197 -2
- package/package.json +1 -1
- package/templates/.agents/rules/session-handoff.mdc +2 -2
- package/templates/.agents/skills/agent-orchestration/SKILL.md +3 -3
- package/templates/.agents/subagents/session-handoff.md +2 -2
- package/templates/.agents/subagents/spec-archiver.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +416 -36
- package/templates/scripts/cursor-spend-hook.cjs +8 -2
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
|
|
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`
|
|
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 `[
|
|
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
|
|
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
|
-
|
|
1806
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2200
|
-
const
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
:
|
|
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,
|
|
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
|
|
2310
|
+
endedAt,
|
|
2223
2311
|
durationMs,
|
|
2224
|
-
role:
|
|
2225
|
-
phase: phaseForRole(
|
|
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
|
|
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,
|
|
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
|
|
2276
|
-
endedAt
|
|
2277
|
-
durationMs
|
|
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
|
-
|
|
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 };
|
package/bin/session-client.js
CHANGED
|
@@ -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' };
|