agent-orchestrator-kit 0.7.0 → 0.8.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,16 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-08-31
8
+
9
+ ### Added
10
+ - **Locked session client** — `handoff --restore` detects `cursor` / `claude` / `amp` (env, Amp parent process, or recent `session.json` tty mapping) and stores `pending.platform` + `pending.threadId`. Persist follows that client: Amp runs `amp threads export` plus local thread JSON; Cursor reads the spend hook; Claude reads `~/.claude/projects`. `--platform` / `AOK_PLATFORM` / `## Metrics` still override.
11
+ - **Amp CLI export adapter** (`amp-cli`) — when the locked client is Amp (or `--collect`), the kit calls `amp threads export <id>` (override `AOK_AMP_BIN`). Fail-open if Amp CLI is missing. `agentMode` (`low`/`medium`) is never stored as `session.model`.
12
+ - **Amp web / pipe restore** — if the parent process is `amp` and stdin is `/dev/null` (no pts), the kit takes the newest id from `amp threads list` instead of stale `session.json` `lastThreadId`.
13
+
14
+ ### Changed
15
+ - Persist without `--collect` now collects **only the locked/resolved client**, not all three adapters. `--collect` still runs every adapter.
16
+
7
17
  ## [0.7.0] - 2026-08-30
8
18
 
9
19
  ### Breaking
@@ -231,6 +241,8 @@ All notable changes to this project will be documented in this file.
231
241
  ### Added
232
242
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
233
243
 
244
+ [0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.0
245
+ [0.7.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.6.0...v0.7.0
234
246
  [0.6.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.5.0...v0.6.0
235
247
  [0.5.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.4.0...v0.5.0
236
248
  [0.4.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.3.0...v0.4.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 (`## 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 (locked Amp/Cursor client + `amp threads export` in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
78
78
 
79
79
  ```bash
80
80
  npx agent-orchestrator-kit@latest update
@@ -806,8 +806,9 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
806
806
  - **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
807
807
  - **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → optional `--collect` adapters. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Never guess. Never invent USD from Amp credits. Never Cursor SDK / npm sqlite / a pricing table. No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
808
808
  - **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session from the archived `## Metrics` (or archive flags), prints the same human summary as `metrics <name>`, and collects adapters only with `--collect`.
809
- - **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
810
- - **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`. Collect is opt-in (`handoff`/`archive`/`metrics --collect`). 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 (`~/.claude/projects/*.jsonl`) and Amp (`~/.local/share/amp/threads/*.json`) already persist usage locally and need no hook.
809
+ - **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
810
+ - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` (`AOK_AMP_BIN`) plus local `threads/*.json`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
811
+ - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL and Amp disk threads remain fallbacks; Amp web/CLI spend is taken from `amp threads export`.
811
812
 
812
813
  Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
813
814
 
@@ -6,6 +6,7 @@ import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
8
  import { collectSpend } from './spend-collect.js';
9
+ import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const KIT_ROOT = join(__dirname, '..');
@@ -484,7 +485,7 @@ function printSpendHealth(projectDir) {
484
485
  ? String(process.env.AMP_DATA_DIR).trim()
485
486
  : join(home, '.local', 'share', 'amp');
486
487
  const ampOk = existsSync(join(ampDir, 'threads'));
487
- console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
488
+ console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}; locked client + amp threads export`);
488
489
  console.log('');
489
490
  }
490
491
 
@@ -1151,7 +1152,7 @@ function inferPlatformFromHost(env) {
1151
1152
  return null;
1152
1153
  }
1153
1154
 
1154
- function resolvePlatform(opts, env, reported) {
1155
+ function resolvePlatform(opts, env, reported, pending) {
1155
1156
  const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
1156
1157
  if (flag) {
1157
1158
  const lower = flag.toLowerCase();
@@ -1172,6 +1173,10 @@ function resolvePlatform(opts, env, reported) {
1172
1173
  if (VALID_PLATFORMS.has(lower)) return { value: lower };
1173
1174
  return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
1174
1175
  }
1176
+ const pendingPlatform = pending && pending.platform != null ? String(pending.platform).trim() : '';
1177
+ if (pendingPlatform && VALID_PLATFORMS.has(pendingPlatform)) {
1178
+ return { value: pendingPlatform };
1179
+ }
1175
1180
  return { value: inferPlatformFromHost(env) };
1176
1181
  }
1177
1182
 
@@ -1856,7 +1861,7 @@ function sessionTotalsFromSources(sources) {
1856
1861
  return { inputTokens, outputTokens, totalTokens, costUsd };
1857
1862
  }
1858
1863
 
1859
- function runCollectSpend(metrics, windowStart, windowEnd) {
1864
+ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1860
1865
  try {
1861
1866
  return collectSpend({
1862
1867
  cwd: process.cwd(),
@@ -1865,6 +1870,10 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
1865
1870
  existingSourceIds: existingSourceIdSet(metrics),
1866
1871
  env: process.env,
1867
1872
  homedir: process.env.HOME,
1873
+ platforms: extra.platforms,
1874
+ ampThreadId: extra.ampThreadId,
1875
+ ampCli: extra.ampCli === true,
1876
+ exportAmpThread: extra.exportAmpThread,
1868
1877
  });
1869
1878
  } catch {
1870
1879
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
@@ -1910,7 +1919,11 @@ function metricsBackfillFile(filePath, changeName) {
1910
1919
  if (!sessions.length) return { filePath, added: 0, empty: true };
1911
1920
  const last = sessions[sessions.length - 1];
1912
1921
  const windowStart = last.startedAt || collectWindowStart(metrics);
1913
- const collected = runCollectSpend(metrics, windowStart, nowIso);
1922
+ const lastPlatform = last && last.platform;
1923
+ const collected = runCollectSpend(metrics, windowStart, nowIso, {
1924
+ ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
1925
+ ampCli: lastPlatform === 'amp',
1926
+ });
1914
1927
  const incoming = collected.sources || [];
1915
1928
  if (!incoming.length) return { filePath, added: 0 };
1916
1929
  const overridden = sessionTotalsLookOverridden(last);
@@ -1935,9 +1948,9 @@ function metricsBackfillFile(filePath, changeName) {
1935
1948
  return { filePath, added: incoming.length };
1936
1949
  }
1937
1950
 
1938
- function adapterSourceName(platform) {
1951
+ function adapterSourceName(platform, via) {
1939
1952
  if (platform === 'claude') return 'claude-jsonl';
1940
- if (platform === 'amp') return 'amp-thread';
1953
+ if (platform === 'amp') return via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
1941
1954
  if (platform === 'cursor') return 'cursor-hook';
1942
1955
  return null;
1943
1956
  }
@@ -2060,11 +2073,17 @@ function recomputeMetricsAggregates(metrics) {
2060
2073
  recomputeSpendMaps(metrics);
2061
2074
  }
2062
2075
 
2063
- function metricsRecordSessionStart(projectDir, changeName, role) {
2076
+ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2064
2077
  const filePath = metricsFilePath(projectDir, changeName);
2065
2078
  const nowIso = new Date().toISOString();
2066
2079
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2067
- metrics.pending = { startedAt: nowIso, role: role || '' };
2080
+ metrics.pending = {
2081
+ startedAt: nowIso,
2082
+ role: role || '',
2083
+ platform: client.platform || null,
2084
+ threadId: client.threadId || null,
2085
+ clientSource: client.source || null,
2086
+ };
2068
2087
  metrics.updatedAt = nowIso;
2069
2088
  saveMetricsFile(filePath, metrics);
2070
2089
  return filePath;
@@ -2079,8 +2098,17 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2079
2098
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2080
2099
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2081
2100
  const windowStart = collectWindowStart(metrics);
2082
- const collected = opts.collect === true
2083
- ? runCollectSpend(metrics, windowStart, nowIso)
2101
+ const pending = metrics.pending || {};
2102
+ const platform = opts.platform || pending.platform || null;
2103
+ const collectAll = opts.collect === true;
2104
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2105
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2106
+ const collected = shouldCollect
2107
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2108
+ platforms,
2109
+ ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2110
+ ampCli: collectAll || platform === 'amp',
2111
+ })
2084
2112
  : { sources: [] };
2085
2113
  const session = {
2086
2114
  startedAt,
@@ -2091,7 +2119,8 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2091
2119
  runtime: fields.runtime || 'local',
2092
2120
  agentId: fields.agentId || 'none',
2093
2121
  model: resolvedModel || null,
2094
- platform: opts.platform || null,
2122
+ platform: opts.platform || pending.platform || null,
2123
+ threadId: opts.ampThreadId || pending.threadId || null,
2095
2124
  tasks: fields.tasks || null,
2096
2125
  sources: [],
2097
2126
  inputTokens: null,
@@ -2120,8 +2149,16 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2120
2149
  const nowIso = new Date().toISOString();
2121
2150
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2122
2151
  const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
2123
- const collected = opts.collect === true
2124
- ? runCollectSpend(metrics, windowStart, nowIso)
2152
+ const collectAll = opts.collect === true;
2153
+ const platform = opts.platform || null;
2154
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2155
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2156
+ const collected = shouldCollect
2157
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2158
+ platforms,
2159
+ ampThreadId: opts.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2160
+ ampCli: collectAll || platform === 'amp',
2161
+ })
2125
2162
  : { sources: [] };
2126
2163
  const reported = opts.reported || emptyMetricsFields();
2127
2164
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
@@ -2197,7 +2234,10 @@ function renderMetricsSummary(metrics) {
2197
2234
  lines.push(`unreported: ${unreported}`);
2198
2235
  if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
2199
2236
  if (metrics.pending) {
2200
- lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
2237
+ const pendingClient = metrics.pending.platform
2238
+ ? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
2239
+ : '';
2240
+ lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'}${pendingClient})`);
2201
2241
  }
2202
2242
 
2203
2243
  const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
@@ -3877,8 +3917,18 @@ program
3877
3917
  log.warn(`Memory JSON empty or missing at ${memoryPath}`);
3878
3918
  }
3879
3919
  if (opts.metrics !== false && existsSync(changeDir)) {
3880
- const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
3920
+ const client = resolveRestoreClient({
3921
+ env: process.env,
3922
+ cwd: projectDir,
3923
+ homedir: process.env.HOME,
3924
+ platform: opts.platform,
3925
+ });
3926
+ const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '', client);
3927
+ const clientLabel = client.platform
3928
+ ? `${client.platform}${client.threadId ? ` ${client.threadId}` : ''} (${client.source})`
3929
+ : 'unknown — pass --platform or fill ## Metrics';
3881
3930
  log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
3931
+ log.info(`metrics: client ${clientLabel}`);
3882
3932
  }
3883
3933
  return;
3884
3934
  }
@@ -3948,7 +3998,8 @@ program
3948
3998
  }
3949
3999
 
3950
4000
  const reported = fields.metrics || emptyMetricsFields();
3951
- const platformResult = resolvePlatform(opts, process.env, reported);
4001
+ const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, new Date().toISOString());
4002
+ const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
3952
4003
  if (platformResult.error) {
3953
4004
  log.err(platformResult.error);
3954
4005
  process.exitCode = 1;
@@ -3977,6 +4028,7 @@ program
3977
4028
  totalTokens: opts.totalTokens,
3978
4029
  costUsd: opts.costUsd,
3979
4030
  collect: opts.collect === true,
4031
+ ampThreadId: (metricsPreview.pending && metricsPreview.pending.threadId) || ampThreadIdFromEnv(process.env) || null,
3980
4032
  reported,
3981
4033
  });
3982
4034
  console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
@@ -0,0 +1,187 @@
1
+ import { existsSync, readFileSync, readlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir as osHomedir } from 'os';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
7
+ const AMP_TTY_MAX_AGE_MS = 2 * 60 * 60 * 1000;
8
+
9
+ function trim(value) {
10
+ return value == null ? '' : String(value).trim();
11
+ }
12
+
13
+ function envFlagOn(value) {
14
+ if (value == null) return false;
15
+ const normalized = String(value).trim().toLowerCase();
16
+ return normalized !== '' && normalized !== '0' && normalized !== 'false';
17
+ }
18
+
19
+ export function ampDataRoot(env = {}, homedir) {
20
+ if (env.AMP_DATA_DIR && String(env.AMP_DATA_DIR).trim()) return String(env.AMP_DATA_DIR).trim();
21
+ if (env.XDG_DATA_HOME && String(env.XDG_DATA_HOME).trim()) {
22
+ return join(String(env.XDG_DATA_HOME).trim(), 'amp');
23
+ }
24
+ return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
25
+ }
26
+
27
+ export function ampThreadIdFromEnv(env = {}) {
28
+ for (const key of ['AMP_CURRENT_THREAD', 'AMP_THREAD_ID']) {
29
+ const value = trim(env[key]);
30
+ if (value) return value;
31
+ }
32
+ return '';
33
+ }
34
+
35
+ export function isUsableTtyPath(raw) {
36
+ const path = String(raw || '').replace(/^tty:/, '').trim();
37
+ if (!path.startsWith('/dev/')) return false;
38
+ if (path === '/dev/null' || path.startsWith('/dev/null')) return false;
39
+ return true;
40
+ }
41
+
42
+ export function currentTtyKey(env = {}, readlink = null) {
43
+ const forced = trim(env.AOK_TTY);
44
+ if (forced) {
45
+ const path = forced.startsWith('tty:') ? forced.slice(4) : forced;
46
+ return isUsableTtyPath(path) ? (forced.startsWith('tty:') ? forced : `tty:${path}`) : '';
47
+ }
48
+ try {
49
+ const fn = readlink || readlinkSync;
50
+ const raw = String(fn('/proc/self/fd/0') || '').trim();
51
+ return isUsableTtyPath(raw) ? `tty:${raw}` : '';
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ const AMP_THREAD_ID_RE = /\bT-[0-9a-fA-F-]{8,}\b/g;
58
+
59
+ export function parseAmpThreadList(text) {
60
+ const ids = [];
61
+ for (const line of String(text || '').split('\n')) {
62
+ if (!line.trim() || /^Title\b/.test(line) || /^─/.test(line)) continue;
63
+ const matches = line.match(AMP_THREAD_ID_RE);
64
+ if (!matches || !matches.length) continue;
65
+ const id = matches[matches.length - 1];
66
+ if (!ids.includes(id)) ids.push(id);
67
+ }
68
+ return ids;
69
+ }
70
+
71
+ export function listRecentAmpThreadIds(options = {}) {
72
+ if (typeof options.listAmpThreads === 'function') {
73
+ try {
74
+ const out = options.listAmpThreads();
75
+ if (Array.isArray(out)) return out.map((id) => trim(id)).filter(Boolean);
76
+ return parseAmpThreadList(out);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+ const env = options.env || {};
82
+ const bin = options.ampBin || trim(env.AOK_AMP_BIN) || 'amp';
83
+ if (bin !== 'amp' && !existsSync(bin)) return [];
84
+ try {
85
+ const text = execFileSync(bin, ['threads', 'list', '--limit', String(options.limit || 5)], {
86
+ encoding: 'utf-8',
87
+ timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 15000,
88
+ env,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ return parseAmpThreadList(text);
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ export function parentProcessComm(ppid = process.ppid, readFile = readFileSync) {
98
+ try {
99
+ return String(readFile(`/proc/${ppid}/comm`, 'utf-8')).trim();
100
+ } catch {
101
+ return '';
102
+ }
103
+ }
104
+
105
+ function looksLikeAmpProcess(comm) {
106
+ const name = String(comm || '').toLowerCase();
107
+ return name === 'amp' || name.startsWith('amp');
108
+ }
109
+
110
+ function isFreshTimestamp(value, now, maxAgeMs) {
111
+ if (value == null || value === '') return false;
112
+ const n = typeof value === 'number' ? (value < 1e12 ? value * 1000 : value) : Date.parse(String(value));
113
+ if (!Number.isFinite(n)) return false;
114
+ return now - n >= 0 && now - n <= maxAgeMs;
115
+ }
116
+
117
+ export function readAmpSessionHint(options = {}) {
118
+ const env = options.env || {};
119
+ const homedir = options.homedir || env.HOME;
120
+ const now = options.now != null ? Number(options.now) : Date.now();
121
+ const maxAgeMs = options.maxAgeMs != null ? Number(options.maxAgeMs) : AMP_TTY_MAX_AGE_MS;
122
+ const filePath = join(ampDataRoot(env, homedir), 'session.json');
123
+ if (!existsSync(filePath)) return { threadId: '', source: '' };
124
+ let data;
125
+ try {
126
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
127
+ } catch {
128
+ return { threadId: '', source: '' };
129
+ }
130
+ if (!data || typeof data !== 'object') return { threadId: '', source: '', lastThreadId: '' };
131
+ const lastThreadId = trim(data.lastThreadId);
132
+ const rawTty = options.ttyKey != null ? options.ttyKey : currentTtyKey(env, options.readlink);
133
+ const ttyKey = isUsableTtyPath(rawTty) ? (String(rawTty).startsWith('tty:') ? rawTty : `tty:${rawTty}`) : '';
134
+ const byTty = data.lastThreadByTerminal && ttyKey ? data.lastThreadByTerminal[ttyKey] : null;
135
+ if (byTty && trim(byTty.lastThreadId) && isFreshTimestamp(byTty.updatedAt, now, maxAgeMs)) {
136
+ return { threadId: trim(byTty.lastThreadId), source: 'amp-session-tty', lastThreadId };
137
+ }
138
+ return { threadId: '', source: '', lastThreadId };
139
+ }
140
+
141
+ export function detectSessionClient(options = {}) {
142
+ const env = options.env || {};
143
+ const ampId = ampThreadIdFromEnv(env);
144
+ if (ampId) {
145
+ return { platform: 'amp', threadId: ampId, source: 'amp-env' };
146
+ }
147
+
148
+ if (envFlagOn(env.CURSOR_AGENT) || trim(env.CURSOR_CONVERSATION_ID)) {
149
+ return { platform: 'cursor', threadId: null, source: 'cursor-env' };
150
+ }
151
+ if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || trim(env.CLAUDE_CODE_ENTRYPOINT)) {
152
+ return { platform: 'claude', threadId: null, source: 'claude-env' };
153
+ }
154
+
155
+ const comm = options.parentComm != null ? options.parentComm : parentProcessComm();
156
+ const hint = readAmpSessionHint(options);
157
+ if (looksLikeAmpProcess(comm)) {
158
+ if (hint.threadId) {
159
+ return { platform: 'amp', threadId: hint.threadId, source: 'amp-parent' };
160
+ }
161
+ const listed = listRecentAmpThreadIds(options);
162
+ return {
163
+ platform: 'amp',
164
+ threadId: listed[0] || null,
165
+ source: listed[0] ? 'amp-threads-list' : 'amp-parent',
166
+ };
167
+ }
168
+ if (hint.threadId) {
169
+ return { platform: 'amp', threadId: hint.threadId, source: hint.source };
170
+ }
171
+
172
+ return { platform: null, threadId: null, source: 'none' };
173
+ }
174
+
175
+ export function resolveRestoreClient(options = {}) {
176
+ const env = options.env || {};
177
+ const detected = detectSessionClient(options);
178
+ const flag = trim(options.platform || env.AOK_PLATFORM).toLowerCase();
179
+ if (flag && VALID_PLATFORMS.has(flag)) {
180
+ return {
181
+ platform: flag,
182
+ threadId: detected.threadId,
183
+ source: options.platform ? 'flag' : 'aok-platform',
184
+ };
185
+ }
186
+ return detected;
187
+ }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'fs';
2
2
  import { join, basename } from 'path';
3
3
  import { homedir as osHomedir } from 'os';
4
+ import { execFileSync } from 'child_process';
5
+ import { listRecentAmpThreadIds } from './session-client.js';
4
6
 
5
7
  const PLATFORMS = ['cursor', 'claude', 'amp'];
6
8
 
@@ -309,6 +311,36 @@ function ampInputTokens(usage) {
309
311
  return has ? sum : null;
310
312
  }
311
313
 
314
+ export function sourcesFromAmpThread(thread, ctx, fileName = '', via = null) {
315
+ const sources = [];
316
+ if (!thread || typeof thread !== 'object') return sources;
317
+ const { cwd, windowStart, windowEnd, existing, env } = ctx;
318
+ if (!ampThreadMatches(thread, cwd, env, fileName)) return sources;
319
+ const threadKey = thread.id ? String(thread.id) : basename(fileName || 'thread', '.json');
320
+ for (const message of ampMessages(thread)) {
321
+ const usage = ampUsage(message);
322
+ if (!usage) continue;
323
+ const rawId = ampId(message);
324
+ if (rawId == null || rawId === '') continue;
325
+ const id = `${threadKey}:${rawId}`;
326
+ if (existing.has(id)) continue;
327
+ if (!inWindow(usage.timestamp, windowStart, windowEnd)) continue;
328
+ const record = sourceRecord({
329
+ id,
330
+ platform: 'amp',
331
+ model: usage.model,
332
+ inputTokens: ampInputTokens(usage),
333
+ outputTokens: usage.outputTokens,
334
+ costUsd: null,
335
+ ampCredits: null,
336
+ at: usage.timestamp,
337
+ });
338
+ if (via) record.via = via;
339
+ sources.push(record);
340
+ }
341
+ return sources;
342
+ }
343
+
312
344
  function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
313
345
  const root = ampRoot(env, homedir);
314
346
  const threadsDir = join(root, 'threads');
@@ -324,6 +356,7 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
324
356
  notes.push('amp: cannot read threads');
325
357
  return sources;
326
358
  }
359
+ const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
327
360
  for (const file of files) {
328
361
  let thread;
329
362
  try {
@@ -331,31 +364,60 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
331
364
  } catch {
332
365
  continue;
333
366
  }
334
- if (!thread || typeof thread !== 'object') continue;
335
- if (!ampThreadMatches(thread, cwd, env, file)) continue;
336
- // messageId values are thread-local counters (1, 3, 5, ...), so a bare id
337
- // collides across threads; namespace with the thread id for global dedup.
338
- const threadKey = thread.id ? String(thread.id) : basename(file, '.json');
339
- for (const message of ampMessages(thread)) {
340
- const usage = ampUsage(message);
341
- if (!usage) continue;
342
- const rawId = ampId(message);
343
- if (rawId == null || rawId === '') continue;
344
- const id = `${threadKey}:${rawId}`;
345
- if (existing.has(id)) continue;
346
- if (!inWindow(usage.timestamp, windowStart, windowEnd)) continue;
347
- sources.push(sourceRecord({
348
- id,
349
- platform: 'amp',
350
- model: usage.model,
351
- inputTokens: ampInputTokens(usage),
352
- outputTokens: usage.outputTokens,
353
- costUsd: null,
354
- ampCredits: null,
355
- at: usage.timestamp,
356
- }));
367
+ sources.push(...sourcesFromAmpThread(thread, ctx, file));
368
+ }
369
+ return sources;
370
+ }
371
+
372
+ export function exportAmpThread(threadId, options = {}) {
373
+ const id = threadId == null ? '' : String(threadId).trim();
374
+ if (!id) return null;
375
+ if (typeof options.exportAmpThread === 'function') {
376
+ try {
377
+ return options.exportAmpThread(id);
378
+ } catch {
379
+ return null;
357
380
  }
358
381
  }
382
+ const bin = options.ampBin || (options.env && options.env.AOK_AMP_BIN) || 'amp';
383
+ if (bin !== 'amp' && !existsSync(bin)) return null;
384
+ try {
385
+ const out = execFileSync(bin, ['threads', 'export', id], {
386
+ encoding: 'utf-8',
387
+ timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 15000,
388
+ env: options.env || process.env,
389
+ stdio: ['ignore', 'pipe', 'pipe'],
390
+ });
391
+ const parsed = JSON.parse(out);
392
+ return parsed && typeof parsed === 'object' ? parsed : null;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ function collectAmpCli(ctx) {
399
+ const { env, notes, ampThreadId } = ctx;
400
+ const ids = [];
401
+ const push = (value) => {
402
+ const id = value == null ? '' : String(value).trim();
403
+ if (id && !ids.includes(id)) ids.push(id);
404
+ };
405
+ push(ampThreadId);
406
+ push(ampCurrentThreadId(env));
407
+ if (!ids.length) {
408
+ for (const id of listRecentAmpThreadIds(ctx)) push(id);
409
+ }
410
+ const sources = [];
411
+ for (const id of ids) {
412
+ const thread = exportAmpThread(id, ctx);
413
+ if (!thread) {
414
+ notes.push(`amp: export failed for ${id}`);
415
+ continue;
416
+ }
417
+ const extracted = sourcesFromAmpThread(thread, ctx, `${id}.json`, 'amp-cli');
418
+ if (!extracted.length) notes.push(`amp: export ${id} had no matching usage`);
419
+ sources.push(...extracted);
420
+ }
359
421
  return sources;
360
422
  }
361
423
 
@@ -424,7 +486,7 @@ function aggregate(sources) {
424
486
  bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
425
487
  bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
426
488
  if (platform === 'claude') bucket.source = 'claude-jsonl';
427
- else if (platform === 'amp') bucket.source = 'amp-thread';
489
+ else if (platform === 'amp') bucket.source = src.via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
428
490
  else bucket.source = 'cursor-hook';
429
491
  }
430
492
  const model = src.model;
@@ -464,22 +526,52 @@ export function collectSpend(options = {}) {
464
526
  const windowStart = options.windowStart;
465
527
  const windowEnd = options.windowEnd;
466
528
  const notes = [];
467
- const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
529
+ const wanted = Array.isArray(options.platforms)
530
+ ? options.platforms.filter((name) => PLATFORMS.includes(name))
531
+ : PLATFORMS;
532
+ const run = (name) => !wanted.length || wanted.includes(name);
533
+ const ctx = {
534
+ cwd,
535
+ windowStart,
536
+ windowEnd,
537
+ existing,
538
+ env,
539
+ homedir,
540
+ notes,
541
+ ampThreadId: options.ampThreadId,
542
+ exportAmpThread: options.exportAmpThread,
543
+ listAmpThreads: options.listAmpThreads,
544
+ ampBin: options.ampBin,
545
+ timeoutMs: options.timeoutMs,
546
+ };
468
547
  let sources = [];
469
- try {
470
- sources = sources.concat(collectClaude(ctx));
471
- } catch {
472
- notes.push('claude: adapter failed');
548
+ if (run('claude')) {
549
+ try {
550
+ sources = sources.concat(collectClaude(ctx));
551
+ } catch {
552
+ notes.push('claude: adapter failed');
553
+ }
473
554
  }
474
- try {
475
- sources = sources.concat(collectAmp(ctx));
476
- } catch {
477
- notes.push('amp: adapter failed');
555
+ if (run('amp')) {
556
+ if (options.ampCli === true || typeof options.exportAmpThread === 'function') {
557
+ try {
558
+ sources = sources.concat(collectAmpCli(ctx));
559
+ } catch {
560
+ notes.push('amp: cli export failed');
561
+ }
562
+ }
563
+ try {
564
+ sources = sources.concat(collectAmp(ctx));
565
+ } catch {
566
+ notes.push('amp: adapter failed');
567
+ }
478
568
  }
479
- try {
480
- sources = sources.concat(collectCursor(ctx));
481
- } catch {
482
- notes.push('cursor: adapter failed');
569
+ if (run('cursor')) {
570
+ try {
571
+ sources = sources.concat(collectCursor(ctx));
572
+ } catch {
573
+ notes.push('cursor: adapter failed');
574
+ }
483
575
  }
484
576
  const { byPlatform, byModel } = aggregate(sources);
485
577
  return { sources, byPlatform, byModel, notes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -12,7 +12,7 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
12
12
  ## Session Start (before any work)
13
13
  1. Honor pasted `/opsx:<phase> <name>` and announce the role.
14
14
  2. `npx agent-orchestrator-kit status`
15
- 3. `npx agent-orchestrator-kit handoff --restore` (or `handoff <name> --restore`). The CLI briefing is canonical — it already reads memory.json and handoff.md; accumulated decisions print from git-tracked `openspec/changes/<name>/decisions.md`, not from Memory. No separate Memory MCP read step.
15
+ 3. `npx agent-orchestrator-kit handoff --restore` (or `handoff <name> --restore`). The CLI briefing is canonical — it already reads memory.json and handoff.md; accumulated decisions print from git-tracked `openspec/changes/<name>/decisions.md`, not from Memory. No separate Memory MCP read step. Restore also locks the session client (`cursor` / `claude` / `amp`) into `metrics.json` `pending` — persist will follow that client’s spend flow. Override with `--platform` when detection is wrong.
16
16
  4. If the restore CLI failed → read `openspec/changes/<name>/handoff.md` directly.
17
17
  5. Spawn `session-handoff` in restore mode ONLY if both the CLI and handoff.md are unavailable (Amp: isolated `subagent-session-handoff`). This is a fallback, never a routine step.
18
18
  6. Free-form continue/next/«далі» with one active change → execute `Handoff.next_command`.
@@ -21,7 +21,7 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
21
21
  ## Session Exit (order)
22
22
  1. The parent writes `openspec/changes/<name>/handoff.md` itself: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime, Metrics.
23
23
  2. Fill `## Metrics` before running persist. Required keys: `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`. Use `unknown` when a value is missing — never invent `0`. This self-report is the primary spend source; `metrics.json` records what the CLI resolved.
24
- 3. `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, records the session into `openspec/changes/<name>/metrics.json`, prints the expanded prompt on stdout). `--model` is the LLM product id of this chat (`claude-opus-5`, `claude-fable-5`, `gpt-5.6-sol`, `cursor-grok-4.6`) — NEVER pass a Closed role (`Architect`, `Implementer`, `Explorer`) or a subagent name (`spec-architect`, `session-handoff`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session-level totals only and do not wipe platform maps; they do not rewrite `## Metrics`. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters (Claude JSONL, Amp threads, Cursor spend hook file). The same `npx agent-orchestrator-kit handoff <name>` works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, a Claude `/cost` parser, or an Amp billing API as a required step. `decisions.md` is the git canon of change decisions; Memory `Decision:*` is a file→Memory mirror only. Cloud sessions pass `--runtime cloud` (or set `AOK_RUNTIME=cloud` / `AOK_AGENT_ID` in the cloud-agent environment).
24
+ 3. `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, records the session into `openspec/changes/<name>/metrics.json`, prints the expanded prompt on stdout). `--model` is the LLM product id of this chat (`claude-opus-5`, `claude-fable-5`, `gpt-5.6-sol`, `cursor-grok-4.6`, `accounts/fireworks/models/glm-5p2`) — NEVER pass a Closed role, a subagent name, or an Amp **mode** (`low`, `medium`, `high`, `ultra`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. Persist collects spend for the client locked at restore (Amp: `amp threads export` + local threads; Cursor: hook file; Claude: JSONL). `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session-level totals only and do not wipe platform maps; they do not rewrite `## Metrics`. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` runs all three adapters, not only the locked client. The same `npx agent-orchestrator-kit handoff <name>` works in Cursor, Claude Code, and Amp. `decisions.md` is the git canon of change decisions; Memory `Decision:*` is a file→Memory mirror only. Cloud sessions pass `--runtime cloud` (or set `AOK_RUNTIME=cloud` / `AOK_AGENT_ID` in the cloud-agent environment).
25
25
  4. Spawn `session-handoff` in persist mode ONLY if step 3 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
26
26
  5. Memory MCP is an optional mirror: if tools are available, update `Change:<name>`, `Handoff:<name>`, `Decision:*` in one call; unavailability never blocks closing.
27
27
  6. Paste CLI stdout as one fenced block. First line `/opsx:…`. Body uses `project.agent_language`. Self-contained (Done/Decisions/Blocked/spawn/HARD STOP). No banner.