agent-orchestrator-kit 0.12.0 → 0.14.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.
@@ -13,7 +13,8 @@ Workflow:
13
13
  - conflicts with existing `openspec/specs/` requirements;
14
14
  - scope creep vs proposal Non-goals;
15
15
  - task self-sufficiency: a blind implementer can execute each task from Files/Do/Done-when alone, without design.md.
16
- 3. Write `review.md` with findings ordered by severity and exactly one verdict: `APPROVE` or `REQUEST CHANGES`.
16
+ MUST NOT stop at the first blocking finding. Finish the full LLM checklist and a complete artifact scan of proposal.md, design.md, tasks.md, all delta specs, and referenced main specs/repo paths before writing the verdict. One still means REQUEST CHANGES, but list every blocking issue of that pass.
17
+ 3. MUST read the existing `review.md` before overwriting it. Write `review.md` with findings ordered by severity and exactly one verdict: `APPROVE` or `REQUEST CHANGES`. REQUEST CHANGES and re-review MUST use headings Checklist, Findings (Blocker / Major / Minor), Required Before Apply, Previous findings.
17
18
  4. On APPROVE, also write `apply-notes.md` (≤ 20 lines): critical constraints, pitfalls, what NOT to touch, verification commands. It is the second and last file you may write.
18
19
  5. Approve only when artifacts are implementable without material guessing.
19
20
 
@@ -23,6 +24,9 @@ Rules:
23
24
  - Do NOT implement fixes found during review.
24
25
  - Do NOT substitute for `code-reviewer`; that agent reviews the implementation diff after apply.
25
26
  - Do NOT approve based only on Tier 1 passing; verify semantics and repository references.
27
+ - The `Previous findings` heading is ALWAYS present after any Tier 2 pass. If no prior `review.md` existed, the body is the literal line `none — first review cycle`. If a prior file existed, each prior Required Before Apply item → `resolved` | `unresolved` plus one-line evidence.
28
+ - Cosmetics stay out of Required Before Apply.
29
+ - Later review MUST carry prior Required Before Apply into Previous findings and MUST still scan the same defect class (LLM-only only: another task whose `Do:` is not executable without design.md; another design behaviour with no delta requirement; another proposal↔tasks drift; another referenced heading/path that does not exist). Tier 1 classes NEVER enter this rescan. MUST NOT emit a one-item RC that lists only the first leftover.
26
30
 
27
31
  Return exactly this report contract:
28
32
 
@@ -25,7 +25,7 @@ Routing table, HARD STOP, and CLI forms: `.agents/rules/` (`agent-orchestration`
25
25
 
26
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
- 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.
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. Spec review discovery loops ≤ 2 (optional Tier 1 structural RC plus one semantic Tier 2 RC; a confirmation APPROVE after an exhaustive propose does not count). Anti-pattern: one-finding review loop — fragments defects across many propose/review sessions.
29
29
 
30
30
  ## Hard rules
31
31
  - One active change (unless mvp profile).
@@ -303,11 +303,13 @@ function dedupeCursorSources(sources) {
303
303
  }
304
304
 
305
305
  function leftoverWindowEnd(metrics, last) {
306
- if (metrics.pending && metrics.pending.startedAt) return metrics.pending.startedAt;
307
306
  if (!last || !last.endedAt) return null;
308
307
  const end = Date.parse(last.endedAt);
309
308
  if (!Number.isFinite(end)) return null;
310
- return new Date(end + CURSOR_LEFTOVER_GRACE_MS).toISOString();
309
+ const grace = new Date(end + CURSOR_LEFTOVER_GRACE_MS).toISOString();
310
+ const pending = metrics.pending && metrics.pending.startedAt;
311
+ if (!pending || last.threadId) return pending || grace;
312
+ return Date.parse(pending) < Date.parse(grace) ? pending : grace;
311
313
  }
312
314
 
313
315
  function leftoverEndExclusive(metrics) {
@@ -317,9 +319,7 @@ function leftoverEndExclusive(metrics) {
317
319
  function existingIds(metrics) {
318
320
  const ids = new Set();
319
321
  for (const session of metrics.sessions || []) {
320
- for (const src of session.sources || []) {
321
- if (src && src.id != null && src.id !== '') ids.add(String(src.id));
322
- }
322
+ for (const id of session.sourceIds || []) ids.add(String(id));
323
323
  }
324
324
  return ids;
325
325
  }
@@ -417,7 +417,7 @@ function sourceTotals(sources) {
417
417
  }
418
418
 
419
419
  function looksOverridden(session) {
420
- const fromSources = sourceTotals(session.sources || []);
420
+ const fromSources = sourceTotals(session.byModel || []);
421
421
  return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
422
422
  const sessionVal = numOrNull(session[key]);
423
423
  const sourceVal = numOrNull(fromSources[key]);
@@ -489,13 +489,7 @@ function recompute(metrics) {
489
489
  phase.endedAt = session.endedAt;
490
490
  }
491
491
  for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
492
- const fromSession = numOrNull(session[spendKey]);
493
- let value = fromSession;
494
- if (value == null) {
495
- let sum = null;
496
- for (const src of session.sources || []) sum = addNullable(sum, numOrNull(src[spendKey]));
497
- value = sum;
498
- }
492
+ const value = numOrNull(session[spendKey]);
499
493
  phase[spendKey] = addNullable(phase[spendKey], value);
500
494
  spend[spendKey] = addNullable(spend[spendKey], value);
501
495
  }
@@ -508,25 +502,21 @@ function recompute(metrics) {
508
502
  }
509
503
  phases[key] = phase;
510
504
 
511
- for (const src of session.sources || []) {
512
- const platform = src.platform;
513
- if (platform && byPlatform[platform]) {
514
- const bucket = byPlatform[platform];
515
- bucket.inputTokens = addNullable(bucket.inputTokens, numOrNull(src.inputTokens));
516
- bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
517
- bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
518
- bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
519
- bucket.ampCredits = addNullable(bucket.ampCredits, numOrNull(src.ampCredits));
520
- bucket.costUsdEstimated = addNullable(bucket.costUsdEstimated, numOrNull(src.costUsdEstimated));
521
- if (platform === 'claude') bucket.source = 'claude-jsonl';
522
- else if (platform === 'amp') bucket.source = 'amp-thread';
523
- else if (platform === 'cursor') bucket.source = 'cursor-hook';
505
+ const platform = session.platform;
506
+ if (platform && byPlatform[platform]) {
507
+ const bucket = byPlatform[platform];
508
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'ampCredits', 'costUsdEstimated']) {
509
+ bucket[key] = addNullable(bucket[key], numOrNull(session[key]));
524
510
  }
511
+ if ((session.sourceIds || []).length) bucket.source = platform === 'cursor' ? 'cursor-hook' : `${platform}-jsonl`;
512
+ }
513
+ const modelRows = (session.byModel || []).length ? session.byModel : [session];
514
+ for (const src of modelRows) {
525
515
  if (src.model) {
526
- const modelKey = `${src.model}::${src.platform || ''}`;
516
+ const modelKey = `${src.model}::${src.platform || session.platform || ''}`;
527
517
  const row = byModel.get(modelKey) || {
528
518
  model: src.model,
529
- platform: src.platform || null,
519
+ platform: src.platform || session.platform || null,
530
520
  inputTokens: null,
531
521
  outputTokens: null,
532
522
  totalTokens: null,
@@ -576,7 +566,6 @@ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowE
576
566
  const filterId = String(filterConversationId || '').trim();
577
567
  const bestById = new Map();
578
568
  for (const [id, row] of byId) {
579
- if (existing.has(id)) continue;
580
569
  if (filterId) {
581
570
  const rowConversationId = row.conversationId == null || row.conversationId === ''
582
571
  ? ''
@@ -624,14 +613,7 @@ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowE
624
613
  }
625
614
 
626
615
  function existingFingerprints(metrics) {
627
- const set = new Set();
628
- for (const session of metrics.sessions || []) {
629
- for (const src of session.sources || []) {
630
- const fp = cursorSpendFingerprint(src);
631
- if (fp) set.add(fp);
632
- }
633
- }
634
- return set;
616
+ return new Set();
635
617
  }
636
618
 
637
619
  function sessionHasSpendNumbers(session) {
@@ -654,7 +636,7 @@ function sessionSpendFrozen(session) {
654
636
 
655
637
  function syncAdapterSessionTotals(session) {
656
638
  if (sessionSpendFrozen(session)) return false;
657
- const totals = sourceTotals(session.sources || []);
639
+ const totals = sourceTotals(session.byModel || []);
658
640
  let changed = false;
659
641
  for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
660
642
  if (session[key] !== totals[key]) {
@@ -662,7 +644,7 @@ function syncAdapterSessionTotals(session) {
662
644
  changed = true;
663
645
  }
664
646
  }
665
- if ((session.sources || []).length > 0 && session.spendSource !== 'adapter') {
647
+ if ((session.sourceIds || []).length > 0 && session.spendSource !== 'adapter') {
666
648
  session.spendSource = 'adapter';
667
649
  changed = true;
668
650
  }
@@ -670,21 +652,12 @@ function syncAdapterSessionTotals(session) {
670
652
  }
671
653
 
672
654
  function enrichMetrics(metrics, cwd) {
673
- const byId = loadCursorUsageById(cwd);
674
655
  let changed = false;
675
656
  for (const session of metrics.sessions || []) {
676
- const deduped = dedupeCursorSources(session.sources || []);
677
- if (deduped.length !== (session.sources || []).length) {
678
- session.sources = deduped;
679
- changed = true;
680
- } else {
681
- session.sources = deduped;
682
- }
683
- if (attachCursorEstimates(session.sources || [], byId)) changed = true;
684
657
  if (syncAdapterSessionTotals(session)) changed = true;
685
658
  if (
686
659
  session.spendSource === 'unreported'
687
- && (session.inputTokens != null || session.totalTokens != null || (session.sources || []).length)
660
+ && (session.inputTokens != null || session.totalTokens != null || (session.sourceIds || []).length)
688
661
  ) {
689
662
  session.spendSource = 'adapter';
690
663
  changed = true;
@@ -693,6 +666,101 @@ function enrichMetrics(metrics, cwd) {
693
666
  return changed;
694
667
  }
695
668
 
669
+ function compactModelRows(sources) {
670
+ const rows = new Map();
671
+ for (const source of sources || []) {
672
+ if (!source) continue;
673
+ const model = source.model == null || source.model === '' ? null : String(source.model);
674
+ const key = `${source.platform || ''}::${model || ''}`;
675
+ const row = rows.get(key) || {
676
+ model,
677
+ platform: source.platform || null,
678
+ inputTokens: null,
679
+ outputTokens: null,
680
+ totalTokens: null,
681
+ costUsd: null,
682
+ costUsdEstimated: null,
683
+ };
684
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
685
+ row[field] = addNullable(row[field], numOrNull(source[field]));
686
+ }
687
+ row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(source.costUsdEstimated)));
688
+ if (source.costSource) row.costSource = source.costSource;
689
+ rows.set(key, row);
690
+ }
691
+ return [...rows.values()];
692
+ }
693
+
694
+ function mergeModelRows(current, incoming) {
695
+ return compactModelRows([...(current || []), ...(incoming || [])]);
696
+ }
697
+
698
+ function normalizeMetricsV2(metrics) {
699
+ metrics.version = 2;
700
+ metrics.sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
701
+ for (const session of metrics.sessions) {
702
+ if (Array.isArray(session.sources)) {
703
+ const bestById = new Map();
704
+ for (const source of session.sources) {
705
+ if (!source || source.id == null || source.id === '') continue;
706
+ const id = String(source.id);
707
+ const previous = bestById.get(id);
708
+ if (!previous || (numOrNull(source.totalTokens) ?? 0) >= (numOrNull(previous.totalTokens) ?? 0)) bestById.set(id, source);
709
+ }
710
+ const compactSources = [...bestById.values()].map((source) => ({
711
+ ...source,
712
+ model: source.model || session.model || null,
713
+ platform: source.platform || session.platform || null,
714
+ }));
715
+ session.sourceIds = [];
716
+ session.sourceTotals = {};
717
+ for (const source of compactSources) {
718
+ if (!source || source.id == null || source.id === '') continue;
719
+ const id = String(source.id);
720
+ if (!session.sourceIds.includes(id)) session.sourceIds.push(id);
721
+ const total = numOrNull(source.totalTokens);
722
+ if (total != null) session.sourceTotals[id] = Math.max(numOrNull(session.sourceTotals[id]) ?? 0, total);
723
+ }
724
+ session.byModel = compactModelRows(compactSources);
725
+ if (!session.platform && compactSources[0] && compactSources[0].platform) session.platform = compactSources[0].platform;
726
+ if (!session.model && compactSources[0] && compactSources[0].model) session.model = compactSources[0].model;
727
+ delete session.sources;
728
+ } else {
729
+ session.sourceIds = Array.isArray(session.sourceIds) ? [...new Set(session.sourceIds.map(String))] : [];
730
+ session.sourceTotals = session.sourceTotals && typeof session.sourceTotals === 'object' ? session.sourceTotals : {};
731
+ session.byModel = Array.isArray(session.byModel) ? session.byModel : [];
732
+ }
733
+ }
734
+ return metrics;
735
+ }
736
+
737
+ function mergeIncomingCompact(session, incoming) {
738
+ session.sourceIds = Array.isArray(session.sourceIds) ? session.sourceIds : [];
739
+ session.sourceTotals = session.sourceTotals && typeof session.sourceTotals === 'object' ? session.sourceTotals : {};
740
+ const deltaSources = [];
741
+ for (const source of incoming || []) {
742
+ const id = String(source.id);
743
+ const previous = numOrNull(session.sourceTotals[id]);
744
+ const next = numOrNull(source.totalTokens);
745
+ if (!session.sourceIds.includes(id)) session.sourceIds.push(id);
746
+ if (next != null) session.sourceTotals[id] = Math.max(previous ?? 0, next);
747
+ if (previous == null || next == null) {
748
+ deltaSources.push(source);
749
+ continue;
750
+ }
751
+ const delta = Math.max(0, next - previous);
752
+ if (!delta) continue;
753
+ const ratio = next > 0 ? delta / next : 0;
754
+ const copy = { ...source };
755
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
756
+ const value = numOrNull(copy[field]);
757
+ if (value != null) copy[field] = field.includes('Usd') ? roundUsd4(value * ratio) : Math.round(value * ratio);
758
+ }
759
+ deltaSources.push(copy);
760
+ }
761
+ session.byModel = mergeModelRows(session.byModel, compactModelRows(deltaSources));
762
+ }
763
+
696
764
  function backfillMetricsFile(cwd, filePath) {
697
765
  if (!existsSync(filePath)) return;
698
766
  let metrics;
@@ -702,18 +770,14 @@ function backfillMetricsFile(cwd, filePath) {
702
770
  return;
703
771
  }
704
772
  if (!metrics || typeof metrics !== 'object') return;
773
+ const schemaChanged = metrics.version !== 2 || (metrics.sessions || []).some((session) => Array.isArray(session.sources));
774
+ normalizeMetricsV2(metrics);
705
775
  const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
706
776
  if (!sessions.length) return;
707
- let collapsed = false;
708
- for (const session of sessions) {
709
- const next = dedupeCursorSources(session.sources || []);
710
- if (next.length !== (session.sources || []).length) collapsed = true;
711
- session.sources = next;
712
- }
713
777
  const last = sessions[sessions.length - 1];
714
778
  const byId = loadCursorUsageById(cwd);
715
779
  const leftoverEnd = leftoverWindowEnd(metrics, last);
716
- const incoming = last.endedAt && leftoverEnd
780
+ const incoming = (last.endedAt && leftoverEnd
717
781
  ? incomingCursorSources(
718
782
  cwd,
719
783
  existingIds(metrics),
@@ -724,11 +788,16 @@ function backfillMetricsFile(cwd, filePath) {
724
788
  leftoverEndExclusive(metrics),
725
789
  last.threadId,
726
790
  )
727
- : [];
791
+ : []).filter((source) => {
792
+ const previous = numOrNull(last.sourceTotals && last.sourceTotals[source.id]);
793
+ return previous == null || (numOrNull(source.totalTokens) ?? 0) > previous;
794
+ });
728
795
  if (incoming.length) {
729
- last.sources = [...(last.sources || []), ...incoming];
796
+ mergeIncomingCompact(last, incoming);
797
+ if (!last.platform) last.platform = 'cursor';
798
+ if (!last.model && incoming[0].model) last.model = incoming[0].model;
730
799
  if (!sessionSpendFrozen(last)) {
731
- const totals = sourceTotals(last.sources);
800
+ const totals = sourceTotals(last.byModel);
732
801
  last.inputTokens = totals.inputTokens;
733
802
  last.outputTokens = totals.outputTokens;
734
803
  last.totalTokens = totals.totalTokens;
@@ -738,7 +807,7 @@ function backfillMetricsFile(cwd, filePath) {
738
807
  }
739
808
  }
740
809
  const enriched = enrichMetrics(metrics, cwd);
741
- if (!incoming.length && !enriched && !collapsed) return;
810
+ if (!incoming.length && !enriched && !schemaChanged) return;
742
811
  metrics.updatedAt = new Date().toISOString();
743
812
  recompute(metrics);
744
813
  writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);