ultra-builder-pro-cli 0.19.1 → 0.21.1

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.
Files changed (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // State-operations layer over .ultra/state.db.
3
+ // State-operations layer over .ultra/.runtime/state.db.
4
4
  //
5
5
  // Every mutation in the Ultra Builder Pro runtime goes through this module.
6
6
  // Callers (MCP server tool handlers, the migration CLI, the orchestrator)
@@ -57,6 +57,14 @@ const TASK_CONTRACT_PATCH_FIELDS = new Set([
57
57
  const SESSION_PATCHABLE = Object.freeze([
58
58
  'pid', 'status', 'lease_expires_at', 'heartbeat_at', 'worktree_path', 'artifact_dir',
59
59
  ]);
60
+ const SESSION_STATUSES = new Set(['running', 'completed', 'crashed', 'orphan']);
61
+ const SESSION_TERMINAL_STATUSES = new Set(['completed', 'crashed']);
62
+ const SESSION_STATUS_TRANSITIONS = Object.freeze({
63
+ running: new Set(['completed', 'crashed', 'orphan']),
64
+ orphan: new Set(['completed', 'crashed']),
65
+ completed: new Set(),
66
+ crashed: new Set(),
67
+ });
60
68
 
61
69
  class StateOpsError extends Error {
62
70
  constructor(code, message, { retriable = false, details } = {}) {
@@ -170,9 +178,44 @@ function normalizeContextRefs(value) {
170
178
  if (!ref || !reason) {
171
179
  throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}] requires ref and reason`);
172
180
  }
173
- return {
174
- ref, reason, kind: String(item.kind || 'source').trim(), required: item.required !== false,
181
+ const kind = String(item.kind || 'source').trim();
182
+ if (!['spec', 'source', 'test', 'docs', 'external'].includes(kind)) {
183
+ throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].kind is unsupported`);
184
+ }
185
+ const expectedDigest = item.expected_digest ?? item.digest ?? null;
186
+ if (expectedDigest !== null && !/^[0-9a-f]{64}$/.test(String(expectedDigest))) {
187
+ throw new StateOpsError(
188
+ 'VALIDATION_ERROR', `context_refs[${index}].expected_digest must be sha256`,
189
+ );
190
+ }
191
+ const freshnessPolicy = String(
192
+ item.freshness_policy || (expectedDigest ? 'digest' : 'existence'),
193
+ ).trim();
194
+ if (!['digest', 'existence', 'advisory'].includes(freshnessPolicy)) {
195
+ throw new StateOpsError(
196
+ 'VALIDATION_ERROR',
197
+ `context_refs[${index}].freshness_policy must be digest, existence, or advisory`,
198
+ );
199
+ }
200
+ const normalized = {
201
+ ref,
202
+ reason,
203
+ kind,
204
+ required: item.required !== false,
205
+ freshness_policy: freshnessPolicy,
175
206
  };
207
+ if (expectedDigest !== null) normalized.expected_digest = String(expectedDigest);
208
+ if (item.anchor !== undefined) {
209
+ const anchor = String(item.anchor).trim();
210
+ if (!anchor) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].anchor cannot be empty`);
211
+ normalized.anchor = anchor;
212
+ }
213
+ if (item.scope !== undefined) {
214
+ const scope = String(item.scope).trim();
215
+ if (!scope) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].scope cannot be empty`);
216
+ normalized.scope = scope;
217
+ }
218
+ return normalized;
176
219
  });
177
220
  }
178
221
 
@@ -609,10 +652,32 @@ function createSession(db, { sid, task_id, runtime, pid = null, worktree_path, a
609
652
  if (!readTask(db, task_id)) {
610
653
  throw new StateOpsError('TASK_NOT_FOUND', `task ${task_id} does not exist`);
611
654
  }
612
- db.prepare(
613
- `INSERT INTO sessions (sid, task_id, runtime, pid, worktree_path, artifact_dir, status, lease_expires_at)
614
- VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`,
615
- ).run(sid, task_id, runtime, pid, worktree_path, artifact_dir, lease);
655
+ try {
656
+ db.prepare(
657
+ `INSERT INTO sessions (sid, task_id, runtime, pid, worktree_path, artifact_dir, status, lease_expires_at)
658
+ VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`,
659
+ ).run(sid, task_id, runtime, pid, worktree_path, artifact_dir, lease);
660
+ } catch (error) {
661
+ if (String(error?.code || '').startsWith('SQLITE_CONSTRAINT')) {
662
+ const conflict = db.prepare(
663
+ "SELECT sid, status FROM sessions WHERE task_id = ? AND status = 'running' LIMIT 1",
664
+ ).get(task_id);
665
+ if (conflict) {
666
+ throw new StateOpsError(
667
+ 'ADMISSION_DENIED',
668
+ `active session exists for task ${task_id} (${conflict.sid})`,
669
+ {
670
+ details: {
671
+ task_id,
672
+ conflict_sid: conflict.sid,
673
+ conflict_status: conflict.status,
674
+ },
675
+ },
676
+ );
677
+ }
678
+ }
679
+ throw error;
680
+ }
616
681
  appendEventInTx(db, {
617
682
  type: 'session_spawned',
618
683
  task_id,
@@ -628,29 +693,104 @@ function updateSession(db, sid, patch = {}) {
628
693
  return tx(db, () => {
629
694
  const cur = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
630
695
  if (!cur) throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
696
+ const requestedStatus = patch.status;
697
+ if (requestedStatus !== undefined && !SESSION_STATUSES.has(requestedStatus)) {
698
+ throw new StateOpsError(
699
+ 'VALIDATION_ERROR',
700
+ `unsupported session status: ${String(requestedStatus)}`,
701
+ );
702
+ }
703
+ if (requestedStatus !== undefined && requestedStatus !== cur.status) {
704
+ if (SESSION_TERMINAL_STATUSES.has(cur.status)) {
705
+ throw new StateOpsError(
706
+ 'SESSION_TERMINAL_CONFLICT',
707
+ `session ${sid} is already ${cur.status} and cannot transition to ${requestedStatus}`,
708
+ {
709
+ details: {
710
+ sid,
711
+ current_status: cur.status,
712
+ requested_status: requestedStatus,
713
+ },
714
+ },
715
+ );
716
+ }
717
+ if (!SESSION_STATUS_TRANSITIONS[cur.status]?.has(requestedStatus)) {
718
+ throw new StateOpsError(
719
+ 'SESSION_STATUS_TRANSITION_INVALID',
720
+ `session ${sid} cannot transition from ${cur.status} to ${requestedStatus}`,
721
+ {
722
+ details: {
723
+ sid,
724
+ current_status: cur.status,
725
+ requested_status: requestedStatus,
726
+ },
727
+ },
728
+ );
729
+ }
730
+ }
731
+ const normalizedPatch = (
732
+ SESSION_TERMINAL_STATUSES.has(requestedStatus)
733
+ )
734
+ ? { ...patch, pid: null }
735
+ : patch;
631
736
  const sets = [];
632
737
  const params = [];
633
- for (const key of Object.keys(patch)) {
738
+ for (const key of Object.keys(normalizedPatch)) {
634
739
  if (!SESSION_PATCHABLE.includes(key)) {
635
740
  throw new StateOpsError('VALIDATION_ERROR', `session field ${key} is not patchable`);
636
741
  }
637
742
  sets.push(`${key} = ?`);
638
- params.push(patch[key]);
743
+ params.push(normalizedPatch[key]);
639
744
  }
640
745
  if (sets.length === 0) return cur;
641
- params.push(sid);
642
- db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE sid = ?`).run(...params);
643
- if (patch.status && patch.status !== cur.status) {
644
- const eventType = patch.status === 'completed' ? 'session_closed'
645
- : patch.status === 'crashed' ? 'session_crashed'
646
- : patch.status === 'orphan' ? 'session_orphaned'
746
+ params.push(sid, cur.status);
747
+ const result = db.prepare(
748
+ `UPDATE sessions SET ${sets.join(', ')} WHERE sid = ? AND status = ?`,
749
+ ).run(...params);
750
+ if (result.changes !== 1) {
751
+ const latest = db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid);
752
+ if (latest && SESSION_TERMINAL_STATUSES.has(latest.status)
753
+ && requestedStatus !== undefined && latest.status !== requestedStatus) {
754
+ throw new StateOpsError(
755
+ 'SESSION_TERMINAL_CONFLICT',
756
+ `session ${sid} is already ${latest.status} and cannot transition to ${requestedStatus}`,
757
+ {
758
+ details: {
759
+ sid,
760
+ current_status: latest.status,
761
+ requested_status: requestedStatus,
762
+ },
763
+ },
764
+ );
765
+ }
766
+ throw new StateOpsError(
767
+ 'SESSION_STATUS_CONFLICT',
768
+ `session ${sid} changed while applying an update`,
769
+ {
770
+ retriable: true,
771
+ details: {
772
+ sid,
773
+ expected_status: cur.status,
774
+ actual_status: latest?.status || null,
775
+ },
776
+ },
777
+ );
778
+ }
779
+ if (requestedStatus && requestedStatus !== cur.status) {
780
+ const eventType = requestedStatus === 'completed' ? 'session_closed'
781
+ : requestedStatus === 'crashed' ? 'session_crashed'
782
+ : requestedStatus === 'orphan' ? 'session_orphaned'
647
783
  : 'session_closed';
648
784
  appendEventInTx(db, {
649
785
  type: eventType,
650
786
  task_id: cur.task_id,
651
787
  session_id: sid,
652
788
  runtime: cur.runtime,
653
- payload: { from: cur.status, to: patch.status },
789
+ payload: {
790
+ from: cur.status,
791
+ to: requestedStatus,
792
+ worker_pid: cur.pid,
793
+ },
654
794
  });
655
795
  }
656
796
  return db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
@@ -782,11 +922,15 @@ function admissionCheck(db, task_id, { freshnessSeconds = 120 } = {}) {
782
922
  };
783
923
  }
784
924
 
785
- function reapOrphanSessions(db, { graceSeconds = 300 } = {}) {
925
+ function reapOrphanSessions(db, {
926
+ graceSeconds = 300,
927
+ exclude_session_ids = [],
928
+ } = {}) {
786
929
  const cutoff = new Date(Date.now() - graceSeconds * 1000).toISOString();
930
+ const excluded = new Set(exclude_session_ids);
787
931
  const candidates = db.prepare(
788
932
  "SELECT * FROM sessions WHERE status = 'running' AND (lease_expires_at < ? OR heartbeat_at < ?)",
789
- ).all(cutoff, cutoff);
933
+ ).all(cutoff, cutoff).filter((session) => !excluded.has(session.sid));
790
934
  const reaped = [];
791
935
  for (const s of candidates) {
792
936
  updateSession(db, s.sid, { status: 'orphan' });
@@ -810,49 +954,103 @@ function readCircuitBreakerRow(db, task_id) {
810
954
  return db.prepare('SELECT * FROM circuit_breaker WHERE task_id = ?').get(task_id) || null;
811
955
  }
812
956
 
813
- function recordTaskFailure(db, task_id, {
957
+ function recordTaskFailureInTx(db, task_id, {
814
958
  reason = 'unknown',
815
959
  session_id = null,
816
960
  fail_threshold = DEFAULT_FAIL_THRESHOLD,
817
961
  } = {}) {
818
962
  if (!task_id) throw new StateOpsError('VALIDATION_ERROR', 'task_id required');
819
- return tx(db, () => {
820
- const now = nowIso();
821
- const existing = readCircuitBreakerRow(db, task_id);
822
- const wasTripped = !!(existing && existing.tripped_at);
823
- const newCount = (existing ? existing.failure_count : 0) + 1;
824
- const crossesThreshold = !wasTripped && newCount >= fail_threshold;
825
- const trippedAt = wasTripped
826
- ? existing.tripped_at
827
- : (crossesThreshold ? now : null);
828
-
829
- if (existing) {
830
- db.prepare(
831
- 'UPDATE circuit_breaker SET failure_count = ?, tripped_at = ?, last_failure_at = ?, last_failure_reason = ? WHERE task_id = ?',
832
- ).run(newCount, trippedAt, now, reason, task_id);
833
- } else {
834
- db.prepare(
835
- 'INSERT INTO circuit_breaker (task_id, failure_count, tripped_at, last_failure_at, last_failure_reason) VALUES (?, ?, ?, ?, ?)',
836
- ).run(task_id, newCount, trippedAt, now, reason);
837
- }
963
+ const now = nowIso();
964
+ const existing = readCircuitBreakerRow(db, task_id);
965
+ const wasTripped = !!(existing && existing.tripped_at);
966
+ const newCount = (existing ? existing.failure_count : 0) + 1;
967
+ const crossesThreshold = !wasTripped && newCount >= fail_threshold;
968
+ const trippedAt = wasTripped
969
+ ? existing.tripped_at
970
+ : (crossesThreshold ? now : null);
971
+
972
+ if (existing) {
973
+ db.prepare(
974
+ 'UPDATE circuit_breaker SET failure_count = ?, tripped_at = ?, last_failure_at = ?, last_failure_reason = ? WHERE task_id = ?',
975
+ ).run(newCount, trippedAt, now, reason, task_id);
976
+ } else {
977
+ db.prepare(
978
+ 'INSERT INTO circuit_breaker (task_id, failure_count, tripped_at, last_failure_at, last_failure_reason) VALUES (?, ?, ?, ?, ?)',
979
+ ).run(task_id, newCount, trippedAt, now, reason);
980
+ }
838
981
 
982
+ appendEventInTx(db, {
983
+ type: 'task_failure',
984
+ task_id,
985
+ session_id,
986
+ payload: { reason, failure_count: newCount },
987
+ });
988
+
989
+ if (crossesThreshold) {
839
990
  appendEventInTx(db, {
840
- type: 'task_failure',
991
+ type: 'task_circuit_broken',
841
992
  task_id,
842
993
  session_id,
843
- payload: { reason, failure_count: newCount },
994
+ payload: { failure_count: newCount, threshold: fail_threshold },
844
995
  });
996
+ }
845
997
 
846
- if (crossesThreshold) {
847
- appendEventInTx(db, {
848
- type: 'task_circuit_broken',
849
- task_id,
850
- session_id,
851
- payload: { failure_count: newCount, threshold: fail_threshold },
852
- });
853
- }
998
+ return { failure_count: newCount, tripped: crossesThreshold || wasTripped };
999
+ }
854
1000
 
855
- return { failure_count: newCount, tripped: crossesThreshold || wasTripped };
1001
+ function recordTaskFailure(db, task_id, options = {}) {
1002
+ return tx(db, () => recordTaskFailureInTx(db, task_id, options));
1003
+ }
1004
+
1005
+ function crashOrphanSession(db, sid, {
1006
+ reason = 'session_crashed_on_boot',
1007
+ fail_threshold = DEFAULT_FAIL_THRESHOLD,
1008
+ } = {}) {
1009
+ if (!sid) throw new StateOpsError('VALIDATION_ERROR', 'sid required');
1010
+ return tx(db, () => {
1011
+ const session = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
1012
+ if (!session) {
1013
+ throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
1014
+ }
1015
+ if (session.status !== 'orphan') {
1016
+ return {
1017
+ changed: false,
1018
+ status: session.status,
1019
+ failure_recorded: false,
1020
+ };
1021
+ }
1022
+ const result = db.prepare(
1023
+ "UPDATE sessions SET status = 'crashed', pid = NULL WHERE sid = ? AND status = 'orphan'",
1024
+ ).run(sid);
1025
+ if (result.changes !== 1) {
1026
+ return {
1027
+ changed: false,
1028
+ status: db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid)?.status || null,
1029
+ failure_recorded: false,
1030
+ };
1031
+ }
1032
+ appendEventInTx(db, {
1033
+ type: 'session_crashed',
1034
+ task_id: session.task_id,
1035
+ session_id: sid,
1036
+ runtime: session.runtime,
1037
+ payload: {
1038
+ from: 'orphan',
1039
+ to: 'crashed',
1040
+ worker_pid: session.pid,
1041
+ },
1042
+ });
1043
+ const failure = recordTaskFailureInTx(db, session.task_id, {
1044
+ reason,
1045
+ session_id: sid,
1046
+ fail_threshold,
1047
+ });
1048
+ return {
1049
+ changed: true,
1050
+ status: 'crashed',
1051
+ failure_recorded: true,
1052
+ failure,
1053
+ };
856
1054
  });
857
1055
  }
858
1056
 
@@ -1004,6 +1202,7 @@ module.exports = {
1004
1202
  heartbeatSession,
1005
1203
  admissionCheck,
1006
1204
  reapOrphanSessions,
1205
+ crashOrphanSession,
1007
1206
  // circuit breaker
1008
1207
  recordTaskFailure,
1009
1208
  resetCircuitBreaker,
@@ -3,7 +3,7 @@
3
3
  // Phase 6.2 — Telemetry collector.
4
4
  //
5
5
  // Double-write model: state.db.telemetry (table, queryable via state-ops
6
- // aggregations) + .ultra/telemetry/{YYYY-MM-DD}.jsonl (full payload, easy
6
+ // aggregations) + .ultra/.runtime/telemetry/{YYYY-MM-DD}.jsonl (full payload, easy
7
7
  // grep, no schema constraint). The table is the authoritative source for
8
8
  // cost panel queries; the jsonl is for human ops + audit trails + future
9
9
  // Phase 6.5 SDK-usage correlation.
@@ -12,6 +12,7 @@ const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
 
14
14
  const { computeCost } = require('./pricing.cjs');
15
+ const runtimePaths = require('./runtime-paths.cjs');
15
16
  const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
16
17
 
17
18
  function todayStamp() {
@@ -19,7 +20,10 @@ function todayStamp() {
19
20
  }
20
21
 
21
22
  function telemetryJsonlPath(rootDir) {
22
- return path.join(rootDir || '.', '.ultra', 'telemetry', `${todayStamp()}.jsonl`);
23
+ return path.join(
24
+ runtimePaths.pathsFor(rootDir || '.').telemetryDir,
25
+ `${todayStamp()}.jsonl`,
26
+ );
23
27
  }
24
28
 
25
29
  function appendTelemetry(db, {
@@ -38,6 +42,11 @@ function appendTelemetry(db, {
38
42
  throw new Error(`unsupported runtime: ${runtime}`);
39
43
  }
40
44
  if (!event_type) throw new Error('appendTelemetry: event_type required');
45
+ const layout = runtimePaths.validateProjectLayout(rootDir, {
46
+ env: process.env,
47
+ allowConfiguredRuntimeLink: true,
48
+ validateRuntimeTree: true,
49
+ });
41
50
 
42
51
  // Auto-derive cost when tokens + runtime known and caller didn't supply one.
43
52
  let effectiveCost = cost_usd;
@@ -50,7 +59,7 @@ function appendTelemetry(db, {
50
59
  'INSERT INTO telemetry (session_id, event_type, tokens_input, tokens_output, tool_name, cost_usd, ts) VALUES (?, ?, ?, ?, ?, ?, ?)',
51
60
  ).run(session_id, event_type, tokens_input, tokens_output, tool_name, effectiveCost, now);
52
61
 
53
- const jsonl = telemetryJsonlPath(rootDir);
62
+ const jsonl = path.join(layout.telemetryDir, `${todayStamp()}.jsonl`);
54
63
  fs.mkdirSync(path.dirname(jsonl), { recursive: true });
55
64
  fs.appendFileSync(jsonl, JSON.stringify({
56
65
  id: Number(result.lastInsertRowid),