agent-orchestrator-kit 0.6.0 → 0.7.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.
@@ -43,6 +43,19 @@ function stripFileUri(uri) {
43
43
  return value.startsWith('file://') ? value.slice('file://'.length) : value;
44
44
  }
45
45
 
46
+ function normalizeFsPath(value) {
47
+ const stripped = stripFileUri(value).trim();
48
+ if (!stripped) return '';
49
+ if (stripped.length > 1 && stripped.endsWith('/')) return stripped.replace(/\/+$/, '');
50
+ return stripped;
51
+ }
52
+
53
+ function pathsEqual(a, b) {
54
+ const left = normalizeFsPath(a);
55
+ const right = normalizeFsPath(b);
56
+ return Boolean(left) && left === right;
57
+ }
58
+
46
59
  function parseTime(value) {
47
60
  if (value == null || value === '') return NaN;
48
61
  if (typeof value === 'number' && Number.isFinite(value)) {
@@ -188,10 +201,69 @@ function ampRoot(env, homedir) {
188
201
  return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
189
202
  }
190
203
 
191
- function ampTreesMatch(thread, cwd) {
204
+ function ampTrees(thread) {
192
205
  const trees = thread && thread.env && thread.env.initial && thread.env.initial.trees;
193
- if (!Array.isArray(trees) || trees.length === 0) return false;
194
- return trees.some((tree) => tree && stripFileUri(tree.uri) === cwd);
206
+ return Array.isArray(trees) ? trees : [];
207
+ }
208
+
209
+ function ampCwdCandidates(thread) {
210
+ const out = [];
211
+ const push = (value) => {
212
+ if (typeof value === 'string' && value.trim()) out.push(value);
213
+ };
214
+ push(thread && thread.cwd);
215
+ push(thread && thread.workdir);
216
+ const env = thread && thread.env;
217
+ if (env && typeof env === 'object') {
218
+ push(env.cwd);
219
+ push(env.PWD);
220
+ push(env.pwd);
221
+ if (env.initial && typeof env.initial === 'object') {
222
+ push(env.initial.cwd);
223
+ push(env.initial.PWD);
224
+ push(env.initial.workdir);
225
+ push(env.initial.workspace);
226
+ }
227
+ }
228
+ const meta = thread && thread.meta;
229
+ if (meta && typeof meta === 'object') {
230
+ push(meta.cwd);
231
+ push(meta.workdir);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ function ampCurrentThreadId(env) {
237
+ if (!env || typeof env !== 'object') return '';
238
+ for (const key of ['AMP_CURRENT_THREAD', 'AMP_THREAD_ID']) {
239
+ const value = env[key];
240
+ if (value != null && String(value).trim()) return String(value).trim();
241
+ }
242
+ return '';
243
+ }
244
+
245
+ function ampThreadMentionsCwd(thread, cwd) {
246
+ const target = normalizeFsPath(cwd);
247
+ if (!target || target.length < 2) return false;
248
+ let blob;
249
+ try {
250
+ blob = JSON.stringify(thread);
251
+ } catch {
252
+ return false;
253
+ }
254
+ return blob.includes(target) || blob.includes(`file://${target}`);
255
+ }
256
+
257
+ function ampThreadMatches(thread, cwd, env, fileName) {
258
+ const trees = ampTrees(thread);
259
+ if (trees.length > 0) {
260
+ return trees.some((tree) => tree && pathsEqual(tree.uri, cwd));
261
+ }
262
+ if (ampCwdCandidates(thread).some((candidate) => pathsEqual(candidate, cwd))) return true;
263
+ const threadKey = thread && thread.id ? String(thread.id) : basename(fileName, '.json');
264
+ const current = ampCurrentThreadId(env);
265
+ if (current && current === threadKey) return true;
266
+ return ampThreadMentionsCwd(thread, cwd);
195
267
  }
196
268
 
197
269
  function ampMessages(thread) {
@@ -260,7 +332,7 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
260
332
  continue;
261
333
  }
262
334
  if (!thread || typeof thread !== 'object') continue;
263
- if (!ampTreesMatch(thread, cwd)) continue;
335
+ if (!ampThreadMatches(thread, cwd, env, file)) continue;
264
336
  // messageId values are thread-local counters (1, 3, 5, ...), so a bare id
265
337
  // collides across threads; namespace with the thread id for global dedup.
266
338
  const threadKey = thread.id ? String(thread.id) : basename(file, '.json');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -21,7 +21,7 @@ Archive is one CLI call, no phase subagents.
21
21
  npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
22
22
  ```
23
23
 
24
- Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session) and runs collect unless `--no-collect`; if `spend.costUsd` is `null` — stderr warning, not a gate.
24
+ Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session) and prints the change-wide metrics summary. Collect runs only with `--collect`; if `spend.costUsd` is `null` — stderr warning, not a gate.
25
25
 
26
26
  4. **Show stdout as-is.** On exit ≠ 0, report the gate from stderr and stop — no manual merge/move.
27
27
 
@@ -19,13 +19,14 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
19
19
  7. Only then start phase work (spawn a specialist when the phase routing requires one).
20
20
 
21
21
  ## Session Exit (order)
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.
23
- 2. `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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. 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. Spawn `session-handoff` in persist mode ONLY if step 2 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
25
- 4. Memory MCP is an optional mirror: if tools are available, update `Change:<name>`, `Handoff:<name>`, `Decision:*` in one call; unavailability never blocks closing.
26
- 5. 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.
27
- 6. If runtime is cloud: after persist, `git add openspec/changes/<name>/` `git commit` `git push` → `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. Persist prints these steps on stderr; the CLI never runs `git commit` / `git push`.
28
- 7. Stop. Next role = new chat.
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
+ 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).
25
+ 4. Spawn `session-handoff` in persist mode ONLY if step 3 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
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
+ 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.
28
+ 7. If runtime is cloud: after persist, `git add openspec/changes/<name>/` → `git commit` → `git push` → `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. Persist prints these steps on stderr; the CLI never runs `git commit` / `git push`.
29
+ 8. Stop. Next role = new chat.
29
30
 
30
31
  ## Archive exception
31
32
  `npx agent-orchestrator-kit archive <name>` writes the final `handoff.md` (`next_command: none`) in the archive folder and upserts memory itself. After a successful archive no fenced next-prompt is required — the pipeline is complete.
@@ -145,11 +145,12 @@ Archive is one deterministic CLI call — `npx agent-orchestrator-kit archive <n
145
145
  - Never edit files outside your role's allowed output
146
146
 
147
147
  **End of each session (HARD STOP — you are NOT done):**
148
- 1. Write `openspec/changes/<name>/handoff.md` in the parent using the template below.
149
- 2. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. The same command 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. The CLI appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (the git canon), upserts Memory JSON with an absolute path (`Decision:*` is a file→Memory mirror only), and prints the expanded self-contained prompt on stdout. Spawn `session-handoff` in persist mode ONLY if this CLI step failed.
150
- 3. If Memory MCP tools are available, mirror `Change:<name>`, `Handoff:<name>`, and new `Decision:<topic>` entities in one call optional; its absence never blocks closing.
151
- 4. Paste the CLI stdout as one fenced next-session prompt. First line is `/opsx:<next> <name>`; body uses `project.agent_language`; keep Done/Decisions/Blocked/spawn/HARD STOP complete. No banner. Do not emit a thin “read Memory” stub.
152
- 5. Do not start the next phase in this chat. If apply, include build/lint status in the persisted Done section.
148
+ 1. Write `openspec/changes/<name>/handoff.md` in the parent using the template below, including `## Metrics`.
149
+ 2. Fill `## Metrics` (`platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`) before persist. Use `unknown` when a value is missing never invent `0`.
150
+ 3. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--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 or rewrite `## Metrics`. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters. The same command 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. The CLI appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (the git canon), upserts Memory JSON with an absolute path (`Decision:*` is a file→Memory mirror only), and prints the expanded self-contained prompt on stdout. Spawn `session-handoff` in persist mode ONLY if this CLI step failed.
151
+ 4. If Memory MCP tools are available, mirror `Change:<name>`, `Handoff:<name>`, and new `Decision:<topic>` entities in one call optional; its absence never blocks closing.
152
+ 5. Paste the CLI stdout as one fenced next-session prompt. First line is `/opsx:<next> <name>`; body uses `project.agent_language`; keep Done/Decisions/Blocked/spawn/HARD STOP complete. No banner. Do not emit a thin “read Memory” stub.
153
+ 6. Do not start the next phase in this chat. If apply, include build/lint status in the persisted Done section.
153
154
 
154
155
  `handoff.md` template:
155
156
 
@@ -197,6 +198,15 @@ Archive is one deterministic CLI call — `npx agent-orchestrator-kit archive <n
197
198
  - runtime: <local | cloud>
198
199
  - agent_id: <id | none>
199
200
 
201
+ ## Metrics
202
+ - platform: <cursor | claude | amp | unknown>
203
+ - model: <llm-product-id | unknown>
204
+ - input_tokens: <n | unknown>
205
+ - output_tokens: <n | unknown>
206
+ - cost_usd: <n | unknown>
207
+ - amp_credits: <n | unknown>
208
+ - spend_source: <self-report | flag | adapter | unreported | unknown>
209
+
200
210
  ## Prompt
201
211
 
202
212
  The Prompt section is overwritten by `npx agent-orchestrator-kit handoff <name>`. Do not hand-write a thin stub.
@@ -218,7 +228,7 @@ The Prompt section is overwritten by `npx agent-orchestrator-kit handoff <name>`
218
228
 
219
229
  Before specialist work, the parent MUST restore context in order: honor the pasted `/opsx:*` command; run `npx agent-orchestrator-kit handoff --restore` (the CLI briefing is canonical — no separate Memory MCP read step); if the CLI failed, read `openspec/changes/<name>/handoff.md`; spawn `session-handoff` in restore mode ONLY when both failed. Missing Memory MCP never blocks a session. With one active change, free-form “continue” uses `Handoff.next_command` instead of asking for the phase. Amp spawns any needed subagent as an isolated `subagent-*` skill.
220
230
 
221
- Before declaring a session closed, the parent MUST, in order: (1) write `openspec/changes/<name>/handoff.md` itself, (2) run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` (exit 0) — NEVER pass a Closed role or subagent name as `--model`; persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl); the parent SHOULD still pass `--model` and MUST NOT guess tokens; spend flags override session totals only; optional `--platform`; the same CLI works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, Claude `/cost`, or Amp billing as a required step; this CLI appends `decisions.md` and mirrors `Decision:*` file→Memory; spawn `session-handoff` persist ONLY if this CLI step failed, (3) paste the CLI stdout prompt whose first line is `/opsx:<next> <name>`. Memory MCP mirroring is an optional single call. Never write Memory back into `decisions.md`. The prompt has no `NEXT_SESSION_PROMPT` label, uses `project.agent_language`, and MUST be self-contained (Done, Decisions, Blocked, attach, spawn, HARD STOP) so the next thread can run if Memory MCP is ignored. Never start the next phase in the current chat. Write session artifacts only to git-tracked paths (never `/tmp`, never gitignored caches). If runtime is cloud: after persist, commit → push → `npx agent-orchestrator-kit handoff <name> --cloud-check` with exit 0; closing without that is an incomplete handoff.
231
+ Before declaring a session closed, the parent MUST, in order: (1) write `openspec/changes/<name>/handoff.md` itself including `## Metrics` (keys `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`; `unknown` when missing), (2) run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` (exit 0) — NEVER pass a Closed role or subagent name as `--model`; the parent SHOULD still pass `--model` and MUST NOT guess tokens; spend flags override session totals only and do not rewrite `## Metrics`; optional `--platform`; optional `--collect` for local adapters; the same CLI works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, Claude `/cost`, or Amp billing as a required step; this CLI appends `decisions.md` and mirrors `Decision:*` file→Memory; spawn `session-handoff` persist ONLY if this CLI step failed, (3) paste the CLI stdout prompt whose first line is `/opsx:<next> <name>`. Memory MCP mirroring is an optional single call. Never write Memory back into `decisions.md`. The prompt has no `NEXT_SESSION_PROMPT` label, uses `project.agent_language`, and MUST be self-contained (Done, Decisions, Blocked, attach, spawn, HARD STOP) so the next thread can run if Memory MCP is ignored. Never start the next phase in the current chat. Write session artifacts only to git-tracked paths (never `/tmp`, never gitignored caches). If runtime is cloud: after persist, commit → push → `npx agent-orchestrator-kit handoff <name> --cloud-check` with exit 0; closing without that is an incomplete handoff.
222
232
 
223
233
  | Entity | Required fields |
224
234
  |--------|-----------------|
@@ -21,11 +21,12 @@ Use when the parent's restore failed (CLI restore and handoff.md both unavailabl
21
21
 
22
22
  Use when the parent's persist failed (`npx agent-orchestrator-kit handoff <name>` did not exit 0). A session is not closed until persist succeeds.
23
23
 
24
- 1. Write or update `openspec/changes/<name>/handoff.md` with every required section: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime.
25
- 2. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. The same command 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. This appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (git canon), upserts `.cursor/memory.json` using an absolute path (`Decision:*` mirrors that file, never the reverse), and prints the expanded next-session prompt on stdout. Cloud sessions pass `--runtime cloud` (or `AOK_RUNTIME` / `AOK_AGENT_ID`).
26
- 3. If Memory MCP tools are available, also create/update `Change:<name>`, `Handoff:<name>`, and each `Decision:<topic>` to match `decisions.md`. MCP failure is not a blocker after the CLI succeeds.
27
- 4. Put the CLI stdout prompt (first line `/opsx:…`) into **Next prompt** unchanged. Do not shorten it. Do not add a banner.
28
- 5. If runtime is cloud: after persist, commit and push `openspec/changes/<name>/`, then `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. The CLI never runs `git commit` / `git push`.
24
+ 1. Write or update `openspec/changes/<name>/handoff.md` with every required section: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime, Metrics.
25
+ 2. Fill `## Metrics` (`platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`) before persist. Use `unknown` when a value is missing. The section is the agent's self-report; the CLI does not overwrite it with resolved values.
26
+ 3. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--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. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters. The same command 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. This appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (git canon), upserts `.cursor/memory.json` using an absolute path (`Decision:*` mirrors that file, never the reverse), and prints the expanded next-session prompt on stdout. Cloud sessions pass `--runtime cloud` (or `AOK_RUNTIME` / `AOK_AGENT_ID`).
27
+ 4. If Memory MCP tools are available, also create/update `Change:<name>`, `Handoff:<name>`, and each `Decision:<topic>` to match `decisions.md`. MCP failure is not a blocker after the CLI succeeds.
28
+ 5. Put the CLI stdout prompt (first line `/opsx:…`) into **Next prompt** unchanged. Do not shorten it. Do not add a banner.
29
+ 6. If runtime is cloud: after persist, commit and push `openspec/changes/<name>/`, then `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. The CLI never runs `git commit` / `git push`.
29
30
 
30
31
  ## Rules
31
32
 
@@ -9,8 +9,9 @@ Workflow:
9
9
 
10
10
  1. Read `.agents/orchestrator.yaml`, the complete change, review verdict, task state, and verification/merge evidence supplied by the conductor.
11
11
  2. Refuse to archive unless required review is approved, all tasks are complete, and the configured merge/CI gate is satisfied.
12
- 3. Run the project-supported OpenSpec archive command so delta requirements are merged into main specs and the change moves to the dated archive path.
13
- 4. Run strict validation after the move and report the resulting archive path and modified main specs.
12
+ 3. Fill `## Metrics` in the change `handoff.md` (Archiver self-report: platform, model, tokens, cost_usd, amp_credits, spend_source; use `unknown` when missing) before running archive.
13
+ 4. Run `npx agent-orchestrator-kit archive <name>` so delta requirements are merged into main specs, the change moves to the dated archive path, and stdout prints the change-wide metrics summary (by phase / by platform / by model).
14
+ 5. Run strict validation after the move and report the resulting archive path and modified main specs.
14
15
 
15
16
  Rules:
16
17
 
@@ -23,7 +23,7 @@ Routing table, HARD STOP, and CLI forms: `.agents/rules/` (`agent-orchestration`
23
23
  | Quick (MVP) | `/opsx:quick <name>` |
24
24
  | Archive | `/opsx:archive` |
25
25
 
26
- Session Start / Exit are **parent-driven** — canonical protocol in `.agents/rules/session-handoff.mdc`. Start: `status` → `handoff --restore` → `handoff.md` fallback. Exit HARD STOP: parent writes `handoff.md` → `npx agent-orchestrator-kit handoff <name>` (exit 0) → paste the CLI `/opsx:*` prompt. `session-handoff` subagent = fallback only. Do not start the next phase here.
26
+ Session Start / Exit are **parent-driven** — canonical protocol in `.agents/rules/session-handoff.mdc`. Start: `status` → `handoff --restore` → `handoff.md` fallback. Exit HARD STOP: parent writes `handoff.md` including `## Metrics` (use `unknown` when a value is missing) → `npx agent-orchestrator-kit handoff <name>` (exit 0; optional `--collect`) → paste the CLI `/opsx:*` prompt. `session-handoff` subagent = fallback only. Do not start the next phase here.
27
27
 
28
28
  Quality gates: `gate-check --tasks <name>` lints the task contract (Files/Do/Done-when, `pipeline.task_contract: warn|strict|off`); `gate-check --review <name>` is deterministic Tier 1 of review — spec-reviewer (Tier 2) is spawned only after it passes and writes `apply-notes.md` on APPROVE.
29
29
 
@@ -10,6 +10,6 @@ See `AGENTS.md` and `.agents/rules/` for routing, HARD STOP, and CLI (`npx` only
10
10
 
11
11
  Lean delegation: explore/design/propose/review spawn a mandatory specialist; apply is parent-driven from `tasks.md` + `apply-notes.md` (subagents optional for independent tasks); archive runs `npx agent-orchestrator-kit archive <name> [--sync]` — no subagent. Review is two-tiered: `gate-check --review` (deterministic) before `spec-reviewer`; `gate-check --tasks` lints the Files/Do/Done-when task contract.
12
12
 
13
- Session Start/Exit are parent-driven (canonical: `.agents/rules/session-handoff.mdc`): restore with `npx agent-orchestrator-kit handoff --restore`; exit — write `handoff.md`, run `npx agent-orchestrator-kit handoff <name>` (exit 0), paste the CLI prompt. `session-handoff` subagent is a fallback only. Do not start the next phase in this chat.
13
+ Session Start/Exit are parent-driven (canonical: `.agents/rules/session-handoff.mdc`): restore with `npx agent-orchestrator-kit handoff --restore`; exit — write `handoff.md` including `## Metrics` (`unknown` when missing), run `npx agent-orchestrator-kit handoff <name>` (exit 0; optional `--collect`), paste the CLI prompt. `session-handoff` subagent is a fallback only. Do not start the next phase in this chat.
14
14
 
15
15
  One active change. No `src/` in explore/design/review. After apply: build/lint. Skills: `.claude/skills/` (synced from `.agents/skills/`).
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ // Cursor hook (sessionEnd): merge leftover hook rows into the last metrics
3
+ // session after `stop` has written `.agents/spend/cursor-usage.jsonl`.
4
+ // Fail-open and silent — never block the agent loop.
5
+ 'use strict';
6
+
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync } = require('fs');
8
+ const { join } = require('path');
9
+
10
+ function numOrNull(value) {
11
+ if (value == null || value === '') return null;
12
+ const n = Number(value);
13
+ return Number.isFinite(n) ? n : null;
14
+ }
15
+
16
+ function addNullable(a, b) {
17
+ if (a == null && b == null) return null;
18
+ return (a ?? 0) + (b ?? 0);
19
+ }
20
+
21
+ function resolveBaseDir(payload) {
22
+ const cwd = process.cwd();
23
+ if (existsSync(join(cwd, 'openspec', 'changes'))) return cwd;
24
+ const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
25
+ for (const root of roots) {
26
+ if (root && existsSync(join(String(root), 'openspec', 'changes'))) return String(root);
27
+ }
28
+ return cwd;
29
+ }
30
+
31
+ function existingIds(metrics) {
32
+ const ids = new Set();
33
+ for (const session of metrics.sessions || []) {
34
+ for (const src of session.sources || []) {
35
+ if (src && src.id != null && src.id !== '') ids.add(String(src.id));
36
+ }
37
+ }
38
+ return ids;
39
+ }
40
+
41
+ function sourceTotals(sources) {
42
+ let inputTokens = null;
43
+ let outputTokens = null;
44
+ let totalTokens = null;
45
+ let costUsd = null;
46
+ for (const src of sources || []) {
47
+ inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
48
+ outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
49
+ totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
50
+ if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
51
+ }
52
+ return { inputTokens, outputTokens, totalTokens, costUsd };
53
+ }
54
+
55
+ function looksOverridden(session) {
56
+ const fromSources = sourceTotals(session.sources || []);
57
+ return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
58
+ const sessionVal = numOrNull(session[key]);
59
+ const sourceVal = numOrNull(fromSources[key]);
60
+ if (sessionVal == null) return false;
61
+ if (sourceVal == null) return true;
62
+ return sessionVal !== sourceVal;
63
+ });
64
+ }
65
+
66
+ function emptyPlatform(source = 'none') {
67
+ return {
68
+ inputTokens: null,
69
+ outputTokens: null,
70
+ totalTokens: null,
71
+ costUsd: null,
72
+ ampCredits: null,
73
+ source,
74
+ };
75
+ }
76
+
77
+ function recompute(metrics) {
78
+ const phases = {};
79
+ const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
80
+ const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
81
+ const byPlatform = {
82
+ cursor: emptyPlatform(),
83
+ claude: emptyPlatform(),
84
+ amp: emptyPlatform(),
85
+ };
86
+ const byModel = new Map();
87
+ let firstStart = null;
88
+ let lastEnd = null;
89
+
90
+ for (const session of metrics.sessions || []) {
91
+ totals.sessions += 1;
92
+ if (session.runtime === 'cloud') totals.cloudSessions += 1;
93
+ totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
94
+ if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
95
+ if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
96
+
97
+ const key = session.phase || 'other';
98
+ const phase = phases[key] || {
99
+ sessions: 0,
100
+ durationMs: null,
101
+ inputTokens: null,
102
+ outputTokens: null,
103
+ totalTokens: null,
104
+ costUsd: null,
105
+ agents: [],
106
+ models: [],
107
+ };
108
+ phase.sessions += 1;
109
+ phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
110
+ for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
111
+ const fromSession = numOrNull(session[spendKey]);
112
+ let value = fromSession;
113
+ if (value == null) {
114
+ let sum = null;
115
+ for (const src of session.sources || []) sum = addNullable(sum, numOrNull(src[spendKey]));
116
+ value = sum;
117
+ }
118
+ phase[spendKey] = addNullable(phase[spendKey], value);
119
+ spend[spendKey] = addNullable(spend[spendKey], value);
120
+ }
121
+ if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
122
+ if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
123
+ if (Array.isArray(session.models)) {
124
+ for (const model of session.models) {
125
+ if (model && !phase.models.includes(model)) phase.models.push(model);
126
+ }
127
+ }
128
+ phases[key] = phase;
129
+
130
+ for (const src of session.sources || []) {
131
+ const platform = src.platform;
132
+ if (platform && byPlatform[platform]) {
133
+ const bucket = byPlatform[platform];
134
+ bucket.inputTokens = addNullable(bucket.inputTokens, numOrNull(src.inputTokens));
135
+ bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
136
+ bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
137
+ bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
138
+ if (platform === 'claude') bucket.source = 'claude-jsonl';
139
+ else if (platform === 'amp') bucket.source = 'amp-thread';
140
+ else if (platform === 'cursor') bucket.source = 'cursor-hook';
141
+ }
142
+ if (src.model) {
143
+ const modelKey = `${src.model}::${src.platform || ''}`;
144
+ const row = byModel.get(modelKey) || {
145
+ model: src.model,
146
+ platform: src.platform || null,
147
+ inputTokens: null,
148
+ outputTokens: null,
149
+ totalTokens: null,
150
+ costUsd: null,
151
+ ampCredits: null,
152
+ };
153
+ row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
154
+ row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
155
+ row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
156
+ row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
157
+ byModel.set(modelKey, row);
158
+ }
159
+ }
160
+ }
161
+
162
+ if (firstStart && lastEnd) {
163
+ totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
164
+ }
165
+ metrics.phases = phases;
166
+ metrics.totals = totals;
167
+ metrics.spend = spend;
168
+ metrics.spendByPlatform = byPlatform;
169
+ metrics.spendByModel = [...byModel.values()];
170
+ }
171
+
172
+ function incomingCursorSources(cwd, existing, windowStart) {
173
+ const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
174
+ if (!existsSync(filePath)) return [];
175
+ const startMs = windowStart ? Date.parse(windowStart) : NaN;
176
+ const bestById = new Map();
177
+ for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
178
+ if (!line.trim()) continue;
179
+ let row;
180
+ try {
181
+ row = JSON.parse(line);
182
+ } catch {
183
+ continue;
184
+ }
185
+ if (!row || typeof row !== 'object') continue;
186
+ const id = row.id == null || row.id === '' ? null : String(row.id);
187
+ if (!id || existing.has(id)) continue;
188
+ const atMs = Date.parse(row.at);
189
+ if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
190
+ const inputTokens = numOrNull(row.inputTokens);
191
+ const outputTokens = numOrNull(row.outputTokens);
192
+ if (inputTokens == null && outputTokens == null) continue;
193
+ const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
194
+ const record = {
195
+ id,
196
+ platform: 'cursor',
197
+ model: row.model || row.modelId || null,
198
+ inputTokens,
199
+ outputTokens,
200
+ totalTokens,
201
+ costUsd: null,
202
+ ampCredits: null,
203
+ at: row.at == null ? null : String(row.at),
204
+ };
205
+ const previous = bestById.get(id);
206
+ if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
207
+ bestById.set(id, record);
208
+ }
209
+ }
210
+ return [...bestById.values()];
211
+ }
212
+
213
+ function backfillChange(cwd, changeName) {
214
+ const filePath = join(cwd, 'openspec', 'changes', changeName, 'metrics.json');
215
+ if (!existsSync(filePath)) return;
216
+ let metrics;
217
+ try {
218
+ metrics = JSON.parse(readFileSync(filePath, 'utf-8'));
219
+ } catch {
220
+ return;
221
+ }
222
+ if (!metrics || typeof metrics !== 'object') return;
223
+ const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
224
+ if (!sessions.length) return;
225
+ const last = sessions[sessions.length - 1];
226
+ const incoming = incomingCursorSources(
227
+ cwd,
228
+ existingIds(metrics),
229
+ last.startedAt || last.endedAt || metrics.createdAt,
230
+ );
231
+ if (!incoming.length) return;
232
+ const overridden = looksOverridden(last);
233
+ last.sources = [...(last.sources || []), ...incoming];
234
+ if (!overridden) {
235
+ const totals = sourceTotals(last.sources);
236
+ last.inputTokens = totals.inputTokens;
237
+ last.outputTokens = totals.outputTokens;
238
+ last.totalTokens = totals.totalTokens;
239
+ last.costUsd = totals.costUsd;
240
+ }
241
+ metrics.updatedAt = new Date().toISOString();
242
+ recompute(metrics);
243
+ writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
244
+ }
245
+
246
+ function main(raw) {
247
+ let payload = {};
248
+ try {
249
+ payload = raw ? JSON.parse(raw) : {};
250
+ } catch {
251
+ payload = {};
252
+ }
253
+ const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
254
+ const changesDir = join(cwd, 'openspec', 'changes');
255
+ if (!existsSync(changesDir)) return;
256
+ for (const name of readdirSync(changesDir)) {
257
+ if (name === 'archive') continue;
258
+ const full = join(changesDir, name);
259
+ try {
260
+ if (!statSync(full).isDirectory()) continue;
261
+ } catch {
262
+ continue;
263
+ }
264
+ backfillChange(cwd, name);
265
+ }
266
+ }
267
+
268
+ if (process.stdin.isTTY) {
269
+ try {
270
+ main('');
271
+ } catch {}
272
+ process.exit(0);
273
+ }
274
+
275
+ let input = '';
276
+ process.stdin.on('data', (chunk) => {
277
+ input += chunk;
278
+ });
279
+ process.stdin.on('end', () => {
280
+ try {
281
+ main(input);
282
+ } catch {}
283
+ process.exit(0);
284
+ });
285
+ process.stdin.on('error', () => process.exit(0));
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Cursor hook (stop / subagentStop): appends per-turn token usage from the hook
2
+ // Cursor hook (stop / subagentStop / afterAgentResponse): appends per-turn token usage from the hook
3
3
  // payload to .agents/spend/cursor-usage.jsonl so `agent-orchestrator-kit handoff`
4
4
  // can collect real Cursor spend offline. Silent and fail-open by design: a hook
5
5
  // must never block the agent loop, so every failure path exits 0 with no output.