agent-orchestrator-kit 0.14.0 → 0.15.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,30 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.15.0] - 2026-09-08
8
+
9
+ ### Added
10
+ - **`costUsdTotal` — one USD figure per change, phase, platform, model, and session.** Each session contributes its billed `costUsd` when present, otherwise its `costUsdEstimated`; sums are rounded to 4 decimals. A change that ran on Amp (billed `$14.48`) plus Claude (`~$5.16`) and Cursor (`~$1.43`) now carries `spend.costUsdTotal: 21.0779` instead of forcing a dashboard to pick `costUsd` and drop the estimated platforms. `costUsd` and `costUsdEstimated` stay separate fields and Amp credits stay out of every USD field. `metrics --summary-json` carries the field; the human `cost:` line prints `$21.08 ($14.48 billed + ~$6.60 est.)`.
11
+
12
+ ### Fixed
13
+ - **Billed sums are rounded like estimates.** `spendByModel[].costUsd`, `spendByPlatform.*.costUsd`, `phases.*.costUsd`, and `spend.costUsd` stored raw float sums such as `4.4399999999999995` and `0.009000000000000001`; they are now `Math.round(x * 10000) / 10000`, the same rule `costUsdEstimated` already had.
14
+ - **Cache-split cost estimates survive the session recompute.** `applyCollectedSessionFields` recomputed `byModel[].costUsdEstimated` from `inputTokens` + `outputTokens` alone and overwrote the value the adapters had already produced. Since `inputTokens` includes `cache_read_*` / `cache_creation_*`, every cached token was billed at the full input rate: a real Claude change with 3.5M tokens estimated `$30.11` instead of `$5.16`, and Cursor rows lost their own rate table to the Claude `$3/$15` fallback. The recompute now only fills rows that have no adapter estimate, so ESTIMATE-ALL still covers Amp rows without `Cost:` while `claude-opus-5` with 100k input + 900k cache read + 10k output stays at `$1.20`.
15
+
16
+ ## [0.14.1] - 2026-09-08
17
+
18
+ ### Fixed
19
+ - **Memory graph survives `handoff` persist.** `loadMemoryItems` classified a memory file by its first character, so every JSONL graph (the format `@modelcontextprotocol/server-memory` reads and writes) was treated as an aggregate `{entities, relations}` document, failed to parse, and returned `[]` — the next persist then overwrote the whole file with just the current change's two entities. Everything the Memory MCP server had accumulated — all `Decision:*`, every other change, and all relations — was destroyed on each persist, leaving cross-session memory permanently one change deep. Detection is now by shape, `handoff --restore` reads JSONL graphs again instead of reporting "Memory JSON empty or missing", and a line that cannot be parsed is preserved verbatim rather than dropped.
20
+ - **`/opsx:*` commands reach the IDEs.** `.agents/commands/` was installed and documented but synced nowhere. `sync` now writes `.cursor/commands/opsx-<phase>.md` (flat, `/opsx-<phase>`) and `.claude/commands/opsx/<phase>.md` (namespaced, so the documented `/opsx:<phase>` exists in Claude Code), with the same stale-file deletion as skills and subagents. `sync-local-agent-skills.sh` matches.
21
+ - **`status` readiness mirrors the archive gates.** "ready to archive" was computed from `tasks.md` checkboxes alone, so it appeared on a change `archive` would refuse for a missing or non-APPROVE verdict. It now applies `require_spec_review` / `require_design_brief` and names the blockers.
22
+ - **`gate-check` no longer passes silently.** When the git diff cannot be computed (shallow clone, missing base ref, no commits) the review gate is verified instead of skipped.
23
+ - The next-thread prompt renders `- Change: <name>` instead of `- Change: - name: <name>`.
24
+
25
+ ### Removed
26
+ - `templates/.cursor/memory.json` — an unused snapshot of the kit's own development memory. It was referenced by nothing (`init` writes an empty `.cursor/memory.json`), gitignored so it never showed up in a diff, yet the `files` allowlist still published it to npm. `files` now excludes `templates/.cursor` so it cannot ship again.
27
+
28
+ ### Added
29
+ - `pipeline.src_glob` in `orchestrator.yaml` — the paths `gate-check` treats as product code. The hardcoded `src/` default silently disabled the review gate in repos whose code lives elsewhere; `--src-glob` still overrides.
30
+
7
31
  ## [0.14.0] - 2026-09-07
8
32
 
9
33
  ### Changed
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 (compact metrics schema v2, exhaustive Tier 2 punch list, bounded collection windows, Claude/Amp estimates, and dashboard JSON in v0.14.0):**
77
+ **🔄 Already have the kit installed? Upgrade to latest (v0.15.0 adds `costUsdTotal`, one USD figure per change / phase / platform / model that sums billed Amp with estimated Cursor and Claude, rounds billed sums like estimates, and keeps cache-split Claude estimates from being re-billed at the full input rate):**
78
78
 
79
79
  ```bash
80
80
  npx agent-orchestrator-kit@latest update
@@ -189,7 +189,7 @@ your-project/
189
189
  |----------|----------|
190
190
  | Orchestration | 5-role pipeline, `AGENTS.md`, `orchestrator.yaml`, review command |
191
191
  | OpenSpec skills | All 7 skills for `/opsx:*` workflow |
192
- | IDE sync | Cursor + Claude Code sync script (`--delete` semantics — removes stale skills/subagents) |
192
+ | IDE sync | Cursor + Claude Code sync script (`--delete` semantics — removes stale skills/subagents/commands) |
193
193
  | Subagents | 12 exclusive routes: guide/setup/session-handoff, explore/design/propose/review/archive stage agents, and apply implementation/test/code-review agents — native in Cursor + Claude Code, isolated Amp `subagent-*` wrappers |
194
194
  | CLI gates | `npx agent-orchestrator-kit status` / `gate-check` / `archive` / `handoff` / `metrics` / `memory-setup` — deterministic review-gate, archive, session-handoff, and change metrics (always via `npx`; see `cli-via-npm.mdc`) |
195
195
  | CI | `agent-verify.yml` — GitHub (default) or GitLab fragment + `prebuild` hook, both run `gate-check` |
@@ -816,7 +816,7 @@ Schema v2 stores compact `sourceIds`, `sourceTotals`, and `byModel` per session
816
816
  - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. When env and Amp parent do not win, restore locks a fresh `session.json` `lastThreadId` as `amp-session-last`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
817
817
  - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. After a successful `stop` / `afterAgentResponse` append the hook runs leftover (fail-open, no stdout). Hook and collect resolve the consumer in a multi-root window (not the first cwd with `.agents` or `openspec`). Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
818
818
 
819
- Aggregates are recomputed on every write: per-phase totals (`startedAt`, `endedAt`, `leadTimeMs` from that phase’s sessions, `durationMs` = sum of session work time — not `totals.leadTimeMs` and not `endedAt − startedAt`, tokens, `costUsd`, `costUsdEstimated` to 4 decimals, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
819
+ Aggregates are recomputed on every write: per-phase totals (`startedAt`, `endedAt`, `leadTimeMs` from that phase’s sessions, `durationMs` = sum of session work time — not `totals.leadTimeMs` and not `endedAt − startedAt`, tokens, `costUsd`, `costUsdEstimated`, `costUsdTotal` to 4 decimals, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD. `costUsdTotal` is the one USD figure per change, phase, platform, model, and session: each session contributes its billed `costUsd` when present, otherwise its `costUsdEstimated`, so a change that ran on Amp (billed) plus Cursor and Claude (estimated) sums all three platforms instead of showing only the billed part. Amp credits never enter it. The human `cost:` line prints it as `$21.08 ($14.48 billed + ~$6.60 est.)`.
820
820
 
821
821
  Fill `## Metrics` in `handoff.md` **before** persist (unknown is fine; do not invent `0`):
822
822
 
@@ -843,7 +843,7 @@ npx agent-orchestrator-kit archive add-thing --sync # finalize + the same t
843
843
 
844
844
  #### Для дашбордів
845
845
 
846
- Use only `phases.<phase>.startedAt`, `endedAt`, and `durationMs` for phase boundaries and duration. Use `totals.leadTimeMs` only for the whole change. Git log MUST NOT be used for phase boundaries; the kit does not provide per-phase commit counts. `metrics <name> --summary-json` returns only aggregate `totals`, `phases`, and spend maps, without sessions or commits.
846
+ Use only `phases.<phase>.startedAt`, `endedAt`, and `durationMs` for phase boundaries and duration. Use `totals.leadTimeMs` only for the whole change. Git log MUST NOT be used for phase boundaries; the kit does not provide per-phase commit counts. `metrics <name> --summary-json` returns only aggregate `totals`, `phases`, and spend maps, without sessions or commits. For the headline cost use `spend.costUsdTotal` (and `phases.<phase>.costUsdTotal`, `spendByPlatform.<platform>.costUsdTotal`, `spendByModel[].costUsdTotal`): `costUsd` alone is only the billed part and `costUsdEstimated` alone is only the estimated part, so picking one of them drops every platform that reported the other.
847
847
 
848
848
  Recording is on by default and never a persist/archive/`gate-check` gate; opt out per persist with `--no-metrics`. Persist and archive collect the locked client without `--collect`; `--collect` runs every adapter. Flags (`--model`, `--platform`, `--input-tokens`, …) override session totals in `metrics.json` and do not rewrite the `## Metrics` section.
849
849
 
@@ -986,11 +986,13 @@ npx agent-orchestrator-kit metrics [change-name] [--json] [--collect]
986
986
  skills/ # Synced from .agents/skills/
987
987
  rules/ # Synced from .agents/rules/
988
988
  agents/ # Synced from .agents/subagents/
989
+ commands/ # Synced from .agents/commands/ (flat — /opsx-apply)
989
990
  memory.json # Memory MCP data
990
991
 
991
992
  .claude/ # Local only — Claude Code runtime
992
993
  skills/ # Synced from .agents/skills/
993
994
  agents/ # Synced from .agents/subagents/
995
+ commands/opsx/ # Synced from .agents/commands/ (namespaced — /opsx:apply)
994
996
  CLAUDE.md # Synced from root CLAUDE.md
995
997
 
996
998
  .amp/ # Local only — Amp config
@@ -1015,6 +1017,16 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1015
1017
 
1016
1018
  ## Changelog
1017
1019
 
1020
+ ### 0.15.0
1021
+ - **`costUsdTotal`** on `spend`, `phases.*`, `spendByPlatform.*`, `spendByModel[]`, and sessions — billed `costUsd` when present, otherwise `costUsdEstimated`, per session; a change that ran on Amp (billed) plus Cursor and Claude (estimated) now sums all three platforms instead of showing only the billed part; the human `cost:` line prints `$21.08 ($14.48 billed + ~$6.60 est.)` and `--summary-json` carries the field
1022
+ - Billed `costUsd` aggregates are rounded to 4 decimals like estimates (no more `4.4399999999999995` in `spendByModel`)
1023
+ - Cache-split Claude cost estimates survive the session recompute instead of being re-billed at the full input rate (`$30.11` → `$5.16` on a 3.5M-token change)
1024
+
1025
+ ### 0.14.1
1026
+ - **Memory graph survives `handoff` persist** — JSONL graphs were misread as an aggregate document and returned empty, so each persist overwrote the file with only the current change; all `Decision:*`, other changes, and relations were lost
1027
+ - `/opsx:*` commands now sync to `.cursor/commands/` (flat) and `.claude/commands/opsx/` (namespaced), so the documented slash commands exist in both IDEs
1028
+ - `status` applies `require_spec_review` / `require_design_brief` before saying "ready to archive"; `gate-check` verifies the gate instead of exiting 0 when the diff is unknown, and reads `pipeline.src_glob` for code outside `src/`
1029
+
1018
1030
  ### 0.14.0
1019
1031
  - **BREAKING: compact metrics schema v2** — sessions persist `sourceIds` / `sourceTotals` / `byModel` instead of `sources`; `metrics --migrate` is schema-only; `metrics --summary-json` for dashboards
1020
1032
  - Bounded persist/leftover windows, thread-scoped Amp usage + fresh Cost, Claude dedup/subagent capture, static Claude/Amp estimates
@@ -220,6 +220,47 @@ function copyDir(src, dest, opts = {}) {
220
220
  }
221
221
  }
222
222
 
223
+ // `.agents/commands/opsx-<phase>.md` is the source of truth for the /opsx:*
224
+ // role commands. Cursor reads `.cursor/commands/<file>.md` as `/<file>`, so it
225
+ // gets a flat copy. Claude Code namespaces by subdirectory —
226
+ // `.claude/commands/opsx/<phase>.md` is what makes the documented
227
+ // `/opsx:<phase>` actually exist there.
228
+ function syncCommands(projectDir, ideDir, { namespaced }) {
229
+ const src = join(projectDir, '.agents', 'commands');
230
+ const dest = join(projectDir, ideDir, 'commands');
231
+ if (!existsSync(src)) return;
232
+
233
+ const files = readdirSync(src).filter((f) => f.endsWith('.md'));
234
+ const written = new Set();
235
+ mkdirSync(dest, { recursive: true });
236
+
237
+ for (const file of files) {
238
+ const match = namespaced && file.match(/^([a-z0-9]+)-(.+\.md)$/i);
239
+ const rel = match ? join(match[1], match[2]) : file;
240
+ const destPath = join(dest, rel);
241
+ mkdirSync(dirname(destPath), { recursive: true });
242
+ copyFileSync(join(src, file), destPath);
243
+ written.add(rel);
244
+ log.ok(destPath.replace(process.cwd() + '/', ''));
245
+ }
246
+
247
+ // Drop commands a kit `update` removed, mirroring the skills/subagents sync.
248
+ const walk = (dir, prefix = '') => {
249
+ for (const entry of readdirSync(dir)) {
250
+ const full = join(dir, entry);
251
+ const rel = prefix ? join(prefix, entry) : entry;
252
+ if (statSync(full).isDirectory()) {
253
+ walk(full, rel);
254
+ if (readdirSync(full).length === 0) rmSync(full, { recursive: true, force: true });
255
+ } else if (!written.has(rel)) {
256
+ rmSync(full, { force: true });
257
+ log.warn(`removed stale: ${join(dest, rel).replace(process.cwd() + '/', '')}`);
258
+ }
259
+ }
260
+ };
261
+ walk(dest);
262
+ }
263
+
223
264
  function gitignoreLines(content) {
224
265
  return content.split('\n').map((l) => l.trim()).filter(Boolean);
225
266
  }
@@ -960,6 +1001,26 @@ function parseHandoffMarkdown(content) {
960
1001
  return sections;
961
1002
  }
962
1003
 
1004
+ // The `## Change` section is a bullet list (`- name: <x>`), but the next-thread
1005
+ // prompt inlines it after its own bullet. Flatten it so the prompt does not
1006
+ // read "- Change: - name: <x>".
1007
+ function inlineChangeLabel(value, fallbackName) {
1008
+ const text = String(value || '').trim();
1009
+ if (!text) return fallbackName;
1010
+ const parts = text
1011
+ .split('\n')
1012
+ .map((line) => line.replace(/^\s*[-*]\s*/, '').trim())
1013
+ .filter(Boolean);
1014
+ if (parts.length === 0) return fallbackName;
1015
+ const named = parts.find((p) => /^name:\s*/i.test(p));
1016
+ if (named) {
1017
+ const rest = parts.filter((p) => p !== named);
1018
+ const label = named.replace(/^name:\s*/i, '').trim() || fallbackName;
1019
+ return rest.length ? `${label} (${rest.join('; ')})` : label;
1020
+ }
1021
+ return parts.join('; ');
1022
+ }
1023
+
963
1024
  function firstLineCommand(value) {
964
1025
  const match = String(value || '').match(/\/opsx:[^\s`]+(?:\s+[^\s`]+)?/);
965
1026
  if (match) return match[0].trim();
@@ -1397,7 +1458,7 @@ function buildNextSessionPrompt(fields, agentLanguage) {
1397
1458
 
1398
1459
  ## Повний контекст попередньої сесії (самодостатній — не покладайся лише на Memory)
1399
1460
  - Закрита роль: ${fields.closedRole || 'не вказано'}
1400
- - Зміна: ${fields.change || name}
1461
+ - Зміна: ${inlineChangeLabel(fields.change, name)}
1401
1462
  - Зроблено:
1402
1463
  ${fields.done || 'не вказано'}
1403
1464
  - Рішення:
@@ -1449,7 +1510,7 @@ Do not mix phases. Do not start the following role in this chat until this phase
1449
1510
 
1450
1511
  ## Full previous-session context (self-contained — do not rely on Memory alone)
1451
1512
  - Closed role: ${fields.closedRole || 'not set'}
1452
- - Change: ${fields.change || name}
1513
+ - Change: ${inlineChangeLabel(fields.change, name)}
1453
1514
  - Done:
1454
1515
  ${fields.done || 'not set'}
1455
1516
  - Decisions:
@@ -1481,26 +1542,48 @@ function loadMemoryItems(filePath) {
1481
1542
  if (!existsSync(filePath)) return [];
1482
1543
  const raw = readFileSync(filePath, 'utf-8').trim();
1483
1544
  if (!raw) return [];
1484
- if (raw.startsWith('{')) {
1485
- try {
1486
- const parsed = JSON.parse(raw);
1545
+
1546
+ // Aggregate form is a single JSON document: { entities: [...], relations: [...] }.
1547
+ // Every JSONL line is an object too, so the *shape* decides — not the first
1548
+ // character. Keying off `{` classified every JSONL graph as an aggregate,
1549
+ // parsed it as one document, failed, and returned [] — which made the next
1550
+ // persist overwrite the whole graph with just the current change.
1551
+ try {
1552
+ const parsed = JSON.parse(raw);
1553
+ if (parsed && !Array.isArray(parsed) && (Array.isArray(parsed.entities) || Array.isArray(parsed.relations))) {
1487
1554
  const entities = (parsed.entities || []).map((entity) => ({ type: 'entity', ...entity }));
1488
1555
  const relations = (parsed.relations || []).map((relation) => ({ type: 'relation', ...relation }));
1489
1556
  return [...entities, ...relations];
1557
+ }
1558
+ } catch {
1559
+ // Not a single JSON document — fall through to JSONL.
1560
+ }
1561
+
1562
+ // JSONL: the format @modelcontextprotocol/server-memory reads and writes,
1563
+ // one entity or relation per line. A line we cannot parse is kept verbatim
1564
+ // so a persist never drops memory it failed to understand.
1565
+ const items = [];
1566
+ for (const line of raw.split('\n')) {
1567
+ const trimmed = line.trim();
1568
+ if (!trimmed) continue;
1569
+ let parsed;
1570
+ try {
1571
+ parsed = JSON.parse(trimmed);
1490
1572
  } catch {
1491
- return [];
1573
+ items.push({ __raw: trimmed });
1574
+ continue;
1492
1575
  }
1576
+ if (parsed && typeof parsed === 'object' && parsed.type) items.push(parsed);
1577
+ else items.push({ __raw: trimmed });
1493
1578
  }
1494
- return raw
1495
- .split('\n')
1496
- .map((line) => line.trim())
1497
- .filter(Boolean)
1498
- .map((line) => JSON.parse(line));
1579
+ return items;
1499
1580
  }
1500
1581
 
1501
1582
  function saveMemoryItems(filePath, items) {
1502
1583
  mkdirSync(dirname(filePath), { recursive: true });
1503
- const body = items.map((item) => JSON.stringify(item)).join('\n');
1584
+ const body = items
1585
+ .map((item) => (item && item.__raw !== undefined ? item.__raw : JSON.stringify(item)))
1586
+ .join('\n');
1504
1587
  writeFileSync(filePath, body ? `${body}\n` : '');
1505
1588
  }
1506
1589
 
@@ -1627,12 +1710,18 @@ function roundUsd4(x) {
1627
1710
  return Math.round(Number(x) * 10000) / 10000;
1628
1711
  }
1629
1712
 
1713
+ function costUsdTotalOf(obj) {
1714
+ const billed = numOrNull(obj && obj.costUsd);
1715
+ if (billed != null) return billed;
1716
+ return numOrNull(obj && obj.costUsdEstimated);
1717
+ }
1718
+
1630
1719
  function metricsFilePath(projectDir, changeName) {
1631
1720
  return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1632
1721
  }
1633
1722
 
1634
1723
  function emptySpendTotals() {
1635
- return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
1724
+ return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null, costUsdTotal: null };
1636
1725
  }
1637
1726
 
1638
1727
  function emptyPlatformSpend(source = 'none') {
@@ -1643,6 +1732,7 @@ function emptyPlatformSpend(source = 'none') {
1643
1732
  costUsd: null,
1644
1733
  ampCredits: null,
1645
1734
  costUsdEstimated: null,
1735
+ costUsdTotal: null,
1646
1736
  source,
1647
1737
  };
1648
1738
  }
@@ -2383,6 +2473,9 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2383
2473
  if (session.costUsd == null) {
2384
2474
  for (const row of session.byModel) {
2385
2475
  if (row.costUsd != null) continue;
2476
+ // adapters already estimated with the cache-read / cache-write split; do not
2477
+ // overwrite it with the coarse input+output estimate below
2478
+ if (row.costUsdEstimated != null) continue;
2386
2479
  const described = describeClaudeCostEstimate({
2387
2480
  model: String(row.model || '').replace(/^Claude\s+/i, 'claude-').replaceAll(' ', '-').toLowerCase(),
2388
2481
  inputTokens: row.inputTokens,
@@ -2463,6 +2556,7 @@ function spendTuple(obj) {
2463
2556
  costUsd: numOrNull(obj && obj.costUsd),
2464
2557
  ampCredits: numOrNull(obj && obj.ampCredits),
2465
2558
  costUsdEstimated: numOrNull(obj && obj.costUsdEstimated),
2559
+ costUsdTotal: numOrNull(obj && obj.costUsdTotal) ?? costUsdTotalOf(obj),
2466
2560
  };
2467
2561
  }
2468
2562
 
@@ -2482,6 +2576,7 @@ function addSpendNums(target, nums) {
2482
2576
  target.costUsd = addNullable(target.costUsd, nums.costUsd);
2483
2577
  target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
2484
2578
  target.costUsdEstimated = addNullable(target.costUsdEstimated, nums.costUsdEstimated);
2579
+ target.costUsdTotal = addNullable(target.costUsdTotal, nums.costUsdTotal);
2485
2580
  }
2486
2581
 
2487
2582
  function recomputeSpendMaps(metrics) {
@@ -2500,6 +2595,7 @@ function recomputeSpendMaps(metrics) {
2500
2595
  costUsd: null,
2501
2596
  ampCredits: null,
2502
2597
  costUsdEstimated: null,
2598
+ costUsdTotal: null,
2503
2599
  };
2504
2600
  addSpendNums(row, nums);
2505
2601
  byModel.set(key, row);
@@ -2520,10 +2616,14 @@ function recomputeSpendMaps(metrics) {
2520
2616
  }
2521
2617
  }
2522
2618
  for (const key of Object.keys(byPlatform)) {
2619
+ byPlatform[key].costUsd = roundUsd4(byPlatform[key].costUsd);
2523
2620
  byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
2621
+ byPlatform[key].costUsdTotal = roundUsd4(byPlatform[key].costUsdTotal);
2524
2622
  }
2525
2623
  for (const row of byModel.values()) {
2624
+ row.costUsd = roundUsd4(row.costUsd);
2526
2625
  row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
2626
+ row.costUsdTotal = roundUsd4(row.costUsdTotal);
2527
2627
  }
2528
2628
  metrics.spendByPlatform = byPlatform;
2529
2629
  metrics.spendByModel = [...byModel.values()];
@@ -2575,6 +2675,13 @@ function recomputeMetricsAggregates(metrics) {
2575
2675
  phase[spendKey] = addNullable(phase[spendKey], value);
2576
2676
  spend[spendKey] = addNullable(spend[spendKey], value);
2577
2677
  }
2678
+ const sessionCostUsdTotal = costUsdTotalOf({
2679
+ costUsd: sessionFieldOrSources(session, 'costUsd'),
2680
+ costUsdEstimated: sessionFieldOrSources(session, 'costUsdEstimated'),
2681
+ });
2682
+ session.costUsdTotal = roundUsd4(sessionCostUsdTotal);
2683
+ phase.costUsdTotal = addNullable(phase.costUsdTotal, sessionCostUsdTotal);
2684
+ spend.costUsdTotal = addNullable(spend.costUsdTotal, sessionCostUsdTotal);
2578
2685
  if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
2579
2686
  if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
2580
2687
  if (Array.isArray(session.models)) {
@@ -2593,9 +2700,13 @@ function recomputeMetricsAggregates(metrics) {
2593
2700
  phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
2594
2701
  ? Math.max(0, endMs - startMs)
2595
2702
  : null;
2703
+ phase.costUsd = roundUsd4(phase.costUsd);
2596
2704
  phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
2705
+ phase.costUsdTotal = roundUsd4(phase.costUsdTotal);
2597
2706
  }
2707
+ spend.costUsd = roundUsd4(spend.costUsd);
2598
2708
  spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
2709
+ spend.costUsdTotal = roundUsd4(spend.costUsdTotal);
2599
2710
  metrics.phases = phases;
2600
2711
  metrics.totals = totals;
2601
2712
  metrics.spend = spend;
@@ -2822,7 +2933,8 @@ function formatMetricsCostLine(spend) {
2822
2933
  const estimated = spend && spend.costUsdEstimated;
2823
2934
  if (billed == null && estimated == null) return '—';
2824
2935
  if (billed != null && estimated != null) {
2825
- return `${formatMetricsCost(billed)} billed + ~${formatMetricsCost(estimated)} est.`;
2936
+ const total = spend.costUsdTotal != null ? spend.costUsdTotal : billed + estimated;
2937
+ return `${formatMetricsCost(total)} (${formatMetricsCost(billed)} billed + ~${formatMetricsCost(estimated)} est.)`;
2826
2938
  }
2827
2939
  if (billed != null) return formatMetricsCost(billed);
2828
2940
  return `~${formatMetricsCost(estimated)} est.`;
@@ -3055,11 +3167,13 @@ function readPipelineConfig(projectDir) {
3055
3167
  const requireBriefMatch = content.match(/require_design_brief:\s*(true|false)/);
3056
3168
  const maxActiveMatch = content.match(/max_active_changes:\s*(\d+)/);
3057
3169
  const taskContractMatch = content.match(/task_contract:\s*(warn|strict|off)/);
3170
+ const srcGlobMatch = content.match(/src_glob:\s*["']?([^"'\s#]+)["']?/);
3058
3171
  return {
3059
3172
  requireSpecReview: requireReviewMatch ? requireReviewMatch[1] === 'true' : true,
3060
3173
  requireDesignBrief: requireBriefMatch ? requireBriefMatch[1] === 'true' : false,
3061
3174
  maxActiveChanges: maxActiveMatch ? parseInt(maxActiveMatch[1], 10) : null,
3062
3175
  taskContract: taskContractMatch ? taskContractMatch[1] : 'warn',
3176
+ srcGlob: srcGlobMatch ? srcGlobMatch[1] : null,
3063
3177
  };
3064
3178
  }
3065
3179
 
@@ -3891,6 +4005,7 @@ program
3891
4005
  }
3892
4006
  copyDir(join(projectDir, '.agents', 'rules'), join(projectDir, '.cursor', 'rules'), { overwrite: true, delete: true });
3893
4007
  copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.cursor', 'agents'), { overwrite: true, delete: true });
4008
+ syncCommands(projectDir, '.cursor', { namespaced: false });
3894
4009
  }
3895
4010
 
3896
4011
  if (syncClaude) {
@@ -3900,6 +4015,7 @@ program
3900
4015
  rmSync(join(projectDir, '.claude', 'skills', wrapper), { recursive: true, force: true });
3901
4016
  }
3902
4017
  copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.claude', 'agents'), { overwrite: true, delete: true });
4018
+ syncCommands(projectDir, '.claude', { namespaced: true });
3903
4019
 
3904
4020
  const claudeMd = join(projectDir, 'CLAUDE.md');
3905
4021
  const claudeDir = join(projectDir, '.claude');
@@ -3937,6 +4053,13 @@ program
3937
4053
  if (changes.length === 0) {
3938
4054
  log.info('No active changes');
3939
4055
  } else {
4056
+ // Readiness must mirror the gates `archive` actually enforces — reporting
4057
+ // "ready" on task count alone told the conductor to archive a change the
4058
+ // CLI would then refuse.
4059
+ const config = readPipelineConfig(projectDir);
4060
+ const requireReview = config ? config.requireSpecReview : true;
4061
+ const requireBrief = config ? config.requireDesignBrief : false;
4062
+
3940
4063
  for (const name of changes) {
3941
4064
  const changeDir = join(projectDir, 'openspec', 'changes', name);
3942
4065
  const progress = parseTasksProgress(changeDir);
@@ -3944,13 +4067,24 @@ program
3944
4067
  const hasBrief = parseDesignBrief(changeDir);
3945
4068
  const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
3946
4069
  const verdictStr = verdict || 'none';
3947
- const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
4070
+
4071
+ const blockers = [];
4072
+ if (!(progress && progress.total > 0 && progress.done === progress.total)) {
4073
+ blockers.push('tasks incomplete');
4074
+ }
4075
+ if (requireReview && !(verdict && /^APPROVE/i.test(verdict))) {
4076
+ blockers.push(verdict ? `review verdict "${verdict}" (need APPROVE)` : 'no review.md');
4077
+ }
4078
+ if (requireBrief && !hasBrief && !hasDesignOptOut(changeDir)) {
4079
+ blockers.push('no design-brief.md');
4080
+ }
3948
4081
 
3949
4082
  console.log(`\n${pc.bold(name)}`);
3950
4083
  console.log(` tasks: ${progressStr}`);
3951
4084
  console.log(` review: ${verdictStr}`);
3952
4085
  console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
3953
- if (readyToArchive) log.ok('ready to archive');
4086
+ if (blockers.length === 0) log.ok('ready to archive');
4087
+ else log.info(`not ready to archive — ${blockers.join('; ')}`);
3954
4088
  }
3955
4089
  console.log('');
3956
4090
  }
@@ -3963,7 +4097,7 @@ program
3963
4097
  program
3964
4098
  .command('gate-check [change-name]')
3965
4099
  .description('Deterministically check the review gate before apply/merge (exit non-zero if unmet)')
3966
- .option('--src-glob <glob>', 'source path filter used to detect code changes', 'src/')
4100
+ .option('--src-glob <glob>', 'source path filter used to detect code changes (default: pipeline.src_glob, else src/)')
3967
4101
  .option('--base <ref>', 'git ref to diff against', 'HEAD~1')
3968
4102
  .option('--staged', 'check staged files (git diff --cached) instead of --base...HEAD', false)
3969
4103
  .option('--tasks <name>', 'lint task contracts (Files/Do/Done-when) of a change')
@@ -4019,16 +4153,21 @@ program
4019
4153
  return;
4020
4154
  }
4021
4155
 
4156
+ // A repo whose code does not live in src/ used to fall through to
4157
+ // "nothing to gate" on every run, silently disabling the review gate.
4158
+ const srcGlob = opts.srcGlob || config.srcGlob || 'src/';
4159
+
4022
4160
  const touchesSrc = opts.staged
4023
- ? gitStagedTouchesGlob(projectDir, opts.srcGlob)
4024
- : gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
4161
+ ? gitStagedTouchesGlob(projectDir, srcGlob)
4162
+ : gitDiffTouchesGlob(projectDir, opts.base, srcGlob);
4025
4163
  if (touchesSrc === false) {
4026
- log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${opts.srcGlob} — nothing to gate`);
4164
+ log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${srcGlob} — nothing to gate`);
4027
4165
  return;
4028
4166
  }
4029
4167
  if (touchesSrc === null) {
4030
- log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — skipping gate-check`);
4031
- return;
4168
+ // Cannot prove nothing changed (shallow clone, missing base ref, no
4169
+ // commits yet) — verify the gate instead of passing a blocking check.
4170
+ log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — verifying the review gate anyway`);
4032
4171
  }
4033
4172
 
4034
4173
  const changes = listActiveChanges(projectDir);
@@ -4039,7 +4178,7 @@ program
4039
4178
  let target = changeName;
4040
4179
  if (!target) {
4041
4180
  if (changes.length === 0) {
4042
- log.warn(`${opts.srcGlob} changed but no active OpenSpec change found — cannot verify review gate`);
4181
+ log.warn(`${srcGlob} changed but no active OpenSpec change found — cannot verify review gate`);
4043
4182
  return;
4044
4183
  }
4045
4184
  target = changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -35,6 +35,7 @@
35
35
  "files": [
36
36
  "bin/",
37
37
  "templates/",
38
+ "!templates/.cursor",
38
39
  "profiles/",
39
40
  "README.md",
40
41
  "CHANGELOG.md",
@@ -11,6 +11,9 @@ pipeline:
11
11
  max_active_changes: 1
12
12
  archive_after_merge: true
13
13
  task_contract: warn
14
+ # Paths gate-check treats as product code. Widen it when code lives
15
+ # outside src/ (e.g. "{src,lib,app}/") or the review gate never fires.
16
+ src_glob: "src/"
14
17
 
15
18
  roles:
16
19
  explorer:
@@ -16,6 +16,9 @@ pipeline:
16
16
  archive_after_merge: false
17
17
  quick_mode_enabled: true
18
18
  task_contract: off
19
+ # Paths gate-check treats as product code. Widen it when code lives
20
+ # outside src/ (e.g. "{src,lib,app}/") or the review gate never fires.
21
+ src_glob: "src/"
19
22
 
20
23
  roles:
21
24
  explorer:
@@ -14,6 +14,9 @@ pipeline:
14
14
  max_active_changes: 1
15
15
  archive_after_merge: true
16
16
  task_contract: warn
17
+ # Paths gate-check treats as product code. Widen it when code lives
18
+ # outside src/ (e.g. "{src,lib,app}/") or the review gate never fires.
19
+ src_glob: "src/"
17
20
 
18
21
  roles:
19
22
  explorer:
@@ -14,6 +14,9 @@ pipeline:
14
14
  max_active_changes: 1
15
15
  archive_after_merge: true
16
16
  task_contract: warn
17
+ # Paths gate-check treats as product code. Widen it when code lives
18
+ # outside src/ (e.g. "{src,lib,app}/") or the review gate never fires.
19
+ src_glob: "src/"
17
20
 
18
21
  roles:
19
22
  explorer:
@@ -13,6 +13,9 @@ pipeline:
13
13
  max_active_changes: 1
14
14
  archive_after_merge: true
15
15
  task_contract: warn
16
+ # Paths gate-check treats as product code. Widen it when code lives
17
+ # outside src/ (e.g. "{src,lib,app}/") or the review gate never fires.
18
+ src_glob: "src/"
16
19
 
17
20
  roles:
18
21
  explorer:
@@ -23,6 +23,12 @@ function roundUsd4(x) {
23
23
  return Math.round(Number(x) * 10000) / 10000;
24
24
  }
25
25
 
26
+ function costUsdTotalOf(obj) {
27
+ const billed = numOrNull(obj && obj.costUsd);
28
+ if (billed != null) return billed;
29
+ return numOrNull(obj && obj.costUsdEstimated);
30
+ }
31
+
26
32
  function timestampMs(value) {
27
33
  if (value == null || value === '') return NaN;
28
34
  const ms = Date.parse(value);
@@ -435,6 +441,7 @@ function emptyPlatform(source = 'none') {
435
441
  costUsd: null,
436
442
  ampCredits: null,
437
443
  costUsdEstimated: null,
444
+ costUsdTotal: null,
438
445
  source,
439
446
  };
440
447
  }
@@ -442,7 +449,7 @@ function emptyPlatform(source = 'none') {
442
449
  function recompute(metrics) {
443
450
  const phases = {};
444
451
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
445
- const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
452
+ const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null, costUsdTotal: null };
446
453
  const byPlatform = {
447
454
  cursor: emptyPlatform(),
448
455
  claude: emptyPlatform(),
@@ -477,6 +484,7 @@ function recompute(metrics) {
477
484
  totalTokens: null,
478
485
  costUsd: null,
479
486
  costUsdEstimated: null,
487
+ costUsdTotal: null,
480
488
  agents: [],
481
489
  models: [],
482
490
  };
@@ -493,6 +501,10 @@ function recompute(metrics) {
493
501
  phase[spendKey] = addNullable(phase[spendKey], value);
494
502
  spend[spendKey] = addNullable(spend[spendKey], value);
495
503
  }
504
+ const sessionCostUsdTotal = costUsdTotalOf(session);
505
+ session.costUsdTotal = roundUsd4(sessionCostUsdTotal);
506
+ phase.costUsdTotal = addNullable(phase.costUsdTotal, sessionCostUsdTotal);
507
+ spend.costUsdTotal = addNullable(spend.costUsdTotal, sessionCostUsdTotal);
496
508
  if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
497
509
  if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
498
510
  if (Array.isArray(session.models)) {
@@ -508,6 +520,7 @@ function recompute(metrics) {
508
520
  for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'ampCredits', 'costUsdEstimated']) {
509
521
  bucket[key] = addNullable(bucket[key], numOrNull(session[key]));
510
522
  }
523
+ bucket.costUsdTotal = addNullable(bucket.costUsdTotal, sessionCostUsdTotal);
511
524
  if ((session.sourceIds || []).length) bucket.source = platform === 'cursor' ? 'cursor-hook' : `${platform}-jsonl`;
512
525
  }
513
526
  const modelRows = (session.byModel || []).length ? session.byModel : [session];
@@ -523,6 +536,7 @@ function recompute(metrics) {
523
536
  costUsd: null,
524
537
  ampCredits: null,
525
538
  costUsdEstimated: null,
539
+ costUsdTotal: null,
526
540
  };
527
541
  row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
528
542
  row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
@@ -530,6 +544,7 @@ function recompute(metrics) {
530
544
  row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
531
545
  row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
532
546
  row.costUsdEstimated = addNullable(row.costUsdEstimated, numOrNull(src.costUsdEstimated));
547
+ row.costUsdTotal = addNullable(row.costUsdTotal, costUsdTotalOf(src));
533
548
  byModel.set(modelKey, row);
534
549
  }
535
550
  }
@@ -544,14 +559,22 @@ function recompute(metrics) {
544
559
  phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
545
560
  ? Math.max(0, endMs - startMs)
546
561
  : null;
562
+ phase.costUsd = roundUsd4(phase.costUsd);
547
563
  phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
564
+ phase.costUsdTotal = roundUsd4(phase.costUsdTotal);
548
565
  }
566
+ spend.costUsd = roundUsd4(spend.costUsd);
549
567
  spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
568
+ spend.costUsdTotal = roundUsd4(spend.costUsdTotal);
550
569
  for (const bucket of Object.values(byPlatform)) {
570
+ bucket.costUsd = roundUsd4(bucket.costUsd);
551
571
  bucket.costUsdEstimated = roundUsd4(bucket.costUsdEstimated);
572
+ bucket.costUsdTotal = roundUsd4(bucket.costUsdTotal);
552
573
  }
553
574
  for (const row of byModel.values()) {
575
+ row.costUsd = roundUsd4(row.costUsd);
554
576
  row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
577
+ row.costUsdTotal = roundUsd4(row.costUsdTotal);
555
578
  }
556
579
  metrics.phases = phases;
557
580
  metrics.totals = totals;
@@ -76,6 +76,13 @@ if [ -d .agents/subagents ]; then
76
76
  ok ".cursor/agents/"
77
77
  fi
78
78
 
79
+ # Cursor reads .cursor/commands/<file>.md as /<file> — flat copy.
80
+ if [ -d .agents/commands ]; then
81
+ mkdir -p .cursor/commands
82
+ rsync -a --delete .agents/commands/ .cursor/commands/
83
+ ok ".cursor/commands/"
84
+ fi
85
+
79
86
  if [ ! -f .mcp.json ] && [ -f .agents/mcp.json.example ]; then
80
87
  cp .agents/mcp.json.example .mcp.json
81
88
  ok ".mcp.json created from example"
@@ -97,6 +104,26 @@ if [ -d .agents/subagents ]; then
97
104
  ok ".claude/agents/"
98
105
  fi
99
106
 
107
+ # Claude Code namespaces commands by subdirectory: .claude/commands/opsx/apply.md
108
+ # is what makes the documented /opsx:apply exist.
109
+ if [ -d .agents/commands ]; then
110
+ rm -rf .claude/commands
111
+ mkdir -p .claude/commands
112
+ for cmd in .agents/commands/*.md; do
113
+ [ -f "$cmd" ] || continue
114
+ BASE="$(basename "$cmd" .md)"
115
+ NS="${BASE%%-*}"
116
+ REST="${BASE#*-}"
117
+ if [ "$NS" != "$BASE" ] && [ -n "$REST" ]; then
118
+ mkdir -p ".claude/commands/$NS"
119
+ cp "$cmd" ".claude/commands/$NS/$REST.md"
120
+ else
121
+ cp "$cmd" ".claude/commands/$BASE.md"
122
+ fi
123
+ done
124
+ ok ".claude/commands/"
125
+ fi
126
+
100
127
  if [ -f CLAUDE.md ]; then
101
128
  cp CLAUDE.md .claude/CLAUDE.md
102
129
  ok ".claude/CLAUDE.md"
@@ -1,11 +0,0 @@
1
- {"type":"entity","name":"Change:add-factory-memory-and-skills","entityType":"Change","observations":["status: archived","tasks: 9/9","last_role: Archiver","review: APPROVE","summary: archived to openspec/changes/archive/2026-08-27-add-factory-memory-and-skills"]}
2
- {"type":"entity","name":"Handoff:add-factory-memory-and-skills","entityType":"Handoff","observations":["next_role: none","next_command: none","summary: archived to openspec/changes/archive/2026-08-27-add-factory-memory-and-skills","blocked: none"]}
3
- {"type":"entity","name":"Decision:apply-complete","entityType":"Decision","observations":["all 9 tasks implemented and verified (npm test 101/101, openspec validate --strict, gate-check --tasks)","change: add-factory-memory-and-skills","date: 2026-08-27"]}
4
- {"type":"entity","name":"Decision:m1-followed","entityType":"Decision","observations":["task 2.3 Skill health stale/missing verified in a temporary init+sync project (smoke test), not in the kit repo","change: add-factory-memory-and-skills","date: 2026-08-27"]}
5
- {"type":"entity","name":"Decision:m2-followed","entityType":"Decision","observations":["skills.kit drift test iterates all five orchestrator.yaml files against templates/.agents/skills/","change: add-factory-memory-and-skills","date: 2026-08-27"]}
6
- {"type":"entity","name":"Decision:i1-honored","entityType":"Decision","observations":["did not change templates/orchestrator.yaml handoff.spawn_handoff_subagent (still false)","change: add-factory-memory-and-skills","date: 2026-08-27"]}
7
- {"type":"relation","from":"Change:add-factory-memory-and-skills","to":"Handoff:add-factory-memory-and-skills","relationType":"hasHandoff"}
8
- {"type":"relation","from":"Change:add-factory-memory-and-skills","to":"Decision:apply-complete","relationType":"hasDecision"}
9
- {"type":"relation","from":"Change:add-factory-memory-and-skills","to":"Decision:m1-followed","relationType":"hasDecision"}
10
- {"type":"relation","from":"Change:add-factory-memory-and-skills","to":"Decision:m2-followed","relationType":"hasDecision"}
11
- {"type":"relation","from":"Change:add-factory-memory-and-skills","to":"Decision:i1-honored","relationType":"hasDecision"}