@massu/core 0.1.2 → 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.
- package/commands/_shared-preamble.md +76 -0
- package/commands/massu-audit-deps.md +211 -0
- package/commands/massu-changelog.md +174 -0
- package/commands/massu-cleanup.md +315 -0
- package/commands/massu-commit.md +481 -0
- package/commands/massu-create-plan.md +752 -0
- package/commands/massu-dead-code.md +131 -0
- package/commands/massu-debug.md +484 -0
- package/commands/massu-deploy.md +91 -0
- package/commands/massu-deps.md +374 -0
- package/commands/massu-doc-gen.md +279 -0
- package/commands/massu-docs.md +364 -0
- package/commands/massu-estimate.md +313 -0
- package/commands/massu-golden-path.md +973 -0
- package/commands/massu-guide.md +167 -0
- package/commands/massu-hotfix.md +480 -0
- package/commands/massu-loop-playwright.md +837 -0
- package/commands/massu-loop.md +775 -0
- package/commands/massu-new-feature.md +511 -0
- package/commands/massu-parity.md +214 -0
- package/commands/massu-plan.md +456 -0
- package/commands/massu-push-light.md +207 -0
- package/commands/massu-push.md +434 -0
- package/commands/massu-refactor.md +410 -0
- package/commands/massu-release.md +363 -0
- package/commands/massu-review.md +238 -0
- package/commands/massu-simplify.md +281 -0
- package/commands/massu-status.md +278 -0
- package/commands/massu-tdd.md +201 -0
- package/commands/massu-test.md +516 -0
- package/commands/massu-verify-playwright.md +281 -0
- package/commands/massu-verify.md +667 -0
- package/dist/cli.js +12522 -0
- package/dist/hooks/cost-tracker.js +80 -5
- package/dist/hooks/post-edit-context.js +72 -6
- package/dist/hooks/post-tool-use.js +234 -57
- package/dist/hooks/pre-compact.js +144 -5
- package/dist/hooks/pre-delete-check.js +141 -11
- package/dist/hooks/quality-event.js +80 -5
- package/dist/hooks/security-gate.js +29 -0
- package/dist/hooks/session-end.js +83 -8
- package/dist/hooks/session-start.js +153 -7
- package/dist/hooks/user-prompt.js +166 -5
- package/package.json +6 -5
- package/src/backfill-sessions.ts +5 -4
- package/src/cli.ts +6 -0
- package/src/commands/doctor.ts +193 -6
- package/src/commands/init.ts +235 -6
- package/src/commands/install-commands.ts +137 -0
- package/src/config.ts +68 -2
- package/src/db.ts +115 -2
- package/src/docs-tools.ts +8 -6
- package/src/hooks/post-edit-context.ts +1 -1
- package/src/hooks/post-tool-use.ts +130 -0
- package/src/hooks/pre-compact.ts +23 -1
- package/src/hooks/pre-delete-check.ts +92 -4
- package/src/hooks/security-gate.ts +32 -0
- package/src/hooks/session-start.ts +97 -4
- package/src/hooks/user-prompt.ts +46 -1
- package/src/import-resolver.ts +2 -1
- package/src/knowledge-db.ts +169 -0
- package/src/knowledge-indexer.ts +704 -0
- package/src/knowledge-tools.ts +1413 -0
- package/src/license.ts +482 -0
- package/src/memory-db.ts +14 -1
- package/src/observation-extractor.ts +11 -4
- package/src/page-deps.ts +3 -2
- package/src/python/coupling-detector.ts +124 -0
- package/src/python/domain-enforcer.ts +83 -0
- package/src/python/impact-analyzer.ts +95 -0
- package/src/python/import-parser.ts +244 -0
- package/src/python/import-resolver.ts +135 -0
- package/src/python/migration-indexer.ts +115 -0
- package/src/python/migration-parser.ts +332 -0
- package/src/python/model-indexer.ts +70 -0
- package/src/python/model-parser.ts +279 -0
- package/src/python/route-indexer.ts +58 -0
- package/src/python/route-parser.ts +317 -0
- package/src/python-tools.ts +629 -0
- package/src/sentinel-db.ts +2 -1
- package/src/server.ts +29 -6
- package/src/session-archiver.ts +4 -5
- package/src/tools.ts +283 -31
- package/README.md +0 -40
|
@@ -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
|
+
}
|