gsd-pi 2.74.0-dev.2b524c3 → 2.74.0-dev.b741afb

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/cli.js +85 -0
  2. package/dist/headless-query.js +4 -1
  3. package/dist/help-text.js +23 -0
  4. package/dist/resources/extensions/gsd/auto/detect-stuck.js +11 -4
  5. package/dist/resources/extensions/gsd/auto/phases.js +45 -1
  6. package/dist/resources/extensions/gsd/auto-post-unit.js +52 -56
  7. package/dist/resources/extensions/gsd/auto-prompts.js +12 -0
  8. package/dist/resources/extensions/gsd/auto.js +8 -2
  9. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +21 -8
  10. package/dist/resources/extensions/gsd/commands/catalog.js +26 -1
  11. package/dist/resources/extensions/gsd/commands/handlers/ops.js +20 -0
  12. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +68 -9
  13. package/dist/resources/extensions/gsd/commands-add-tests.js +111 -0
  14. package/dist/resources/extensions/gsd/commands-backlog.js +140 -0
  15. package/dist/resources/extensions/gsd/commands-do.js +79 -0
  16. package/dist/resources/extensions/gsd/commands-maintenance.js +6 -6
  17. package/dist/resources/extensions/gsd/commands-pr-branch.js +180 -0
  18. package/dist/resources/extensions/gsd/commands-session-report.js +82 -0
  19. package/dist/resources/extensions/gsd/commands-ship.js +187 -0
  20. package/dist/resources/extensions/gsd/db-writer.js +3 -5
  21. package/dist/resources/extensions/gsd/graph-context.js +66 -0
  22. package/dist/resources/extensions/gsd/gsd-db.js +321 -0
  23. package/dist/resources/extensions/gsd/index.js +15 -2
  24. package/dist/resources/extensions/gsd/md-importer.js +3 -4
  25. package/dist/resources/extensions/gsd/memory-store.js +19 -51
  26. package/dist/resources/extensions/gsd/milestone-validation-gates.js +13 -12
  27. package/dist/resources/extensions/gsd/native-git-bridge.js +7 -4
  28. package/dist/resources/extensions/gsd/prompts/add-tests.md +35 -0
  29. package/dist/resources/extensions/gsd/state.js +5 -1
  30. package/dist/resources/extensions/gsd/tools/complete-slice.js +15 -0
  31. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +3 -14
  32. package/dist/resources/extensions/gsd/triage-resolution.js +2 -5
  33. package/dist/resources/extensions/gsd/workflow-manifest.js +8 -69
  34. package/dist/resources/extensions/gsd/workflow-migration.js +21 -22
  35. package/dist/resources/extensions/gsd/workflow-projections.js +4 -1
  36. package/dist/resources/extensions/gsd/workflow-reconcile.js +14 -11
  37. package/dist/tsconfig.extensions.tsbuildinfo +1 -0
  38. package/dist/web/standalone/.next/BUILD_ID +1 -1
  39. package/dist/web/standalone/.next/app-path-routes-manifest.json +7 -7
  40. package/dist/web/standalone/.next/build-manifest.json +2 -2
  41. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  42. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.html +1 -1
  59. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app-paths-manifest.json +7 -7
  66. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  68. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  69. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  70. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/package.json +3 -2
  72. package/packages/daemon/package.json +2 -2
  73. package/packages/mcp-server/dist/index.d.ts +3 -0
  74. package/packages/mcp-server/dist/index.d.ts.map +1 -1
  75. package/packages/mcp-server/dist/index.js +3 -0
  76. package/packages/mcp-server/dist/index.js.map +1 -1
  77. package/packages/mcp-server/dist/readers/graph.d.ts +87 -0
  78. package/packages/mcp-server/dist/readers/graph.d.ts.map +1 -0
  79. package/packages/mcp-server/dist/readers/graph.js +548 -0
  80. package/packages/mcp-server/dist/readers/graph.js.map +1 -0
  81. package/packages/mcp-server/dist/readers/index.d.ts +2 -0
  82. package/packages/mcp-server/dist/readers/index.d.ts.map +1 -1
  83. package/packages/mcp-server/dist/readers/index.js +1 -0
  84. package/packages/mcp-server/dist/readers/index.js.map +1 -1
  85. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  86. package/packages/mcp-server/dist/server.js +65 -0
  87. package/packages/mcp-server/dist/server.js.map +1 -1
  88. package/packages/mcp-server/package.json +2 -2
  89. package/packages/mcp-server/src/index.ts +15 -0
  90. package/packages/mcp-server/src/readers/graph.test.ts +426 -0
  91. package/packages/mcp-server/src/readers/graph.ts +708 -0
  92. package/packages/mcp-server/src/readers/index.ts +12 -0
  93. package/packages/mcp-server/src/server.ts +83 -0
  94. package/packages/mcp-server/tsconfig.json +1 -0
  95. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -0
  96. package/packages/native/package.json +2 -2
  97. package/packages/native/tsconfig.tsbuildinfo +1 -0
  98. package/packages/pi-agent-core/package.json +1 -1
  99. package/packages/pi-agent-core/tsconfig.json +1 -0
  100. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -0
  101. package/packages/pi-ai/package.json +1 -1
  102. package/packages/pi-ai/tsconfig.json +1 -0
  103. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -0
  104. package/packages/pi-coding-agent/tsconfig.json +1 -0
  105. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -0
  106. package/packages/pi-tui/package.json +1 -1
  107. package/packages/pi-tui/tsconfig.json +1 -0
  108. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -0
  109. package/packages/rpc-client/package.json +1 -1
  110. package/packages/rpc-client/tsconfig.json +1 -0
  111. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -0
  112. package/src/resources/extensions/gsd/auto/detect-stuck.ts +12 -4
  113. package/src/resources/extensions/gsd/auto/loop-deps.ts +6 -0
  114. package/src/resources/extensions/gsd/auto/phases.ts +68 -1
  115. package/src/resources/extensions/gsd/auto-post-unit.ts +60 -57
  116. package/src/resources/extensions/gsd/auto-prompts.ts +13 -0
  117. package/src/resources/extensions/gsd/auto.ts +7 -0
  118. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +24 -8
  119. package/src/resources/extensions/gsd/commands/catalog.ts +26 -1
  120. package/src/resources/extensions/gsd/commands/handlers/ops.ts +20 -0
  121. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +74 -9
  122. package/src/resources/extensions/gsd/commands-add-tests.ts +137 -0
  123. package/src/resources/extensions/gsd/commands-backlog.ts +182 -0
  124. package/src/resources/extensions/gsd/commands-do.ts +109 -0
  125. package/src/resources/extensions/gsd/commands-maintenance.ts +6 -6
  126. package/src/resources/extensions/gsd/commands-pr-branch.ts +234 -0
  127. package/src/resources/extensions/gsd/commands-session-report.ts +101 -0
  128. package/src/resources/extensions/gsd/commands-ship.ts +219 -0
  129. package/src/resources/extensions/gsd/db-writer.ts +3 -5
  130. package/src/resources/extensions/gsd/graph-context.ts +85 -0
  131. package/src/resources/extensions/gsd/gsd-db.ts +467 -0
  132. package/src/resources/extensions/gsd/index.ts +18 -2
  133. package/src/resources/extensions/gsd/md-importer.ts +3 -5
  134. package/src/resources/extensions/gsd/memory-store.ts +31 -62
  135. package/src/resources/extensions/gsd/milestone-validation-gates.ts +13 -14
  136. package/src/resources/extensions/gsd/native-git-bridge.ts +11 -12
  137. package/src/resources/extensions/gsd/prompts/add-tests.md +35 -0
  138. package/src/resources/extensions/gsd/state.ts +9 -2
  139. package/src/resources/extensions/gsd/tests/commands-backlog.test.ts +158 -0
  140. package/src/resources/extensions/gsd/tests/commands-do.test.ts +127 -0
  141. package/src/resources/extensions/gsd/tests/commands-pr-branch.test.ts +68 -0
  142. package/src/resources/extensions/gsd/tests/commands-session-report.test.ts +82 -0
  143. package/src/resources/extensions/gsd/tests/commands-ship.test.ts +71 -0
  144. package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts +154 -0
  146. package/src/resources/extensions/gsd/tests/graph-context.test.ts +337 -0
  147. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +68 -1
  148. package/src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts +140 -0
  149. package/src/resources/extensions/gsd/tests/single-writer-invariant.test.ts +180 -0
  150. package/src/resources/extensions/gsd/tests/workflow-logger-wiring.test.ts +223 -0
  151. package/src/resources/extensions/gsd/tools/complete-slice.ts +19 -0
  152. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +3 -11
  153. package/src/resources/extensions/gsd/triage-resolution.ts +2 -7
  154. package/src/resources/extensions/gsd/workflow-manifest.ts +9 -104
  155. package/src/resources/extensions/gsd/workflow-migration.ts +21 -29
  156. package/src/resources/extensions/gsd/workflow-projections.ts +8 -1
  157. package/src/resources/extensions/gsd/workflow-reconcile.ts +15 -15
  158. /package/dist/web/standalone/.next/static/{YzIEI9sxJy4t5xgClF08g → XnHY5eXUsTCFmNodWHetD}/_buildManifest.js +0 -0
  159. /package/dist/web/standalone/.next/static/{YzIEI9sxJy4t5xgClF08g → XnHY5eXUsTCFmNodWHetD}/_ssgManifest.js +0 -0
@@ -0,0 +1,708 @@
1
+ // GSD MCP Server — knowledge graph reader
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ /**
5
+ * Knowledge Graph for GSD projects.
6
+ *
7
+ * Parses .gsd/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,
8
+ * KNOWLEDGE.md) into a graph of nodes and edges. Parse errors in any
9
+ * single artifact are caught and never propagate — the artifact is skipped
10
+ * and the rest of the graph is returned.
11
+ *
12
+ * writeGraph() is atomic: writes to graph.tmp.json then renames to graph.json.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { resolveGsdRoot, findMilestoneIds, resolveMilestoneDir, findSliceIds, resolveSliceDir } from './paths.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type NodeType =
24
+ | 'milestone'
25
+ | 'slice'
26
+ | 'task'
27
+ | 'rule'
28
+ | 'pattern'
29
+ | 'lesson'
30
+ | 'concept';
31
+
32
+ export type EdgeType =
33
+ | 'contains'
34
+ | 'depends_on'
35
+ | 'relates_to'
36
+ | 'implements';
37
+
38
+ export type ConfidenceTier = 'EXTRACTED' | 'INFERRED' | 'AMBIGUOUS';
39
+
40
+ export interface GraphNode {
41
+ id: string;
42
+ label: string;
43
+ type: NodeType;
44
+ description?: string;
45
+ confidence: ConfidenceTier;
46
+ sourceFile?: string;
47
+ }
48
+
49
+ export interface GraphEdge {
50
+ from: string;
51
+ to: string;
52
+ type: EdgeType;
53
+ confidence: ConfidenceTier;
54
+ }
55
+
56
+ export interface KnowledgeGraph {
57
+ nodes: GraphNode[];
58
+ edges: GraphEdge[];
59
+ builtAt: string;
60
+ }
61
+
62
+ export interface GraphStatusResult {
63
+ exists: boolean;
64
+ lastBuild?: string;
65
+ nodeCount?: number;
66
+ edgeCount?: number;
67
+ stale?: boolean;
68
+ ageHours?: number;
69
+ }
70
+
71
+ export interface GraphQueryResult {
72
+ nodes: GraphNode[];
73
+ edges: GraphEdge[];
74
+ term: string;
75
+ budget: number;
76
+ }
77
+
78
+ export interface GraphDiffResult {
79
+ nodes: {
80
+ added: string[];
81
+ removed: string[];
82
+ changed: string[];
83
+ };
84
+ edges: {
85
+ added: string[];
86
+ removed: string[];
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Graph file paths
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function graphsDir(gsdRoot: string): string {
95
+ return join(gsdRoot, 'graphs');
96
+ }
97
+
98
+ function graphJsonPath(gsdRoot: string): string {
99
+ return join(graphsDir(gsdRoot), 'graph.json');
100
+ }
101
+
102
+ function graphTmpPath(gsdRoot: string): string {
103
+ return join(graphsDir(gsdRoot), 'graph.tmp.json');
104
+ }
105
+
106
+ function snapshotPath(gsdRoot: string): string {
107
+ return join(graphsDir(gsdRoot), '.last-build-snapshot.json');
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Parsers — each returns nodes/edges and never throws
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Parse STATE.md for active milestone and phase concepts.
116
+ */
117
+ function parseStateFile(gsdRoot: string, nodes: GraphNode[], _edges: GraphEdge[]): void {
118
+ const statePath = join(gsdRoot, 'STATE.md');
119
+ if (!existsSync(statePath)) return;
120
+
121
+ let content: string;
122
+ try {
123
+ content = readFileSync(statePath, 'utf-8');
124
+ } catch {
125
+ return;
126
+ }
127
+
128
+ // Extract active milestone
129
+ const activeMilestoneMatch = content.match(/\*\*Active Milestone:\*\*\s+([A-Z]\d+):\s+(.+)/i);
130
+ if (activeMilestoneMatch) {
131
+ const [, milestoneId, title] = activeMilestoneMatch;
132
+ const id = `milestone:${milestoneId}`;
133
+ if (!nodes.some((n) => n.id === id)) {
134
+ nodes.push({
135
+ id,
136
+ label: `${milestoneId}: ${title.trim()}`,
137
+ type: 'milestone',
138
+ description: `Active milestone: ${milestoneId}`,
139
+ confidence: 'EXTRACTED',
140
+ sourceFile: 'STATE.md',
141
+ });
142
+ }
143
+ }
144
+
145
+ // Extract phase as concept
146
+ const phaseMatch = content.match(/\*\*Phase:\*\*\s+(\S+)/i);
147
+ if (phaseMatch) {
148
+ const phase = phaseMatch[1].trim();
149
+ nodes.push({
150
+ id: `concept:phase:${phase}`,
151
+ label: `Phase: ${phase}`,
152
+ type: 'concept',
153
+ confidence: 'EXTRACTED',
154
+ sourceFile: 'STATE.md',
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Parse KNOWLEDGE.md for rules, patterns, and lessons.
161
+ */
162
+ function parseKnowledgeFile(gsdRoot: string, nodes: GraphNode[], _edges: GraphEdge[]): void {
163
+ const knowledgePath = join(gsdRoot, 'KNOWLEDGE.md');
164
+ if (!existsSync(knowledgePath)) return;
165
+
166
+ let content: string;
167
+ try {
168
+ content = readFileSync(knowledgePath, 'utf-8');
169
+ } catch {
170
+ return;
171
+ }
172
+
173
+ // Parse Rules table
174
+ const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i);
175
+ if (rulesMatch) {
176
+ for (const line of rulesMatch[1].split('\n')) {
177
+ if (!line.includes('|')) continue;
178
+ const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
179
+ if (cells.length < 3) continue;
180
+ if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue;
181
+ const id = cells[0];
182
+ if (!/^K\d+$/i.test(id)) continue;
183
+ nodes.push({
184
+ id: `rule:${id}`,
185
+ label: id,
186
+ type: 'rule',
187
+ description: cells[2] ?? '',
188
+ confidence: 'EXTRACTED',
189
+ sourceFile: 'KNOWLEDGE.md',
190
+ });
191
+ }
192
+ }
193
+
194
+ // Parse Patterns table
195
+ const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i);
196
+ if (patternsMatch) {
197
+ for (const line of patternsMatch[1].split('\n')) {
198
+ if (!line.includes('|')) continue;
199
+ const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
200
+ if (cells.length < 2) continue;
201
+ if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue;
202
+ const id = cells[0];
203
+ if (!/^P\d+$/i.test(id)) continue;
204
+ nodes.push({
205
+ id: `pattern:${id}`,
206
+ label: id,
207
+ type: 'pattern',
208
+ description: cells[1] ?? '',
209
+ confidence: 'EXTRACTED',
210
+ sourceFile: 'KNOWLEDGE.md',
211
+ });
212
+ }
213
+ }
214
+
215
+ // Parse Lessons Learned table
216
+ const lessonsMatch = content.match(/## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i);
217
+ if (lessonsMatch) {
218
+ for (const line of lessonsMatch[1].split('\n')) {
219
+ if (!line.includes('|')) continue;
220
+ const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
221
+ if (cells.length < 2) continue;
222
+ if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue;
223
+ const id = cells[0];
224
+ if (!/^L\d+$/i.test(id)) continue;
225
+ nodes.push({
226
+ id: `lesson:${id}`,
227
+ label: id,
228
+ type: 'lesson',
229
+ description: cells[1] ?? '',
230
+ confidence: 'EXTRACTED',
231
+ sourceFile: 'KNOWLEDGE.md',
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Parse milestone ROADMAP.md files for milestones and slices.
239
+ */
240
+ function parseMilestoneFiles(
241
+ gsdRoot: string,
242
+ nodes: GraphNode[],
243
+ edges: GraphEdge[],
244
+ ): void {
245
+ const milestoneIds = findMilestoneIds(gsdRoot);
246
+
247
+ for (const milestoneId of milestoneIds) {
248
+ try {
249
+ parseSingleMilestone(gsdRoot, milestoneId, nodes, edges);
250
+ } catch {
251
+ // Skip this milestone on any error
252
+ }
253
+ }
254
+ }
255
+
256
+ function parseSingleMilestone(
257
+ gsdRoot: string,
258
+ milestoneId: string,
259
+ nodes: GraphNode[],
260
+ edges: GraphEdge[],
261
+ ): void {
262
+ const mDir = resolveMilestoneDir(gsdRoot, milestoneId);
263
+ if (!mDir) return;
264
+
265
+ const milestoneNodeId = `milestone:${milestoneId}`;
266
+
267
+ // Try to read the roadmap file
268
+ const roadmapPath = join(mDir, `${milestoneId}-ROADMAP.md`);
269
+ let roadmapContent: string | null = null;
270
+ if (existsSync(roadmapPath)) {
271
+ try {
272
+ roadmapContent = readFileSync(roadmapPath, 'utf-8');
273
+ } catch {
274
+ // Skip
275
+ }
276
+ }
277
+
278
+ // Extract milestone title from roadmap
279
+ let milestoneTitle = milestoneId;
280
+ if (roadmapContent) {
281
+ const titleMatch = roadmapContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m);
282
+ if (titleMatch) milestoneTitle = `${milestoneId}: ${titleMatch[1].trim()}`;
283
+ }
284
+
285
+ // Ensure milestone node exists
286
+ if (!nodes.some((n) => n.id === milestoneNodeId)) {
287
+ nodes.push({
288
+ id: milestoneNodeId,
289
+ label: milestoneTitle,
290
+ type: 'milestone',
291
+ confidence: 'EXTRACTED',
292
+ sourceFile: roadmapContent ? `milestones/${milestoneId}/${milestoneId}-ROADMAP.md` : undefined,
293
+ });
294
+ }
295
+
296
+ // Parse slices from roadmap table or filesystem
297
+ const sliceIds = findSliceIds(gsdRoot, milestoneId);
298
+ for (const sliceId of sliceIds) {
299
+ try {
300
+ parseSingleSlice(gsdRoot, milestoneId, sliceId, milestoneNodeId, nodes, edges);
301
+ } catch {
302
+ // Skip this slice on any error
303
+ }
304
+ }
305
+ }
306
+
307
+ function parseSingleSlice(
308
+ gsdRoot: string,
309
+ milestoneId: string,
310
+ sliceId: string,
311
+ milestoneNodeId: string,
312
+ nodes: GraphNode[],
313
+ edges: GraphEdge[],
314
+ ): void {
315
+ const sDir = resolveSliceDir(gsdRoot, milestoneId, sliceId);
316
+ if (!sDir) return;
317
+
318
+ const sliceNodeId = `slice:${milestoneId}:${sliceId}`;
319
+
320
+ // Try to read the slice plan
321
+ const planPath = join(sDir, `${sliceId}-PLAN.md`);
322
+ let sliceTitle = `${milestoneId}/${sliceId}`;
323
+ let planContent: string | null = null;
324
+
325
+ if (existsSync(planPath)) {
326
+ try {
327
+ planContent = readFileSync(planPath, 'utf-8');
328
+ const titleMatch = planContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m);
329
+ if (titleMatch) sliceTitle = `${sliceId}: ${titleMatch[1].trim()}`;
330
+ } catch {
331
+ // Use default title
332
+ }
333
+ }
334
+
335
+ nodes.push({
336
+ id: sliceNodeId,
337
+ label: sliceTitle,
338
+ type: 'slice',
339
+ confidence: 'EXTRACTED',
340
+ sourceFile: planContent ? `milestones/${milestoneId}/slices/${sliceId}/${sliceId}-PLAN.md` : undefined,
341
+ });
342
+
343
+ // Edge: milestone contains slice
344
+ edges.push({
345
+ from: milestoneNodeId,
346
+ to: sliceNodeId,
347
+ type: 'contains',
348
+ confidence: 'EXTRACTED',
349
+ });
350
+
351
+ // Parse tasks from the slice plan
352
+ if (planContent) {
353
+ parseTasksFromPlan(planContent, milestoneId, sliceId, sliceNodeId, nodes, edges);
354
+ }
355
+ }
356
+
357
+ function parseTasksFromPlan(
358
+ content: string,
359
+ milestoneId: string,
360
+ sliceId: string,
361
+ sliceNodeId: string,
362
+ nodes: GraphNode[],
363
+ edges: GraphEdge[],
364
+ ): void {
365
+ // Match lines like: - [ ] **T01: Title** — description
366
+ const taskPattern = /[-*]\s+\[[ x]\]\s+\*\*(T\d+):\s*([^*]+)\*\*/g;
367
+ let match: RegExpExecArray | null;
368
+
369
+ while ((match = taskPattern.exec(content)) !== null) {
370
+ const [, taskId, taskTitle] = match;
371
+ const taskNodeId = `task:${milestoneId}:${sliceId}:${taskId}`;
372
+
373
+ nodes.push({
374
+ id: taskNodeId,
375
+ label: `${taskId}: ${taskTitle.trim()}`,
376
+ type: 'task',
377
+ confidence: 'EXTRACTED',
378
+ });
379
+
380
+ edges.push({
381
+ from: sliceNodeId,
382
+ to: taskNodeId,
383
+ type: 'contains',
384
+ confidence: 'EXTRACTED',
385
+ });
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // buildGraph
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * Build a KnowledgeGraph by parsing all .gsd/ artifacts.
395
+ *
396
+ * Parse errors in any single artifact are caught — the artifact is skipped
397
+ * and never causes buildGraph() to throw.
398
+ */
399
+ export async function buildGraph(projectDir: string): Promise<KnowledgeGraph> {
400
+ const gsdRoot = resolveGsdRoot(resolve(projectDir));
401
+
402
+ const nodes: GraphNode[] = [];
403
+ const edges: GraphEdge[] = [];
404
+
405
+ // Each parser is wrapped so a crash in one never stops others
406
+ const parsers: Array<(g: string, n: GraphNode[], e: GraphEdge[]) => void> = [
407
+ parseStateFile,
408
+ parseKnowledgeFile,
409
+ parseMilestoneFiles,
410
+ ];
411
+
412
+ for (const parser of parsers) {
413
+ try {
414
+ parser(gsdRoot, nodes, edges);
415
+ } catch {
416
+ // Parsing error — skip this artifact, mark as ambiguous
417
+ nodes.push({
418
+ id: `error:${parser.name}:${Date.now()}`,
419
+ label: `Parse error in ${parser.name}`,
420
+ type: 'concept',
421
+ confidence: 'AMBIGUOUS',
422
+ });
423
+ }
424
+ }
425
+
426
+ // Deduplicate nodes by id (keep first occurrence)
427
+ const seen = new Set<string>();
428
+ const dedupedNodes = nodes.filter((n) => {
429
+ if (seen.has(n.id)) return false;
430
+ seen.add(n.id);
431
+ return true;
432
+ });
433
+
434
+ return {
435
+ nodes: dedupedNodes,
436
+ edges,
437
+ builtAt: new Date().toISOString(),
438
+ };
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // writeGraph — atomic write via tmp + rename
443
+ // ---------------------------------------------------------------------------
444
+
445
+ /**
446
+ * Write the graph to .gsd/graphs/graph.json atomically.
447
+ *
448
+ * Writes to graph.tmp.json first, then renames to graph.json.
449
+ * Creates the graphs/ directory if it does not exist.
450
+ */
451
+ export async function writeGraph(gsdRoot: string, graph: KnowledgeGraph): Promise<void> {
452
+ const dir = graphsDir(gsdRoot);
453
+ mkdirSync(dir, { recursive: true });
454
+
455
+ const tmp = graphTmpPath(gsdRoot);
456
+ const final = graphJsonPath(gsdRoot);
457
+
458
+ writeFileSync(tmp, JSON.stringify(graph, null, 2), 'utf-8');
459
+ renameSync(tmp, final);
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // writeSnapshot
464
+ // ---------------------------------------------------------------------------
465
+
466
+ /**
467
+ * Copy the current graph.json to .last-build-snapshot.json.
468
+ * Adds a snapshotAt timestamp to the copy.
469
+ */
470
+ export async function writeSnapshot(gsdRoot: string): Promise<void> {
471
+ const src = graphJsonPath(gsdRoot);
472
+ if (!existsSync(src)) return;
473
+
474
+ const dir = graphsDir(gsdRoot);
475
+ mkdirSync(dir, { recursive: true });
476
+
477
+ const raw = readFileSync(src, 'utf-8');
478
+ let graph: KnowledgeGraph;
479
+ try {
480
+ graph = JSON.parse(raw) as KnowledgeGraph;
481
+ } catch {
482
+ return;
483
+ }
484
+ const snapshot = { ...graph, snapshotAt: new Date().toISOString() };
485
+
486
+ writeFileSync(snapshotPath(gsdRoot), JSON.stringify(snapshot, null, 2), 'utf-8');
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // graphStatus
491
+ // ---------------------------------------------------------------------------
492
+
493
+ /**
494
+ * Return status of the graph: whether it exists, its age, and whether it is stale.
495
+ * Stale means builtAt is older than 24 hours.
496
+ */
497
+ export async function graphStatus(projectDir: string): Promise<GraphStatusResult> {
498
+ const gsdRoot = resolveGsdRoot(resolve(projectDir));
499
+ const graphPath = graphJsonPath(gsdRoot);
500
+
501
+ if (!existsSync(graphPath)) {
502
+ return { exists: false };
503
+ }
504
+
505
+ try {
506
+ const raw = readFileSync(graphPath, 'utf-8');
507
+ const graph = JSON.parse(raw) as KnowledgeGraph;
508
+
509
+ const builtAt = graph.builtAt;
510
+ const ageMs = Date.now() - new Date(builtAt).getTime();
511
+ const ageHours = ageMs / (1000 * 60 * 60);
512
+ const stale = ageHours > 24;
513
+
514
+ return {
515
+ exists: true,
516
+ lastBuild: builtAt,
517
+ nodeCount: graph.nodes.length,
518
+ edgeCount: graph.edges.length,
519
+ stale,
520
+ ageHours,
521
+ };
522
+ } catch {
523
+ return { exists: false };
524
+ }
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // applyBudget — trim edges to stay within token budget
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Given a set of seed node IDs and the full graph, apply BFS to collect
533
+ * reachable nodes and edges. Trims AMBIGUOUS edges first, then INFERRED,
534
+ * stopping when the estimated token count drops within budget.
535
+ *
536
+ * Budget is a rough token estimate: 1 node ≈ 20 tokens, 1 edge ≈ 10 tokens.
537
+ */
538
+ function applyBudget(
539
+ graph: KnowledgeGraph,
540
+ seedIds: Set<string>,
541
+ budget: number,
542
+ ): { nodes: GraphNode[]; edges: GraphEdge[] } {
543
+ // BFS to collect reachable nodes (start from seeds)
544
+ const reachable = new Set<string>(seedIds);
545
+ const queue = [...seedIds];
546
+
547
+ while (queue.length > 0) {
548
+ const current = queue.shift()!;
549
+ for (const edge of graph.edges) {
550
+ if (edge.from === current && !reachable.has(edge.to)) {
551
+ reachable.add(edge.to);
552
+ queue.push(edge.to);
553
+ }
554
+ }
555
+ }
556
+
557
+ let resultNodes = graph.nodes.filter((n) => reachable.has(n.id));
558
+ let resultEdges = graph.edges.filter(
559
+ (e) => reachable.has(e.from) && reachable.has(e.to),
560
+ );
561
+
562
+ // Estimate tokens and trim if over budget
563
+ // Trim AMBIGUOUS edges first, then INFERRED
564
+ const estimate = (): number =>
565
+ resultNodes.length * 20 + resultEdges.length * 10;
566
+
567
+ if (estimate() > budget) {
568
+ resultEdges = resultEdges.filter((e) => e.confidence !== 'AMBIGUOUS');
569
+ }
570
+ if (estimate() > budget) {
571
+ resultEdges = resultEdges.filter((e) => e.confidence !== 'INFERRED');
572
+ }
573
+ if (estimate() > budget) {
574
+ // Hard trim — keep only seed nodes and their EXTRACTED edges
575
+ const seedNodes = resultNodes.filter((n) => seedIds.has(n.id));
576
+ const seedEdges = resultEdges.filter(
577
+ (e) => seedIds.has(e.from) && e.confidence === 'EXTRACTED',
578
+ );
579
+ return { nodes: seedNodes, edges: seedEdges };
580
+ }
581
+
582
+ return { nodes: resultNodes, edges: resultEdges };
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // graphQuery
587
+ // ---------------------------------------------------------------------------
588
+
589
+ /**
590
+ * Query the graph for nodes matching a term (case-insensitive on label + description).
591
+ * BFS from seed nodes, applying budget trimming.
592
+ *
593
+ * Reads from the pre-built graph.json. Falls back to an empty result if no
594
+ * graph exists.
595
+ */
596
+ export async function graphQuery(
597
+ projectDir: string,
598
+ term: string,
599
+ budget = 4000,
600
+ ): Promise<GraphQueryResult> {
601
+ const gsdRoot = resolveGsdRoot(resolve(projectDir));
602
+ const graphPath = graphJsonPath(gsdRoot);
603
+
604
+ if (!existsSync(graphPath)) {
605
+ return { nodes: [], edges: [], term, budget };
606
+ }
607
+
608
+ let graph: KnowledgeGraph;
609
+ try {
610
+ const raw = readFileSync(graphPath, 'utf-8');
611
+ graph = JSON.parse(raw) as KnowledgeGraph;
612
+ } catch {
613
+ return { nodes: [], edges: [], term, budget };
614
+ }
615
+
616
+ if (!term || term.trim() === '') {
617
+ // Empty term — return empty result
618
+ return { nodes: [], edges: [], term, budget };
619
+ }
620
+
621
+ const lower = term.toLowerCase();
622
+
623
+ // Find seed nodes that match the term
624
+ const seedIds = new Set<string>(
625
+ graph.nodes
626
+ .filter((n) => {
627
+ const labelMatch = n.label.toLowerCase().includes(lower);
628
+ const descMatch = n.description?.toLowerCase().includes(lower) ?? false;
629
+ return labelMatch || descMatch;
630
+ })
631
+ .map((n) => n.id),
632
+ );
633
+
634
+ if (seedIds.size === 0) {
635
+ return { nodes: [], edges: [], term, budget };
636
+ }
637
+
638
+ const result = applyBudget(graph, seedIds, budget);
639
+ return { ...result, term, budget };
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // graphDiff
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /**
647
+ * Compare the current graph.json with .last-build-snapshot.json.
648
+ * Returns added/removed/changed nodes and added/removed edges.
649
+ *
650
+ * If no snapshot exists, returns empty diff arrays.
651
+ */
652
+ export async function graphDiff(projectDir: string): Promise<GraphDiffResult> {
653
+ const gsdRoot = resolveGsdRoot(resolve(projectDir));
654
+ const empty: GraphDiffResult = {
655
+ nodes: { added: [], removed: [], changed: [] },
656
+ edges: { added: [], removed: [] },
657
+ };
658
+
659
+ const graphPath = graphJsonPath(gsdRoot);
660
+ const snap = snapshotPath(gsdRoot);
661
+
662
+ if (!existsSync(graphPath)) return empty;
663
+ if (!existsSync(snap)) return empty;
664
+
665
+ let current: KnowledgeGraph;
666
+ let snapshot: KnowledgeGraph;
667
+
668
+ try {
669
+ current = JSON.parse(readFileSync(graphPath, 'utf-8')) as KnowledgeGraph;
670
+ } catch {
671
+ return empty;
672
+ }
673
+
674
+ try {
675
+ snapshot = JSON.parse(readFileSync(snap, 'utf-8')) as KnowledgeGraph;
676
+ } catch {
677
+ return empty;
678
+ }
679
+
680
+ const currentNodeIds = new Set(current.nodes.map((n) => n.id));
681
+ const snapshotNodeIds = new Set(snapshot.nodes.map((n) => n.id));
682
+
683
+ const added = current.nodes.filter((n) => !snapshotNodeIds.has(n.id)).map((n) => n.id);
684
+ const removed = snapshot.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id);
685
+
686
+ // Changed: same id but different label or description
687
+ const snapshotNodeMap = new Map(snapshot.nodes.map((n) => [n.id, n]));
688
+ const changed = current.nodes
689
+ .filter((n) => {
690
+ const snap = snapshotNodeMap.get(n.id);
691
+ if (!snap) return false;
692
+ return n.label !== snap.label || n.description !== snap.description;
693
+ })
694
+ .map((n) => n.id);
695
+
696
+ // Edges — compare by string key "from->to:type"
697
+ const edgeKey = (e: GraphEdge): string => `${e.from}->${e.to}:${e.type}`;
698
+ const currentEdgeKeys = new Set(current.edges.map(edgeKey));
699
+ const snapshotEdgeKeys = new Set(snapshot.edges.map(edgeKey));
700
+
701
+ const edgesAdded = current.edges.filter((e) => !snapshotEdgeKeys.has(edgeKey(e))).map(edgeKey);
702
+ const edgesRemoved = snapshot.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e))).map(edgeKey);
703
+
704
+ return {
705
+ nodes: { added, removed, changed },
706
+ edges: { added: edgesAdded, removed: edgesRemoved },
707
+ };
708
+ }
@@ -14,3 +14,15 @@ export { readKnowledge } from './knowledge.js';
14
14
  export type { KnowledgeResult, KnowledgeEntry } from './knowledge.js';
15
15
  export { runDoctorLite } from './doctor-lite.js';
16
16
  export type { DoctorResult, DoctorIssue } from './doctor-lite.js';
17
+ export { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './graph.js';
18
+ export type {
19
+ NodeType,
20
+ EdgeType,
21
+ ConfidenceTier,
22
+ GraphNode,
23
+ GraphEdge,
24
+ KnowledgeGraph,
25
+ GraphStatusResult,
26
+ GraphQueryResult,
27
+ GraphDiffResult,
28
+ } from './graph.js';