@fermindi/pwn-cli 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/cli/batch.js +333 -0
  4. package/cli/codespaces.js +303 -0
  5. package/cli/index.js +91 -0
  6. package/cli/inject.js +53 -0
  7. package/cli/knowledge.js +531 -0
  8. package/cli/notify.js +135 -0
  9. package/cli/patterns.js +665 -0
  10. package/cli/status.js +91 -0
  11. package/cli/validate.js +61 -0
  12. package/package.json +70 -0
  13. package/src/core/inject.js +128 -0
  14. package/src/core/state.js +91 -0
  15. package/src/core/validate.js +202 -0
  16. package/src/core/workspace.js +176 -0
  17. package/src/index.js +20 -0
  18. package/src/knowledge/gc.js +308 -0
  19. package/src/knowledge/lifecycle.js +401 -0
  20. package/src/knowledge/promote.js +364 -0
  21. package/src/knowledge/references.js +342 -0
  22. package/src/patterns/matcher.js +218 -0
  23. package/src/patterns/registry.js +375 -0
  24. package/src/patterns/triggers.js +423 -0
  25. package/src/services/batch-service.js +849 -0
  26. package/src/services/notification-service.js +342 -0
  27. package/templates/codespaces/devcontainer.json +52 -0
  28. package/templates/codespaces/setup.sh +70 -0
  29. package/templates/workspace/.ai/README.md +164 -0
  30. package/templates/workspace/.ai/agents/README.md +204 -0
  31. package/templates/workspace/.ai/agents/claude.md +625 -0
  32. package/templates/workspace/.ai/config/.gitkeep +0 -0
  33. package/templates/workspace/.ai/config/README.md +79 -0
  34. package/templates/workspace/.ai/config/notifications.template.json +20 -0
  35. package/templates/workspace/.ai/memory/deadends.md +79 -0
  36. package/templates/workspace/.ai/memory/decisions.md +58 -0
  37. package/templates/workspace/.ai/memory/patterns.md +65 -0
  38. package/templates/workspace/.ai/patterns/backend/README.md +126 -0
  39. package/templates/workspace/.ai/patterns/frontend/README.md +103 -0
  40. package/templates/workspace/.ai/patterns/index.md +256 -0
  41. package/templates/workspace/.ai/patterns/triggers.json +1087 -0
  42. package/templates/workspace/.ai/patterns/universal/README.md +141 -0
  43. package/templates/workspace/.ai/state.template.json +8 -0
  44. package/templates/workspace/.ai/tasks/active.md +77 -0
  45. package/templates/workspace/.ai/tasks/backlog.md +95 -0
  46. package/templates/workspace/.ai/workflows/batch-task.md +356 -0
@@ -0,0 +1,364 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ getKnowledgeRegistry,
5
+ saveKnowledgeRegistry,
6
+ getKnowledgeItem,
7
+ changeStatus,
8
+ getAllKnowledge
9
+ } from './lifecycle.js';
10
+
11
+ /**
12
+ * Promotion criteria configuration
13
+ */
14
+ export const PROMOTION_CRITERIA = {
15
+ // Minimum times the decision was applied in code
16
+ minAppliedCount: 3,
17
+ // Minimum different contexts where it was applied
18
+ minAppliedContexts: 2,
19
+ // Minimum access count
20
+ minAccessCount: 5
21
+ };
22
+
23
+ /**
24
+ * Check if a decision is eligible for promotion to pattern
25
+ * @param {string} id - Decision ID
26
+ * @param {string} cwd - Working directory
27
+ * @returns {Object} Eligibility result
28
+ */
29
+ export function checkPromotionEligibility(id, cwd = process.cwd()) {
30
+ const item = getKnowledgeItem(id, cwd);
31
+
32
+ if (!item) {
33
+ return { eligible: false, reason: 'Item not found' };
34
+ }
35
+
36
+ if (item.type !== 'decision') {
37
+ return { eligible: false, reason: 'Only decisions can be promoted to patterns' };
38
+ }
39
+
40
+ if (item.status === 'archived' || item.status === 'garbage') {
41
+ return { eligible: false, reason: `Item is ${item.status}` };
42
+ }
43
+
44
+ const criteria = {
45
+ appliedCount: {
46
+ current: item.appliedCount || 0,
47
+ required: PROMOTION_CRITERIA.minAppliedCount,
48
+ met: (item.appliedCount || 0) >= PROMOTION_CRITERIA.minAppliedCount
49
+ },
50
+ appliedContexts: {
51
+ current: (item.appliedContexts || []).length,
52
+ required: PROMOTION_CRITERIA.minAppliedContexts,
53
+ met: (item.appliedContexts || []).length >= PROMOTION_CRITERIA.minAppliedContexts
54
+ },
55
+ accessCount: {
56
+ current: item.accessCount || 0,
57
+ required: PROMOTION_CRITERIA.minAccessCount,
58
+ met: (item.accessCount || 0) >= PROMOTION_CRITERIA.minAccessCount
59
+ }
60
+ };
61
+
62
+ const allMet = criteria.appliedCount.met &&
63
+ criteria.appliedContexts.met &&
64
+ criteria.accessCount.met;
65
+
66
+ return {
67
+ eligible: allMet,
68
+ criteria,
69
+ item: {
70
+ id: item.id,
71
+ title: item.title,
72
+ status: item.status,
73
+ appliedContexts: item.appliedContexts || []
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Get all decisions that are candidates for promotion
80
+ * @param {string} cwd - Working directory
81
+ * @returns {Object[]}
82
+ */
83
+ export function getPromotionCandidates(cwd = process.cwd()) {
84
+ const decisions = getAllKnowledge({ type: 'decision' }, cwd);
85
+ const candidates = [];
86
+
87
+ for (const decision of decisions) {
88
+ if (decision.status === 'archived' || decision.status === 'garbage') {
89
+ continue;
90
+ }
91
+
92
+ const eligibility = checkPromotionEligibility(decision.id, cwd);
93
+
94
+ if (eligibility.eligible || decision.status === 'candidate_pattern') {
95
+ candidates.push({
96
+ id: decision.id,
97
+ title: decision.title,
98
+ status: decision.status,
99
+ eligibility: eligibility.criteria,
100
+ isEligible: eligibility.eligible
101
+ });
102
+ }
103
+ }
104
+
105
+ // Sort by applied count descending
106
+ return candidates.sort((a, b) =>
107
+ (b.eligibility?.appliedCount?.current || 0) -
108
+ (a.eligibility?.appliedCount?.current || 0)
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Promote a decision to a pattern
114
+ * Creates a new pattern file and updates the decision status
115
+ * @param {string} id - Decision ID
116
+ * @param {Object} options - Options
117
+ * @param {string} options.category - Pattern category (frontend, backend, universal)
118
+ * @param {string} options.name - Pattern name (for filename)
119
+ * @param {string} cwd - Working directory
120
+ * @returns {Object}
121
+ */
122
+ export function promoteToPattern(id, options = {}, cwd = process.cwd()) {
123
+ const eligibility = checkPromotionEligibility(id, cwd);
124
+
125
+ if (!eligibility.eligible && !options.force) {
126
+ return {
127
+ success: false,
128
+ error: 'Decision does not meet promotion criteria',
129
+ eligibility
130
+ };
131
+ }
132
+
133
+ const item = getKnowledgeItem(id, cwd);
134
+ if (!item) {
135
+ return { success: false, error: 'Item not found' };
136
+ }
137
+
138
+ // Determine category
139
+ const category = options.category || inferCategory(item);
140
+ const patternName = options.name || slugify(item.title);
141
+
142
+ // Create pattern file
143
+ const patternDir = join(cwd, '.ai', 'patterns', category);
144
+ if (!existsSync(patternDir)) {
145
+ mkdirSync(patternDir, { recursive: true });
146
+ }
147
+
148
+ const patternFile = join(patternDir, `${patternName}.md`);
149
+ const patternContent = generatePatternContent(item);
150
+
151
+ writeFileSync(patternFile, patternContent);
152
+
153
+ // Update decision status
154
+ const registry = getKnowledgeRegistry(cwd);
155
+ registry.items[id].status = 'promoted';
156
+ registry.items[id].promotedTo = `patterns/${category}/${patternName}.md`;
157
+ registry.items[id].promotedAt = new Date().toISOString();
158
+ saveKnowledgeRegistry(registry, cwd);
159
+
160
+ // Add to patterns index
161
+ updatePatternsIndex(item, category, patternName, cwd);
162
+
163
+ return {
164
+ success: true,
165
+ patternFile: `patterns/${category}/${patternName}.md`,
166
+ decision: id,
167
+ category
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Infer the category for a pattern based on its contexts
173
+ * @param {Object} item - Knowledge item
174
+ * @returns {string}
175
+ */
176
+ function inferCategory(item) {
177
+ const contexts = item.appliedContexts || [];
178
+ const contextStr = contexts.join(' ').toLowerCase();
179
+
180
+ // Check for frontend patterns
181
+ const frontendKeywords = ['component', 'react', 'vue', 'svelte', 'ui', 'css', 'style', 'hook'];
182
+ if (frontendKeywords.some(kw => contextStr.includes(kw))) {
183
+ return 'frontend';
184
+ }
185
+
186
+ // Check for backend patterns
187
+ const backendKeywords = ['api', 'server', 'database', 'auth', 'middleware', 'route', 'controller'];
188
+ if (backendKeywords.some(kw => contextStr.includes(kw))) {
189
+ return 'backend';
190
+ }
191
+
192
+ return 'universal';
193
+ }
194
+
195
+ /**
196
+ * Generate pattern content from a decision
197
+ * @param {Object} item - Knowledge item
198
+ * @returns {string}
199
+ */
200
+ function generatePatternContent(item) {
201
+ const content = `# ${item.title}
202
+
203
+ > Promoted from ${item.id} on ${new Date().toISOString().split('T')[0]}
204
+
205
+ ## Context
206
+
207
+ This pattern was identified after being applied ${item.appliedCount || 0} times across ${(item.appliedContexts || []).length} different contexts.
208
+
209
+ ## Pattern
210
+
211
+ <!-- Describe the pattern here -->
212
+
213
+ ## When to Use
214
+
215
+ Applied contexts:
216
+ ${(item.appliedContexts || []).map(ctx => `- ${ctx}`).join('\n')}
217
+
218
+ ## Example
219
+
220
+ \`\`\`
221
+ // Add example code here
222
+ \`\`\`
223
+
224
+ ## Related
225
+
226
+ - Source: ${item.id}
227
+ ${(item.relatedDecisions || []).map(d => `- ${d}`).join('\n')}
228
+
229
+ ## Statistics
230
+
231
+ - Applied: ${item.appliedCount || 0} times
232
+ - Accessed: ${item.accessCount || 0} times
233
+ - First created: ${item.createdAt || 'Unknown'}
234
+ `;
235
+
236
+ return content;
237
+ }
238
+
239
+ /**
240
+ * Convert a string to a slug
241
+ * @param {string} str - Input string
242
+ * @returns {string}
243
+ */
244
+ function slugify(str) {
245
+ return str
246
+ .toLowerCase()
247
+ .replace(/[^a-z0-9]+/g, '-')
248
+ .replace(/^-|-$/g, '')
249
+ .substring(0, 50);
250
+ }
251
+
252
+ /**
253
+ * Update patterns/index.md with new pattern
254
+ * @param {Object} item - Knowledge item
255
+ * @param {string} category - Pattern category
256
+ * @param {string} patternName - Pattern filename (without .md)
257
+ * @param {string} cwd - Working directory
258
+ */
259
+ function updatePatternsIndex(item, category, patternName, cwd = process.cwd()) {
260
+ const indexPath = join(cwd, '.ai', 'patterns', 'index.md');
261
+
262
+ if (!existsSync(indexPath)) {
263
+ return;
264
+ }
265
+
266
+ const entry = `\n## ${category}/${patternName}\n` +
267
+ `- **Promoted from:** ${item.id}\n` +
268
+ `- **Applied:** ${item.appliedCount || 0} times\n` +
269
+ `- **Description:** ${item.title}\n`;
270
+
271
+ appendFileSync(indexPath, entry);
272
+ }
273
+
274
+ /**
275
+ * Get promotion statistics
276
+ * @param {string} cwd - Working directory
277
+ * @returns {Object}
278
+ */
279
+ export function getPromotionStats(cwd = process.cwd()) {
280
+ const registry = getKnowledgeRegistry(cwd);
281
+ if (!registry) {
282
+ return {
283
+ totalDecisions: 0,
284
+ promotedCount: 0,
285
+ candidateCount: 0,
286
+ eligibleCount: 0,
287
+ candidates: []
288
+ };
289
+ }
290
+
291
+ const items = Object.values(registry.items);
292
+ const decisions = items.filter(i => i.type === 'decision');
293
+
294
+ const promoted = decisions.filter(d => d.status === 'promoted');
295
+ const candidates = decisions.filter(d => d.status === 'candidate_pattern');
296
+
297
+ // Check eligibility for active decisions
298
+ let eligibleCount = 0;
299
+ for (const d of decisions) {
300
+ if (d.status === 'active') {
301
+ const elig = checkPromotionEligibility(d.id, cwd);
302
+ if (elig.eligible) eligibleCount++;
303
+ }
304
+ }
305
+
306
+ return {
307
+ totalDecisions: decisions.length,
308
+ promotedCount: promoted.length,
309
+ candidateCount: candidates.length,
310
+ eligibleCount,
311
+ promoted: promoted.map(p => ({
312
+ id: p.id,
313
+ title: p.title,
314
+ promotedTo: p.promotedTo,
315
+ promotedAt: p.promotedAt
316
+ })),
317
+ candidates: getPromotionCandidates(cwd).slice(0, 5)
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Suggest next best candidate for promotion
323
+ * @param {string} cwd - Working directory
324
+ * @returns {Object|null}
325
+ */
326
+ export function suggestPromotion(cwd = process.cwd()) {
327
+ const candidates = getPromotionCandidates(cwd);
328
+
329
+ // Find the first eligible candidate
330
+ const eligible = candidates.find(c => c.isEligible);
331
+ if (eligible) {
332
+ return {
333
+ type: 'ready',
334
+ ...eligible,
335
+ message: `${eligible.id} is ready for promotion to pattern`
336
+ };
337
+ }
338
+
339
+ // Find the closest to eligible
340
+ if (candidates.length > 0) {
341
+ const closest = candidates[0];
342
+ const criteria = closest.eligibility;
343
+
344
+ const missing = [];
345
+ if (!criteria.appliedCount.met) {
346
+ missing.push(`${criteria.appliedCount.required - criteria.appliedCount.current} more applications needed`);
347
+ }
348
+ if (!criteria.appliedContexts.met) {
349
+ missing.push(`${criteria.appliedContexts.required - criteria.appliedContexts.current} more contexts needed`);
350
+ }
351
+ if (!criteria.accessCount.met) {
352
+ missing.push(`${criteria.accessCount.required - criteria.accessCount.current} more accesses needed`);
353
+ }
354
+
355
+ return {
356
+ type: 'close',
357
+ ...closest,
358
+ missing,
359
+ message: `${closest.id} is close to promotion: ${missing.join(', ')}`
360
+ };
361
+ }
362
+
363
+ return null;
364
+ }
@@ -0,0 +1,342 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
2
+ import { join, relative } from 'path';
3
+ import {
4
+ getKnowledgeRegistry,
5
+ saveKnowledgeRegistry,
6
+ updateKnowledgeItem
7
+ } from './lifecycle.js';
8
+
9
+ /**
10
+ * Scan a file for knowledge references (DEC-XXX, DE-XXX)
11
+ * @param {string} filePath - Path to file
12
+ * @returns {string[]} Array of referenced IDs
13
+ */
14
+ export function scanFileForReferences(filePath) {
15
+ if (!existsSync(filePath)) {
16
+ return [];
17
+ }
18
+
19
+ try {
20
+ const content = readFileSync(filePath, 'utf8');
21
+ const references = new Set();
22
+
23
+ // Match DEC-XXX and DE-XXX patterns
24
+ const decisionRegex = /DEC-\d+/g;
25
+ const deadendRegex = /DE-\d+/g;
26
+
27
+ let match;
28
+ while ((match = decisionRegex.exec(content)) !== null) {
29
+ references.add(match[0]);
30
+ }
31
+ while ((match = deadendRegex.exec(content)) !== null) {
32
+ references.add(match[0]);
33
+ }
34
+
35
+ return Array.from(references);
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Recursively scan directory for knowledge references
43
+ * @param {string} dir - Directory to scan
44
+ * @param {Object} options - Options
45
+ * @param {string[]} options.exclude - Patterns to exclude
46
+ * @returns {Map<string, string[]>} Map of file path to referenced IDs
47
+ */
48
+ export function scanDirectoryForReferences(dir, options = {}) {
49
+ const exclude = options.exclude || [
50
+ 'node_modules', '.git', '.ai', 'dist', 'build', 'coverage',
51
+ '.next', '.nuxt', '.output', 'vendor', '__pycache__'
52
+ ];
53
+
54
+ const results = new Map();
55
+
56
+ function walk(currentDir) {
57
+ try {
58
+ const entries = readdirSync(currentDir);
59
+
60
+ for (const entry of entries) {
61
+ const fullPath = join(currentDir, entry);
62
+
63
+ // Skip excluded directories
64
+ if (exclude.some(pattern => entry === pattern || fullPath.includes(pattern))) {
65
+ continue;
66
+ }
67
+
68
+ try {
69
+ const stat = statSync(fullPath);
70
+
71
+ if (stat.isDirectory()) {
72
+ walk(fullPath);
73
+ } else if (stat.isFile()) {
74
+ // Skip binary and non-text files
75
+ if (isScannable(entry)) {
76
+ const refs = scanFileForReferences(fullPath);
77
+ if (refs.length > 0) {
78
+ results.set(fullPath, refs);
79
+ }
80
+ }
81
+ }
82
+ } catch {
83
+ // Skip files we can't access
84
+ }
85
+ }
86
+ } catch {
87
+ // Skip directories we can't access
88
+ }
89
+ }
90
+
91
+ walk(dir);
92
+ return results;
93
+ }
94
+
95
+ /**
96
+ * Check if a file should be scanned for references
97
+ * @param {string} filename - File name
98
+ * @returns {boolean}
99
+ */
100
+ function isScannable(filename) {
101
+ const scannableExtensions = [
102
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
103
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.scala',
104
+ '.c', '.cpp', '.h', '.hpp', '.cs',
105
+ '.php', '.swift', '.m',
106
+ '.md', '.mdx', '.txt', '.rst',
107
+ '.yml', '.yaml', '.json', '.toml',
108
+ '.html', '.css', '.scss', '.less',
109
+ '.sql', '.graphql', '.prisma',
110
+ '.sh', '.bash', '.zsh', '.fish',
111
+ '.vue', '.svelte', '.astro'
112
+ ];
113
+
114
+ const extension = filename.substring(filename.lastIndexOf('.'));
115
+ return scannableExtensions.includes(extension);
116
+ }
117
+
118
+ /**
119
+ * Build a reference map: which files reference which knowledge items
120
+ * @param {string} cwd - Working directory
121
+ * @returns {Object} Reference map
122
+ */
123
+ export function buildReferenceMap(cwd = process.cwd()) {
124
+ const fileRefs = scanDirectoryForReferences(cwd);
125
+
126
+ // Build inverse map: knowledge ID -> files
127
+ const knowledgeToFiles = new Map();
128
+
129
+ for (const [file, refs] of fileRefs) {
130
+ const relativePath = relative(cwd, file);
131
+ for (const ref of refs) {
132
+ if (!knowledgeToFiles.has(ref)) {
133
+ knowledgeToFiles.set(ref, []);
134
+ }
135
+ knowledgeToFiles.get(ref).push(relativePath);
136
+ }
137
+ }
138
+
139
+ return {
140
+ fileToKnowledge: Object.fromEntries(
141
+ Array.from(fileRefs.entries()).map(([file, refs]) => [
142
+ relative(cwd, file),
143
+ refs
144
+ ])
145
+ ),
146
+ knowledgeToFiles: Object.fromEntries(knowledgeToFiles)
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Update knowledge registry with current file references
152
+ * @param {string} cwd - Working directory
153
+ * @returns {Object} Update result
154
+ */
155
+ export function updateReferences(cwd = process.cwd()) {
156
+ const registry = getKnowledgeRegistry(cwd);
157
+ if (!registry) {
158
+ return { error: 'No knowledge registry found. Run sync first.' };
159
+ }
160
+
161
+ const refMap = buildReferenceMap(cwd);
162
+ const result = { updated: [], orphaned: [], newRefs: [] };
163
+
164
+ // Update each knowledge item with its references
165
+ for (const [id, item] of Object.entries(registry.items)) {
166
+ const currentFiles = refMap.knowledgeToFiles[id] || [];
167
+ const previousFiles = item.relatedFiles || [];
168
+
169
+ // Find changes
170
+ const added = currentFiles.filter(f => !previousFiles.includes(f));
171
+ const removed = previousFiles.filter(f => !currentFiles.includes(f));
172
+
173
+ if (added.length > 0 || removed.length > 0) {
174
+ registry.items[id].relatedFiles = currentFiles;
175
+ result.updated.push({ id, added, removed });
176
+ }
177
+
178
+ // Check for orphaned items (no references)
179
+ if (currentFiles.length === 0 && item.status === 'active') {
180
+ result.orphaned.push(id);
181
+ }
182
+
183
+ // Track new references for metrics
184
+ if (added.length > 0) {
185
+ result.newRefs.push({ id, files: added });
186
+ }
187
+ }
188
+
189
+ saveKnowledgeRegistry(registry, cwd);
190
+ return result;
191
+ }
192
+
193
+ /**
194
+ * Add a manual reference link
195
+ * @param {string} knowledgeId - Knowledge item ID
196
+ * @param {string} target - Target (file path, decision ID, task ID)
197
+ * @param {string} targetType - Type: 'file', 'decision', 'task'
198
+ * @param {string} cwd - Working directory
199
+ * @returns {Object|null}
200
+ */
201
+ export function addReference(knowledgeId, target, targetType, cwd = process.cwd()) {
202
+ const registry = getKnowledgeRegistry(cwd);
203
+ if (!registry || !registry.items[knowledgeId]) {
204
+ return null;
205
+ }
206
+
207
+ const item = registry.items[knowledgeId];
208
+
209
+ switch (targetType) {
210
+ case 'file':
211
+ item.relatedFiles = item.relatedFiles || [];
212
+ if (!item.relatedFiles.includes(target)) {
213
+ item.relatedFiles.push(target);
214
+ }
215
+ break;
216
+ case 'decision':
217
+ item.relatedDecisions = item.relatedDecisions || [];
218
+ if (!item.relatedDecisions.includes(target)) {
219
+ item.relatedDecisions.push(target);
220
+ }
221
+ break;
222
+ case 'task':
223
+ item.relatedTasks = item.relatedTasks || [];
224
+ if (!item.relatedTasks.includes(target)) {
225
+ item.relatedTasks.push(target);
226
+ }
227
+ break;
228
+ default:
229
+ return null;
230
+ }
231
+
232
+ saveKnowledgeRegistry(registry, cwd);
233
+ return item;
234
+ }
235
+
236
+ /**
237
+ * Remove a reference link
238
+ * @param {string} knowledgeId - Knowledge item ID
239
+ * @param {string} target - Target to remove
240
+ * @param {string} targetType - Type: 'file', 'decision', 'task'
241
+ * @param {string} cwd - Working directory
242
+ * @returns {Object|null}
243
+ */
244
+ export function removeReference(knowledgeId, target, targetType, cwd = process.cwd()) {
245
+ const registry = getKnowledgeRegistry(cwd);
246
+ if (!registry || !registry.items[knowledgeId]) {
247
+ return null;
248
+ }
249
+
250
+ const item = registry.items[knowledgeId];
251
+
252
+ switch (targetType) {
253
+ case 'file':
254
+ item.relatedFiles = (item.relatedFiles || []).filter(f => f !== target);
255
+ break;
256
+ case 'decision':
257
+ item.relatedDecisions = (item.relatedDecisions || []).filter(d => d !== target);
258
+ break;
259
+ case 'task':
260
+ item.relatedTasks = (item.relatedTasks || []).filter(t => t !== target);
261
+ break;
262
+ default:
263
+ return null;
264
+ }
265
+
266
+ saveKnowledgeRegistry(registry, cwd);
267
+ return item;
268
+ }
269
+
270
+ /**
271
+ * Find broken references (references to non-existent files)
272
+ * @param {string} cwd - Working directory
273
+ * @returns {Object[]} Broken references
274
+ */
275
+ export function findBrokenReferences(cwd = process.cwd()) {
276
+ const registry = getKnowledgeRegistry(cwd);
277
+ if (!registry) {
278
+ return [];
279
+ }
280
+
281
+ const broken = [];
282
+
283
+ for (const [id, item] of Object.entries(registry.items)) {
284
+ const brokenFiles = (item.relatedFiles || []).filter(file => {
285
+ const fullPath = join(cwd, file);
286
+ return !existsSync(fullPath);
287
+ });
288
+
289
+ const brokenDecisions = (item.relatedDecisions || []).filter(decId => {
290
+ return !registry.items[decId];
291
+ });
292
+
293
+ if (brokenFiles.length > 0 || brokenDecisions.length > 0) {
294
+ broken.push({
295
+ id,
296
+ brokenFiles,
297
+ brokenDecisions
298
+ });
299
+ }
300
+ }
301
+
302
+ return broken;
303
+ }
304
+
305
+ /**
306
+ * Get reference statistics
307
+ * @param {string} cwd - Working directory
308
+ * @returns {Object}
309
+ */
310
+ export function getReferenceStats(cwd = process.cwd()) {
311
+ const registry = getKnowledgeRegistry(cwd);
312
+ if (!registry) {
313
+ return { totalRefs: 0, avgRefsPerItem: 0, orphaned: 0, mostReferenced: [] };
314
+ }
315
+
316
+ const items = Object.values(registry.items);
317
+ let totalRefs = 0;
318
+ let orphaned = 0;
319
+ const refCounts = [];
320
+
321
+ for (const item of items) {
322
+ const refCount = (item.relatedFiles || []).length +
323
+ (item.relatedDecisions || []).length +
324
+ (item.relatedTasks || []).length;
325
+ totalRefs += refCount;
326
+ refCounts.push({ id: item.id, title: item.title, count: refCount });
327
+
328
+ if (refCount === 0 && item.status === 'active') {
329
+ orphaned++;
330
+ }
331
+ }
332
+
333
+ // Sort by ref count descending
334
+ refCounts.sort((a, b) => b.count - a.count);
335
+
336
+ return {
337
+ totalRefs,
338
+ avgRefsPerItem: items.length > 0 ? (totalRefs / items.length).toFixed(1) : 0,
339
+ orphaned,
340
+ mostReferenced: refCounts.slice(0, 5)
341
+ };
342
+ }