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