obsidian-accomplishments-mcp 0.1.10 → 0.1.11

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 (269) hide show
  1. package/README.md +154 -182
  2. package/dist/index.js +207 -38
  3. package/dist/index.js.map +1 -1
  4. package/dist/integration.test.d.ts +8 -0
  5. package/dist/integration.test.d.ts.map +1 -0
  6. package/dist/integration.test.js +979 -0
  7. package/dist/integration.test.js.map +1 -0
  8. package/dist/models/types.d.ts +1 -2
  9. package/dist/models/types.d.ts.map +1 -1
  10. package/dist/models/types.js.map +1 -1
  11. package/dist/models/v2-types.d.ts +460 -0
  12. package/dist/models/v2-types.d.ts.map +1 -0
  13. package/dist/models/v2-types.js +137 -0
  14. package/dist/models/v2-types.js.map +1 -0
  15. package/dist/models/v2-types.test.d.ts +5 -0
  16. package/dist/models/v2-types.test.d.ts.map +1 -0
  17. package/dist/models/v2-types.test.js +133 -0
  18. package/dist/models/v2-types.test.js.map +1 -0
  19. package/dist/parsers/canvas-parser.d.ts +1 -1
  20. package/dist/parsers/canvas-parser.d.ts.map +1 -1
  21. package/dist/parsers/canvas-parser.js +1 -1
  22. package/dist/parsers/canvas-parser.js.map +1 -1
  23. package/dist/parsers/markdown-parser.js +9 -9
  24. package/dist/parsers/markdown-parser.js.map +1 -1
  25. package/dist/services/v2/archive-manager.d.ts +96 -0
  26. package/dist/services/v2/archive-manager.d.ts.map +1 -0
  27. package/dist/services/v2/archive-manager.js +281 -0
  28. package/dist/services/v2/archive-manager.js.map +1 -0
  29. package/dist/services/v2/canvas-manager.d.ts +155 -0
  30. package/dist/services/v2/canvas-manager.d.ts.map +1 -0
  31. package/dist/services/v2/canvas-manager.js +540 -0
  32. package/dist/services/v2/canvas-manager.js.map +1 -0
  33. package/dist/services/v2/canvas-manager.test.d.ts +5 -0
  34. package/dist/services/v2/canvas-manager.test.d.ts.map +1 -0
  35. package/dist/services/v2/canvas-manager.test.js +327 -0
  36. package/dist/services/v2/canvas-manager.test.js.map +1 -0
  37. package/dist/services/v2/cascade-manager.d.ts +54 -0
  38. package/dist/services/v2/cascade-manager.d.ts.map +1 -0
  39. package/dist/services/v2/cascade-manager.js +220 -0
  40. package/dist/services/v2/cascade-manager.js.map +1 -0
  41. package/dist/services/v2/cycle-detector.d.ts +76 -0
  42. package/dist/services/v2/cycle-detector.d.ts.map +1 -0
  43. package/dist/services/v2/cycle-detector.js +183 -0
  44. package/dist/services/v2/cycle-detector.js.map +1 -0
  45. package/dist/services/v2/cycle-detector.test.d.ts +7 -0
  46. package/dist/services/v2/cycle-detector.test.d.ts.map +1 -0
  47. package/dist/services/v2/cycle-detector.test.js +125 -0
  48. package/dist/services/v2/cycle-detector.test.js.map +1 -0
  49. package/dist/services/v2/entity-parser.d.ts +54 -0
  50. package/dist/services/v2/entity-parser.d.ts.map +1 -0
  51. package/dist/services/v2/entity-parser.js +418 -0
  52. package/dist/services/v2/entity-parser.js.map +1 -0
  53. package/dist/services/v2/entity-parser.test.d.ts +5 -0
  54. package/dist/services/v2/entity-parser.test.d.ts.map +1 -0
  55. package/dist/services/v2/entity-parser.test.js +637 -0
  56. package/dist/services/v2/entity-parser.test.js.map +1 -0
  57. package/dist/services/v2/entity-serializer.d.ts +94 -0
  58. package/dist/services/v2/entity-serializer.d.ts.map +1 -0
  59. package/dist/services/v2/entity-serializer.js +583 -0
  60. package/dist/services/v2/entity-serializer.js.map +1 -0
  61. package/dist/services/v2/entity-serializer.test.d.ts +5 -0
  62. package/dist/services/v2/entity-serializer.test.d.ts.map +1 -0
  63. package/dist/services/v2/entity-serializer.test.js +241 -0
  64. package/dist/services/v2/entity-serializer.test.js.map +1 -0
  65. package/dist/services/v2/entity-validator.d.ts +65 -0
  66. package/dist/services/v2/entity-validator.d.ts.map +1 -0
  67. package/dist/services/v2/entity-validator.js +573 -0
  68. package/dist/services/v2/entity-validator.js.map +1 -0
  69. package/dist/services/v2/entity-validator.test.d.ts +5 -0
  70. package/dist/services/v2/entity-validator.test.d.ts.map +1 -0
  71. package/dist/services/v2/entity-validator.test.js +519 -0
  72. package/dist/services/v2/entity-validator.test.js.map +1 -0
  73. package/dist/services/v2/file-manager.d.ts +73 -0
  74. package/dist/services/v2/file-manager.d.ts.map +1 -0
  75. package/dist/services/v2/file-manager.js +310 -0
  76. package/dist/services/v2/file-manager.js.map +1 -0
  77. package/dist/services/v2/file-manager.test.d.ts +5 -0
  78. package/dist/services/v2/file-manager.test.d.ts.map +1 -0
  79. package/dist/services/v2/file-manager.test.js +339 -0
  80. package/dist/services/v2/file-manager.test.js.map +1 -0
  81. package/dist/services/v2/index-manager.d.ts +68 -0
  82. package/dist/services/v2/index-manager.d.ts.map +1 -0
  83. package/dist/services/v2/index-manager.js +228 -0
  84. package/dist/services/v2/index-manager.js.map +1 -0
  85. package/dist/services/v2/index-manager.test.d.ts +5 -0
  86. package/dist/services/v2/index-manager.test.d.ts.map +1 -0
  87. package/dist/services/v2/index-manager.test.js +386 -0
  88. package/dist/services/v2/index-manager.test.js.map +1 -0
  89. package/dist/services/v2/index-service.d.ts +82 -0
  90. package/dist/services/v2/index-service.d.ts.map +1 -0
  91. package/dist/services/v2/index-service.js +274 -0
  92. package/dist/services/v2/index-service.js.map +1 -0
  93. package/dist/services/v2/index-service.test.d.ts +5 -0
  94. package/dist/services/v2/index-service.test.d.ts.map +1 -0
  95. package/dist/services/v2/index-service.test.js +117 -0
  96. package/dist/services/v2/index-service.test.js.map +1 -0
  97. package/dist/services/v2/lifecycle-manager.d.ts +59 -0
  98. package/dist/services/v2/lifecycle-manager.d.ts.map +1 -0
  99. package/dist/services/v2/lifecycle-manager.js +310 -0
  100. package/dist/services/v2/lifecycle-manager.js.map +1 -0
  101. package/dist/services/v2/lifecycle-manager.test.d.ts +5 -0
  102. package/dist/services/v2/lifecycle-manager.test.d.ts.map +1 -0
  103. package/dist/services/v2/lifecycle-manager.test.js +141 -0
  104. package/dist/services/v2/lifecycle-manager.test.js.map +1 -0
  105. package/dist/services/v2/path-resolver.d.ts +64 -0
  106. package/dist/services/v2/path-resolver.d.ts.map +1 -0
  107. package/dist/services/v2/path-resolver.js +174 -0
  108. package/dist/services/v2/path-resolver.js.map +1 -0
  109. package/dist/services/v2/progress-computer.d.ts +46 -0
  110. package/dist/services/v2/progress-computer.d.ts.map +1 -0
  111. package/dist/services/v2/progress-computer.js +200 -0
  112. package/dist/services/v2/progress-computer.js.map +1 -0
  113. package/dist/services/v2/search-service.d.ts +68 -0
  114. package/dist/services/v2/search-service.d.ts.map +1 -0
  115. package/dist/services/v2/search-service.js +194 -0
  116. package/dist/services/v2/search-service.js.map +1 -0
  117. package/dist/services/v2/transitive-dependency-remover.d.ts +54 -0
  118. package/dist/services/v2/transitive-dependency-remover.d.ts.map +1 -0
  119. package/dist/services/v2/transitive-dependency-remover.js +156 -0
  120. package/dist/services/v2/transitive-dependency-remover.js.map +1 -0
  121. package/dist/services/v2/transitive-dependency-remover.test.d.ts +7 -0
  122. package/dist/services/v2/transitive-dependency-remover.test.d.ts.map +1 -0
  123. package/dist/services/v2/transitive-dependency-remover.test.js +119 -0
  124. package/dist/services/v2/transitive-dependency-remover.test.js.map +1 -0
  125. package/dist/services/v2/v2-runtime.d.ts +374 -0
  126. package/dist/services/v2/v2-runtime.d.ts.map +1 -0
  127. package/dist/services/v2/v2-runtime.js +1908 -0
  128. package/dist/services/v2/v2-runtime.js.map +1 -0
  129. package/dist/services/v2/v2-runtime.test.d.ts +5 -0
  130. package/dist/services/v2/v2-runtime.test.d.ts.map +1 -0
  131. package/dist/services/v2/v2-runtime.test.js +658 -0
  132. package/dist/services/v2/v2-runtime.test.js.map +1 -0
  133. package/dist/services/v2/workstream-normalizer.d.ts +59 -0
  134. package/dist/services/v2/workstream-normalizer.d.ts.map +1 -0
  135. package/dist/services/v2/workstream-normalizer.js +137 -0
  136. package/dist/services/v2/workstream-normalizer.js.map +1 -0
  137. package/dist/services/v2/workstream-normalizer.test.d.ts +7 -0
  138. package/dist/services/v2/workstream-normalizer.test.d.ts.map +1 -0
  139. package/dist/services/v2/workstream-normalizer.test.js +130 -0
  140. package/dist/services/v2/workstream-normalizer.test.js.map +1 -0
  141. package/dist/test-runner.d.ts +4 -1
  142. package/dist/test-runner.d.ts.map +1 -1
  143. package/dist/test-runner.js +44 -249
  144. package/dist/test-runner.js.map +1 -1
  145. package/dist/tools/batch-operations-tools.d.ts +54 -0
  146. package/dist/tools/batch-operations-tools.d.ts.map +1 -0
  147. package/dist/tools/batch-operations-tools.js +370 -0
  148. package/dist/tools/batch-operations-tools.js.map +1 -0
  149. package/dist/tools/decision-document-tools.d.ts +78 -0
  150. package/dist/tools/decision-document-tools.d.ts.map +1 -0
  151. package/dist/tools/decision-document-tools.js +260 -0
  152. package/dist/tools/decision-document-tools.js.map +1 -0
  153. package/dist/tools/entity-management-tools.d.ts +79 -0
  154. package/dist/tools/entity-management-tools.d.ts.map +1 -0
  155. package/dist/tools/entity-management-tools.js +851 -0
  156. package/dist/tools/entity-management-tools.js.map +1 -0
  157. package/dist/tools/entity-management-tools.test.d.ts +5 -0
  158. package/dist/tools/entity-management-tools.test.d.ts.map +1 -0
  159. package/dist/tools/entity-management-tools.test.js +530 -0
  160. package/dist/tools/entity-management-tools.test.js.map +1 -0
  161. package/dist/tools/index.d.ts +15 -331
  162. package/dist/tools/index.d.ts.map +1 -1
  163. package/dist/tools/index.js +510 -47
  164. package/dist/tools/index.js.map +1 -1
  165. package/dist/tools/index.test.d.ts +8 -0
  166. package/dist/tools/index.test.d.ts.map +1 -0
  167. package/dist/tools/index.test.js +429 -0
  168. package/dist/tools/index.test.js.map +1 -0
  169. package/dist/tools/project-understanding-tools.d.ts +75 -0
  170. package/dist/tools/project-understanding-tools.d.ts.map +1 -0
  171. package/dist/tools/project-understanding-tools.js +751 -0
  172. package/dist/tools/project-understanding-tools.js.map +1 -0
  173. package/dist/tools/search-navigation-tools.d.ts +77 -0
  174. package/dist/tools/search-navigation-tools.d.ts.map +1 -0
  175. package/dist/tools/search-navigation-tools.js +379 -0
  176. package/dist/tools/search-navigation-tools.js.map +1 -0
  177. package/dist/tools/tool-types.d.ts +703 -0
  178. package/dist/tools/tool-types.d.ts.map +1 -0
  179. package/dist/tools/tool-types.js +7 -0
  180. package/dist/tools/tool-types.js.map +1 -0
  181. package/dist/utils/config.d.ts +0 -4
  182. package/dist/utils/config.d.ts.map +1 -1
  183. package/dist/utils/config.js +2 -19
  184. package/dist/utils/config.js.map +1 -1
  185. package/package.json +16 -1
  186. package/dist/services/accomplishment-service.d.ts +0 -33
  187. package/dist/services/accomplishment-service.d.ts.map +0 -1
  188. package/dist/services/accomplishment-service.js +0 -296
  189. package/dist/services/accomplishment-service.js.map +0 -1
  190. package/dist/services/canvas-service.d.ts +0 -96
  191. package/dist/services/canvas-service.d.ts.map +0 -1
  192. package/dist/services/canvas-service.js +0 -231
  193. package/dist/services/canvas-service.js.map +0 -1
  194. package/dist/services/context-doc-service.d.ts +0 -70
  195. package/dist/services/context-doc-service.d.ts.map +0 -1
  196. package/dist/services/context-doc-service.js +0 -229
  197. package/dist/services/context-doc-service.js.map +0 -1
  198. package/dist/services/dependency-service.d.ts +0 -22
  199. package/dist/services/dependency-service.d.ts.map +0 -1
  200. package/dist/services/dependency-service.js +0 -99
  201. package/dist/services/dependency-service.js.map +0 -1
  202. package/dist/services/status-indicator-service.d.ts +0 -40
  203. package/dist/services/status-indicator-service.d.ts.map +0 -1
  204. package/dist/services/status-indicator-service.js +0 -173
  205. package/dist/services/status-indicator-service.js.map +0 -1
  206. package/dist/services/task-service.d.ts +0 -32
  207. package/dist/services/task-service.d.ts.map +0 -1
  208. package/dist/services/task-service.js +0 -152
  209. package/dist/services/task-service.js.map +0 -1
  210. package/dist/test-real-vault.d.ts +0 -6
  211. package/dist/test-real-vault.d.ts.map +0 -1
  212. package/dist/test-real-vault.js +0 -30
  213. package/dist/test-real-vault.js.map +0 -1
  214. package/dist/tools/batch-operations.d.ts +0 -246
  215. package/dist/tools/batch-operations.d.ts.map +0 -1
  216. package/dist/tools/batch-operations.js +0 -235
  217. package/dist/tools/batch-operations.js.map +0 -1
  218. package/dist/tools/get-accomplishment.d.ts +0 -42
  219. package/dist/tools/get-accomplishment.d.ts.map +0 -1
  220. package/dist/tools/get-accomplishment.js +0 -93
  221. package/dist/tools/get-accomplishment.js.map +0 -1
  222. package/dist/tools/get-accomplishments-graph.d.ts +0 -26
  223. package/dist/tools/get-accomplishments-graph.d.ts.map +0 -1
  224. package/dist/tools/get-accomplishments-graph.js +0 -137
  225. package/dist/tools/get-accomplishments-graph.js.map +0 -1
  226. package/dist/tools/get-blocked-items.d.ts +0 -15
  227. package/dist/tools/get-blocked-items.d.ts.map +0 -1
  228. package/dist/tools/get-blocked-items.js +0 -73
  229. package/dist/tools/get-blocked-items.js.map +0 -1
  230. package/dist/tools/get-current-work.d.ts +0 -15
  231. package/dist/tools/get-current-work.d.ts.map +0 -1
  232. package/dist/tools/get-current-work.js +0 -68
  233. package/dist/tools/get-current-work.js.map +0 -1
  234. package/dist/tools/get-project-status.d.ts +0 -26
  235. package/dist/tools/get-project-status.d.ts.map +0 -1
  236. package/dist/tools/get-project-status.js +0 -98
  237. package/dist/tools/get-project-status.js.map +0 -1
  238. package/dist/tools/get-ready-to-start.d.ts +0 -15
  239. package/dist/tools/get-ready-to-start.d.ts.map +0 -1
  240. package/dist/tools/get-ready-to-start.js +0 -47
  241. package/dist/tools/get-ready-to-start.js.map +0 -1
  242. package/dist/tools/list-accomplishments.d.ts +0 -42
  243. package/dist/tools/list-accomplishments.d.ts.map +0 -1
  244. package/dist/tools/list-accomplishments.js +0 -40
  245. package/dist/tools/list-accomplishments.js.map +0 -1
  246. package/dist/tools/manage-accomplishment.d.ts +0 -147
  247. package/dist/tools/manage-accomplishment.d.ts.map +0 -1
  248. package/dist/tools/manage-accomplishment.js +0 -153
  249. package/dist/tools/manage-accomplishment.js.map +0 -1
  250. package/dist/tools/manage-dependency.d.ts +0 -41
  251. package/dist/tools/manage-dependency.d.ts.map +0 -1
  252. package/dist/tools/manage-dependency.js +0 -66
  253. package/dist/tools/manage-dependency.js.map +0 -1
  254. package/dist/tools/manage-task.d.ts +0 -119
  255. package/dist/tools/manage-task.d.ts.map +0 -1
  256. package/dist/tools/manage-task.js +0 -126
  257. package/dist/tools/manage-task.js.map +0 -1
  258. package/dist/tools/reconcile-canvas.d.ts +0 -33
  259. package/dist/tools/reconcile-canvas.d.ts.map +0 -1
  260. package/dist/tools/reconcile-canvas.js +0 -41
  261. package/dist/tools/reconcile-canvas.js.map +0 -1
  262. package/dist/tools/set-work-focus.d.ts +0 -48
  263. package/dist/tools/set-work-focus.d.ts.map +0 -1
  264. package/dist/tools/set-work-focus.js +0 -78
  265. package/dist/tools/set-work-focus.js.map +0 -1
  266. package/dist/tools/sync-dependencies.d.ts +0 -33
  267. package/dist/tools/sync-dependencies.d.ts.map +0 -1
  268. package/dist/tools/sync-dependencies.js +0 -144
  269. package/dist/tools/sync-dependencies.js.map +0 -1
@@ -0,0 +1,1908 @@
1
+ /**
2
+ * V2 Runtime
3
+ *
4
+ * Wires all V2 services together and provides dependency implementations
5
+ * for the V2 MCP tools.
6
+ */
7
+ import * as fs from 'fs/promises';
8
+ import { watch } from 'fs';
9
+ import * as path from 'path';
10
+ import { getEntityTypeFromId, } from '../../models/v2-types.js';
11
+ import { ProjectIndex } from './index-service.js';
12
+ import { AtomicFileManager } from './file-manager.js';
13
+ import { LifecycleManager } from './lifecycle-manager.js';
14
+ import { PathResolver } from './path-resolver.js';
15
+ import { EntityParser } from './entity-parser.js';
16
+ import { EntitySerializer } from './entity-serializer.js';
17
+ import { SearchIndex } from './search-service.js';
18
+ import { CanvasManager } from './canvas-manager.js';
19
+ /**
20
+ * Configuration for the Obsidian plugin endpoint.
21
+ */
22
+ const OBSIDIAN_PLUGIN_ENDPOINT = 'http://127.0.0.1:12312';
23
+ const OBSIDIAN_PLUGIN_TIMEOUT_MS = 5000; // 5 second timeout
24
+ /**
25
+ * Notify the Obsidian plugin to perform an operation.
26
+ * This sends a POST request to the plugin's local HTTP endpoint.
27
+ * The request is fire-and-forget - it does not block the calling operation.
28
+ *
29
+ * @param operation - The operation to perform ('populate' or 'reposition')
30
+ */
31
+ export function notifyObsidianPlugin(operation) {
32
+ console.error(`[V2Runtime] Notifying Obsidian plugin: action=${operation}`);
33
+ // Fire-and-forget: don't await the fetch, just let it run in the background
34
+ const controller = new AbortController();
35
+ const timeoutId = setTimeout(() => controller.abort(), OBSIDIAN_PLUGIN_TIMEOUT_MS);
36
+ fetch(OBSIDIAN_PLUGIN_ENDPOINT, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ },
41
+ body: JSON.stringify({ action: operation }),
42
+ signal: controller.signal,
43
+ })
44
+ .then((response) => {
45
+ clearTimeout(timeoutId);
46
+ if (response.ok) {
47
+ console.error(`[V2Runtime] Obsidian plugin notification successful: action=${operation}`);
48
+ }
49
+ else {
50
+ console.error(`[V2Runtime] Obsidian plugin notification failed: ${response.status} ${response.statusText}`);
51
+ }
52
+ })
53
+ .catch((error) => {
54
+ clearTimeout(timeoutId);
55
+ if (error instanceof Error && error.name === 'AbortError') {
56
+ console.error(`[V2Runtime] Obsidian plugin notification timed out after ${OBSIDIAN_PLUGIN_TIMEOUT_MS}ms`);
57
+ }
58
+ else {
59
+ // Silently log errors - plugin might not be running
60
+ // This is expected when Obsidian is not open
61
+ console.error(`[V2Runtime] Could not notify Obsidian plugin (may not be running):`, error instanceof Error ? error.message : error);
62
+ }
63
+ });
64
+ }
65
+ // =============================================================================
66
+ // V2 Runtime Class
67
+ // =============================================================================
68
+ /**
69
+ * V2 Runtime - orchestrates all V2 services and provides dependency implementations.
70
+ */
71
+ export class V2Runtime {
72
+ config;
73
+ index;
74
+ fileManager;
75
+ lifecycleManager;
76
+ pathResolver;
77
+ parser;
78
+ serializer;
79
+ searchIndex;
80
+ canvasManager;
81
+ // File watchers
82
+ watchers = [];
83
+ // Debounce timers for file changes
84
+ debounceTimers = new Map();
85
+ DEBOUNCE_MS = 100;
86
+ // ID prefix mapping for each entity type
87
+ idPrefixes = new Map([
88
+ ['milestone', 'M'],
89
+ ['story', 'S'],
90
+ ['task', 'T'],
91
+ ['decision', 'DEC'],
92
+ ['document', 'DOC'],
93
+ ['feature', 'F'],
94
+ ]);
95
+ // Track duplicate IDs (id -> array of file paths)
96
+ duplicateIds = new Map();
97
+ constructor(config) {
98
+ this.config = config;
99
+ this.index = new ProjectIndex();
100
+ this.fileManager = new AtomicFileManager(config.vaultPath);
101
+ this.lifecycleManager = new LifecycleManager();
102
+ this.pathResolver = new PathResolver(config);
103
+ this.parser = new EntityParser();
104
+ this.serializer = new EntitySerializer();
105
+ this.searchIndex = new SearchIndex();
106
+ this.canvasManager = new CanvasManager(config.vaultPath, config.defaultCanvas, async (entityId) => {
107
+ const entity = await this.getEntity(entityId);
108
+ return entity?.vault_path || null;
109
+ });
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Initialization
113
+ // ---------------------------------------------------------------------------
114
+ /** Initialize the runtime by scanning the vault and starting file watchers */
115
+ async initialize() {
116
+ await this.scanVault();
117
+ this.startFileWatchers();
118
+ }
119
+ /** Shutdown the runtime and cleanup resources */
120
+ async shutdown() {
121
+ this.stopFileWatchers();
122
+ }
123
+ /** Get the canvas manager for direct canvas operations */
124
+ getCanvasManager() {
125
+ return this.canvasManager;
126
+ }
127
+ /** Scan vault and build indexes */
128
+ async scanVault() {
129
+ const folders = this.pathResolver.getAllAbsoluteEntityFolders();
130
+ console.error(`[V2Runtime] Scanning ${folders.length} entity folders:`, folders);
131
+ let totalEntities = 0;
132
+ for (const folder of folders) {
133
+ const count = await this.scanFolder(folder);
134
+ totalEntities += count;
135
+ }
136
+ console.error(`[V2Runtime] Scan complete. Found ${totalEntities} entities. Index size: ${this.index.getAll().length}`);
137
+ }
138
+ /** Recursively scan a folder for entity files */
139
+ async scanFolder(folder) {
140
+ let count = 0;
141
+ try {
142
+ const entries = await fs.readdir(folder, { withFileTypes: true });
143
+ for (const entry of entries) {
144
+ const fullPath = path.join(folder, entry.name);
145
+ if (entry.isDirectory()) {
146
+ // Recursively scan subdirectories
147
+ count += await this.scanFolder(fullPath);
148
+ }
149
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
150
+ const entity = await this.loadEntity(fullPath);
151
+ if (entity)
152
+ count++;
153
+ }
154
+ }
155
+ }
156
+ catch (err) {
157
+ if (err.code === 'ENOENT') {
158
+ console.error(`[V2Runtime] Folder does not exist (skipping): ${folder}`);
159
+ }
160
+ else {
161
+ console.error(`[V2Runtime] Error scanning folder ${folder}:`, err);
162
+ }
163
+ }
164
+ return count;
165
+ }
166
+ /** Load a single entity from file and update ProjectIndex */
167
+ async loadEntity(absolutePath) {
168
+ try {
169
+ const content = await fs.readFile(absolutePath, 'utf-8');
170
+ const vaultPath = this.pathResolver.toVaultPath(absolutePath);
171
+ const result = this.parser.parse(content, vaultPath);
172
+ const entity = result.entity;
173
+ // Check for duplicate ID
174
+ const existingPaths = this.duplicateIds.get(entity.id);
175
+ if (existingPaths) {
176
+ // Already have this ID - add to duplicates list
177
+ if (!existingPaths.includes(absolutePath)) {
178
+ existingPaths.push(absolutePath);
179
+ console.warn(`[V2Runtime] Duplicate entity ID detected: ${entity.id}\n` +
180
+ ` Files with this ID:\n` +
181
+ existingPaths.map(p => ` - ${p}`).join('\n'));
182
+ }
183
+ }
184
+ else {
185
+ // Check if entity already exists in ProjectIndex (first duplicate detection)
186
+ const existingMetadata = this.index.get(entity.id);
187
+ if (existingMetadata) {
188
+ const originalPath = this.pathResolver.toAbsolutePath(existingMetadata.vault_path);
189
+ if (originalPath !== absolutePath) {
190
+ const paths = [originalPath, absolutePath];
191
+ this.duplicateIds.set(entity.id, paths);
192
+ console.warn(`[V2Runtime] Duplicate entity ID detected: ${entity.id}\n` +
193
+ ` Files with this ID:\n` +
194
+ paths.map(p => ` - ${p}`).join('\n'));
195
+ }
196
+ }
197
+ }
198
+ // Index for search
199
+ this.searchIndex.index(entity.id, entity.title, this.getEntityContent(entity), entity.type, entity.archived || false);
200
+ // Get file mtime for metadata
201
+ const stats = await fs.stat(absolutePath);
202
+ const fileMtime = stats.mtimeMs;
203
+ // Remove old relationships if entity was already indexed (re-load case)
204
+ this.removeRelationships(entity.id);
205
+ // Index metadata in ProjectIndex (this is the single source of truth)
206
+ const metadata = this.createEntityMetadata(entity, fileMtime);
207
+ this.index.set(metadata);
208
+ // Index relationships in ProjectIndex
209
+ this.indexRelationships(entity);
210
+ return entity;
211
+ }
212
+ catch (err) {
213
+ console.error(`Error loading entity from ${absolutePath}:`, err);
214
+ return null;
215
+ }
216
+ }
217
+ /** Remove entity from index by file path (safe delete) */
218
+ removeEntityByPath(absolutePath) {
219
+ const vaultPath = this.pathResolver.toVaultPath(absolutePath);
220
+ // Find entity by vault_path in ProjectIndex
221
+ const entityId = this.index.getIdByPath(vaultPath);
222
+ if (entityId) {
223
+ // Safe delete: only remove entity if this path is the canonical path for this ID
224
+ // This handles the case where duplicate files with the same ID existed
225
+ const canonicalPath = this.index.getPathById(entityId);
226
+ if (canonicalPath === vaultPath) {
227
+ // This is the canonical file - remove the entity entirely
228
+ this.searchIndex.remove(entityId);
229
+ this.removeRelationships(entityId);
230
+ this.index.delete(entityId);
231
+ }
232
+ else {
233
+ // This was a duplicate file - just remove the stale path mapping
234
+ console.warn(`[V2Runtime] Removing stale path mapping for ${entityId}: ${vaultPath} (canonical: ${canonicalPath})`);
235
+ this.index.removePathMapping(vaultPath);
236
+ }
237
+ }
238
+ }
239
+ /** Get content from entity for search indexing */
240
+ getEntityContent(entity) {
241
+ switch (entity.type) {
242
+ case 'milestone': return entity.objective || '';
243
+ case 'story': {
244
+ const s = entity;
245
+ const parts = [s.outcome, s.notes].filter(Boolean);
246
+ return parts.join(' ');
247
+ }
248
+ case 'task': {
249
+ const t = entity;
250
+ const parts = [t.goal, t.description, t.technical_notes, t.notes].filter(Boolean);
251
+ return parts.join(' ');
252
+ }
253
+ case 'decision': {
254
+ const d = entity;
255
+ const parts = [d.context, d.decision, d.rationale].filter(Boolean);
256
+ return parts.join(' ');
257
+ }
258
+ case 'document': return entity.content || '';
259
+ case 'feature': {
260
+ const f = entity;
261
+ // Return actual markdown content if available, otherwise user_story
262
+ return f.content || f.user_story || '';
263
+ }
264
+ default: return '';
265
+ }
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // Index Relationship Management
269
+ // ---------------------------------------------------------------------------
270
+ /**
271
+ * Index all relationships for an entity into ProjectIndex.
272
+ * This enables O(1) lookups for dependencies, children, etc.
273
+ */
274
+ indexRelationships(entity) {
275
+ // Parent-child relationships (Story -> Milestone, Task -> Story)
276
+ if (entity.type === 'story' && entity.parent) {
277
+ this.index.addRelationship(entity.parent, 'parent_of', entity.id);
278
+ }
279
+ if (entity.type === 'task' && entity.parent) {
280
+ this.index.addRelationship(entity.parent, 'parent_of', entity.id);
281
+ }
282
+ // Dependency relationships: depends_on means "I am blocked by these"
283
+ // So if A depends_on B, then B blocks A
284
+ const dependsOn = entity.depends_on;
285
+ if (dependsOn && Array.isArray(dependsOn)) {
286
+ for (const depId of dependsOn) {
287
+ this.index.addRelationship(depId, 'blocks', entity.id);
288
+ }
289
+ }
290
+ // Implementation relationships (Story implements Document)
291
+ if (entity.type === 'story' && entity.implements) {
292
+ for (const docId of entity.implements) {
293
+ this.index.addRelationship(entity.id, 'implements', docId);
294
+ }
295
+ }
296
+ if (entity.type === 'milestone' && entity.implements) {
297
+ for (const docId of entity.implements) {
298
+ this.index.addRelationship(entity.id, 'implements', docId);
299
+ }
300
+ }
301
+ // Supersedes relationships (Decision supersedes Decision)
302
+ if (entity.type === 'decision' && entity.supersedes) {
303
+ this.index.addRelationship(entity.id, 'supersedes', entity.supersedes);
304
+ }
305
+ // Versioning relationships (Document previous_version -> next_version)
306
+ if (entity.type === 'document' && entity.previous_version) {
307
+ this.index.addRelationship(entity.id, 'previous_version', entity.previous_version);
308
+ }
309
+ }
310
+ /**
311
+ * Remove forward relationships for an entity from ProjectIndex.
312
+ * Called before re-indexing an entity's relationships.
313
+ *
314
+ * Excludes `parent_of` and `blocks` relationships because those are "owned" by
315
+ * other entities (children and dependents respectively), not by this entity.
316
+ * - `parent_of` is indexed when a child sets its `parent` field
317
+ * - `blocks` is indexed when a dependent sets its `depends_on` field
318
+ */
319
+ removeRelationships(entityId) {
320
+ // Exclude parent_of and blocks - these are indexed by children/dependents
321
+ this.index.removeForwardRelationships(entityId, ['parent_of', 'blocks']);
322
+ }
323
+ /**
324
+ * Create EntityMetadata from an Entity for the primary index.
325
+ */
326
+ createEntityMetadata(entity, fileMtime = 0) {
327
+ const metadata = {
328
+ id: entity.id,
329
+ type: entity.type,
330
+ title: entity.title,
331
+ workstream: entity.workstream,
332
+ status: entity.status,
333
+ archived: entity.archived,
334
+ in_progress: this.isEntityInProgress(entity),
335
+ canvas_source: entity.canvas_source,
336
+ vault_path: entity.vault_path,
337
+ updated_at: entity.updated_at,
338
+ file_mtime: fileMtime,
339
+ children_count: 0, // Will be updated when children are indexed
340
+ };
341
+ // Add type-specific fields
342
+ if (entity.type === 'milestone') {
343
+ metadata.priority = entity.priority;
344
+ }
345
+ if (entity.type === 'story') {
346
+ metadata.priority = entity.priority;
347
+ metadata.parent_id = entity.parent;
348
+ }
349
+ if (entity.type === 'task') {
350
+ metadata.parent_id = entity.parent;
351
+ }
352
+ return metadata;
353
+ }
354
+ /**
355
+ * Check if an entity is in progress.
356
+ */
357
+ isEntityInProgress(entity) {
358
+ if (entity.type === 'milestone' || entity.type === 'story' || entity.type === 'task' || entity.type === 'feature') {
359
+ return entity.status === 'In Progress';
360
+ }
361
+ return false;
362
+ }
363
+ // ---------------------------------------------------------------------------
364
+ // File Watching
365
+ // ---------------------------------------------------------------------------
366
+ /** Start file watchers for all entity folders */
367
+ startFileWatchers() {
368
+ const folders = this.pathResolver.getAllAbsoluteEntityFolders();
369
+ for (const folder of folders) {
370
+ try {
371
+ const watcher = watch(folder, { persistent: true }, (eventType, filename) => {
372
+ if (!filename || !filename.endsWith('.md'))
373
+ return;
374
+ const absolutePath = path.join(folder, filename);
375
+ this.handleFileChange(eventType, absolutePath);
376
+ });
377
+ this.watchers.push(watcher);
378
+ }
379
+ catch (err) {
380
+ // Folder might not exist yet - that's okay
381
+ if (err.code !== 'ENOENT') {
382
+ console.error(`Error watching folder ${folder}:`, err);
383
+ }
384
+ }
385
+ }
386
+ }
387
+ /** Stop all file watchers */
388
+ stopFileWatchers() {
389
+ for (const watcher of this.watchers) {
390
+ watcher.close();
391
+ }
392
+ this.watchers = [];
393
+ // Clear any pending debounce timers
394
+ for (const timer of this.debounceTimers.values()) {
395
+ clearTimeout(timer);
396
+ }
397
+ this.debounceTimers.clear();
398
+ }
399
+ /** Handle file change event with debouncing */
400
+ handleFileChange(eventType, absolutePath) {
401
+ // Clear existing timer for this path
402
+ const existingTimer = this.debounceTimers.get(absolutePath);
403
+ if (existingTimer) {
404
+ clearTimeout(existingTimer);
405
+ }
406
+ // Set new debounced handler
407
+ const timer = setTimeout(async () => {
408
+ this.debounceTimers.delete(absolutePath);
409
+ await this.processFileChange(absolutePath);
410
+ }, this.DEBOUNCE_MS);
411
+ this.debounceTimers.set(absolutePath, timer);
412
+ }
413
+ /** Process a file change after debouncing */
414
+ async processFileChange(absolutePath) {
415
+ try {
416
+ // Check if file exists
417
+ await fs.access(absolutePath);
418
+ // File exists - reload it (handles both create and modify)
419
+ await this.loadEntity(absolutePath);
420
+ }
421
+ catch (err) {
422
+ if (err.code === 'ENOENT') {
423
+ // File was deleted - remove from cache
424
+ this.removeEntityByPath(absolutePath);
425
+ }
426
+ else {
427
+ console.error(`Error processing file change for ${absolutePath}:`, err);
428
+ }
429
+ }
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // Core Entity Operations
433
+ // ---------------------------------------------------------------------------
434
+ /** Get entity by ID - loads from disk using ProjectIndex */
435
+ async getEntity(id) {
436
+ const metadata = this.index.get(id);
437
+ if (!metadata) {
438
+ // Entity not in index
439
+ return null;
440
+ }
441
+ if (!metadata.vault_path) {
442
+ console.error(`[V2Runtime] Entity ${id} has no vault_path in index`);
443
+ return null;
444
+ }
445
+ const absolutePath = this.pathResolver.toAbsolutePath(metadata.vault_path);
446
+ try {
447
+ const content = await fs.readFile(absolutePath, 'utf-8');
448
+ const result = this.parser.parse(content, metadata.vault_path);
449
+ return result.entity;
450
+ }
451
+ catch (err) {
452
+ if (err.code === 'ENOENT') {
453
+ // File doesn't exist - remove stale entry from index
454
+ console.warn(`[V2Runtime] Removing stale index entry for ${id} - file not found: ${absolutePath}`);
455
+ this.index.delete(id);
456
+ this.searchIndex.remove(id);
457
+ }
458
+ else {
459
+ console.error(`[V2Runtime] Error reading entity ${id} from ${absolutePath}:`, err);
460
+ }
461
+ return null;
462
+ }
463
+ }
464
+ /** Get all entities - loads from disk using ProjectIndex */
465
+ async getAllEntities(options) {
466
+ // First filter metadata from ProjectIndex (fast)
467
+ let metadataList = this.index.getAll();
468
+ if (!options?.includeArchived) {
469
+ metadataList = metadataList.filter((m) => !m.archived);
470
+ }
471
+ if (!options?.includeCompleted) {
472
+ metadataList = metadataList.filter((m) => m.status !== 'Completed');
473
+ }
474
+ if (options?.workstream) {
475
+ metadataList = metadataList.filter((m) => m.workstream === options.workstream);
476
+ }
477
+ if (options?.types) {
478
+ metadataList = metadataList.filter((m) => options.types.includes(m.type));
479
+ }
480
+ // Then load full entities from disk (only for filtered results)
481
+ const entities = [];
482
+ for (const metadata of metadataList) {
483
+ const entity = await this.getEntity(metadata.id);
484
+ if (entity) {
485
+ entities.push(entity);
486
+ }
487
+ }
488
+ return entities;
489
+ }
490
+ /** Get entity status */
491
+ getEntityStatus(entity) {
492
+ switch (entity.type) {
493
+ case 'milestone': return entity.status;
494
+ case 'story': return entity.status;
495
+ case 'task': return entity.status;
496
+ case 'decision': return entity.status;
497
+ case 'document': return entity.status;
498
+ case 'feature': return entity.status;
499
+ default: return 'Unknown';
500
+ }
501
+ }
502
+ /** Get entity workstream */
503
+ getEntityWorkstream(entity) {
504
+ switch (entity.type) {
505
+ case 'milestone': return entity.workstream;
506
+ case 'story': return entity.workstream;
507
+ case 'task': return entity.workstream || '';
508
+ case 'decision': return entity.workstream;
509
+ case 'document': return entity.workstream;
510
+ case 'feature': return entity.workstream || '';
511
+ default: return '';
512
+ }
513
+ }
514
+ /**
515
+ * Get the highest ID number for a given entity type by scanning vault files.
516
+ * This ensures we never generate duplicate IDs even if entities were created
517
+ * by the Obsidian plugin while the MCP server was running.
518
+ *
519
+ * NOTE: This scans the vault on every call to guarantee accuracy.
520
+ * The index may be stale if the plugin creates entities.
521
+ */
522
+ async getHighestIdForType(type) {
523
+ const prefix = this.idPrefixes.get(type);
524
+ if (!prefix)
525
+ return 0;
526
+ let highest = 0;
527
+ const folders = this.pathResolver.getAllAbsoluteEntityFolders();
528
+ for (const folder of folders) {
529
+ try {
530
+ const files = await this.getAllMarkdownFilesInFolder(folder);
531
+ for (const filePath of files) {
532
+ try {
533
+ const content = await fs.readFile(filePath, 'utf-8');
534
+ const vaultPath = this.pathResolver.toVaultPath(filePath);
535
+ const result = this.parser.parse(content, vaultPath);
536
+ // Only consider entities of the target type
537
+ if (result.entity.type === type) {
538
+ // Extract numeric part from ID (e.g., "S-042" -> 42)
539
+ const match = result.entity.id.match(new RegExp(`^${prefix}-(\\d+)$`));
540
+ if (match) {
541
+ const num = parseInt(match[1], 10);
542
+ if (num > highest) {
543
+ highest = num;
544
+ }
545
+ }
546
+ }
547
+ }
548
+ catch {
549
+ // Skip files that can't be parsed (not entities)
550
+ continue;
551
+ }
552
+ }
553
+ }
554
+ catch (err) {
555
+ const error = err;
556
+ if (error.code !== 'ENOENT') {
557
+ console.error(`[V2Runtime] Error scanning folder ${folder}:`, err);
558
+ }
559
+ }
560
+ }
561
+ return highest;
562
+ }
563
+ /**
564
+ * Get all markdown files in a folder (recursively)
565
+ */
566
+ async getAllMarkdownFilesInFolder(folder) {
567
+ const files = [];
568
+ try {
569
+ const entries = await fs.readdir(folder, { withFileTypes: true });
570
+ for (const entry of entries) {
571
+ const fullPath = path.join(folder, entry.name);
572
+ if (entry.isDirectory()) {
573
+ // Recursively scan subdirectories
574
+ const subFiles = await this.getAllMarkdownFilesInFolder(fullPath);
575
+ files.push(...subFiles);
576
+ }
577
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
578
+ files.push(fullPath);
579
+ }
580
+ }
581
+ }
582
+ catch (err) {
583
+ const error = err;
584
+ if (error.code !== 'ENOENT') {
585
+ console.error(`[V2Runtime] Error reading folder ${folder}:`, err);
586
+ }
587
+ }
588
+ return files;
589
+ }
590
+ /** Get next ID for entity type (zero-padded to 3 digits) */
591
+ async getNextId(type) {
592
+ // Scan the vault to find the highest existing ID for this type
593
+ // This prevents ID collisions with entities created by the Obsidian plugin
594
+ const highest = await this.getHighestIdForType(type);
595
+ const next = highest + 1;
596
+ const prefix = this.idPrefixes.get(type);
597
+ if (!prefix) {
598
+ throw new Error(`Unknown entity type: ${type}`);
599
+ }
600
+ // Zero-pad to 3 digits (e.g., S-001, M-012, T-123)
601
+ const padded = String(next).padStart(3, '0');
602
+ return `${prefix}-${padded}`;
603
+ }
604
+ /** Get all duplicate entity IDs and their file paths */
605
+ getDuplicateIds() {
606
+ return new Map(this.duplicateIds);
607
+ }
608
+ /** Check if there are any duplicate IDs */
609
+ hasDuplicateIds() {
610
+ return this.duplicateIds.size > 0;
611
+ }
612
+ /** Check if an entity exists in the index */
613
+ entityExists(id) {
614
+ return this.index.has(id);
615
+ }
616
+ /** Get entity type from ID (from index) */
617
+ getEntityTypeFromCache(id) {
618
+ const metadata = this.index.get(id);
619
+ return metadata?.type ?? null;
620
+ }
621
+ /** Write entity to file */
622
+ async writeEntity(entity) {
623
+ const filePath = this.pathResolver.getEntityPath(entity.id, entity.title);
624
+ const absolutePath = this.pathResolver.toAbsolutePath(filePath);
625
+ // Check if entity exists at a different path (title change scenario)
626
+ // If so, delete the old file to prevent duplicates
627
+ const existingPath = this.index.getPathById(entity.id);
628
+ if (existingPath && existingPath !== filePath) {
629
+ const oldAbsolutePath = this.pathResolver.toAbsolutePath(existingPath);
630
+ try {
631
+ await fs.unlink(oldAbsolutePath);
632
+ // Remove old path mapping
633
+ this.index.removePathMapping(existingPath);
634
+ console.error(`[V2Runtime] Deleted old file after title change: ${existingPath} -> ${filePath}`);
635
+ }
636
+ catch (err) {
637
+ if (err.code !== 'ENOENT') {
638
+ console.error(`[V2Runtime] Error deleting old file ${oldAbsolutePath}:`, err);
639
+ }
640
+ }
641
+ }
642
+ // Set vault_path on entity before serializing
643
+ entity.vault_path = filePath;
644
+ const content = this.serializer.serialize(entity);
645
+ // Ensure directory exists
646
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
647
+ await fs.writeFile(absolutePath, content, 'utf-8');
648
+ // Update search index
649
+ this.searchIndex.index(entity.id, entity.title, this.getEntityContent(entity), entity.type, entity.archived || false);
650
+ // Get file mtime for metadata
651
+ const stats = await fs.stat(absolutePath);
652
+ const fileMtime = stats.mtimeMs;
653
+ // Remove old relationships (in case entity was updated)
654
+ this.removeRelationships(entity.id);
655
+ // Update ProjectIndex metadata
656
+ const metadata = this.createEntityMetadata(entity, fileMtime);
657
+ this.index.set(metadata);
658
+ // Re-index relationships
659
+ this.indexRelationships(entity);
660
+ // Sync bidirectional implements/implemented_by relationships
661
+ await this.syncBidirectionalRelationships(entity);
662
+ // Notify Obsidian plugin to refresh canvas (fire-and-forget)
663
+ notifyObsidianPlugin('populate');
664
+ }
665
+ /**
666
+ * Sync all bidirectional relationships.
667
+ * Ensures consistency across all symmetric relationship pairs:
668
+ * - parent ↔ children
669
+ * - depends_on ↔ blocks
670
+ * - implements ↔ implemented_by
671
+ * - supersedes ↔ superseded_by
672
+ * - previous_version ↔ next_version
673
+ */
674
+ async syncBidirectionalRelationships(entity) {
675
+ // 1. Hierarchy: parent ↔ children
676
+ await this.syncParentChildRelationship(entity);
677
+ // 2. Dependencies: depends_on ↔ blocks
678
+ await this.syncDependencyRelationship(entity);
679
+ // 3. Implementation: implements ↔ implemented_by
680
+ await this.syncImplementsRelationship(entity);
681
+ // 4. Supersession: supersedes ↔ superseded_by (Decision only)
682
+ await this.syncSupersedesRelationship(entity);
683
+ // 5. Versioning: previous_version ↔ next_version (Document only)
684
+ await this.syncVersioningRelationship(entity);
685
+ }
686
+ /**
687
+ * Sync parent ↔ children relationship.
688
+ */
689
+ async syncParentChildRelationship(entity) {
690
+ if (entity.type === 'story') {
691
+ const story = entity;
692
+ if (story.parent) {
693
+ await this.ensureChildInParent(story.parent, story.id);
694
+ }
695
+ }
696
+ else if (entity.type === 'task') {
697
+ const task = entity;
698
+ if (task.parent) {
699
+ await this.ensureChildInParent(task.parent, task.id);
700
+ }
701
+ }
702
+ else if (entity.type === 'milestone') {
703
+ const milestone = entity;
704
+ if (milestone.children && milestone.children.length > 0) {
705
+ for (const childId of milestone.children) {
706
+ await this.ensureParentInChild(childId, milestone.id);
707
+ }
708
+ }
709
+ }
710
+ }
711
+ /**
712
+ * Sync depends_on ↔ blocks relationship.
713
+ */
714
+ async syncDependencyRelationship(entity) {
715
+ const dependsOn = entity.depends_on;
716
+ if (dependsOn && dependsOn.length > 0) {
717
+ for (const depId of dependsOn) {
718
+ await this.ensureAffects(depId, entity.id);
719
+ }
720
+ }
721
+ const affects = entity.affects;
722
+ if (affects && affects.length > 0) {
723
+ for (const affectedId of affects) {
724
+ await this.ensureDependsOn(affectedId, entity.id);
725
+ }
726
+ }
727
+ }
728
+ /**
729
+ * Sync implements ↔ implemented_by relationship.
730
+ */
731
+ async syncImplementsRelationship(entity) {
732
+ if (entity.type === 'story' || entity.type === 'milestone') {
733
+ const implements_ = entity.implements;
734
+ if (implements_ && implements_.length > 0) {
735
+ for (const targetId of implements_) {
736
+ // Check if it's a document or feature ID
737
+ const targetType = getEntityTypeFromId(targetId);
738
+ if (targetType === 'document') {
739
+ await this.ensureImplementedBy(targetId, entity.id);
740
+ }
741
+ else if (targetType === 'feature') {
742
+ await this.ensureFeatureImplementedBy(targetId, entity.id);
743
+ }
744
+ }
745
+ }
746
+ }
747
+ else if (entity.type === 'document') {
748
+ const implementedBy = entity.implemented_by;
749
+ if (implementedBy && implementedBy.length > 0) {
750
+ for (const storyId of implementedBy) {
751
+ await this.ensureImplements(storyId, entity.id);
752
+ }
753
+ }
754
+ }
755
+ else if (entity.type === 'feature') {
756
+ const implementedBy = entity.implemented_by;
757
+ if (implementedBy && implementedBy.length > 0) {
758
+ for (const implementerId of implementedBy) {
759
+ await this.ensureImplementsFeature(implementerId, entity.id);
760
+ }
761
+ }
762
+ }
763
+ }
764
+ /**
765
+ * Sync supersedes ↔ superseded_by relationship (Decision only).
766
+ */
767
+ async syncSupersedesRelationship(entity) {
768
+ if (entity.type !== 'decision')
769
+ return;
770
+ const decision = entity;
771
+ if (decision.supersedes) {
772
+ await this.ensureSupersededBy(decision.supersedes, decision.id);
773
+ }
774
+ if (decision.superseded_by) {
775
+ await this.ensureSupersedes(decision.superseded_by, decision.id);
776
+ }
777
+ }
778
+ /**
779
+ * Sync previous_version ↔ next_version relationship (Document only).
780
+ */
781
+ async syncVersioningRelationship(entity) {
782
+ if (entity.type !== 'document')
783
+ return;
784
+ const document = entity;
785
+ if (document.previous_version) {
786
+ await this.ensureNextVersion(document.previous_version, document.id);
787
+ }
788
+ if (document.next_version) {
789
+ await this.ensurePreviousVersion(document.next_version, document.id);
790
+ }
791
+ }
792
+ /**
793
+ * Ensure a parent entity's children array includes the given child ID.
794
+ */
795
+ async ensureChildInParent(parentId, childId) {
796
+ const parent = await this.getEntity(parentId);
797
+ if (!parent)
798
+ return;
799
+ if (parent.type === 'milestone') {
800
+ const milestone = parent;
801
+ const currentChildren = milestone.children || [];
802
+ if (currentChildren.includes(childId))
803
+ return;
804
+ milestone.children = [...currentChildren, childId];
805
+ milestone.updated_at = new Date().toISOString();
806
+ await this.writeEntityDirect(milestone);
807
+ console.error(`[V2Runtime] Synced children: added ${childId} to ${parentId}`);
808
+ }
809
+ else if (parent.type === 'story') {
810
+ const story = parent;
811
+ const currentChildren = story.children || [];
812
+ if (currentChildren.includes(childId))
813
+ return;
814
+ story.children = [...currentChildren, childId];
815
+ story.updated_at = new Date().toISOString();
816
+ await this.writeEntityDirect(story);
817
+ console.error(`[V2Runtime] Synced children: added ${childId} to ${parentId}`);
818
+ }
819
+ }
820
+ /**
821
+ * Ensure a child entity's parent field is set to the given parent ID.
822
+ */
823
+ async ensureParentInChild(childId, parentId) {
824
+ const child = await this.getEntity(childId);
825
+ if (!child)
826
+ return;
827
+ if (child.type === 'story') {
828
+ const story = child;
829
+ if (story.parent === parentId)
830
+ return;
831
+ story.parent = parentId;
832
+ story.updated_at = new Date().toISOString();
833
+ await this.writeEntityDirect(story);
834
+ console.error(`[V2Runtime] Synced parent: set ${parentId} on ${childId}`);
835
+ }
836
+ else if (child.type === 'task') {
837
+ const task = child;
838
+ if (task.parent === parentId)
839
+ return;
840
+ task.parent = parentId;
841
+ task.updated_at = new Date().toISOString();
842
+ await this.writeEntityDirect(task);
843
+ console.error(`[V2Runtime] Synced parent: set ${parentId} on ${childId}`);
844
+ }
845
+ }
846
+ /**
847
+ * Ensure an entity's affects array includes the given affected ID.
848
+ */
849
+ async ensureAffects(affecterId, affectedId) {
850
+ const affecter = await this.getEntity(affecterId);
851
+ if (!affecter)
852
+ return;
853
+ const currentAffects = affecter.affects || [];
854
+ if (currentAffects.includes(affectedId))
855
+ return;
856
+ affecter.affects = [...currentAffects, affectedId];
857
+ affecter.updated_at = new Date().toISOString();
858
+ await this.writeEntityDirect(affecter);
859
+ console.error(`[V2Runtime] Synced affects: added ${affectedId} to ${affecterId}`);
860
+ }
861
+ /**
862
+ * Ensure an entity's depends_on array includes the given dependency ID.
863
+ */
864
+ async ensureDependsOn(entityId, dependencyId) {
865
+ const entity = await this.getEntity(entityId);
866
+ if (!entity)
867
+ return;
868
+ const currentDependsOn = entity.depends_on || [];
869
+ if (currentDependsOn.includes(dependencyId))
870
+ return;
871
+ entity.depends_on = [...currentDependsOn, dependencyId];
872
+ entity.updated_at = new Date().toISOString();
873
+ await this.writeEntityDirect(entity);
874
+ console.error(`[V2Runtime] Synced depends_on: added ${dependencyId} to ${entityId}`);
875
+ }
876
+ /**
877
+ * Ensure a decision's superseded_by field is set.
878
+ */
879
+ async ensureSupersededBy(oldDecisionId, newDecisionId) {
880
+ const oldDecision = await this.getEntity(oldDecisionId);
881
+ if (!oldDecision || oldDecision.type !== 'decision')
882
+ return;
883
+ const decision = oldDecision;
884
+ if (decision.superseded_by === newDecisionId)
885
+ return;
886
+ decision.superseded_by = newDecisionId;
887
+ decision.updated_at = new Date().toISOString();
888
+ await this.writeEntityDirect(decision);
889
+ console.error(`[V2Runtime] Synced superseded_by: set ${newDecisionId} on ${oldDecisionId}`);
890
+ }
891
+ /**
892
+ * Ensure a decision's supersedes field is set.
893
+ */
894
+ async ensureSupersedes(newDecisionId, oldDecisionId) {
895
+ const newDecision = await this.getEntity(newDecisionId);
896
+ if (!newDecision || newDecision.type !== 'decision')
897
+ return;
898
+ const decision = newDecision;
899
+ if (decision.supersedes === oldDecisionId)
900
+ return;
901
+ decision.supersedes = oldDecisionId;
902
+ decision.updated_at = new Date().toISOString();
903
+ await this.writeEntityDirect(decision);
904
+ console.error(`[V2Runtime] Synced supersedes: set ${oldDecisionId} on ${newDecisionId}`);
905
+ }
906
+ /**
907
+ * Ensure a document's next_version field is set.
908
+ */
909
+ async ensureNextVersion(oldDocId, newDocId) {
910
+ const oldDoc = await this.getEntity(oldDocId);
911
+ if (!oldDoc || oldDoc.type !== 'document')
912
+ return;
913
+ const document = oldDoc;
914
+ if (document.next_version === newDocId)
915
+ return;
916
+ document.next_version = newDocId;
917
+ document.updated_at = new Date().toISOString();
918
+ await this.writeEntityDirect(document);
919
+ console.error(`[V2Runtime] Synced next_version: set ${newDocId} on ${oldDocId}`);
920
+ }
921
+ /**
922
+ * Ensure a document's previous_version field is set.
923
+ */
924
+ async ensurePreviousVersion(newDocId, oldDocId) {
925
+ const newDoc = await this.getEntity(newDocId);
926
+ if (!newDoc || newDoc.type !== 'document')
927
+ return;
928
+ const document = newDoc;
929
+ if (document.previous_version === oldDocId)
930
+ return;
931
+ document.previous_version = oldDocId;
932
+ document.updated_at = new Date().toISOString();
933
+ await this.writeEntityDirect(document);
934
+ console.error(`[V2Runtime] Synced previous_version: set ${oldDocId} on ${newDocId}`);
935
+ }
936
+ async ensureImplementedBy(docId, implementerId) {
937
+ const doc = await this.getEntity(docId);
938
+ if (!doc || doc.type !== 'document')
939
+ return;
940
+ const document = doc;
941
+ const currentImplementedBy = document.implemented_by || [];
942
+ // Check if already present
943
+ if (currentImplementedBy.includes(implementerId))
944
+ return;
945
+ // Add the implementer and save
946
+ document.implemented_by = [...currentImplementedBy, implementerId];
947
+ document.updated_at = new Date().toISOString();
948
+ // Write directly to avoid infinite recursion (don't call writeEntity)
949
+ await this.writeEntityDirect(document);
950
+ console.error(`[V2Runtime] Synced implemented_by: added ${implementerId} to ${docId}`);
951
+ }
952
+ /**
953
+ * Ensure a Story/Milestone's implements includes the given document ID.
954
+ * Only updates if the relationship is missing.
955
+ */
956
+ async ensureImplements(entityId, docId) {
957
+ const entity = await this.getEntity(entityId);
958
+ if (!entity)
959
+ return;
960
+ if (entity.type === 'story') {
961
+ const story = entity;
962
+ const currentImplements = story.implements || [];
963
+ if (currentImplements.includes(docId))
964
+ return;
965
+ story.implements = [...currentImplements, docId];
966
+ story.updated_at = new Date().toISOString();
967
+ await this.writeEntityDirect(story);
968
+ console.error(`[V2Runtime] Synced implements: added ${docId} to ${entityId}`);
969
+ }
970
+ else if (entity.type === 'milestone') {
971
+ const milestone = entity;
972
+ const currentImplements = milestone.implements || [];
973
+ if (currentImplements.includes(docId))
974
+ return;
975
+ milestone.implements = [...currentImplements, docId];
976
+ milestone.updated_at = new Date().toISOString();
977
+ await this.writeEntityDirect(milestone);
978
+ console.error(`[V2Runtime] Synced implements: added ${docId} to ${entityId}`);
979
+ }
980
+ }
981
+ /**
982
+ * Ensure a Feature's implemented_by includes the given story/milestone ID.
983
+ */
984
+ async ensureFeatureImplementedBy(featureId, implementerId) {
985
+ const feat = await this.getEntity(featureId);
986
+ if (!feat || feat.type !== 'feature')
987
+ return;
988
+ const feature = feat;
989
+ const currentImplementedBy = feature.implemented_by || [];
990
+ // Check if already present
991
+ if (currentImplementedBy.includes(implementerId))
992
+ return;
993
+ // Add the implementer and save
994
+ feature.implemented_by = [...currentImplementedBy, implementerId];
995
+ feature.updated_at = new Date().toISOString();
996
+ await this.writeEntityDirect(feature);
997
+ console.error(`[V2Runtime] Synced implemented_by: added ${implementerId} to ${featureId}`);
998
+ }
999
+ /**
1000
+ * Ensure a Story/Milestone's implements includes the given feature ID.
1001
+ */
1002
+ async ensureImplementsFeature(entityId, featureId) {
1003
+ const entity = await this.getEntity(entityId);
1004
+ if (!entity)
1005
+ return;
1006
+ if (entity.type === 'story') {
1007
+ const story = entity;
1008
+ const currentImplements = story.implements || [];
1009
+ if (currentImplements.includes(featureId))
1010
+ return;
1011
+ story.implements = [...currentImplements, featureId];
1012
+ story.updated_at = new Date().toISOString();
1013
+ await this.writeEntityDirect(story);
1014
+ console.error(`[V2Runtime] Synced implements: added ${featureId} to ${entityId}`);
1015
+ }
1016
+ else if (entity.type === 'milestone') {
1017
+ const milestone = entity;
1018
+ const currentImplements = milestone.implements || [];
1019
+ if (currentImplements.includes(featureId))
1020
+ return;
1021
+ milestone.implements = [...currentImplements, featureId];
1022
+ milestone.updated_at = new Date().toISOString();
1023
+ await this.writeEntityDirect(milestone);
1024
+ console.error(`[V2Runtime] Synced implements: added ${featureId} to ${entityId}`);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Write entity directly without triggering relationship sync (to avoid recursion).
1029
+ */
1030
+ async writeEntityDirect(entity) {
1031
+ const filePath = this.pathResolver.getEntityPath(entity.id, entity.title);
1032
+ const absolutePath = this.pathResolver.toAbsolutePath(filePath);
1033
+ entity.vault_path = filePath;
1034
+ const content = this.serializer.serialize(entity);
1035
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
1036
+ await fs.writeFile(absolutePath, content, 'utf-8');
1037
+ // Update indexes
1038
+ this.searchIndex.index(entity.id, entity.title, this.getEntityContent(entity), entity.type, entity.archived || false);
1039
+ const stats = await fs.stat(absolutePath);
1040
+ this.removeRelationships(entity.id);
1041
+ const metadata = this.createEntityMetadata(entity, stats.mtimeMs);
1042
+ this.index.set(metadata);
1043
+ this.indexRelationships(entity);
1044
+ }
1045
+ /** Get children of an entity - uses ProjectIndex for O(1) lookup */
1046
+ async getChildren(parentId) {
1047
+ // Use ProjectIndex relationship graph for O(1) lookup
1048
+ // parent_of relationship: parent -> child
1049
+ const childIds = this.index.getRelated(parentId, 'parent_of');
1050
+ const children = [];
1051
+ for (const childId of childIds) {
1052
+ const entity = await this.getEntity(childId);
1053
+ if (entity) {
1054
+ children.push(entity);
1055
+ }
1056
+ }
1057
+ return children;
1058
+ }
1059
+ /** Get parent of an entity */
1060
+ async getParent(id) {
1061
+ const entity = await this.getEntity(id);
1062
+ if (!entity)
1063
+ return null;
1064
+ let parentId;
1065
+ if (entity.type === 'story') {
1066
+ parentId = entity.parent;
1067
+ }
1068
+ else if (entity.type === 'task') {
1069
+ parentId = entity.parent;
1070
+ }
1071
+ return parentId ? this.getEntity(parentId) : null;
1072
+ }
1073
+ /** Get siblings of an entity */
1074
+ async getSiblings(id) {
1075
+ const entity = await this.getEntity(id);
1076
+ if (!entity)
1077
+ return [];
1078
+ const parent = await this.getParent(id);
1079
+ if (!parent) {
1080
+ // Top-level entity - siblings are same type (use ProjectIndex)
1081
+ const allMetadata = this.index.getAll();
1082
+ const siblings = [];
1083
+ for (const m of allMetadata) {
1084
+ if (m.type === entity.type && m.id !== id) {
1085
+ const sibling = await this.getEntity(m.id);
1086
+ if (sibling) {
1087
+ siblings.push(sibling);
1088
+ }
1089
+ }
1090
+ }
1091
+ return siblings;
1092
+ }
1093
+ const children = await this.getChildren(parent.id);
1094
+ return children.filter(c => c.id !== id);
1095
+ }
1096
+ /** Get entity path */
1097
+ async getEntityPath(id) {
1098
+ const entity = await this.getEntity(id);
1099
+ if (!entity)
1100
+ return '';
1101
+ return this.pathResolver.getEntityPath(id, entity.title);
1102
+ }
1103
+ // ---------------------------------------------------------------------------
1104
+ // Dependency Operations
1105
+ // ---------------------------------------------------------------------------
1106
+ /**
1107
+ * Get entities that this entity depends on (what blocks this entity).
1108
+ * Uses ProjectIndex relationship graph for O(1) lookup.
1109
+ *
1110
+ * If entity A has depends_on: [B], then A is blocked by B.
1111
+ * This returns [B] when called with A's id.
1112
+ */
1113
+ async getDependencies(id) {
1114
+ // In the index: B blocks A is stored as forward(B, 'blocks', A)
1115
+ // So to find what blocks A, we look at reverse relationships
1116
+ const blockerIds = this.index.getRelatedReverse(id, 'blocks');
1117
+ const deps = [];
1118
+ for (const depId of blockerIds) {
1119
+ const dep = await this.getEntity(depId);
1120
+ if (dep)
1121
+ deps.push(dep);
1122
+ }
1123
+ return deps;
1124
+ }
1125
+ /**
1126
+ * Get entities that depend on this entity (what this entity blocks).
1127
+ * Uses ProjectIndex relationship graph for O(1) lookup.
1128
+ *
1129
+ * If entity A has depends_on: [B], then B blocks A.
1130
+ * This returns [A] when called with B's id.
1131
+ */
1132
+ async getDependents(id) {
1133
+ // In the index: B blocks A is stored as forward(B, 'blocks', A)
1134
+ // So to find what B blocks, we look at forward relationships
1135
+ const dependentIds = this.index.getRelated(id, 'blocks');
1136
+ const dependents = [];
1137
+ for (const depId of dependentIds) {
1138
+ const dep = await this.getEntity(depId);
1139
+ if (dep)
1140
+ dependents.push(dep);
1141
+ }
1142
+ return dependents;
1143
+ }
1144
+ // ---------------------------------------------------------------------------
1145
+ // Status Operations
1146
+ // ---------------------------------------------------------------------------
1147
+ /** Validate status transition */
1148
+ validateStatusTransition(entity, newStatus) {
1149
+ // Use lifecycle manager's canTransition method
1150
+ const result = this.lifecycleManager.canTransition(entity, newStatus);
1151
+ return { valid: result.allowed, reason: result.reason };
1152
+ }
1153
+ /** Compute cascade effects of status change */
1154
+ async computeCascadeEffects(entity, newStatus) {
1155
+ // For now, return empty - cascade logic can be added later
1156
+ return [];
1157
+ }
1158
+ // ---------------------------------------------------------------------------
1159
+ // Archive Operations
1160
+ // ---------------------------------------------------------------------------
1161
+ /** Move entity to archive */
1162
+ async moveToArchive(id, archivePath) {
1163
+ const entity = await this.getEntity(id);
1164
+ if (!entity)
1165
+ throw new Error(`Entity not found: ${id}`);
1166
+ const currentPath = this.pathResolver.getEntityPath(id, entity.title);
1167
+ // If archivePath is provided, it's a folder path - append the filename
1168
+ // Otherwise use the full archive path from path resolver
1169
+ let targetPath;
1170
+ if (archivePath) {
1171
+ const filename = path.basename(currentPath);
1172
+ targetPath = path.join(archivePath, filename);
1173
+ }
1174
+ else {
1175
+ targetPath = this.pathResolver.getArchivePath(id, entity.title);
1176
+ }
1177
+ const absoluteCurrent = this.pathResolver.toAbsolutePath(currentPath);
1178
+ const absoluteTarget = this.pathResolver.toAbsolutePath(targetPath);
1179
+ // Ensure archive directory exists
1180
+ await fs.mkdir(path.dirname(absoluteTarget), { recursive: true });
1181
+ // Move file
1182
+ await fs.rename(absoluteCurrent, absoluteTarget);
1183
+ // Update entity on disk with archived flag
1184
+ entity.archived = true;
1185
+ entity.vault_path = targetPath;
1186
+ const content = this.serializer.serialize(entity);
1187
+ await fs.writeFile(absoluteTarget, content, 'utf-8');
1188
+ // Update ProjectIndex with new path and archived status
1189
+ const stats = await fs.stat(absoluteTarget);
1190
+ const metadata = this.createEntityMetadata(entity, stats.mtimeMs);
1191
+ this.index.set(metadata);
1192
+ // Notify Obsidian plugin to refresh canvas (fire-and-forget)
1193
+ notifyObsidianPlugin('populate');
1194
+ return targetPath;
1195
+ }
1196
+ /** Restore entity from archive */
1197
+ async restoreFromArchive(id) {
1198
+ const entity = await this.getEntity(id);
1199
+ if (!entity)
1200
+ throw new Error(`Entity not found: ${id}`);
1201
+ const archivePath = this.pathResolver.getArchivePath(id, entity.title);
1202
+ const targetPath = this.pathResolver.getEntityPath(id, entity.title);
1203
+ const absoluteArchive = this.pathResolver.toAbsolutePath(archivePath);
1204
+ const absoluteTarget = this.pathResolver.toAbsolutePath(targetPath);
1205
+ // Ensure target directory exists
1206
+ await fs.mkdir(path.dirname(absoluteTarget), { recursive: true });
1207
+ // Move file
1208
+ await fs.rename(absoluteArchive, absoluteTarget);
1209
+ // Update entity on disk with archived flag
1210
+ entity.archived = false;
1211
+ entity.vault_path = targetPath;
1212
+ const content = this.serializer.serialize(entity);
1213
+ await fs.writeFile(absoluteTarget, content, 'utf-8');
1214
+ // Update ProjectIndex with new path and archived status
1215
+ const stats = await fs.stat(absoluteTarget);
1216
+ const metadata = this.createEntityMetadata(entity, stats.mtimeMs);
1217
+ this.index.set(metadata);
1218
+ // Notify Obsidian plugin to refresh canvas (fire-and-forget)
1219
+ notifyObsidianPlugin('populate');
1220
+ return targetPath;
1221
+ }
1222
+ // ---------------------------------------------------------------------------
1223
+ // Conversion Helpers
1224
+ // ---------------------------------------------------------------------------
1225
+ /** Convert entity to summary */
1226
+ toEntitySummary(entity) {
1227
+ const summary = {
1228
+ id: entity.id,
1229
+ type: entity.type,
1230
+ title: entity.title,
1231
+ status: this.getEntityStatus(entity),
1232
+ workstream: this.getEntityWorkstream(entity),
1233
+ last_updated: entity.updated_at || new Date().toISOString(),
1234
+ };
1235
+ // Add parent for stories and tasks
1236
+ if ('parent' in entity && entity.parent) {
1237
+ const parentEntity = this.index.get(entity.parent);
1238
+ summary.parent = {
1239
+ id: entity.parent,
1240
+ title: parentEntity?.title || 'Unknown',
1241
+ };
1242
+ }
1243
+ return summary;
1244
+ }
1245
+ /** Convert entity to full representation */
1246
+ async toEntityFull(entity) {
1247
+ const summary = this.toEntitySummary(entity);
1248
+ const children = await this.getChildren(entity.id);
1249
+ // Get dependencies using the indexed relationships
1250
+ const dependencies = await this.getDependencies(entity.id);
1251
+ const dependents = await this.getDependents(entity.id);
1252
+ // Build the full entity representation
1253
+ const full = {
1254
+ ...summary,
1255
+ content: this.getEntityContent(entity),
1256
+ children_count: children.length,
1257
+ children: children.map(c => this.toEntitySummary(c)),
1258
+ dependencies: {
1259
+ blocks: dependents.map(e => e.id), // Entities that this entity blocks
1260
+ blocked_by: dependencies.map(e => e.id), // Entities that block this entity
1261
+ },
1262
+ dependency_details: {
1263
+ blocks: dependents.map(e => this.toEntitySummary(e)),
1264
+ blocked_by: dependencies.map(e => this.toEntitySummary(e)),
1265
+ },
1266
+ };
1267
+ // Add type-specific fields
1268
+ if (entity.type === 'milestone' || entity.type === 'story') {
1269
+ full.priority = entity.priority;
1270
+ }
1271
+ // Add document-specific fields
1272
+ if (entity.type === 'document') {
1273
+ const doc = entity;
1274
+ if (doc.documents && doc.documents.length > 0) {
1275
+ full.documents = doc.documents;
1276
+ }
1277
+ }
1278
+ // Add feature-specific fields
1279
+ if (entity.type === 'feature') {
1280
+ const feature = entity;
1281
+ full.user_story = feature.user_story;
1282
+ full.tier = feature.tier;
1283
+ full.phase = feature.phase;
1284
+ if (feature.documented_by && feature.documented_by.length > 0) {
1285
+ full.documented_by = feature.documented_by;
1286
+ }
1287
+ if (feature.implemented_by && feature.implemented_by.length > 0) {
1288
+ full.implemented_by = feature.implemented_by;
1289
+ }
1290
+ if (feature.decided_by && feature.decided_by.length > 0) {
1291
+ full.decided_by = feature.decided_by;
1292
+ }
1293
+ if (feature.test_refs && feature.test_refs.length > 0) {
1294
+ full.test_refs = feature.test_refs;
1295
+ }
1296
+ }
1297
+ return full;
1298
+ }
1299
+ /** Get current timestamp */
1300
+ getCurrentTimestamp() {
1301
+ return new Date().toISOString();
1302
+ }
1303
+ // ---------------------------------------------------------------------------
1304
+ // Search Operations
1305
+ // ---------------------------------------------------------------------------
1306
+ /** Search entities */
1307
+ async searchEntities(query, options) {
1308
+ const results = this.searchIndex.search(query, {
1309
+ types: options?.types,
1310
+ includeArchived: options?.archived,
1311
+ limit: options?.limit,
1312
+ });
1313
+ const output = [];
1314
+ for (const result of results) {
1315
+ const entity = await this.getEntity(result.id);
1316
+ if (!entity)
1317
+ continue;
1318
+ // Apply additional filters
1319
+ if (options?.statuses && !options.statuses.includes(this.getEntityStatus(entity))) {
1320
+ continue;
1321
+ }
1322
+ if (options?.workstreams && !options.workstreams.includes(this.getEntityWorkstream(entity))) {
1323
+ continue;
1324
+ }
1325
+ output.push({
1326
+ entity,
1327
+ score: result.score,
1328
+ snippet: this.getEntityContent(entity).substring(0, 200),
1329
+ });
1330
+ }
1331
+ return output;
1332
+ }
1333
+ /** Get task progress for a story */
1334
+ async getTaskProgress(storyId) {
1335
+ const children = await this.getChildren(storyId);
1336
+ const tasks = children.filter(c => c.type === 'task');
1337
+ const completed = tasks.filter(t => t.status === 'Completed').length;
1338
+ return { total: tasks.length, completed };
1339
+ }
1340
+ // ---------------------------------------------------------------------------
1341
+ // Decision & Document Operations
1342
+ // ---------------------------------------------------------------------------
1343
+ /** Get all decisions */
1344
+ async getAllDecisions(options) {
1345
+ const entities = await this.getAllEntities({
1346
+ includeArchived: options?.includeArchived,
1347
+ includeCompleted: true,
1348
+ types: ['decision'],
1349
+ workstream: options?.workstream,
1350
+ });
1351
+ let decisions = entities;
1352
+ if (!options?.includeSuperseded) {
1353
+ decisions = decisions.filter(d => d.status !== 'Superseded');
1354
+ }
1355
+ return decisions;
1356
+ }
1357
+ /** Get all documents */
1358
+ async getAllDocuments(options) {
1359
+ const entities = await this.getAllEntities({
1360
+ includeArchived: false,
1361
+ includeCompleted: true,
1362
+ types: ['document'],
1363
+ workstream: options?.workstream,
1364
+ });
1365
+ return entities;
1366
+ }
1367
+ /** Get all stories */
1368
+ async getAllStories(options) {
1369
+ const entities = await this.getAllEntities({
1370
+ includeArchived: false,
1371
+ includeCompleted: true,
1372
+ types: ['story'],
1373
+ workstream: options?.workstream,
1374
+ });
1375
+ let stories = entities;
1376
+ if (options?.priorities) {
1377
+ stories = stories.filter(s => options.priorities.includes(s.priority || 'Medium'));
1378
+ }
1379
+ return stories;
1380
+ }
1381
+ /** Get all features */
1382
+ async getAllFeatures(options) {
1383
+ const entities = await this.getAllEntities({
1384
+ includeArchived: false,
1385
+ includeCompleted: true,
1386
+ types: ['feature'],
1387
+ workstream: options?.workstream,
1388
+ });
1389
+ let features = entities;
1390
+ if (options?.tier) {
1391
+ features = features.filter(f => f.tier === options.tier);
1392
+ }
1393
+ if (options?.phase) {
1394
+ // Use String() to ensure consistent comparison (phase might be passed as number)
1395
+ const phaseStr = String(options.phase);
1396
+ features = features.filter(f => f.phase === phaseStr);
1397
+ }
1398
+ if (!options?.includeDeferred) {
1399
+ features = features.filter(f => f.status !== 'Deferred');
1400
+ }
1401
+ return features;
1402
+ }
1403
+ /** Create a decision */
1404
+ async createDecision(data) {
1405
+ const id = await this.getNextId('decision');
1406
+ const now = this.getCurrentTimestamp();
1407
+ const decision = {
1408
+ id,
1409
+ type: 'decision',
1410
+ title: data.title,
1411
+ context: data.context,
1412
+ decision: data.decision,
1413
+ rationale: data.rationale,
1414
+ workstream: data.workstream,
1415
+ decided_by: data.decided_by,
1416
+ decided_on: now,
1417
+ status: 'Decided',
1418
+ affects: data.affects || [],
1419
+ supersedes: data.supersedes,
1420
+ archived: false,
1421
+ created_at: now,
1422
+ updated_at: now,
1423
+ canvas_source: '',
1424
+ cssclasses: [],
1425
+ vault_path: '',
1426
+ };
1427
+ await this.writeEntity(decision);
1428
+ return decision;
1429
+ }
1430
+ /** Update a document */
1431
+ async updateDocument(id, data) {
1432
+ const entity = await this.getEntity(id);
1433
+ if (!entity || entity.type !== 'document') {
1434
+ throw new Error(`Document not found: ${id}`);
1435
+ }
1436
+ const updated = {
1437
+ ...entity,
1438
+ ...data,
1439
+ updated_at: this.getCurrentTimestamp(),
1440
+ };
1441
+ await this.writeEntity(updated);
1442
+ return updated;
1443
+ }
1444
+ /** Get decisions affecting a document */
1445
+ async getDecisionsAffectingDocument(docId) {
1446
+ const decisions = await this.getAllDecisions({ includeSuperseded: true });
1447
+ // For now, return decisions that reference this document
1448
+ // This would need more sophisticated tracking in a real implementation
1449
+ return decisions.filter(d => d.affects?.includes(docId));
1450
+ }
1451
+ /** Generate entity ID - uses vault scanning for consistency */
1452
+ async generateId(type) {
1453
+ // Scan the vault to find the highest existing ID for this type
1454
+ // This prevents ID collisions with entities created by the Obsidian plugin
1455
+ const highest = await this.getHighestIdForType(type);
1456
+ const next = highest + 1;
1457
+ const prefix = this.idPrefixes.get(type);
1458
+ if (!prefix) {
1459
+ throw new Error(`Unknown entity type: ${type}`);
1460
+ }
1461
+ const padded = String(next).padStart(3, '0');
1462
+ return `${prefix}-${padded}`;
1463
+ }
1464
+ // ---------------------------------------------------------------------------
1465
+ // Implementation Handoff Operations
1466
+ // ---------------------------------------------------------------------------
1467
+ /** Get related decisions for an entity */
1468
+ async getRelatedDecisions(entityId) {
1469
+ const decisions = await this.getAllDecisions();
1470
+ return decisions.filter(d => d.affects?.includes(entityId));
1471
+ }
1472
+ /** Get blocking entities */
1473
+ async getBlockingEntities(entityId) {
1474
+ return this.getDependencies(entityId);
1475
+ }
1476
+ /** Check if entity has open TODOs */
1477
+ async hasOpenTodos(entityId) {
1478
+ const entity = await this.getEntity(entityId);
1479
+ if (!entity)
1480
+ return false;
1481
+ const content = this.getEntityContent(entity);
1482
+ return content.includes('- [ ]') || content.includes('TODO');
1483
+ }
1484
+ /** Get acceptance criteria */
1485
+ async getAcceptanceCriteria(entityId) {
1486
+ const entity = await this.getEntity(entityId);
1487
+ if (!entity || entity.type !== 'story')
1488
+ return [];
1489
+ return entity.acceptance_criteria || [];
1490
+ }
1491
+ /** Get implementation context */
1492
+ async getImplementationContext(entityId) {
1493
+ const entity = await this.getEntity(entityId);
1494
+ if (!entity)
1495
+ return undefined;
1496
+ // Implementation context would be stored in entity metadata
1497
+ return entity.implementation_context;
1498
+ }
1499
+ /** Get related documents */
1500
+ async getRelatedDocuments(entityId) {
1501
+ const entity = await this.getEntity(entityId);
1502
+ if (!entity)
1503
+ return [];
1504
+ const implements_ = entity.implements;
1505
+ if (!implements_)
1506
+ return [];
1507
+ const docs = [];
1508
+ for (const docId of implements_) {
1509
+ const doc = await this.getEntity(docId);
1510
+ if (doc && doc.type === 'document') {
1511
+ docs.push(doc);
1512
+ }
1513
+ }
1514
+ return docs;
1515
+ }
1516
+ /** Search content for pattern */
1517
+ async searchContent(entityId, pattern) {
1518
+ const entity = await this.getEntity(entityId);
1519
+ if (!entity)
1520
+ return false;
1521
+ const content = this.getEntityContent(entity);
1522
+ return content.toLowerCase().includes(pattern.toLowerCase());
1523
+ }
1524
+ // ---------------------------------------------------------------------------
1525
+ // Relationship Reconciliation
1526
+ // ---------------------------------------------------------------------------
1527
+ /**
1528
+ * Reconcile all implements/implemented_by relationships across the vault.
1529
+ * This scans all entities and ensures bidirectional consistency:
1530
+ * - If Story/Milestone has `implements: [DOC-001]`, ensure DOC-001 has `implemented_by: [S-001]`
1531
+ * - If Document has `implemented_by: [S-001]`, ensure S-001 has `implements: [DOC-001]`
1532
+ *
1533
+ * @param options.dry_run If true, only report what would be changed without making changes
1534
+ * @returns Summary of reconciliation actions taken
1535
+ */
1536
+ async reconcileImplementsRelationships(options) {
1537
+ const dryRun = options?.dry_run ?? false;
1538
+ const details = [];
1539
+ const changes = [];
1540
+ const warnings = [];
1541
+ let updated = 0;
1542
+ // Get all entities
1543
+ const allIds = this.index.getAllIds();
1544
+ // First pass: collect all implements relationships from stories/milestones
1545
+ // Separate maps for documents and features since they have different ID types
1546
+ const docImplementsMap = new Map();
1547
+ const featureImplementsMap = new Map();
1548
+ for (const id of allIds) {
1549
+ const entity = await this.getEntity(id);
1550
+ if (!entity)
1551
+ continue;
1552
+ if (entity.type === 'story' || entity.type === 'milestone') {
1553
+ const implements_ = entity.implements;
1554
+ if (implements_ && implements_.length > 0) {
1555
+ for (const targetId of implements_) {
1556
+ const targetType = getEntityTypeFromId(targetId);
1557
+ if (targetType === 'document') {
1558
+ const docId = targetId;
1559
+ if (!docImplementsMap.has(docId)) {
1560
+ docImplementsMap.set(docId, new Set());
1561
+ }
1562
+ docImplementsMap.get(docId).add(id);
1563
+ }
1564
+ else if (targetType === 'feature') {
1565
+ const featureId = targetId;
1566
+ if (!featureImplementsMap.has(featureId)) {
1567
+ featureImplementsMap.set(featureId, new Set());
1568
+ }
1569
+ featureImplementsMap.get(featureId).add(id);
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ // Second pass: update documents with missing implemented_by
1576
+ for (const id of allIds) {
1577
+ const entity = await this.getEntity(id);
1578
+ if (!entity || entity.type !== 'document')
1579
+ continue;
1580
+ const doc = entity;
1581
+ const expectedImplementers = docImplementsMap.get(doc.id) || new Set();
1582
+ const currentImplementedBy = new Set(doc.implemented_by || []);
1583
+ // Check for references to non-existent entities
1584
+ for (const implementerId of currentImplementedBy) {
1585
+ if (!this.entityExists(implementerId)) {
1586
+ warnings.push({
1587
+ entity_id: doc.id,
1588
+ issue: `References non-existent entity ${implementerId} in implemented_by`,
1589
+ });
1590
+ }
1591
+ }
1592
+ // Find missing implementers
1593
+ const missingImplementers = [];
1594
+ for (const implementerId of expectedImplementers) {
1595
+ if (!currentImplementedBy.has(implementerId)) {
1596
+ missingImplementers.push(implementerId);
1597
+ }
1598
+ }
1599
+ if (missingImplementers.length > 0) {
1600
+ // Add to changes array (new format)
1601
+ changes.push({
1602
+ entity_id: doc.id,
1603
+ field: 'implemented_by',
1604
+ action: 'added',
1605
+ values: missingImplementers,
1606
+ reason: `Synced from ${missingImplementers.map(id => `${id}.implements`).join(', ')}`,
1607
+ });
1608
+ if (!dryRun) {
1609
+ doc.implemented_by = [...(doc.implemented_by || []), ...missingImplementers];
1610
+ doc.updated_at = new Date().toISOString();
1611
+ await this.writeEntityDirect(doc);
1612
+ updated++;
1613
+ }
1614
+ // Legacy details format
1615
+ for (const implementerId of missingImplementers) {
1616
+ details.push({
1617
+ entity_id: doc.id,
1618
+ action: 'added_implemented_by',
1619
+ related_id: implementerId,
1620
+ });
1621
+ }
1622
+ }
1623
+ }
1624
+ // Second pass (features): update features with missing implemented_by
1625
+ for (const id of allIds) {
1626
+ const entity = await this.getEntity(id);
1627
+ if (!entity || entity.type !== 'feature')
1628
+ continue;
1629
+ const feature = entity;
1630
+ const expectedImplementers = featureImplementsMap.get(feature.id) || new Set();
1631
+ const currentImplementedBy = new Set(feature.implemented_by || []);
1632
+ // Check for references to non-existent entities
1633
+ for (const implementerId of currentImplementedBy) {
1634
+ if (!this.entityExists(implementerId)) {
1635
+ warnings.push({
1636
+ entity_id: feature.id,
1637
+ issue: `References non-existent entity ${implementerId} in implemented_by`,
1638
+ });
1639
+ }
1640
+ }
1641
+ // Find missing implementers
1642
+ const missingImplementers = [];
1643
+ for (const implementerId of expectedImplementers) {
1644
+ if (!currentImplementedBy.has(implementerId)) {
1645
+ missingImplementers.push(implementerId);
1646
+ }
1647
+ }
1648
+ if (missingImplementers.length > 0) {
1649
+ // Add to changes array (new format)
1650
+ changes.push({
1651
+ entity_id: feature.id,
1652
+ field: 'implemented_by',
1653
+ action: 'added',
1654
+ values: missingImplementers,
1655
+ reason: `Synced from ${missingImplementers.map(id => `${id}.implements`).join(', ')}`,
1656
+ });
1657
+ if (!dryRun) {
1658
+ feature.implemented_by = [...(feature.implemented_by || []), ...missingImplementers];
1659
+ feature.updated_at = new Date().toISOString();
1660
+ await this.writeEntityDirect(feature);
1661
+ updated++;
1662
+ }
1663
+ for (const implementerId of missingImplementers) {
1664
+ details.push({
1665
+ entity_id: feature.id,
1666
+ action: 'added_implemented_by',
1667
+ related_id: implementerId,
1668
+ });
1669
+ }
1670
+ }
1671
+ }
1672
+ // Third pass: collect all implemented_by from documents and update stories/milestones
1673
+ const implementedByMap = new Map();
1674
+ for (const id of allIds) {
1675
+ const entity = await this.getEntity(id);
1676
+ if (!entity || entity.type !== 'document')
1677
+ continue;
1678
+ const doc = entity;
1679
+ if (doc.implemented_by && doc.implemented_by.length > 0) {
1680
+ for (const storyId of doc.implemented_by) {
1681
+ if (!implementedByMap.has(storyId)) {
1682
+ implementedByMap.set(storyId, new Set());
1683
+ }
1684
+ implementedByMap.get(storyId).add(doc.id);
1685
+ }
1686
+ }
1687
+ }
1688
+ // Fourth pass: update stories/milestones with missing implements
1689
+ for (const id of allIds) {
1690
+ const entity = await this.getEntity(id);
1691
+ if (!entity)
1692
+ continue;
1693
+ if (entity.type !== 'story' && entity.type !== 'milestone')
1694
+ continue;
1695
+ const storyOrMilestone = entity;
1696
+ const expectedDocs = implementedByMap.get(id) || new Set();
1697
+ const currentImplements = new Set(storyOrMilestone.implements || []);
1698
+ // Check for references to non-existent entities
1699
+ for (const targetId of currentImplements) {
1700
+ if (!this.entityExists(targetId)) {
1701
+ warnings.push({
1702
+ entity_id: entity.id,
1703
+ issue: `References non-existent entity ${targetId} in implements`,
1704
+ });
1705
+ }
1706
+ }
1707
+ // Find missing docs
1708
+ const missingDocs = [];
1709
+ for (const docId of expectedDocs) {
1710
+ if (!currentImplements.has(docId)) {
1711
+ missingDocs.push(docId);
1712
+ }
1713
+ }
1714
+ if (missingDocs.length > 0) {
1715
+ // Add to changes array (new format)
1716
+ changes.push({
1717
+ entity_id: entity.id,
1718
+ field: 'implements',
1719
+ action: 'added',
1720
+ values: missingDocs,
1721
+ reason: `Synced from ${missingDocs.map(id => `${id}.implemented_by`).join(', ')}`,
1722
+ });
1723
+ if (!dryRun) {
1724
+ if (entity.type === 'story') {
1725
+ entity.implements = [...(entity.implements || []), ...missingDocs];
1726
+ }
1727
+ else {
1728
+ entity.implements = [...(entity.implements || []), ...missingDocs];
1729
+ }
1730
+ entity.updated_at = new Date().toISOString();
1731
+ await this.writeEntityDirect(entity);
1732
+ updated++;
1733
+ }
1734
+ // Legacy details format
1735
+ for (const docId of missingDocs) {
1736
+ details.push({
1737
+ entity_id: entity.id,
1738
+ action: 'added_implements',
1739
+ related_id: docId,
1740
+ });
1741
+ }
1742
+ }
1743
+ }
1744
+ return {
1745
+ scanned: allIds.length,
1746
+ updated: dryRun ? 0 : updated,
1747
+ dry_run: dryRun,
1748
+ changes,
1749
+ warnings,
1750
+ details,
1751
+ };
1752
+ }
1753
+ // ---------------------------------------------------------------------------
1754
+ // Dependency Provider Methods
1755
+ // ---------------------------------------------------------------------------
1756
+ /** Get entity management dependencies */
1757
+ getEntityManagementDeps() {
1758
+ return {
1759
+ getEntity: (id) => this.getEntity(id),
1760
+ getNextId: (type) => this.getNextId(type),
1761
+ getChildren: (id) => this.getChildren(id),
1762
+ entityExists: (id) => this.entityExists(id),
1763
+ getEntityType: (id) => this.getEntityTypeFromCache(id),
1764
+ writeEntity: (entity) => this.writeEntity(entity),
1765
+ moveToArchive: (id, path) => this.moveToArchive(id, path),
1766
+ restoreFromArchive: (id) => this.restoreFromArchive(id),
1767
+ validateStatusTransition: (entity, status) => this.validateStatusTransition(entity, status),
1768
+ computeCascadeEffects: (entity, status) => this.computeCascadeEffects(entity, status),
1769
+ toEntityFull: (entity) => this.toEntityFull(entity),
1770
+ getCurrentTimestamp: () => this.getCurrentTimestamp(),
1771
+ // Canvas operations
1772
+ addToCanvas: async (entity, canvasPath) => {
1773
+ const nodeId = await this.canvasManager.addNode(entity.vault_path, canvasPath, undefined, this.canvasManager.getDimensionsForType(entity.type));
1774
+ return nodeId !== null;
1775
+ },
1776
+ removeFromCanvas: async (id, canvasPath) => {
1777
+ const entity = await this.getEntity(id);
1778
+ if (!entity)
1779
+ return false;
1780
+ return this.canvasManager.removeNode(entity.vault_path, canvasPath);
1781
+ },
1782
+ };
1783
+ }
1784
+ /** Get batch operations dependencies */
1785
+ getBatchOperationsDeps() {
1786
+ return {
1787
+ createEntity: async (type, data) => {
1788
+ const id = await this.getNextId(type);
1789
+ const now = this.getCurrentTimestamp();
1790
+ // Build entity with all required base fields plus type-specific data
1791
+ const baseFields = {
1792
+ id,
1793
+ type,
1794
+ created_at: now,
1795
+ updated_at: now,
1796
+ archived: false,
1797
+ canvas_source: '',
1798
+ cssclasses: [],
1799
+ vault_path: '',
1800
+ };
1801
+ // Merge with provided data (which should include title, workstream, status, etc.)
1802
+ const entity = { ...baseFields, ...data };
1803
+ await this.writeEntity(entity);
1804
+ return entity;
1805
+ },
1806
+ getEntity: (id) => this.getEntity(id),
1807
+ entityExists: (id) => this.entityExists(id),
1808
+ getEntityType: (id) => this.getEntityTypeFromCache(id),
1809
+ updateEntityStatus: async (id, status) => {
1810
+ const entity = await this.getEntity(id);
1811
+ if (!entity)
1812
+ throw new Error(`Entity not found: ${id}`);
1813
+ entity.status = status;
1814
+ entity.updated_at = this.getCurrentTimestamp();
1815
+ await this.writeEntity(entity);
1816
+ },
1817
+ writeEntity: (entity) => this.writeEntity(entity),
1818
+ archiveEntity: (id, path) => this.moveToArchive(id, path).then(() => { }),
1819
+ getChildren: (id) => this.getChildren(id),
1820
+ validateStatusTransition: (entity, status) => this.validateStatusTransition(entity, status),
1821
+ computeCascadeEffects: (entity, status) => this.computeCascadeEffects(entity, status),
1822
+ getCurrentTimestamp: () => this.getCurrentTimestamp(),
1823
+ // Canvas operations
1824
+ addToCanvas: async (entity, canvasPath) => {
1825
+ const nodeId = await this.canvasManager.addNode(entity.vault_path, canvasPath, undefined, this.canvasManager.getDimensionsForType(entity.type));
1826
+ return nodeId !== null;
1827
+ },
1828
+ removeFromCanvas: async (id, canvasPath) => {
1829
+ const entity = await this.getEntity(id);
1830
+ if (!entity)
1831
+ return false;
1832
+ return this.canvasManager.removeNode(entity.vault_path, canvasPath);
1833
+ },
1834
+ toEntityFull: (entity) => this.toEntityFull(entity),
1835
+ };
1836
+ }
1837
+ /** Get project understanding dependencies */
1838
+ getProjectUnderstandingDeps() {
1839
+ return {
1840
+ getAllEntities: (options) => this.getAllEntities(options),
1841
+ toEntitySummary: (entity) => this.toEntitySummary(entity),
1842
+ getBlockers: (id) => this.getDependencies(id),
1843
+ getBlockedBy: (id) => this.getDependents(id),
1844
+ getLastUpdated: (entity) => new Date(entity.updated_at || entity.created_at || Date.now()),
1845
+ };
1846
+ }
1847
+ /** Get search navigation dependencies */
1848
+ getSearchNavigationDeps() {
1849
+ return {
1850
+ searchEntities: (query, options) => this.searchEntities(query, options),
1851
+ getAllEntities: (options) => this.getAllEntities(options),
1852
+ getEntity: (id) => this.getEntity(id),
1853
+ getEntityPath: (id) => this.getEntityPath(id),
1854
+ toEntitySummary: (entity) => this.toEntitySummary(entity),
1855
+ toEntityFull: (entity) => this.toEntityFull(entity),
1856
+ getParent: (id) => this.getParent(id),
1857
+ getChildren: (id) => this.getChildren(id),
1858
+ getSiblings: (id) => this.getSiblings(id),
1859
+ getDependencies: (id) => this.getDependencies(id),
1860
+ getDependents: (id) => this.getDependents(id),
1861
+ getTaskProgress: (id) => this.getTaskProgress(id),
1862
+ };
1863
+ }
1864
+ /** Get decision document dependencies */
1865
+ getDecisionDocumentDeps() {
1866
+ return {
1867
+ createDecision: (data) => this.createDecision(data),
1868
+ getEntity: (id) => this.getEntity(id),
1869
+ getAllDecisions: (options) => this.getAllDecisions(options),
1870
+ getAllDocuments: () => this.getAllDocuments(),
1871
+ updateDocument: (id, data) => this.updateDocument(id, data),
1872
+ toEntityFull: (entity) => this.toEntityFull(entity),
1873
+ getCurrentTimestamp: () => this.getCurrentTimestamp(),
1874
+ generateId: async (type) => await this.generateId(type),
1875
+ getDecisionsAffectingDocument: (id) => this.getDecisionsAffectingDocument(id),
1876
+ searchContent: (id, pattern) => this.searchContent(id, pattern),
1877
+ addToCanvas: async (entity, canvasPath) => {
1878
+ const nodeId = await this.canvasManager.addNode(entity.vault_path, canvasPath, undefined, this.canvasManager.getDimensionsForType(entity.type));
1879
+ return nodeId !== null;
1880
+ },
1881
+ };
1882
+ }
1883
+ /** Get feature coverage dependencies */
1884
+ getFeatureCoverageDeps() {
1885
+ return {
1886
+ getAllFeatures: (options) => this.getAllFeatures(options),
1887
+ getEntity: (id) => this.getEntity(id),
1888
+ getAllDocuments: (options) => this.getAllDocuments(options),
1889
+ };
1890
+ }
1891
+ }
1892
+ // =============================================================================
1893
+ // Singleton Runtime Instance
1894
+ // =============================================================================
1895
+ let runtimeInstance = null;
1896
+ /** Get or create the V2 runtime instance */
1897
+ export async function getV2Runtime(config) {
1898
+ if (!runtimeInstance) {
1899
+ runtimeInstance = new V2Runtime(config);
1900
+ await runtimeInstance.initialize();
1901
+ }
1902
+ return runtimeInstance;
1903
+ }
1904
+ /** Reset the runtime (for testing) */
1905
+ export function resetV2Runtime() {
1906
+ runtimeInstance = null;
1907
+ }
1908
+ //# sourceMappingURL=v2-runtime.js.map