@massu/core 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/commands/_shared-preamble.md +76 -0
  2. package/commands/massu-audit-deps.md +211 -0
  3. package/commands/massu-changelog.md +174 -0
  4. package/commands/massu-cleanup.md +315 -0
  5. package/commands/massu-commit.md +481 -0
  6. package/commands/massu-create-plan.md +752 -0
  7. package/commands/massu-dead-code.md +131 -0
  8. package/commands/massu-debug.md +484 -0
  9. package/commands/massu-deploy.md +91 -0
  10. package/commands/massu-deps.md +374 -0
  11. package/commands/massu-doc-gen.md +279 -0
  12. package/commands/massu-docs.md +364 -0
  13. package/commands/massu-estimate.md +313 -0
  14. package/commands/massu-golden-path.md +973 -0
  15. package/commands/massu-guide.md +167 -0
  16. package/commands/massu-hotfix.md +480 -0
  17. package/commands/massu-loop-playwright.md +837 -0
  18. package/commands/massu-loop.md +775 -0
  19. package/commands/massu-new-feature.md +511 -0
  20. package/commands/massu-parity.md +214 -0
  21. package/commands/massu-plan.md +456 -0
  22. package/commands/massu-push-light.md +207 -0
  23. package/commands/massu-push.md +434 -0
  24. package/commands/massu-refactor.md +410 -0
  25. package/commands/massu-release.md +363 -0
  26. package/commands/massu-review.md +238 -0
  27. package/commands/massu-simplify.md +281 -0
  28. package/commands/massu-status.md +278 -0
  29. package/commands/massu-tdd.md +201 -0
  30. package/commands/massu-test.md +516 -0
  31. package/commands/massu-verify-playwright.md +281 -0
  32. package/commands/massu-verify.md +667 -0
  33. package/dist/cli.js +7772 -3140
  34. package/dist/hooks/cost-tracker.js +103 -40
  35. package/dist/hooks/post-edit-context.js +74 -8
  36. package/dist/hooks/post-tool-use.js +268 -106
  37. package/dist/hooks/pre-compact.js +167 -43
  38. package/dist/hooks/pre-delete-check.js +159 -42
  39. package/dist/hooks/quality-event.js +103 -40
  40. package/dist/hooks/security-gate.js +29 -0
  41. package/dist/hooks/session-end.js +143 -84
  42. package/dist/hooks/session-start.js +186 -49
  43. package/dist/hooks/user-prompt.js +189 -43
  44. package/package.json +10 -15
  45. package/src/adr-generator.ts +9 -2
  46. package/src/analytics.ts +9 -3
  47. package/src/audit-trail.ts +10 -3
  48. package/src/backfill-sessions.ts +5 -4
  49. package/src/cli.ts +6 -0
  50. package/src/cloud-sync.ts +14 -18
  51. package/src/commands/doctor.ts +193 -6
  52. package/src/commands/init.ts +230 -5
  53. package/src/commands/install-commands.ts +137 -0
  54. package/src/config.ts +68 -2
  55. package/src/cost-tracker.ts +11 -6
  56. package/src/db.ts +115 -2
  57. package/src/dependency-scorer.ts +9 -2
  58. package/src/docs-tools.ts +21 -16
  59. package/src/hooks/post-edit-context.ts +4 -4
  60. package/src/hooks/post-tool-use.ts +130 -0
  61. package/src/hooks/pre-compact.ts +23 -1
  62. package/src/hooks/pre-delete-check.ts +92 -4
  63. package/src/hooks/security-gate.ts +32 -0
  64. package/src/hooks/session-end.ts +3 -3
  65. package/src/hooks/session-start.ts +99 -6
  66. package/src/hooks/user-prompt.ts +46 -1
  67. package/src/import-resolver.ts +2 -1
  68. package/src/knowledge-db.ts +169 -0
  69. package/src/knowledge-indexer.ts +704 -0
  70. package/src/knowledge-tools.ts +1413 -0
  71. package/src/license.ts +482 -0
  72. package/src/memory-db.ts +1364 -23
  73. package/src/memory-tools.ts +14 -15
  74. package/src/observability-tools.ts +13 -2
  75. package/src/observation-extractor.ts +11 -4
  76. package/src/page-deps.ts +3 -2
  77. package/src/prompt-analyzer.ts +9 -2
  78. package/src/python/coupling-detector.ts +124 -0
  79. package/src/python/domain-enforcer.ts +83 -0
  80. package/src/python/impact-analyzer.ts +95 -0
  81. package/src/python/import-parser.ts +244 -0
  82. package/src/python/import-resolver.ts +135 -0
  83. package/src/python/migration-indexer.ts +115 -0
  84. package/src/python/migration-parser.ts +332 -0
  85. package/src/python/model-indexer.ts +70 -0
  86. package/src/python/model-parser.ts +279 -0
  87. package/src/python/route-indexer.ts +58 -0
  88. package/src/python/route-parser.ts +317 -0
  89. package/src/python-tools.ts +629 -0
  90. package/src/regression-detector.ts +9 -3
  91. package/src/security-scorer.ts +9 -2
  92. package/src/sentinel-db.ts +45 -89
  93. package/src/sentinel-tools.ts +8 -11
  94. package/src/server.ts +29 -7
  95. package/src/session-archiver.ts +4 -5
  96. package/src/team-knowledge.ts +9 -2
  97. package/src/tools.ts +1032 -44
  98. package/src/validate-features-runner.ts +0 -1
  99. package/src/validation-engine.ts +9 -2
  100. package/README.md +0 -40
  101. package/dist/server.js +0 -7008
  102. package/src/__tests__/adr-generator.test.ts +0 -260
  103. package/src/__tests__/analytics.test.ts +0 -282
  104. package/src/__tests__/audit-trail.test.ts +0 -382
  105. package/src/__tests__/backfill-sessions.test.ts +0 -690
  106. package/src/__tests__/cli.test.ts +0 -290
  107. package/src/__tests__/cloud-sync.test.ts +0 -261
  108. package/src/__tests__/config-sections.test.ts +0 -359
  109. package/src/__tests__/config.test.ts +0 -732
  110. package/src/__tests__/cost-tracker.test.ts +0 -348
  111. package/src/__tests__/db.test.ts +0 -177
  112. package/src/__tests__/dependency-scorer.test.ts +0 -325
  113. package/src/__tests__/docs-integration.test.ts +0 -178
  114. package/src/__tests__/docs-tools.test.ts +0 -199
  115. package/src/__tests__/domains.test.ts +0 -236
  116. package/src/__tests__/hooks.test.ts +0 -221
  117. package/src/__tests__/import-resolver.test.ts +0 -95
  118. package/src/__tests__/integration/path-traversal.test.ts +0 -134
  119. package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
  120. package/src/__tests__/integration/tool-registration.test.ts +0 -146
  121. package/src/__tests__/memory-db.test.ts +0 -404
  122. package/src/__tests__/memory-enhancements.test.ts +0 -316
  123. package/src/__tests__/memory-tools.test.ts +0 -199
  124. package/src/__tests__/middleware-tree.test.ts +0 -177
  125. package/src/__tests__/observability-tools.test.ts +0 -595
  126. package/src/__tests__/observability.test.ts +0 -437
  127. package/src/__tests__/observation-extractor.test.ts +0 -167
  128. package/src/__tests__/page-deps.test.ts +0 -60
  129. package/src/__tests__/prompt-analyzer.test.ts +0 -298
  130. package/src/__tests__/regression-detector.test.ts +0 -295
  131. package/src/__tests__/rules.test.ts +0 -87
  132. package/src/__tests__/schema-mapper.test.ts +0 -29
  133. package/src/__tests__/security-scorer.test.ts +0 -238
  134. package/src/__tests__/security-utils.test.ts +0 -175
  135. package/src/__tests__/sentinel-db.test.ts +0 -491
  136. package/src/__tests__/sentinel-scanner.test.ts +0 -750
  137. package/src/__tests__/sentinel-tools.test.ts +0 -324
  138. package/src/__tests__/sentinel-types.test.ts +0 -750
  139. package/src/__tests__/server.test.ts +0 -452
  140. package/src/__tests__/session-archiver.test.ts +0 -524
  141. package/src/__tests__/session-state-generator.test.ts +0 -900
  142. package/src/__tests__/team-knowledge.test.ts +0 -327
  143. package/src/__tests__/tools.test.ts +0 -340
  144. package/src/__tests__/transcript-parser.test.ts +0 -195
  145. package/src/__tests__/trpc-index.test.ts +0 -25
  146. package/src/__tests__/validate-features-runner.test.ts +0 -517
  147. package/src/__tests__/validation-engine.test.ts +0 -300
  148. package/src/core-tools.ts +0 -685
  149. package/src/memory-queries.ts +0 -804
  150. package/src/memory-schema.ts +0 -546
  151. package/src/tool-helpers.ts +0 -41
@@ -0,0 +1,704 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import type Database from 'better-sqlite3';
5
+ import { createHash } from 'crypto';
6
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
7
+ import { resolve, relative, basename, extname } from 'path';
8
+ import { getConfig, getResolvedPaths, getProjectRoot } from './config.ts';
9
+
10
+ // ============================================================
11
+ // Types
12
+ // ============================================================
13
+
14
+ interface IndexStats {
15
+ filesIndexed: number;
16
+ chunksCreated: number;
17
+ edgesCreated: number;
18
+ }
19
+
20
+ interface CRRule {
21
+ rule_id: string;
22
+ rule_text: string;
23
+ vr_type: string;
24
+ reference_path: string;
25
+ }
26
+
27
+ interface VRType {
28
+ vr_type: string;
29
+ command: string;
30
+ expected: string;
31
+ use_when: string;
32
+ catches?: string;
33
+ category?: string;
34
+ }
35
+
36
+ interface IncidentRow {
37
+ incident_num: number;
38
+ date: string;
39
+ type: string;
40
+ gap_found: string;
41
+ prevention: string;
42
+ cr_added?: string;
43
+ }
44
+
45
+ interface SchemaMismatch {
46
+ table_name: string;
47
+ wrong_column: string;
48
+ correct_column: string;
49
+ }
50
+
51
+ interface Section {
52
+ heading: string;
53
+ content: string;
54
+ line_start: number;
55
+ line_end: number;
56
+ }
57
+
58
+ // ============================================================
59
+ // Resolved Knowledge Paths
60
+ // ============================================================
61
+
62
+ /**
63
+ * Get resolved paths for knowledge indexing.
64
+ * These are derived from config and project root, not hardcoded.
65
+ */
66
+ function getKnowledgePaths() {
67
+ const resolved = getResolvedPaths();
68
+ const config = getConfig();
69
+ const root = getProjectRoot();
70
+
71
+ return {
72
+ /** .claude/ directory at project root (config-driven) */
73
+ claudeDir: resolved.claudeDir,
74
+
75
+ /** Claude memory directory (user-level, project-scoped, config-driven) */
76
+ memoryDir: resolved.memoryDir,
77
+
78
+ /** Plans directory (config-driven) */
79
+ plansDir: resolved.plansDir,
80
+
81
+ /** Docs directory (config-driven) */
82
+ docsDir: resolved.docsDir,
83
+
84
+ /** Knowledge database path (config-driven) */
85
+ knowledgeDbPath: resolved.knowledgeDbPath,
86
+
87
+ /** Project root */
88
+ projectRoot: root,
89
+
90
+ /** Project name */
91
+ projectName: config.project.name,
92
+ };
93
+ }
94
+
95
+ // ============================================================
96
+ // File Discovery
97
+ // ============================================================
98
+
99
+ function discoverMarkdownFiles(baseDir: string): string[] {
100
+ const files: string[] = [];
101
+ function walk(dir: string): void {
102
+ try {
103
+ const entries = readdirSync(dir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const fullPath = resolve(dir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ // Skip session-state/archive (ephemeral, 80+ files)
108
+ if (entry.name === 'archive' && dir.includes('session-state')) continue;
109
+ // Skip status/archive
110
+ if (entry.name === 'archive' && dir.includes('status')) continue;
111
+ // Skip node_modules
112
+ if (entry.name === 'node_modules') continue;
113
+ walk(fullPath);
114
+ } else if (entry.isFile() && extname(entry.name) === '.md') {
115
+ files.push(fullPath);
116
+ }
117
+ }
118
+ } catch {
119
+ // Directory may not exist
120
+ }
121
+ }
122
+ walk(baseDir);
123
+ return files;
124
+ }
125
+
126
+ export function categorizeFile(filePath: string): string {
127
+ const paths = getKnowledgePaths();
128
+
129
+ // Plan and docs paths checked FIRST — external dirs produce bad relative paths from .claude/
130
+ if (filePath.startsWith(paths.plansDir)) return 'plan';
131
+
132
+ // Categorize docs subdirectories
133
+ if (filePath.startsWith(paths.docsDir)) {
134
+ const relFromDocs = relative(paths.docsDir, filePath).replace(/\\/g, '/').toLowerCase();
135
+ if (relFromDocs.startsWith('plans/')) return 'plan';
136
+ if (relFromDocs.includes('architecture')) return 'architecture';
137
+ if (relFromDocs.includes('security')) return 'security';
138
+ if (relFromDocs.includes('deployment')) return 'deployment';
139
+ if (relFromDocs.includes('testing')) return 'testing';
140
+ if (relFromDocs.includes('database')) return 'database-docs';
141
+ if (relFromDocs.includes('audits') || relFromDocs.includes('audit')) return 'audit';
142
+ if (relFromDocs.includes('analysis')) return 'analysis';
143
+ if (relFromDocs.includes('development-intelligence')) return 'dev-intelligence';
144
+ if (relFromDocs.includes('reports')) return 'reports';
145
+ if (relFromDocs.includes('strategy')) return 'strategy';
146
+ return 'docs';
147
+ }
148
+
149
+ // Memory directory (user-level Claude memory)
150
+ const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
151
+ if (filePath.includes(`${claudeDirName}/projects/`) && filePath.includes('/memory/')) return 'memory';
152
+
153
+ const rel = relative(paths.claudeDir, filePath).replace(/\\/g, '/');
154
+ const firstDir = rel.split('/')[0];
155
+ const knownCategories = getConfig().conventions?.knowledgeCategories ?? [
156
+ 'patterns', 'commands', 'incidents', 'reference', 'protocols',
157
+ 'checklists', 'playbooks', 'critical', 'scripts', 'status',
158
+ 'templates', 'loop-state', 'session-state', 'agents',
159
+ ];
160
+ if (knownCategories.includes(firstDir)) return firstDir;
161
+ // Files at .claude/ root (like CLAUDE.md)
162
+ return 'root';
163
+ }
164
+
165
+ function hashContent(content: string): string {
166
+ return createHash('sha256').update(content).digest('hex');
167
+ }
168
+
169
+ // ============================================================
170
+ // Markdown Parsers
171
+ // ============================================================
172
+
173
+ export function parseCRTable(content: string): CRRule[] {
174
+ const rules: CRRule[] = [];
175
+ // Match CR table rows: | CR-N | Rule text | VR-* | reference |
176
+ const tableRegex = /\|\s*(CR-\d+)\s*\|\s*([^|]+)\|\s*([^|]+)\|\s*\[?([^\]|]+)\]?[^|]*\|/g;
177
+ let match: RegExpExecArray | null;
178
+ while ((match = tableRegex.exec(content)) !== null) {
179
+ rules.push({
180
+ rule_id: match[1].trim(),
181
+ rule_text: match[2].trim(),
182
+ vr_type: match[3].trim(),
183
+ reference_path: match[4].trim().replace(/\(.*\)/, '').trim(),
184
+ });
185
+ }
186
+ return rules;
187
+ }
188
+
189
+ export function parseVRTable(content: string): VRType[] {
190
+ const types: VRType[] = [];
191
+ // Match VR table rows: | VR-* | `command` | expected | use when |
192
+ const tableRegex = /\|\s*(VR-[\w-]+)\s*\|\s*`([^`]+)`\s*\|\s*([^|]+)\|\s*([^|]+)\|/g;
193
+ let match: RegExpExecArray | null;
194
+ while ((match = tableRegex.exec(content)) !== null) {
195
+ types.push({
196
+ vr_type: match[1].trim(),
197
+ command: match[2].trim(),
198
+ expected: match[3].trim(),
199
+ use_when: match[4].trim(),
200
+ });
201
+ }
202
+ return types;
203
+ }
204
+
205
+ export function parseIncidents(content: string): IncidentRow[] {
206
+ const incidents: IncidentRow[] = [];
207
+ // Match incident summary table rows: | N | date | type | gap | prevention |
208
+ const tableRegex = /\|\s*(\d+)\s*\|\s*([^|]+)\|\s*([^|]+)\|\s*([^|]+)\|\s*([^|]+)\|/g;
209
+ let match: RegExpExecArray | null;
210
+ while ((match = tableRegex.exec(content)) !== null) {
211
+ const num = parseInt(match[1].trim(), 10);
212
+ if (isNaN(num) || num === 0) continue; // Skip header
213
+ incidents.push({
214
+ incident_num: num,
215
+ date: match[2].trim(),
216
+ type: match[3].trim(),
217
+ gap_found: match[4].trim(),
218
+ prevention: match[5].trim(),
219
+ });
220
+ }
221
+ return incidents;
222
+ }
223
+
224
+ export function parseSchemaMismatches(content: string): SchemaMismatch[] {
225
+ const mismatches: SchemaMismatch[] = [];
226
+ // Match: | table_name | wrong_column | correct_column |
227
+ // Look for the specific "Known Schema Mismatches" section (stop at next H2/H3 heading, not at ---)
228
+ const sectionMatch = content.match(/### Known Schema Mismatches[\s\S]*?(?=\n##\s|\n---\n|$)/);
229
+ if (!sectionMatch) return mismatches;
230
+
231
+ const section = sectionMatch[0];
232
+ // Match table data rows: | word | word | word | (skips header/separator via word-char check)
233
+ const rowRegex = /\|\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(\w+)\s*\|/g;
234
+ let match: RegExpExecArray | null;
235
+ while ((match = rowRegex.exec(section)) !== null) {
236
+ // Skip header row (has "Table" or "WRONG" etc.)
237
+ if (match[1] === 'Table' || match[2] === 'WRONG' || match[1].startsWith('-')) continue;
238
+ mismatches.push({
239
+ table_name: match[1].trim(),
240
+ wrong_column: match[2].trim(),
241
+ correct_column: match[3].trim(),
242
+ });
243
+ }
244
+ return mismatches;
245
+ }
246
+
247
+ export function parseSections(content: string, _filePath: string): Section[] {
248
+ if (!content.trim()) return [];
249
+
250
+ const sections: Section[] = [];
251
+ const lines = content.split('\n');
252
+ let currentHeading = '';
253
+ let currentContent: string[] = [];
254
+ let currentStart = 1;
255
+
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const line = lines[i];
258
+ const headingMatch = line.match(/^(#{2,3})\s+(.+)/);
259
+ if (headingMatch) {
260
+ // Save previous section
261
+ if (currentContent.length > 0) {
262
+ sections.push({
263
+ heading: currentHeading,
264
+ content: currentContent.join('\n').trim(),
265
+ line_start: currentStart,
266
+ line_end: i,
267
+ });
268
+ }
269
+ currentHeading = headingMatch[2].trim();
270
+ currentContent = [];
271
+ currentStart = i + 1;
272
+ } else {
273
+ currentContent.push(line);
274
+ }
275
+ }
276
+
277
+ // Last section
278
+ if (currentContent.length > 0) {
279
+ sections.push({
280
+ heading: currentHeading,
281
+ content: currentContent.join('\n').trim(),
282
+ line_start: currentStart,
283
+ line_end: lines.length,
284
+ });
285
+ }
286
+
287
+ return sections;
288
+ }
289
+
290
+ // ============================================================
291
+ // Corrections Parser
292
+ // ============================================================
293
+
294
+ export interface CorrectionEntry {
295
+ date: string;
296
+ title: string;
297
+ wrong: string;
298
+ correction: string;
299
+ rule: string;
300
+ cr_rule?: string;
301
+ }
302
+
303
+ export function parseCorrections(content: string): CorrectionEntry[] {
304
+ const entries: CorrectionEntry[] = [];
305
+ const entryRegex = /### (\d{4}-\d{2}-\d{2}) - ([^\n]+)\n([\s\S]*?)(?=\n### |\n## |$)/g;
306
+ let match;
307
+ while ((match = entryRegex.exec(content)) !== null) {
308
+ const block = match[0];
309
+ const date = match[1];
310
+ const title = match[2];
311
+ const wrong = block.match(/\*\*Wrong\*\*:\s*(.+)/)?.[1] || '';
312
+ const correction = block.match(/\*\*Correction\*\*:\s*(.+)/)?.[1] || '';
313
+ const rule = block.match(/\*\*Rule\*\*:\s*(.+)/)?.[1] || '';
314
+ const cr = block.match(/\*\*CR\*\*:\s*(CR-\d+)/)?.[1];
315
+ entries.push({ date, title, wrong, correction, rule, cr_rule: cr });
316
+ }
317
+ return entries;
318
+ }
319
+
320
+ function extractTitle(content: string, filePath: string): string {
321
+ const h1Match = content.match(/^#\s+(.+)/m);
322
+ if (h1Match) return h1Match[1].trim();
323
+ return basename(filePath, '.md');
324
+ }
325
+
326
+ function extractDescription(content: string): string | null {
327
+ // Try frontmatter description
328
+ const fmMatch = content.match(/^---\s*\n[\s\S]*?description:\s*"?([^"\n]+)"?\s*\n[\s\S]*?---/);
329
+ if (fmMatch) return fmMatch[1].trim();
330
+ // First non-heading, non-empty paragraph
331
+ const lines = content.split('\n');
332
+ for (const line of lines) {
333
+ const trimmed = line.trim();
334
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---') && !trimmed.startsWith('|') && trimmed.length > 20) {
335
+ return trimmed.substring(0, 200);
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+
341
+ // ============================================================
342
+ // Cross-Reference Builder
343
+ // ============================================================
344
+
345
+ export function buildCrossReferences(db: Database.Database): number {
346
+ let edgeCount = 0;
347
+ const insertEdge = db.prepare(
348
+ 'INSERT OR IGNORE INTO knowledge_edges (source_type, source_id, target_type, target_id, edge_type) VALUES (?, ?, ?, ?, ?)'
349
+ );
350
+
351
+ // CR -> VR edges (from knowledge_rules)
352
+ const rules = db.prepare('SELECT rule_id, vr_type, reference_path FROM knowledge_rules').all() as CRRule[];
353
+ for (const rule of rules) {
354
+ if (rule.vr_type && rule.vr_type !== 'VR-*') {
355
+ // Split compound VR types (e.g., "VR-SCHEMA" or "VR-*")
356
+ const vrTypes = rule.vr_type.split(/[,\s]+/).filter(v => v.startsWith('VR-'));
357
+ for (const vr of vrTypes) {
358
+ insertEdge.run('cr', rule.rule_id, 'vr', vr, 'enforced_by');
359
+ edgeCount++;
360
+ }
361
+ }
362
+ if (rule.reference_path) {
363
+ const patternName = basename(rule.reference_path, '.md');
364
+ insertEdge.run('cr', rule.rule_id, 'pattern', patternName, 'references');
365
+ edgeCount++;
366
+ }
367
+ }
368
+
369
+ // Incident -> CR edges (from knowledge_incidents)
370
+ const incidents = db.prepare('SELECT incident_num, cr_added FROM knowledge_incidents WHERE cr_added IS NOT NULL').all() as { incident_num: number; cr_added: string }[];
371
+ for (const inc of incidents) {
372
+ if (inc.cr_added) {
373
+ const crIds = inc.cr_added.match(/CR-\d+/g) || [];
374
+ for (const crId of crIds) {
375
+ insertEdge.run('incident', String(inc.incident_num), 'cr', crId, 'caused');
376
+ edgeCount++;
377
+ }
378
+ }
379
+ }
380
+
381
+ // Scan all chunks for cross-references
382
+ const chunks = db.prepare('SELECT id, content, metadata FROM knowledge_chunks').all() as { id: number; content: string; metadata: string }[];
383
+ for (const chunk of chunks) {
384
+ const text = chunk.content;
385
+
386
+ // Find CR references in content
387
+ const crRefs = text.match(/CR-\d+/g);
388
+ if (crRefs) {
389
+ for (const cr of [...new Set(crRefs)]) {
390
+ insertEdge.run('chunk', String(chunk.id), 'cr', cr, 'references');
391
+ edgeCount++;
392
+ }
393
+ }
394
+
395
+ // Find VR references
396
+ const vrRefs = text.match(/VR-[\w-]+/g);
397
+ if (vrRefs) {
398
+ for (const vr of [...new Set(vrRefs)]) {
399
+ insertEdge.run('chunk', String(chunk.id), 'vr', vr, 'references');
400
+ edgeCount++;
401
+ }
402
+ }
403
+
404
+ // Find incident references
405
+ const incRefs = text.match(/Incident #(\d+)/gi);
406
+ if (incRefs) {
407
+ for (const ref of incRefs) {
408
+ const numMatch = ref.match(/\d+/);
409
+ if (numMatch) {
410
+ insertEdge.run('chunk', String(chunk.id), 'incident', numMatch[0], 'references');
411
+ edgeCount++;
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ return edgeCount;
418
+ }
419
+
420
+ // ============================================================
421
+ // Indexer Functions
422
+ // ============================================================
423
+
424
+ export function indexAllKnowledge(db: Database.Database): IndexStats {
425
+ const stats: IndexStats = { filesIndexed: 0, chunksCreated: 0, edgesCreated: 0 };
426
+ const paths = getKnowledgePaths();
427
+
428
+ const insertDoc = db.prepare(
429
+ 'INSERT INTO knowledge_documents (file_path, category, title, description, content_hash, indexed_at, indexed_at_epoch) VALUES (?, ?, ?, ?, ?, ?, ?)'
430
+ );
431
+ const insertChunk = db.prepare(
432
+ 'INSERT INTO knowledge_chunks (document_id, chunk_type, heading, content, line_start, line_end, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)'
433
+ );
434
+ const insertRule = db.prepare(
435
+ 'INSERT OR IGNORE INTO knowledge_rules (rule_id, rule_text, vr_type, reference_path) VALUES (?, ?, ?, ?)'
436
+ );
437
+ const insertVR = db.prepare(
438
+ 'INSERT OR IGNORE INTO knowledge_verifications (vr_type, command, expected, use_when, catches, category) VALUES (?, ?, ?, ?, ?, ?)'
439
+ );
440
+ const insertIncident = db.prepare(
441
+ 'INSERT OR IGNORE INTO knowledge_incidents (incident_num, date, type, gap_found, prevention) VALUES (?, ?, ?, ?, ?)'
442
+ );
443
+ const insertMismatch = db.prepare(
444
+ 'INSERT INTO knowledge_schema_mismatches (table_name, wrong_column, correct_column) VALUES (?, ?, ?)'
445
+ );
446
+
447
+ // Discover all .claude/ markdown files
448
+ const files = discoverMarkdownFiles(paths.claudeDir);
449
+
450
+ // Also index memory directory (different location)
451
+ try {
452
+ const memFiles = discoverMarkdownFiles(paths.memoryDir);
453
+ files.push(...memFiles);
454
+ } catch {
455
+ // Memory dir may not exist
456
+ }
457
+
458
+ // Scan plan documents
459
+ if (existsSync(paths.plansDir)) {
460
+ const planFiles = discoverMarkdownFiles(paths.plansDir);
461
+ files.push(...planFiles);
462
+ }
463
+
464
+ // Scan broader docs (skip plans/ since already scanned, skip configured exclude patterns)
465
+ if (existsSync(paths.docsDir)) {
466
+ const excludePatterns = getConfig().conventions?.excludePatterns ?? ['/ARCHIVE/', '/SESSION-HISTORY/'];
467
+ const docsFiles = discoverMarkdownFiles(paths.docsDir)
468
+ .filter(f => !f.includes('/plans/') && !excludePatterns.some(p => f.includes(p)));
469
+ files.push(...docsFiles);
470
+ }
471
+
472
+ const now = new Date();
473
+ const nowIso = now.toISOString();
474
+ const nowEpoch = now.getTime();
475
+
476
+ const transaction = db.transaction(() => {
477
+ // Atomic reindex: clear everything inside transaction so rollback restores data on failure
478
+ // Drop FTS5 triggers before bulk deletion to avoid trigger errors on empty FTS5 table
479
+ db.exec('DROP TRIGGER IF EXISTS kc_fts_delete');
480
+ db.exec('DROP TRIGGER IF EXISTS kc_fts_update');
481
+ db.exec('DELETE FROM knowledge_edges');
482
+ db.exec('DELETE FROM knowledge_fts');
483
+ db.exec('DELETE FROM knowledge_chunks');
484
+ db.exec('DELETE FROM knowledge_documents');
485
+ db.exec('DELETE FROM knowledge_rules');
486
+ db.exec('DELETE FROM knowledge_verifications');
487
+ db.exec('DELETE FROM knowledge_incidents');
488
+ db.exec('DELETE FROM knowledge_schema_mismatches');
489
+
490
+ // Recreate FTS5 triggers for the insert phase
491
+ try {
492
+ db.exec(`
493
+ CREATE TRIGGER IF NOT EXISTS kc_fts_insert AFTER INSERT ON knowledge_chunks BEGIN
494
+ INSERT INTO knowledge_fts(rowid, heading, content, chunk_type, file_path)
495
+ SELECT new.id, new.heading, new.content, new.chunk_type, kd.file_path
496
+ FROM knowledge_documents kd WHERE kd.id = new.document_id;
497
+ END;
498
+ CREATE TRIGGER IF NOT EXISTS kc_fts_delete AFTER DELETE ON knowledge_chunks BEGIN
499
+ INSERT INTO knowledge_fts(knowledge_fts, rowid, heading, content, chunk_type, file_path)
500
+ SELECT 'delete', old.id, old.heading, old.content, old.chunk_type, kd.file_path
501
+ FROM knowledge_documents kd WHERE kd.id = old.document_id;
502
+ END;
503
+ CREATE TRIGGER IF NOT EXISTS kc_fts_update AFTER UPDATE ON knowledge_chunks BEGIN
504
+ INSERT INTO knowledge_fts(knowledge_fts, rowid, heading, content, chunk_type, file_path)
505
+ SELECT 'delete', old.id, old.heading, old.content, old.chunk_type, kd.file_path
506
+ FROM knowledge_documents kd WHERE kd.id = old.document_id;
507
+ INSERT INTO knowledge_fts(rowid, heading, content, chunk_type, file_path)
508
+ SELECT new.id, new.heading, new.content, new.chunk_type, kd.file_path
509
+ FROM knowledge_documents kd WHERE kd.id = new.document_id;
510
+ END;
511
+ `);
512
+ } catch { /* Triggers may already exist */ }
513
+
514
+ for (const filePath of files) {
515
+ if (!existsSync(filePath)) continue;
516
+ const content = readFileSync(filePath, 'utf-8');
517
+ const hash = hashContent(content);
518
+ const relPath = filePath.startsWith(paths.claudeDir)
519
+ ? relative(paths.claudeDir, filePath)
520
+ : filePath.startsWith(paths.plansDir)
521
+ ? 'plans/' + relative(paths.plansDir, filePath)
522
+ : filePath.startsWith(paths.docsDir)
523
+ ? 'docs/' + relative(paths.docsDir, filePath)
524
+ : filePath.startsWith(paths.memoryDir)
525
+ ? `memory/${relative(paths.memoryDir, filePath)}`
526
+ : basename(filePath);
527
+ const category = categorizeFile(filePath);
528
+ const title = extractTitle(content, filePath);
529
+ const description = extractDescription(content);
530
+
531
+ // Insert document (documents FIRST — triggers need parent row)
532
+ const result = insertDoc.run(relPath, category, title, description, hash, nowIso, nowEpoch);
533
+ const docId = result.lastInsertRowid;
534
+ stats.filesIndexed++;
535
+
536
+ // Parse sections into chunks (triggers auto-populate FTS5)
537
+ const sections = parseSections(content, filePath);
538
+ for (const section of sections) {
539
+ if (section.content.length > 10) { // Skip trivially small sections
540
+ insertChunk.run(docId, 'section', section.heading, section.content, section.line_start, section.line_end, '{}');
541
+ stats.chunksCreated++;
542
+ }
543
+ }
544
+
545
+ // Special parsing for specific files
546
+ const fileName = basename(filePath);
547
+ const fileNameLower = fileName.toLowerCase();
548
+ const relPathLower = relPath.toLowerCase();
549
+
550
+ // Check if this file is the main CLAUDE.md (config-driven filename)
551
+ const claudeMdName = basename(getResolvedPaths().claudeMdPath).toLowerCase();
552
+ if (fileNameLower === claudeMdName || relPathLower.includes(claudeMdName)) {
553
+ // Extract CR rules
554
+ const crRules = parseCRTable(content);
555
+ for (const rule of crRules) {
556
+ insertRule.run(rule.rule_id, rule.rule_text, rule.vr_type, rule.reference_path);
557
+ insertChunk.run(docId, 'rule', rule.rule_id, `${rule.rule_text} | VR: ${rule.vr_type}`, null, null, JSON.stringify({ cr_id: rule.rule_id, vr_type: rule.vr_type }));
558
+ stats.chunksCreated++;
559
+ }
560
+
561
+ // Extract VR types
562
+ const vrTypes = parseVRTable(content);
563
+ for (const vr of vrTypes) {
564
+ insertVR.run(vr.vr_type, vr.command, vr.expected, vr.use_when, vr.catches || null, vr.category || 'core');
565
+ }
566
+
567
+ // Extract schema mismatches
568
+ const mismatches = parseSchemaMismatches(content);
569
+ for (const m of mismatches) {
570
+ insertMismatch.run(m.table_name, m.wrong_column, m.correct_column);
571
+ insertChunk.run(docId, 'mismatch', m.table_name, `${m.table_name}: ${m.wrong_column} -> ${m.correct_column}`, null, null, JSON.stringify({ table: m.table_name }));
572
+ stats.chunksCreated++;
573
+ }
574
+ }
575
+
576
+ if (fileNameLower === 'incident-log.md') {
577
+ const incidents = parseIncidents(content);
578
+ for (const inc of incidents) {
579
+ insertIncident.run(inc.incident_num, inc.date, inc.type, inc.gap_found, inc.prevention);
580
+ insertChunk.run(docId, 'incident', `Incident #${inc.incident_num}`, `${inc.type}: ${inc.gap_found} | Prevention: ${inc.prevention}`, null, null, JSON.stringify({ incident_num: inc.incident_num }));
581
+ stats.chunksCreated++;
582
+ }
583
+ }
584
+
585
+ if (fileNameLower === 'vr-verification-reference.md') {
586
+ const vrTypes = parseVRTable(content);
587
+ for (const vr of vrTypes) {
588
+ insertVR.run(vr.vr_type, vr.command, vr.expected, vr.use_when, vr.catches || null, vr.category || null);
589
+ }
590
+ }
591
+
592
+ // Index commands
593
+ if (category === 'commands' && fileName !== '_shared-preamble.md') {
594
+ const cmdName = basename(filePath, '.md');
595
+ insertChunk.run(docId, 'command', cmdName, content.substring(0, 1000), 1, null, JSON.stringify({ command_name: cmdName }));
596
+ stats.chunksCreated++;
597
+ }
598
+
599
+ // Parse plan documents for structured metadata
600
+ if (category === 'plan') {
601
+ // Extract plan items (P1-001, P2-001, etc.)
602
+ const planItemRegex = /^###\s+(P\d+-\d+):\s+(.+)$/gm;
603
+ let planMatch;
604
+ while ((planMatch = planItemRegex.exec(content)) !== null) {
605
+ insertChunk.run(docId, 'pattern', planMatch[1], `${planMatch[1]}: ${planMatch[2]}`, null, null, JSON.stringify({ plan_item_id: planMatch[1] }));
606
+ stats.chunksCreated++;
607
+ }
608
+
609
+ // Extract IMPLEMENTATION STATUS if present
610
+ const statusMatch = content.match(/# IMPLEMENTATION STATUS[\s\S]*?\n(?=\n#[^#]|\n---|$)/);
611
+ if (statusMatch) {
612
+ insertChunk.run(docId, 'section', 'IMPLEMENTATION STATUS', statusMatch[0], null, null, '{}');
613
+ stats.chunksCreated++;
614
+ }
615
+
616
+ // Extract file paths mentioned in plan (src/*, scripts/*)
617
+ const fileRefRegex = /(?:src|scripts)\/[\w\-\/]+\.(?:ts|tsx|sql|md)/g;
618
+ const fileRefs = [...new Set(content.match(fileRefRegex) || [])];
619
+ if (fileRefs.length > 0) {
620
+ const fileRefsChunk = fileRefs.join('\n');
621
+ insertChunk.run(docId, 'section', 'Referenced Files', fileRefsChunk, null, null, JSON.stringify({ file_refs: fileRefs }));
622
+ stats.chunksCreated++;
623
+ }
624
+ }
625
+
626
+ // Parse corrections.md for structured correction entries
627
+ if (fileNameLower === 'corrections.md') {
628
+ const corrections = parseCorrections(content);
629
+ for (const c of corrections) {
630
+ insertChunk.run(docId, 'section', `Correction: ${c.title}`,
631
+ `Wrong: ${c.wrong}\nCorrection: ${c.correction}\nRule: ${c.rule}`,
632
+ null, null, JSON.stringify({ is_correction: true, date: c.date, cr_rule: c.cr_rule }));
633
+ stats.chunksCreated++;
634
+ if (c.cr_rule) {
635
+ db.prepare('INSERT OR IGNORE INTO knowledge_edges (source_type, source_id, target_type, target_id, edge_type) VALUES (?, ?, ?, ?, ?)')
636
+ .run('correction', c.title, 'cr', c.cr_rule, 'enforces');
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ // Build cross-references after all data inserted
643
+ stats.edgesCreated = buildCrossReferences(db);
644
+
645
+ // Update staleness metadata — use current time AFTER indexing to avoid race conditions
646
+ // where files modified during indexing appear stale immediately
647
+ const finalNow = new Date();
648
+ const finalIso = finalNow.toISOString();
649
+ const finalEpoch = finalNow.getTime();
650
+ db.prepare("INSERT OR REPLACE INTO knowledge_meta (key, value) VALUES ('last_index_time', ?)").run(finalIso);
651
+ db.prepare("INSERT OR REPLACE INTO knowledge_meta (key, value) VALUES ('last_index_epoch', ?)").run(String(finalEpoch));
652
+ db.prepare("INSERT OR REPLACE INTO knowledge_meta (key, value) VALUES ('files_indexed', ?)").run(String(stats.filesIndexed));
653
+ });
654
+
655
+ transaction();
656
+ return stats;
657
+ }
658
+
659
+ export function isKnowledgeStale(db: Database.Database): boolean {
660
+ const lastEpoch = db.prepare("SELECT value FROM knowledge_meta WHERE key = 'last_index_epoch'").get() as { value: string } | undefined;
661
+ if (!lastEpoch) return true;
662
+
663
+ const lastIndexTime = parseInt(lastEpoch.value, 10);
664
+ if (isNaN(lastIndexTime)) return true;
665
+
666
+ const paths = getKnowledgePaths();
667
+
668
+ // Check if any .claude/ file has been modified since last index
669
+ const files = discoverMarkdownFiles(paths.claudeDir);
670
+
671
+ // Also check memory directory for staleness
672
+ try {
673
+ files.push(...discoverMarkdownFiles(paths.memoryDir));
674
+ } catch { /* Memory dir may not exist */ }
675
+
676
+ // Also check plans and docs directories for staleness
677
+ if (existsSync(paths.plansDir)) {
678
+ files.push(...discoverMarkdownFiles(paths.plansDir));
679
+ }
680
+ if (existsSync(paths.docsDir)) {
681
+ const excludePatterns = getConfig().conventions?.excludePatterns ?? ['/ARCHIVE/', '/SESSION-HISTORY/'];
682
+ const docsFiles = discoverMarkdownFiles(paths.docsDir)
683
+ .filter(f => !f.includes('/plans/') && !excludePatterns.some(p => f.includes(p)));
684
+ files.push(...docsFiles);
685
+ }
686
+
687
+ for (const filePath of files) {
688
+ try {
689
+ const stat = statSync(filePath);
690
+ if (stat.mtimeMs > lastIndexTime) return true;
691
+ } catch {
692
+ continue;
693
+ }
694
+ }
695
+
696
+ return false;
697
+ }
698
+
699
+ export function indexIfStale(db: Database.Database): IndexStats {
700
+ if (isKnowledgeStale(db)) {
701
+ return indexAllKnowledge(db);
702
+ }
703
+ return { filesIndexed: 0, chunksCreated: 0, edgesCreated: 0 };
704
+ }