@fermindi/pwn-cli 0.1.1 → 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 -67
  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 -204
  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,364 +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
- }
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
+ }