@fermindi/pwn-cli 0.1.0 → 0.2.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 -21
  2. package/README.md +265 -251
  3. package/cli/batch.js +333 -333
  4. package/cli/codespaces.js +303 -303
  5. package/cli/index.js +98 -91
  6. package/cli/inject.js +78 -53
  7. package/cli/knowledge.js +531 -531
  8. package/cli/migrate.js +466 -0
  9. package/cli/notify.js +135 -135
  10. package/cli/patterns.js +665 -665
  11. package/cli/status.js +91 -91
  12. package/cli/validate.js +61 -61
  13. package/package.json +70 -70
  14. package/src/core/inject.js +208 -128
  15. package/src/core/state.js +91 -91
  16. package/src/core/validate.js +202 -202
  17. package/src/core/workspace.js +176 -176
  18. package/src/index.js +20 -20
  19. package/src/knowledge/gc.js +308 -308
  20. package/src/knowledge/lifecycle.js +401 -401
  21. package/src/knowledge/promote.js +364 -364
  22. package/src/knowledge/references.js +342 -342
  23. package/src/patterns/matcher.js +218 -218
  24. package/src/patterns/registry.js +375 -375
  25. package/src/patterns/triggers.js +423 -423
  26. package/src/services/batch-service.js +849 -849
  27. package/src/services/notification-service.js +342 -342
  28. package/templates/codespaces/devcontainer.json +52 -52
  29. package/templates/codespaces/setup.sh +70 -70
  30. package/templates/workspace/.ai/README.md +164 -164
  31. package/templates/workspace/.ai/agents/README.md +204 -204
  32. package/templates/workspace/.ai/agents/claude.md +625 -625
  33. package/templates/workspace/.ai/config/README.md +79 -79
  34. package/templates/workspace/.ai/config/notifications.template.json +20 -20
  35. package/templates/workspace/.ai/memory/deadends.md +79 -79
  36. package/templates/workspace/.ai/memory/decisions.md +58 -58
  37. package/templates/workspace/.ai/memory/patterns.md +65 -65
  38. package/templates/workspace/.ai/patterns/backend/README.md +126 -126
  39. package/templates/workspace/.ai/patterns/frontend/README.md +103 -103
  40. package/templates/workspace/.ai/patterns/index.md +256 -256
  41. package/templates/workspace/.ai/patterns/triggers.json +1087 -1087
  42. package/templates/workspace/.ai/patterns/universal/README.md +141 -141
  43. package/templates/workspace/.ai/state.template.json +8 -8
  44. package/templates/workspace/.ai/tasks/active.md +77 -77
  45. package/templates/workspace/.ai/tasks/backlog.md +95 -95
  46. package/templates/workspace/.ai/workflows/batch-task.md +356 -356
@@ -1,401 +1,401 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- /**
5
- * Knowledge Item Status
6
- * @typedef {'active' | 'candidate_pattern' | 'archived' | 'garbage'} KnowledgeStatus
7
- */
8
-
9
- /**
10
- * Knowledge Item
11
- * @typedef {Object} KnowledgeItem
12
- * @property {string} id - Item ID (DEC-001, DE-001)
13
- * @property {string} type - 'decision' | 'deadend'
14
- * @property {string} title - Item title
15
- * @property {string} status - Current status
16
- * @property {string[]} relatedFiles - Code files that reference this
17
- * @property {string[]} relatedDecisions - Other decisions that reference
18
- * @property {string[]} relatedTasks - Tasks that depend on this
19
- * @property {number} accessCount - Times consulted
20
- * @property {number} appliedCount - Times applied in code
21
- * @property {string[]} appliedContexts - Different contexts applied
22
- * @property {string} createdAt - ISO date string
23
- * @property {string} lastAccessed - ISO date string
24
- */
25
-
26
- /**
27
- * Get the path to knowledge.json registry
28
- * @param {string} cwd - Working directory
29
- * @returns {string}
30
- */
31
- export function getKnowledgePath(cwd = process.cwd()) {
32
- return join(cwd, '.ai', 'config', 'knowledge.json');
33
- }
34
-
35
- /**
36
- * Initialize knowledge registry if it doesn't exist
37
- * @param {string} cwd - Working directory
38
- * @returns {Object} Knowledge registry
39
- */
40
- export function initKnowledgeRegistry(cwd = process.cwd()) {
41
- const knowledgePath = getKnowledgePath(cwd);
42
- const configDir = join(cwd, '.ai', 'config');
43
-
44
- if (!existsSync(configDir)) {
45
- mkdirSync(configDir, { recursive: true });
46
- }
47
-
48
- if (!existsSync(knowledgePath)) {
49
- const initial = {
50
- version: '1.0.0',
51
- items: {},
52
- lastGC: null,
53
- lastSync: new Date().toISOString()
54
- };
55
- writeFileSync(knowledgePath, JSON.stringify(initial, null, 2));
56
- return initial;
57
- }
58
-
59
- return JSON.parse(readFileSync(knowledgePath, 'utf8'));
60
- }
61
-
62
- /**
63
- * Get knowledge registry
64
- * @param {string} cwd - Working directory
65
- * @returns {Object|null}
66
- */
67
- export function getKnowledgeRegistry(cwd = process.cwd()) {
68
- const knowledgePath = getKnowledgePath(cwd);
69
-
70
- if (!existsSync(knowledgePath)) {
71
- return null;
72
- }
73
-
74
- try {
75
- return JSON.parse(readFileSync(knowledgePath, 'utf8'));
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
- /**
82
- * Save knowledge registry
83
- * @param {Object} registry - Registry to save
84
- * @param {string} cwd - Working directory
85
- */
86
- export function saveKnowledgeRegistry(registry, cwd = process.cwd()) {
87
- const knowledgePath = getKnowledgePath(cwd);
88
- registry.lastSync = new Date().toISOString();
89
- writeFileSync(knowledgePath, JSON.stringify(registry, null, 2));
90
- }
91
-
92
- /**
93
- * Parse decisions.md to extract decision items
94
- * @param {string} cwd - Working directory
95
- * @returns {KnowledgeItem[]}
96
- */
97
- export function parseDecisions(cwd = process.cwd()) {
98
- const decisionsPath = join(cwd, '.ai', 'memory', 'decisions.md');
99
-
100
- if (!existsSync(decisionsPath)) {
101
- return [];
102
- }
103
-
104
- const content = readFileSync(decisionsPath, 'utf8');
105
- const items = [];
106
-
107
- // Match pattern: ## DEC-XXX: Title
108
- const decisionRegex = /^## (DEC-\d+): (.+)$/gm;
109
- let match;
110
-
111
- while ((match = decisionRegex.exec(content)) !== null) {
112
- const id = match[1];
113
- const title = match[2];
114
-
115
- // Extract status from the block
116
- const blockStart = match.index;
117
- const nextMatch = content.indexOf('\n## ', blockStart + 1);
118
- const blockEnd = nextMatch === -1 ? content.length : nextMatch;
119
- const block = content.substring(blockStart, blockEnd);
120
-
121
- const statusMatch = block.match(/\*\*Status:\*\* (\w+)/);
122
- const dateMatch = block.match(/\*\*Date:\*\* ([\d-]+)/);
123
-
124
- items.push({
125
- id,
126
- type: 'decision',
127
- title,
128
- status: statusMatch ? statusMatch[1].toLowerCase() : 'active',
129
- relatedFiles: [],
130
- relatedDecisions: [],
131
- relatedTasks: [],
132
- accessCount: 0,
133
- appliedCount: 0,
134
- appliedContexts: [],
135
- createdAt: dateMatch ? dateMatch[1] : null,
136
- lastAccessed: null
137
- });
138
- }
139
-
140
- return items;
141
- }
142
-
143
- /**
144
- * Parse deadends.md to extract dead-end items
145
- * @param {string} cwd - Working directory
146
- * @returns {KnowledgeItem[]}
147
- */
148
- export function parseDeadends(cwd = process.cwd()) {
149
- const deadendsPath = join(cwd, '.ai', 'memory', 'deadends.md');
150
-
151
- if (!existsSync(deadendsPath)) {
152
- return [];
153
- }
154
-
155
- const content = readFileSync(deadendsPath, 'utf8');
156
- const items = [];
157
-
158
- // Match pattern: ## DE-XXX: Title
159
- const deadendRegex = /^## (DE-\d+): (.+)$/gm;
160
- let match;
161
-
162
- while ((match = deadendRegex.exec(content)) !== null) {
163
- const id = match[1];
164
- const title = match[2];
165
-
166
- // Extract date from the block
167
- const blockStart = match.index;
168
- const nextMatch = content.indexOf('\n## ', blockStart + 1);
169
- const blockEnd = nextMatch === -1 ? content.length : nextMatch;
170
- const block = content.substring(blockStart, blockEnd);
171
-
172
- const dateMatch = block.match(/\*\*Date:\*\* ([\d-]+)/);
173
-
174
- items.push({
175
- id,
176
- type: 'deadend',
177
- title,
178
- status: 'active',
179
- relatedFiles: [],
180
- relatedDecisions: [],
181
- relatedTasks: [],
182
- accessCount: 0,
183
- appliedCount: 0,
184
- appliedContexts: [],
185
- createdAt: dateMatch ? dateMatch[1] : null,
186
- lastAccessed: null
187
- });
188
- }
189
-
190
- return items;
191
- }
192
-
193
- /**
194
- * Sync knowledge registry with markdown files
195
- * Discovers new items and preserves existing metadata
196
- * @param {string} cwd - Working directory
197
- * @returns {Object} Sync result
198
- */
199
- export function syncKnowledge(cwd = process.cwd()) {
200
- const registry = initKnowledgeRegistry(cwd);
201
- const decisions = parseDecisions(cwd);
202
- const deadends = parseDeadends(cwd);
203
-
204
- const allItems = [...decisions, ...deadends];
205
- const result = { added: [], updated: [], removed: [] };
206
-
207
- // Track existing IDs
208
- const existingIds = new Set(Object.keys(registry.items));
209
- const currentIds = new Set(allItems.map(item => item.id));
210
-
211
- // Add or update items
212
- for (const item of allItems) {
213
- if (registry.items[item.id]) {
214
- // Preserve existing metadata, update title
215
- registry.items[item.id].title = item.title;
216
- if (item.createdAt && !registry.items[item.id].createdAt) {
217
- registry.items[item.id].createdAt = item.createdAt;
218
- }
219
- result.updated.push(item.id);
220
- } else {
221
- // New item
222
- registry.items[item.id] = {
223
- ...item,
224
- lastAccessed: new Date().toISOString()
225
- };
226
- result.added.push(item.id);
227
- }
228
- }
229
-
230
- // Mark removed items
231
- for (const id of existingIds) {
232
- if (!currentIds.has(id)) {
233
- // Item was removed from markdown
234
- result.removed.push(id);
235
- // Don't delete, mark for GC
236
- registry.items[id].status = 'garbage';
237
- }
238
- }
239
-
240
- saveKnowledgeRegistry(registry, cwd);
241
- return result;
242
- }
243
-
244
- /**
245
- * Get a knowledge item
246
- * @param {string} id - Item ID
247
- * @param {string} cwd - Working directory
248
- * @returns {KnowledgeItem|null}
249
- */
250
- export function getKnowledgeItem(id, cwd = process.cwd()) {
251
- const registry = getKnowledgeRegistry(cwd);
252
- if (!registry) return null;
253
- return registry.items[id] || null;
254
- }
255
-
256
- /**
257
- * Update a knowledge item
258
- * @param {string} id - Item ID
259
- * @param {Object} updates - Fields to update
260
- * @param {string} cwd - Working directory
261
- * @returns {KnowledgeItem|null}
262
- */
263
- export function updateKnowledgeItem(id, updates, cwd = process.cwd()) {
264
- const registry = getKnowledgeRegistry(cwd);
265
- if (!registry || !registry.items[id]) return null;
266
-
267
- registry.items[id] = {
268
- ...registry.items[id],
269
- ...updates
270
- };
271
-
272
- saveKnowledgeRegistry(registry, cwd);
273
- return registry.items[id];
274
- }
275
-
276
- /**
277
- * Record an access to a knowledge item
278
- * @param {string} id - Item ID
279
- * @param {string} cwd - Working directory
280
- * @returns {KnowledgeItem|null}
281
- */
282
- export function recordAccess(id, cwd = process.cwd()) {
283
- const registry = getKnowledgeRegistry(cwd);
284
- if (!registry || !registry.items[id]) return null;
285
-
286
- registry.items[id].accessCount = (registry.items[id].accessCount || 0) + 1;
287
- registry.items[id].lastAccessed = new Date().toISOString();
288
-
289
- saveKnowledgeRegistry(registry, cwd);
290
- return registry.items[id];
291
- }
292
-
293
- /**
294
- * Record that a knowledge item was applied in a context
295
- * @param {string} id - Item ID
296
- * @param {string} context - Context description (e.g., file path, feature name)
297
- * @param {string} cwd - Working directory
298
- * @returns {KnowledgeItem|null}
299
- */
300
- export function recordApplication(id, context, cwd = process.cwd()) {
301
- const registry = getKnowledgeRegistry(cwd);
302
- if (!registry || !registry.items[id]) return null;
303
-
304
- const item = registry.items[id];
305
- item.appliedCount = (item.appliedCount || 0) + 1;
306
- item.appliedContexts = item.appliedContexts || [];
307
-
308
- if (!item.appliedContexts.includes(context)) {
309
- item.appliedContexts.push(context);
310
- }
311
-
312
- item.lastAccessed = new Date().toISOString();
313
-
314
- // Check for promotion candidate
315
- if (item.type === 'decision' &&
316
- item.appliedCount >= 3 &&
317
- item.appliedContexts.length >= 2 &&
318
- item.status === 'active') {
319
- item.status = 'candidate_pattern';
320
- }
321
-
322
- saveKnowledgeRegistry(registry, cwd);
323
- return item;
324
- }
325
-
326
- /**
327
- * Get all knowledge items
328
- * @param {Object} options - Filter options
329
- * @param {string} options.type - Filter by type
330
- * @param {string} options.status - Filter by status
331
- * @param {string} cwd - Working directory
332
- * @returns {KnowledgeItem[]}
333
- */
334
- export function getAllKnowledge(options = {}, cwd = process.cwd()) {
335
- const registry = getKnowledgeRegistry(cwd);
336
- if (!registry) return [];
337
-
338
- let items = Object.values(registry.items);
339
-
340
- if (options.type) {
341
- items = items.filter(item => item.type === options.type);
342
- }
343
-
344
- if (options.status) {
345
- items = items.filter(item => item.status === options.status);
346
- }
347
-
348
- return items;
349
- }
350
-
351
- /**
352
- * Get knowledge statistics
353
- * @param {string} cwd - Working directory
354
- * @returns {Object}
355
- */
356
- export function getKnowledgeStats(cwd = process.cwd()) {
357
- const registry = getKnowledgeRegistry(cwd);
358
- if (!registry) {
359
- return {
360
- total: 0,
361
- decisions: 0,
362
- deadends: 0,
363
- active: 0,
364
- candidatePatterns: 0,
365
- archived: 0,
366
- garbage: 0,
367
- lastSync: null,
368
- lastGC: null
369
- };
370
- }
371
-
372
- const items = Object.values(registry.items);
373
-
374
- return {
375
- total: items.length,
376
- decisions: items.filter(i => i.type === 'decision').length,
377
- deadends: items.filter(i => i.type === 'deadend').length,
378
- active: items.filter(i => i.status === 'active').length,
379
- candidatePatterns: items.filter(i => i.status === 'candidate_pattern').length,
380
- archived: items.filter(i => i.status === 'archived').length,
381
- garbage: items.filter(i => i.status === 'garbage').length,
382
- lastSync: registry.lastSync,
383
- lastGC: registry.lastGC
384
- };
385
- }
386
-
387
- /**
388
- * Change status of a knowledge item
389
- * @param {string} id - Item ID
390
- * @param {KnowledgeStatus} newStatus - New status
391
- * @param {string} cwd - Working directory
392
- * @returns {KnowledgeItem|null}
393
- */
394
- export function changeStatus(id, newStatus, cwd = process.cwd()) {
395
- const validStatuses = ['active', 'candidate_pattern', 'archived', 'garbage'];
396
- if (!validStatuses.includes(newStatus)) {
397
- throw new Error(`Invalid status: ${newStatus}. Valid: ${validStatuses.join(', ')}`);
398
- }
399
-
400
- return updateKnowledgeItem(id, { status: newStatus }, cwd);
401
- }
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Knowledge Item Status
6
+ * @typedef {'active' | 'candidate_pattern' | 'archived' | 'garbage'} KnowledgeStatus
7
+ */
8
+
9
+ /**
10
+ * Knowledge Item
11
+ * @typedef {Object} KnowledgeItem
12
+ * @property {string} id - Item ID (DEC-001, DE-001)
13
+ * @property {string} type - 'decision' | 'deadend'
14
+ * @property {string} title - Item title
15
+ * @property {string} status - Current status
16
+ * @property {string[]} relatedFiles - Code files that reference this
17
+ * @property {string[]} relatedDecisions - Other decisions that reference
18
+ * @property {string[]} relatedTasks - Tasks that depend on this
19
+ * @property {number} accessCount - Times consulted
20
+ * @property {number} appliedCount - Times applied in code
21
+ * @property {string[]} appliedContexts - Different contexts applied
22
+ * @property {string} createdAt - ISO date string
23
+ * @property {string} lastAccessed - ISO date string
24
+ */
25
+
26
+ /**
27
+ * Get the path to knowledge.json registry
28
+ * @param {string} cwd - Working directory
29
+ * @returns {string}
30
+ */
31
+ export function getKnowledgePath(cwd = process.cwd()) {
32
+ return join(cwd, '.ai', 'config', 'knowledge.json');
33
+ }
34
+
35
+ /**
36
+ * Initialize knowledge registry if it doesn't exist
37
+ * @param {string} cwd - Working directory
38
+ * @returns {Object} Knowledge registry
39
+ */
40
+ export function initKnowledgeRegistry(cwd = process.cwd()) {
41
+ const knowledgePath = getKnowledgePath(cwd);
42
+ const configDir = join(cwd, '.ai', 'config');
43
+
44
+ if (!existsSync(configDir)) {
45
+ mkdirSync(configDir, { recursive: true });
46
+ }
47
+
48
+ if (!existsSync(knowledgePath)) {
49
+ const initial = {
50
+ version: '1.0.0',
51
+ items: {},
52
+ lastGC: null,
53
+ lastSync: new Date().toISOString()
54
+ };
55
+ writeFileSync(knowledgePath, JSON.stringify(initial, null, 2));
56
+ return initial;
57
+ }
58
+
59
+ return JSON.parse(readFileSync(knowledgePath, 'utf8'));
60
+ }
61
+
62
+ /**
63
+ * Get knowledge registry
64
+ * @param {string} cwd - Working directory
65
+ * @returns {Object|null}
66
+ */
67
+ export function getKnowledgeRegistry(cwd = process.cwd()) {
68
+ const knowledgePath = getKnowledgePath(cwd);
69
+
70
+ if (!existsSync(knowledgePath)) {
71
+ return null;
72
+ }
73
+
74
+ try {
75
+ return JSON.parse(readFileSync(knowledgePath, 'utf8'));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Save knowledge registry
83
+ * @param {Object} registry - Registry to save
84
+ * @param {string} cwd - Working directory
85
+ */
86
+ export function saveKnowledgeRegistry(registry, cwd = process.cwd()) {
87
+ const knowledgePath = getKnowledgePath(cwd);
88
+ registry.lastSync = new Date().toISOString();
89
+ writeFileSync(knowledgePath, JSON.stringify(registry, null, 2));
90
+ }
91
+
92
+ /**
93
+ * Parse decisions.md to extract decision items
94
+ * @param {string} cwd - Working directory
95
+ * @returns {KnowledgeItem[]}
96
+ */
97
+ export function parseDecisions(cwd = process.cwd()) {
98
+ const decisionsPath = join(cwd, '.ai', 'memory', 'decisions.md');
99
+
100
+ if (!existsSync(decisionsPath)) {
101
+ return [];
102
+ }
103
+
104
+ const content = readFileSync(decisionsPath, 'utf8');
105
+ const items = [];
106
+
107
+ // Match pattern: ## DEC-XXX: Title
108
+ const decisionRegex = /^## (DEC-\d+): (.+)$/gm;
109
+ let match;
110
+
111
+ while ((match = decisionRegex.exec(content)) !== null) {
112
+ const id = match[1];
113
+ const title = match[2];
114
+
115
+ // Extract status from the block
116
+ const blockStart = match.index;
117
+ const nextMatch = content.indexOf('\n## ', blockStart + 1);
118
+ const blockEnd = nextMatch === -1 ? content.length : nextMatch;
119
+ const block = content.substring(blockStart, blockEnd);
120
+
121
+ const statusMatch = block.match(/\*\*Status:\*\* (\w+)/);
122
+ const dateMatch = block.match(/\*\*Date:\*\* ([\d-]+)/);
123
+
124
+ items.push({
125
+ id,
126
+ type: 'decision',
127
+ title,
128
+ status: statusMatch ? statusMatch[1].toLowerCase() : 'active',
129
+ relatedFiles: [],
130
+ relatedDecisions: [],
131
+ relatedTasks: [],
132
+ accessCount: 0,
133
+ appliedCount: 0,
134
+ appliedContexts: [],
135
+ createdAt: dateMatch ? dateMatch[1] : null,
136
+ lastAccessed: null
137
+ });
138
+ }
139
+
140
+ return items;
141
+ }
142
+
143
+ /**
144
+ * Parse deadends.md to extract dead-end items
145
+ * @param {string} cwd - Working directory
146
+ * @returns {KnowledgeItem[]}
147
+ */
148
+ export function parseDeadends(cwd = process.cwd()) {
149
+ const deadendsPath = join(cwd, '.ai', 'memory', 'deadends.md');
150
+
151
+ if (!existsSync(deadendsPath)) {
152
+ return [];
153
+ }
154
+
155
+ const content = readFileSync(deadendsPath, 'utf8');
156
+ const items = [];
157
+
158
+ // Match pattern: ## DE-XXX: Title
159
+ const deadendRegex = /^## (DE-\d+): (.+)$/gm;
160
+ let match;
161
+
162
+ while ((match = deadendRegex.exec(content)) !== null) {
163
+ const id = match[1];
164
+ const title = match[2];
165
+
166
+ // Extract date from the block
167
+ const blockStart = match.index;
168
+ const nextMatch = content.indexOf('\n## ', blockStart + 1);
169
+ const blockEnd = nextMatch === -1 ? content.length : nextMatch;
170
+ const block = content.substring(blockStart, blockEnd);
171
+
172
+ const dateMatch = block.match(/\*\*Date:\*\* ([\d-]+)/);
173
+
174
+ items.push({
175
+ id,
176
+ type: 'deadend',
177
+ title,
178
+ status: 'active',
179
+ relatedFiles: [],
180
+ relatedDecisions: [],
181
+ relatedTasks: [],
182
+ accessCount: 0,
183
+ appliedCount: 0,
184
+ appliedContexts: [],
185
+ createdAt: dateMatch ? dateMatch[1] : null,
186
+ lastAccessed: null
187
+ });
188
+ }
189
+
190
+ return items;
191
+ }
192
+
193
+ /**
194
+ * Sync knowledge registry with markdown files
195
+ * Discovers new items and preserves existing metadata
196
+ * @param {string} cwd - Working directory
197
+ * @returns {Object} Sync result
198
+ */
199
+ export function syncKnowledge(cwd = process.cwd()) {
200
+ const registry = initKnowledgeRegistry(cwd);
201
+ const decisions = parseDecisions(cwd);
202
+ const deadends = parseDeadends(cwd);
203
+
204
+ const allItems = [...decisions, ...deadends];
205
+ const result = { added: [], updated: [], removed: [] };
206
+
207
+ // Track existing IDs
208
+ const existingIds = new Set(Object.keys(registry.items));
209
+ const currentIds = new Set(allItems.map(item => item.id));
210
+
211
+ // Add or update items
212
+ for (const item of allItems) {
213
+ if (registry.items[item.id]) {
214
+ // Preserve existing metadata, update title
215
+ registry.items[item.id].title = item.title;
216
+ if (item.createdAt && !registry.items[item.id].createdAt) {
217
+ registry.items[item.id].createdAt = item.createdAt;
218
+ }
219
+ result.updated.push(item.id);
220
+ } else {
221
+ // New item
222
+ registry.items[item.id] = {
223
+ ...item,
224
+ lastAccessed: new Date().toISOString()
225
+ };
226
+ result.added.push(item.id);
227
+ }
228
+ }
229
+
230
+ // Mark removed items
231
+ for (const id of existingIds) {
232
+ if (!currentIds.has(id)) {
233
+ // Item was removed from markdown
234
+ result.removed.push(id);
235
+ // Don't delete, mark for GC
236
+ registry.items[id].status = 'garbage';
237
+ }
238
+ }
239
+
240
+ saveKnowledgeRegistry(registry, cwd);
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Get a knowledge item
246
+ * @param {string} id - Item ID
247
+ * @param {string} cwd - Working directory
248
+ * @returns {KnowledgeItem|null}
249
+ */
250
+ export function getKnowledgeItem(id, cwd = process.cwd()) {
251
+ const registry = getKnowledgeRegistry(cwd);
252
+ if (!registry) return null;
253
+ return registry.items[id] || null;
254
+ }
255
+
256
+ /**
257
+ * Update a knowledge item
258
+ * @param {string} id - Item ID
259
+ * @param {Object} updates - Fields to update
260
+ * @param {string} cwd - Working directory
261
+ * @returns {KnowledgeItem|null}
262
+ */
263
+ export function updateKnowledgeItem(id, updates, cwd = process.cwd()) {
264
+ const registry = getKnowledgeRegistry(cwd);
265
+ if (!registry || !registry.items[id]) return null;
266
+
267
+ registry.items[id] = {
268
+ ...registry.items[id],
269
+ ...updates
270
+ };
271
+
272
+ saveKnowledgeRegistry(registry, cwd);
273
+ return registry.items[id];
274
+ }
275
+
276
+ /**
277
+ * Record an access to a knowledge item
278
+ * @param {string} id - Item ID
279
+ * @param {string} cwd - Working directory
280
+ * @returns {KnowledgeItem|null}
281
+ */
282
+ export function recordAccess(id, cwd = process.cwd()) {
283
+ const registry = getKnowledgeRegistry(cwd);
284
+ if (!registry || !registry.items[id]) return null;
285
+
286
+ registry.items[id].accessCount = (registry.items[id].accessCount || 0) + 1;
287
+ registry.items[id].lastAccessed = new Date().toISOString();
288
+
289
+ saveKnowledgeRegistry(registry, cwd);
290
+ return registry.items[id];
291
+ }
292
+
293
+ /**
294
+ * Record that a knowledge item was applied in a context
295
+ * @param {string} id - Item ID
296
+ * @param {string} context - Context description (e.g., file path, feature name)
297
+ * @param {string} cwd - Working directory
298
+ * @returns {KnowledgeItem|null}
299
+ */
300
+ export function recordApplication(id, context, cwd = process.cwd()) {
301
+ const registry = getKnowledgeRegistry(cwd);
302
+ if (!registry || !registry.items[id]) return null;
303
+
304
+ const item = registry.items[id];
305
+ item.appliedCount = (item.appliedCount || 0) + 1;
306
+ item.appliedContexts = item.appliedContexts || [];
307
+
308
+ if (!item.appliedContexts.includes(context)) {
309
+ item.appliedContexts.push(context);
310
+ }
311
+
312
+ item.lastAccessed = new Date().toISOString();
313
+
314
+ // Check for promotion candidate
315
+ if (item.type === 'decision' &&
316
+ item.appliedCount >= 3 &&
317
+ item.appliedContexts.length >= 2 &&
318
+ item.status === 'active') {
319
+ item.status = 'candidate_pattern';
320
+ }
321
+
322
+ saveKnowledgeRegistry(registry, cwd);
323
+ return item;
324
+ }
325
+
326
+ /**
327
+ * Get all knowledge items
328
+ * @param {Object} options - Filter options
329
+ * @param {string} options.type - Filter by type
330
+ * @param {string} options.status - Filter by status
331
+ * @param {string} cwd - Working directory
332
+ * @returns {KnowledgeItem[]}
333
+ */
334
+ export function getAllKnowledge(options = {}, cwd = process.cwd()) {
335
+ const registry = getKnowledgeRegistry(cwd);
336
+ if (!registry) return [];
337
+
338
+ let items = Object.values(registry.items);
339
+
340
+ if (options.type) {
341
+ items = items.filter(item => item.type === options.type);
342
+ }
343
+
344
+ if (options.status) {
345
+ items = items.filter(item => item.status === options.status);
346
+ }
347
+
348
+ return items;
349
+ }
350
+
351
+ /**
352
+ * Get knowledge statistics
353
+ * @param {string} cwd - Working directory
354
+ * @returns {Object}
355
+ */
356
+ export function getKnowledgeStats(cwd = process.cwd()) {
357
+ const registry = getKnowledgeRegistry(cwd);
358
+ if (!registry) {
359
+ return {
360
+ total: 0,
361
+ decisions: 0,
362
+ deadends: 0,
363
+ active: 0,
364
+ candidatePatterns: 0,
365
+ archived: 0,
366
+ garbage: 0,
367
+ lastSync: null,
368
+ lastGC: null
369
+ };
370
+ }
371
+
372
+ const items = Object.values(registry.items);
373
+
374
+ return {
375
+ total: items.length,
376
+ decisions: items.filter(i => i.type === 'decision').length,
377
+ deadends: items.filter(i => i.type === 'deadend').length,
378
+ active: items.filter(i => i.status === 'active').length,
379
+ candidatePatterns: items.filter(i => i.status === 'candidate_pattern').length,
380
+ archived: items.filter(i => i.status === 'archived').length,
381
+ garbage: items.filter(i => i.status === 'garbage').length,
382
+ lastSync: registry.lastSync,
383
+ lastGC: registry.lastGC
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Change status of a knowledge item
389
+ * @param {string} id - Item ID
390
+ * @param {KnowledgeStatus} newStatus - New status
391
+ * @param {string} cwd - Working directory
392
+ * @returns {KnowledgeItem|null}
393
+ */
394
+ export function changeStatus(id, newStatus, cwd = process.cwd()) {
395
+ const validStatuses = ['active', 'candidate_pattern', 'archived', 'garbage'];
396
+ if (!validStatuses.includes(newStatus)) {
397
+ throw new Error(`Invalid status: ${newStatus}. Valid: ${validStatuses.join(', ')}`);
398
+ }
399
+
400
+ return updateKnowledgeItem(id, { status: newStatus }, cwd);
401
+ }