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.
- package/README.md +154 -182
- package/dist/index.js +207 -38
- package/dist/index.js.map +1 -1
- package/dist/integration.test.d.ts +8 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +979 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/models/types.d.ts +1 -2
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js.map +1 -1
- package/dist/models/v2-types.d.ts +460 -0
- package/dist/models/v2-types.d.ts.map +1 -0
- package/dist/models/v2-types.js +137 -0
- package/dist/models/v2-types.js.map +1 -0
- package/dist/models/v2-types.test.d.ts +5 -0
- package/dist/models/v2-types.test.d.ts.map +1 -0
- package/dist/models/v2-types.test.js +133 -0
- package/dist/models/v2-types.test.js.map +1 -0
- package/dist/parsers/canvas-parser.d.ts +1 -1
- package/dist/parsers/canvas-parser.d.ts.map +1 -1
- package/dist/parsers/canvas-parser.js +1 -1
- package/dist/parsers/canvas-parser.js.map +1 -1
- package/dist/parsers/markdown-parser.js +9 -9
- package/dist/parsers/markdown-parser.js.map +1 -1
- package/dist/services/v2/archive-manager.d.ts +96 -0
- package/dist/services/v2/archive-manager.d.ts.map +1 -0
- package/dist/services/v2/archive-manager.js +281 -0
- package/dist/services/v2/archive-manager.js.map +1 -0
- package/dist/services/v2/canvas-manager.d.ts +155 -0
- package/dist/services/v2/canvas-manager.d.ts.map +1 -0
- package/dist/services/v2/canvas-manager.js +540 -0
- package/dist/services/v2/canvas-manager.js.map +1 -0
- package/dist/services/v2/canvas-manager.test.d.ts +5 -0
- package/dist/services/v2/canvas-manager.test.d.ts.map +1 -0
- package/dist/services/v2/canvas-manager.test.js +327 -0
- package/dist/services/v2/canvas-manager.test.js.map +1 -0
- package/dist/services/v2/cascade-manager.d.ts +54 -0
- package/dist/services/v2/cascade-manager.d.ts.map +1 -0
- package/dist/services/v2/cascade-manager.js +220 -0
- package/dist/services/v2/cascade-manager.js.map +1 -0
- package/dist/services/v2/cycle-detector.d.ts +76 -0
- package/dist/services/v2/cycle-detector.d.ts.map +1 -0
- package/dist/services/v2/cycle-detector.js +183 -0
- package/dist/services/v2/cycle-detector.js.map +1 -0
- package/dist/services/v2/cycle-detector.test.d.ts +7 -0
- package/dist/services/v2/cycle-detector.test.d.ts.map +1 -0
- package/dist/services/v2/cycle-detector.test.js +125 -0
- package/dist/services/v2/cycle-detector.test.js.map +1 -0
- package/dist/services/v2/entity-parser.d.ts +54 -0
- package/dist/services/v2/entity-parser.d.ts.map +1 -0
- package/dist/services/v2/entity-parser.js +418 -0
- package/dist/services/v2/entity-parser.js.map +1 -0
- package/dist/services/v2/entity-parser.test.d.ts +5 -0
- package/dist/services/v2/entity-parser.test.d.ts.map +1 -0
- package/dist/services/v2/entity-parser.test.js +637 -0
- package/dist/services/v2/entity-parser.test.js.map +1 -0
- package/dist/services/v2/entity-serializer.d.ts +94 -0
- package/dist/services/v2/entity-serializer.d.ts.map +1 -0
- package/dist/services/v2/entity-serializer.js +583 -0
- package/dist/services/v2/entity-serializer.js.map +1 -0
- package/dist/services/v2/entity-serializer.test.d.ts +5 -0
- package/dist/services/v2/entity-serializer.test.d.ts.map +1 -0
- package/dist/services/v2/entity-serializer.test.js +241 -0
- package/dist/services/v2/entity-serializer.test.js.map +1 -0
- package/dist/services/v2/entity-validator.d.ts +65 -0
- package/dist/services/v2/entity-validator.d.ts.map +1 -0
- package/dist/services/v2/entity-validator.js +573 -0
- package/dist/services/v2/entity-validator.js.map +1 -0
- package/dist/services/v2/entity-validator.test.d.ts +5 -0
- package/dist/services/v2/entity-validator.test.d.ts.map +1 -0
- package/dist/services/v2/entity-validator.test.js +519 -0
- package/dist/services/v2/entity-validator.test.js.map +1 -0
- package/dist/services/v2/file-manager.d.ts +73 -0
- package/dist/services/v2/file-manager.d.ts.map +1 -0
- package/dist/services/v2/file-manager.js +310 -0
- package/dist/services/v2/file-manager.js.map +1 -0
- package/dist/services/v2/file-manager.test.d.ts +5 -0
- package/dist/services/v2/file-manager.test.d.ts.map +1 -0
- package/dist/services/v2/file-manager.test.js +339 -0
- package/dist/services/v2/file-manager.test.js.map +1 -0
- package/dist/services/v2/index-manager.d.ts +68 -0
- package/dist/services/v2/index-manager.d.ts.map +1 -0
- package/dist/services/v2/index-manager.js +228 -0
- package/dist/services/v2/index-manager.js.map +1 -0
- package/dist/services/v2/index-manager.test.d.ts +5 -0
- package/dist/services/v2/index-manager.test.d.ts.map +1 -0
- package/dist/services/v2/index-manager.test.js +386 -0
- package/dist/services/v2/index-manager.test.js.map +1 -0
- package/dist/services/v2/index-service.d.ts +82 -0
- package/dist/services/v2/index-service.d.ts.map +1 -0
- package/dist/services/v2/index-service.js +274 -0
- package/dist/services/v2/index-service.js.map +1 -0
- package/dist/services/v2/index-service.test.d.ts +5 -0
- package/dist/services/v2/index-service.test.d.ts.map +1 -0
- package/dist/services/v2/index-service.test.js +117 -0
- package/dist/services/v2/index-service.test.js.map +1 -0
- package/dist/services/v2/lifecycle-manager.d.ts +59 -0
- package/dist/services/v2/lifecycle-manager.d.ts.map +1 -0
- package/dist/services/v2/lifecycle-manager.js +310 -0
- package/dist/services/v2/lifecycle-manager.js.map +1 -0
- package/dist/services/v2/lifecycle-manager.test.d.ts +5 -0
- package/dist/services/v2/lifecycle-manager.test.d.ts.map +1 -0
- package/dist/services/v2/lifecycle-manager.test.js +141 -0
- package/dist/services/v2/lifecycle-manager.test.js.map +1 -0
- package/dist/services/v2/path-resolver.d.ts +64 -0
- package/dist/services/v2/path-resolver.d.ts.map +1 -0
- package/dist/services/v2/path-resolver.js +174 -0
- package/dist/services/v2/path-resolver.js.map +1 -0
- package/dist/services/v2/progress-computer.d.ts +46 -0
- package/dist/services/v2/progress-computer.d.ts.map +1 -0
- package/dist/services/v2/progress-computer.js +200 -0
- package/dist/services/v2/progress-computer.js.map +1 -0
- package/dist/services/v2/search-service.d.ts +68 -0
- package/dist/services/v2/search-service.d.ts.map +1 -0
- package/dist/services/v2/search-service.js +194 -0
- package/dist/services/v2/search-service.js.map +1 -0
- package/dist/services/v2/transitive-dependency-remover.d.ts +54 -0
- package/dist/services/v2/transitive-dependency-remover.d.ts.map +1 -0
- package/dist/services/v2/transitive-dependency-remover.js +156 -0
- package/dist/services/v2/transitive-dependency-remover.js.map +1 -0
- package/dist/services/v2/transitive-dependency-remover.test.d.ts +7 -0
- package/dist/services/v2/transitive-dependency-remover.test.d.ts.map +1 -0
- package/dist/services/v2/transitive-dependency-remover.test.js +119 -0
- package/dist/services/v2/transitive-dependency-remover.test.js.map +1 -0
- package/dist/services/v2/v2-runtime.d.ts +374 -0
- package/dist/services/v2/v2-runtime.d.ts.map +1 -0
- package/dist/services/v2/v2-runtime.js +1908 -0
- package/dist/services/v2/v2-runtime.js.map +1 -0
- package/dist/services/v2/v2-runtime.test.d.ts +5 -0
- package/dist/services/v2/v2-runtime.test.d.ts.map +1 -0
- package/dist/services/v2/v2-runtime.test.js +658 -0
- package/dist/services/v2/v2-runtime.test.js.map +1 -0
- package/dist/services/v2/workstream-normalizer.d.ts +59 -0
- package/dist/services/v2/workstream-normalizer.d.ts.map +1 -0
- package/dist/services/v2/workstream-normalizer.js +137 -0
- package/dist/services/v2/workstream-normalizer.js.map +1 -0
- package/dist/services/v2/workstream-normalizer.test.d.ts +7 -0
- package/dist/services/v2/workstream-normalizer.test.d.ts.map +1 -0
- package/dist/services/v2/workstream-normalizer.test.js +130 -0
- package/dist/services/v2/workstream-normalizer.test.js.map +1 -0
- package/dist/test-runner.d.ts +4 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +44 -249
- package/dist/test-runner.js.map +1 -1
- package/dist/tools/batch-operations-tools.d.ts +54 -0
- package/dist/tools/batch-operations-tools.d.ts.map +1 -0
- package/dist/tools/batch-operations-tools.js +370 -0
- package/dist/tools/batch-operations-tools.js.map +1 -0
- package/dist/tools/decision-document-tools.d.ts +78 -0
- package/dist/tools/decision-document-tools.d.ts.map +1 -0
- package/dist/tools/decision-document-tools.js +260 -0
- package/dist/tools/decision-document-tools.js.map +1 -0
- package/dist/tools/entity-management-tools.d.ts +79 -0
- package/dist/tools/entity-management-tools.d.ts.map +1 -0
- package/dist/tools/entity-management-tools.js +851 -0
- package/dist/tools/entity-management-tools.js.map +1 -0
- package/dist/tools/entity-management-tools.test.d.ts +5 -0
- package/dist/tools/entity-management-tools.test.d.ts.map +1 -0
- package/dist/tools/entity-management-tools.test.js +530 -0
- package/dist/tools/entity-management-tools.test.js.map +1 -0
- package/dist/tools/index.d.ts +15 -331
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +510 -47
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/index.test.d.ts +8 -0
- package/dist/tools/index.test.d.ts.map +1 -0
- package/dist/tools/index.test.js +429 -0
- package/dist/tools/index.test.js.map +1 -0
- package/dist/tools/project-understanding-tools.d.ts +75 -0
- package/dist/tools/project-understanding-tools.d.ts.map +1 -0
- package/dist/tools/project-understanding-tools.js +751 -0
- package/dist/tools/project-understanding-tools.js.map +1 -0
- package/dist/tools/search-navigation-tools.d.ts +77 -0
- package/dist/tools/search-navigation-tools.d.ts.map +1 -0
- package/dist/tools/search-navigation-tools.js +379 -0
- package/dist/tools/search-navigation-tools.js.map +1 -0
- package/dist/tools/tool-types.d.ts +703 -0
- package/dist/tools/tool-types.d.ts.map +1 -0
- package/dist/tools/tool-types.js +7 -0
- package/dist/tools/tool-types.js.map +1 -0
- package/dist/utils/config.d.ts +0 -4
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +2 -19
- package/dist/utils/config.js.map +1 -1
- package/package.json +16 -1
- package/dist/services/accomplishment-service.d.ts +0 -33
- package/dist/services/accomplishment-service.d.ts.map +0 -1
- package/dist/services/accomplishment-service.js +0 -296
- package/dist/services/accomplishment-service.js.map +0 -1
- package/dist/services/canvas-service.d.ts +0 -96
- package/dist/services/canvas-service.d.ts.map +0 -1
- package/dist/services/canvas-service.js +0 -231
- package/dist/services/canvas-service.js.map +0 -1
- package/dist/services/context-doc-service.d.ts +0 -70
- package/dist/services/context-doc-service.d.ts.map +0 -1
- package/dist/services/context-doc-service.js +0 -229
- package/dist/services/context-doc-service.js.map +0 -1
- package/dist/services/dependency-service.d.ts +0 -22
- package/dist/services/dependency-service.d.ts.map +0 -1
- package/dist/services/dependency-service.js +0 -99
- package/dist/services/dependency-service.js.map +0 -1
- package/dist/services/status-indicator-service.d.ts +0 -40
- package/dist/services/status-indicator-service.d.ts.map +0 -1
- package/dist/services/status-indicator-service.js +0 -173
- package/dist/services/status-indicator-service.js.map +0 -1
- package/dist/services/task-service.d.ts +0 -32
- package/dist/services/task-service.d.ts.map +0 -1
- package/dist/services/task-service.js +0 -152
- package/dist/services/task-service.js.map +0 -1
- package/dist/test-real-vault.d.ts +0 -6
- package/dist/test-real-vault.d.ts.map +0 -1
- package/dist/test-real-vault.js +0 -30
- package/dist/test-real-vault.js.map +0 -1
- package/dist/tools/batch-operations.d.ts +0 -246
- package/dist/tools/batch-operations.d.ts.map +0 -1
- package/dist/tools/batch-operations.js +0 -235
- package/dist/tools/batch-operations.js.map +0 -1
- package/dist/tools/get-accomplishment.d.ts +0 -42
- package/dist/tools/get-accomplishment.d.ts.map +0 -1
- package/dist/tools/get-accomplishment.js +0 -93
- package/dist/tools/get-accomplishment.js.map +0 -1
- package/dist/tools/get-accomplishments-graph.d.ts +0 -26
- package/dist/tools/get-accomplishments-graph.d.ts.map +0 -1
- package/dist/tools/get-accomplishments-graph.js +0 -137
- package/dist/tools/get-accomplishments-graph.js.map +0 -1
- package/dist/tools/get-blocked-items.d.ts +0 -15
- package/dist/tools/get-blocked-items.d.ts.map +0 -1
- package/dist/tools/get-blocked-items.js +0 -73
- package/dist/tools/get-blocked-items.js.map +0 -1
- package/dist/tools/get-current-work.d.ts +0 -15
- package/dist/tools/get-current-work.d.ts.map +0 -1
- package/dist/tools/get-current-work.js +0 -68
- package/dist/tools/get-current-work.js.map +0 -1
- package/dist/tools/get-project-status.d.ts +0 -26
- package/dist/tools/get-project-status.d.ts.map +0 -1
- package/dist/tools/get-project-status.js +0 -98
- package/dist/tools/get-project-status.js.map +0 -1
- package/dist/tools/get-ready-to-start.d.ts +0 -15
- package/dist/tools/get-ready-to-start.d.ts.map +0 -1
- package/dist/tools/get-ready-to-start.js +0 -47
- package/dist/tools/get-ready-to-start.js.map +0 -1
- package/dist/tools/list-accomplishments.d.ts +0 -42
- package/dist/tools/list-accomplishments.d.ts.map +0 -1
- package/dist/tools/list-accomplishments.js +0 -40
- package/dist/tools/list-accomplishments.js.map +0 -1
- package/dist/tools/manage-accomplishment.d.ts +0 -147
- package/dist/tools/manage-accomplishment.d.ts.map +0 -1
- package/dist/tools/manage-accomplishment.js +0 -153
- package/dist/tools/manage-accomplishment.js.map +0 -1
- package/dist/tools/manage-dependency.d.ts +0 -41
- package/dist/tools/manage-dependency.d.ts.map +0 -1
- package/dist/tools/manage-dependency.js +0 -66
- package/dist/tools/manage-dependency.js.map +0 -1
- package/dist/tools/manage-task.d.ts +0 -119
- package/dist/tools/manage-task.d.ts.map +0 -1
- package/dist/tools/manage-task.js +0 -126
- package/dist/tools/manage-task.js.map +0 -1
- package/dist/tools/reconcile-canvas.d.ts +0 -33
- package/dist/tools/reconcile-canvas.d.ts.map +0 -1
- package/dist/tools/reconcile-canvas.js +0 -41
- package/dist/tools/reconcile-canvas.js.map +0 -1
- package/dist/tools/set-work-focus.d.ts +0 -48
- package/dist/tools/set-work-focus.d.ts.map +0 -1
- package/dist/tools/set-work-focus.js +0 -78
- package/dist/tools/set-work-focus.js.map +0 -1
- package/dist/tools/sync-dependencies.d.ts +0 -33
- package/dist/tools/sync-dependencies.d.ts.map +0 -1
- package/dist/tools/sync-dependencies.js +0 -144
- 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
|