@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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/cli/batch.js +333 -0
- package/cli/codespaces.js +303 -0
- package/cli/index.js +91 -0
- package/cli/inject.js +53 -0
- package/cli/knowledge.js +531 -0
- package/cli/notify.js +135 -0
- package/cli/patterns.js +665 -0
- package/cli/status.js +91 -0
- package/cli/validate.js +61 -0
- package/package.json +70 -0
- package/src/core/inject.js +128 -0
- package/src/core/state.js +91 -0
- package/src/core/validate.js +202 -0
- package/src/core/workspace.js +176 -0
- package/src/index.js +20 -0
- package/src/knowledge/gc.js +308 -0
- package/src/knowledge/lifecycle.js +401 -0
- package/src/knowledge/promote.js +364 -0
- package/src/knowledge/references.js +342 -0
- package/src/patterns/matcher.js +218 -0
- package/src/patterns/registry.js +375 -0
- package/src/patterns/triggers.js +423 -0
- package/src/services/batch-service.js +849 -0
- package/src/services/notification-service.js +342 -0
- package/templates/codespaces/devcontainer.json +52 -0
- package/templates/codespaces/setup.sh +70 -0
- package/templates/workspace/.ai/README.md +164 -0
- package/templates/workspace/.ai/agents/README.md +204 -0
- package/templates/workspace/.ai/agents/claude.md +625 -0
- package/templates/workspace/.ai/config/.gitkeep +0 -0
- package/templates/workspace/.ai/config/README.md +79 -0
- package/templates/workspace/.ai/config/notifications.template.json +20 -0
- package/templates/workspace/.ai/memory/deadends.md +79 -0
- package/templates/workspace/.ai/memory/decisions.md +58 -0
- package/templates/workspace/.ai/memory/patterns.md +65 -0
- package/templates/workspace/.ai/patterns/backend/README.md +126 -0
- package/templates/workspace/.ai/patterns/frontend/README.md +103 -0
- package/templates/workspace/.ai/patterns/index.md +256 -0
- package/templates/workspace/.ai/patterns/triggers.json +1087 -0
- package/templates/workspace/.ai/patterns/universal/README.md +141 -0
- package/templates/workspace/.ai/state.template.json +8 -0
- package/templates/workspace/.ai/tasks/active.md +77 -0
- package/templates/workspace/.ai/tasks/backlog.md +95 -0
- 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
|
+
}
|