@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,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern Matcher - Glob and regex matching utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides minimatch-like glob matching without external dependencies.
|
|
5
|
+
* Supports common glob patterns used in trigger evaluation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert glob pattern to regex
|
|
10
|
+
* @param {string} pattern - Glob pattern
|
|
11
|
+
* @returns {RegExp}
|
|
12
|
+
*/
|
|
13
|
+
export function globToRegex(pattern) {
|
|
14
|
+
// Escape special regex characters except glob wildcards
|
|
15
|
+
let regex = pattern
|
|
16
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
// ** matches any path segments
|
|
18
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
19
|
+
// * matches any characters except path separator
|
|
20
|
+
.replace(/\*/g, '[^/\\\\]*')
|
|
21
|
+
// ? matches single character
|
|
22
|
+
.replace(/\?/g, '[^/\\\\]')
|
|
23
|
+
// Restore globstar
|
|
24
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
25
|
+
|
|
26
|
+
// Handle brace expansion {a,b,c}
|
|
27
|
+
if (regex.includes('\\{') && regex.includes('\\}')) {
|
|
28
|
+
regex = regex.replace(/\\\{([^}]+)\\\}/g, (_, options) => {
|
|
29
|
+
const parts = options.split(',').map(p => p.trim());
|
|
30
|
+
return `(?:${parts.join('|')})`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return new RegExp(`^${regex}$`, 'i');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Test if a string matches a glob pattern
|
|
39
|
+
* @param {string} str - String to test
|
|
40
|
+
* @param {string} pattern - Glob pattern
|
|
41
|
+
* @param {Object} [options] - Match options
|
|
42
|
+
* @param {boolean} [options.dot=false] - Match dotfiles
|
|
43
|
+
* @param {boolean} [options.nocase=true] - Case insensitive
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function minimatch(str, pattern, options = {}) {
|
|
47
|
+
const { dot = false, nocase = true } = options;
|
|
48
|
+
|
|
49
|
+
// Normalize path separators
|
|
50
|
+
const normalizedStr = str.replace(/\\/g, '/');
|
|
51
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
52
|
+
|
|
53
|
+
// Handle negation
|
|
54
|
+
if (normalizedPattern.startsWith('!')) {
|
|
55
|
+
return !minimatch(normalizedStr, normalizedPattern.slice(1), options);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip dotfiles unless dot option is true
|
|
59
|
+
if (!dot && normalizedStr.split('/').some(part => part.startsWith('.'))) {
|
|
60
|
+
// Allow if pattern explicitly matches dotfiles
|
|
61
|
+
if (!normalizedPattern.includes('/.') && !normalizedPattern.startsWith('.')) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const regex = globToRegex(normalizedPattern);
|
|
68
|
+
if (nocase) {
|
|
69
|
+
return new RegExp(regex.source, 'i').test(normalizedStr);
|
|
70
|
+
}
|
|
71
|
+
return regex.test(normalizedStr);
|
|
72
|
+
} catch {
|
|
73
|
+
// Fallback to simple string matching
|
|
74
|
+
return normalizedStr === normalizedPattern;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find all matching items from a list
|
|
80
|
+
* @param {string[]} list - List of strings to match against
|
|
81
|
+
* @param {string} pattern - Glob pattern
|
|
82
|
+
* @param {Object} [options] - Match options
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
export function minimatchFilter(list, pattern, options = {}) {
|
|
86
|
+
return list.filter(item => minimatch(item, pattern, options));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Test if string matches any of the patterns
|
|
91
|
+
* @param {string} str - String to test
|
|
92
|
+
* @param {string[]} patterns - Array of glob patterns
|
|
93
|
+
* @param {Object} [options] - Match options
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function minimatchAny(str, patterns, options = {}) {
|
|
97
|
+
return patterns.some(pattern => minimatch(str, pattern, options));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Test if string matches all of the patterns
|
|
102
|
+
* @param {string} str - String to test
|
|
103
|
+
* @param {string[]} patterns - Array of glob patterns
|
|
104
|
+
* @param {Object} [options] - Match options
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function minimatchAll(str, patterns, options = {}) {
|
|
108
|
+
return patterns.every(pattern => minimatch(str, pattern, options));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a matcher function for a pattern
|
|
113
|
+
* @param {string} pattern - Glob pattern
|
|
114
|
+
* @param {Object} [options] - Match options
|
|
115
|
+
* @returns {Function} Matcher function
|
|
116
|
+
*/
|
|
117
|
+
export function createMatcher(pattern, options = {}) {
|
|
118
|
+
return (str) => minimatch(str, pattern, options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Match file extension
|
|
123
|
+
* @param {string} fileName - File name or path
|
|
124
|
+
* @param {string|string[]} extensions - Extension(s) to match (with or without dot)
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function matchExtension(fileName, extensions) {
|
|
128
|
+
const exts = Array.isArray(extensions) ? extensions : [extensions];
|
|
129
|
+
const normalizedExts = exts.map(ext => ext.startsWith('.') ? ext : `.${ext}`);
|
|
130
|
+
const fileExt = fileName.includes('.') ? '.' + fileName.split('.').pop() : '';
|
|
131
|
+
|
|
132
|
+
return normalizedExts.some(ext => ext.toLowerCase() === fileExt.toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Match path prefix
|
|
137
|
+
* @param {string} filePath - File path
|
|
138
|
+
* @param {string|string[]} prefixes - Path prefix(es) to match
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function matchPathPrefix(filePath, prefixes) {
|
|
142
|
+
const prfxs = Array.isArray(prefixes) ? prefixes : [prefixes];
|
|
143
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
144
|
+
|
|
145
|
+
return prfxs.some(prefix => {
|
|
146
|
+
const normalizedPrefix = prefix.replace(/\\/g, '/');
|
|
147
|
+
return normalizedPath.startsWith(normalizedPrefix);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract file parts for matching
|
|
153
|
+
* @param {string} filePath - File path
|
|
154
|
+
* @returns {Object}
|
|
155
|
+
*/
|
|
156
|
+
export function parseFilePath(filePath) {
|
|
157
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
158
|
+
const parts = normalized.split('/');
|
|
159
|
+
const fileName = parts.pop() || '';
|
|
160
|
+
const dirPath = parts.join('/');
|
|
161
|
+
const extMatch = fileName.match(/\.([^.]+)$/);
|
|
162
|
+
const extension = extMatch ? extMatch[1] : '';
|
|
163
|
+
const baseName = extension ? fileName.slice(0, -(extension.length + 1)) : fileName;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
fullPath: normalized,
|
|
167
|
+
dirPath,
|
|
168
|
+
fileName,
|
|
169
|
+
baseName,
|
|
170
|
+
extension,
|
|
171
|
+
depth: parts.length
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Score a match for prioritization
|
|
177
|
+
* Higher score = better match
|
|
178
|
+
* @param {string} str - String that matched
|
|
179
|
+
* @param {string} pattern - Pattern that matched
|
|
180
|
+
* @returns {number}
|
|
181
|
+
*/
|
|
182
|
+
export function scoreMatch(str, pattern) {
|
|
183
|
+
let score = 0;
|
|
184
|
+
|
|
185
|
+
// Exact match = highest score
|
|
186
|
+
if (str === pattern) {
|
|
187
|
+
score += 100;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Shorter patterns = more specific = higher score
|
|
191
|
+
const patternComplexity = (pattern.match(/\*/g) || []).length;
|
|
192
|
+
score += 50 - patternComplexity * 10;
|
|
193
|
+
|
|
194
|
+
// File extension patterns are more specific
|
|
195
|
+
if (pattern.startsWith('*.') && !pattern.includes('/')) {
|
|
196
|
+
score += 20;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Path patterns with specific directories
|
|
200
|
+
if (pattern.includes('/') && !pattern.startsWith('**/')) {
|
|
201
|
+
score += 15;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return Math.max(0, score);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default {
|
|
208
|
+
globToRegex,
|
|
209
|
+
minimatch,
|
|
210
|
+
minimatchFilter,
|
|
211
|
+
minimatchAny,
|
|
212
|
+
minimatchAll,
|
|
213
|
+
createMatcher,
|
|
214
|
+
matchExtension,
|
|
215
|
+
matchPathPrefix,
|
|
216
|
+
parseFilePath,
|
|
217
|
+
scoreMatch
|
|
218
|
+
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern Registry - Manages pattern storage and retrieval
|
|
3
|
+
*
|
|
4
|
+
* Patterns are organized in categories:
|
|
5
|
+
* - frontend/ (react, vue, svelte, styling, component-libs)
|
|
6
|
+
* - backend/ (express, fastapi, database, auth)
|
|
7
|
+
* - universal/ (typescript, testing, error-handling, git, build-tools)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
11
|
+
import { join, relative, basename, dirname } from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pattern entry structure
|
|
15
|
+
* @typedef {Object} Pattern
|
|
16
|
+
* @property {string} id - Unique identifier (category/name)
|
|
17
|
+
* @property {string} name - Human-readable name
|
|
18
|
+
* @property {string} category - Pattern category (frontend, backend, universal)
|
|
19
|
+
* @property {string} path - Relative path to pattern directory
|
|
20
|
+
* @property {string} description - Pattern description
|
|
21
|
+
* @property {string[]} triggers - Associated trigger IDs
|
|
22
|
+
* @property {Object} metadata - Additional metadata
|
|
23
|
+
* @property {number} metadata.usageCount - How many times pattern was applied
|
|
24
|
+
* @property {string} metadata.lastUsed - ISO timestamp of last usage
|
|
25
|
+
* @property {string} metadata.createdAt - ISO timestamp of creation
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PatternRegistry - Singleton for managing patterns
|
|
30
|
+
*/
|
|
31
|
+
export class PatternRegistry {
|
|
32
|
+
constructor(workspacePath) {
|
|
33
|
+
this.workspacePath = workspacePath;
|
|
34
|
+
this.aiPath = join(workspacePath, '.ai');
|
|
35
|
+
this.patternsPath = join(this.aiPath, 'patterns');
|
|
36
|
+
this.registryPath = join(this.patternsPath, 'registry.json');
|
|
37
|
+
this.patterns = new Map();
|
|
38
|
+
this.loaded = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initialize or load the registry
|
|
43
|
+
*/
|
|
44
|
+
async load() {
|
|
45
|
+
if (this.loaded) return;
|
|
46
|
+
|
|
47
|
+
// Create patterns directory if it doesn't exist
|
|
48
|
+
if (!existsSync(this.patternsPath)) {
|
|
49
|
+
mkdirSync(this.patternsPath, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Load existing registry or scan for patterns
|
|
53
|
+
if (existsSync(this.registryPath)) {
|
|
54
|
+
try {
|
|
55
|
+
const data = JSON.parse(readFileSync(this.registryPath, 'utf8'));
|
|
56
|
+
for (const pattern of data.patterns || []) {
|
|
57
|
+
this.patterns.set(pattern.id, pattern);
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.warn('⚠️ Could not load registry, scanning patterns directory...');
|
|
61
|
+
await this.scan();
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
await this.scan();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.loaded = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Scan patterns directory and build registry
|
|
72
|
+
*/
|
|
73
|
+
async scan() {
|
|
74
|
+
this.patterns.clear();
|
|
75
|
+
|
|
76
|
+
const categories = ['frontend', 'backend', 'universal'];
|
|
77
|
+
|
|
78
|
+
for (const category of categories) {
|
|
79
|
+
const categoryPath = join(this.patternsPath, category);
|
|
80
|
+
if (!existsSync(categoryPath)) continue;
|
|
81
|
+
|
|
82
|
+
const entries = readdirSync(categoryPath, { withFileTypes: true });
|
|
83
|
+
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (!entry.isDirectory()) continue;
|
|
86
|
+
|
|
87
|
+
const patternPath = join(categoryPath, entry.name);
|
|
88
|
+
const readmePath = join(patternPath, 'README.md');
|
|
89
|
+
|
|
90
|
+
const pattern = {
|
|
91
|
+
id: `${category}/${entry.name}`,
|
|
92
|
+
name: this.formatName(entry.name),
|
|
93
|
+
category,
|
|
94
|
+
path: relative(this.aiPath, patternPath),
|
|
95
|
+
description: this.extractDescription(readmePath),
|
|
96
|
+
triggers: [],
|
|
97
|
+
metadata: {
|
|
98
|
+
usageCount: 0,
|
|
99
|
+
lastUsed: null,
|
|
100
|
+
createdAt: new Date().toISOString()
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.patterns.set(pattern.id, pattern);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Save the scanned registry
|
|
109
|
+
await this.save();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract description from README.md first line
|
|
114
|
+
*/
|
|
115
|
+
extractDescription(readmePath) {
|
|
116
|
+
if (!existsSync(readmePath)) {
|
|
117
|
+
return 'No description available';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(readmePath, 'utf8');
|
|
122
|
+
const lines = content.split('\n');
|
|
123
|
+
|
|
124
|
+
// Find first non-empty, non-heading line
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const trimmed = line.trim();
|
|
127
|
+
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
|
|
128
|
+
return trimmed.slice(0, 100) + (trimmed.length > 100 ? '...' : '');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return 'Pattern documentation available';
|
|
133
|
+
} catch {
|
|
134
|
+
return 'No description available';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format pattern name from directory name
|
|
140
|
+
*/
|
|
141
|
+
formatName(dirName) {
|
|
142
|
+
return dirName
|
|
143
|
+
.split(/[-_]/)
|
|
144
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
145
|
+
.join(' ');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Save registry to disk
|
|
150
|
+
*/
|
|
151
|
+
async save() {
|
|
152
|
+
const data = {
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
lastUpdated: new Date().toISOString(),
|
|
155
|
+
patterns: Array.from(this.patterns.values())
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
writeFileSync(this.registryPath, JSON.stringify(data, null, 2));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get a pattern by ID
|
|
163
|
+
* @param {string} id - Pattern ID (e.g., 'frontend/react')
|
|
164
|
+
* @returns {Pattern|null}
|
|
165
|
+
*/
|
|
166
|
+
get(id) {
|
|
167
|
+
return this.patterns.get(id) || null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get all patterns
|
|
172
|
+
* @returns {Pattern[]}
|
|
173
|
+
*/
|
|
174
|
+
getAll() {
|
|
175
|
+
return Array.from(this.patterns.values());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get patterns by category
|
|
180
|
+
* @param {string} category - Category name
|
|
181
|
+
* @returns {Pattern[]}
|
|
182
|
+
*/
|
|
183
|
+
getByCategory(category) {
|
|
184
|
+
return this.getAll().filter(p => p.category === category);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Search patterns by name or description
|
|
189
|
+
* @param {string} query - Search query
|
|
190
|
+
* @returns {Pattern[]}
|
|
191
|
+
*/
|
|
192
|
+
search(query) {
|
|
193
|
+
const lowerQuery = query.toLowerCase();
|
|
194
|
+
return this.getAll().filter(p =>
|
|
195
|
+
p.name.toLowerCase().includes(lowerQuery) ||
|
|
196
|
+
p.description.toLowerCase().includes(lowerQuery) ||
|
|
197
|
+
p.id.toLowerCase().includes(lowerQuery)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Register a new pattern
|
|
203
|
+
* @param {Pattern} pattern - Pattern to register
|
|
204
|
+
*/
|
|
205
|
+
async register(pattern) {
|
|
206
|
+
// Ensure required fields
|
|
207
|
+
if (!pattern.id || !pattern.name || !pattern.category) {
|
|
208
|
+
throw new Error('Pattern must have id, name, and category');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Create pattern directory if it doesn't exist
|
|
212
|
+
const patternDir = join(this.patternsPath, pattern.path || pattern.id);
|
|
213
|
+
if (!existsSync(patternDir)) {
|
|
214
|
+
mkdirSync(patternDir, { recursive: true });
|
|
215
|
+
|
|
216
|
+
// Create README.md with description
|
|
217
|
+
const readmePath = join(patternDir, 'README.md');
|
|
218
|
+
writeFileSync(readmePath, `# ${pattern.name}\n\n${pattern.description || ''}\n`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Set metadata
|
|
222
|
+
pattern.metadata = pattern.metadata || {
|
|
223
|
+
usageCount: 0,
|
|
224
|
+
lastUsed: null,
|
|
225
|
+
createdAt: new Date().toISOString()
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
this.patterns.set(pattern.id, pattern);
|
|
229
|
+
await this.save();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Remove a pattern
|
|
234
|
+
* @param {string} id - Pattern ID
|
|
235
|
+
*/
|
|
236
|
+
async remove(id) {
|
|
237
|
+
this.patterns.delete(id);
|
|
238
|
+
await this.save();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Record pattern usage
|
|
243
|
+
* @param {string} id - Pattern ID
|
|
244
|
+
*/
|
|
245
|
+
async recordUsage(id) {
|
|
246
|
+
const pattern = this.patterns.get(id);
|
|
247
|
+
if (pattern) {
|
|
248
|
+
pattern.metadata.usageCount++;
|
|
249
|
+
pattern.metadata.lastUsed = new Date().toISOString();
|
|
250
|
+
await this.save();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get pattern content (README.md)
|
|
256
|
+
* @param {string} id - Pattern ID
|
|
257
|
+
* @returns {string|null}
|
|
258
|
+
*/
|
|
259
|
+
getContent(id) {
|
|
260
|
+
const pattern = this.patterns.get(id);
|
|
261
|
+
if (!pattern) return null;
|
|
262
|
+
|
|
263
|
+
// pattern.path is relative to aiPath (e.g., "patterns/frontend/react")
|
|
264
|
+
const readmePath = join(this.aiPath, pattern.path, 'README.md');
|
|
265
|
+
if (!existsSync(readmePath)) return null;
|
|
266
|
+
|
|
267
|
+
return readFileSync(readmePath, 'utf8');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get all files in a pattern directory
|
|
272
|
+
* @param {string} id - Pattern ID
|
|
273
|
+
* @returns {Object[]} Array of {name, path, content}
|
|
274
|
+
*/
|
|
275
|
+
getPatternFiles(id) {
|
|
276
|
+
const pattern = this.patterns.get(id);
|
|
277
|
+
if (!pattern) return [];
|
|
278
|
+
|
|
279
|
+
// pattern.path is relative to aiPath (e.g., "patterns/frontend/react")
|
|
280
|
+
const patternDir = join(this.aiPath, pattern.path);
|
|
281
|
+
if (!existsSync(patternDir)) return [];
|
|
282
|
+
|
|
283
|
+
const files = [];
|
|
284
|
+
const entries = readdirSync(patternDir, { withFileTypes: true });
|
|
285
|
+
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry.isFile()) {
|
|
288
|
+
const filePath = join(patternDir, entry.name);
|
|
289
|
+
files.push({
|
|
290
|
+
name: entry.name,
|
|
291
|
+
path: relative(this.aiPath, filePath),
|
|
292
|
+
content: readFileSync(filePath, 'utf8')
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return files;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get usage statistics
|
|
302
|
+
* @returns {Object}
|
|
303
|
+
*/
|
|
304
|
+
getStats() {
|
|
305
|
+
const patterns = this.getAll();
|
|
306
|
+
const byCategory = {};
|
|
307
|
+
let totalUsage = 0;
|
|
308
|
+
let mostUsed = null;
|
|
309
|
+
|
|
310
|
+
for (const pattern of patterns) {
|
|
311
|
+
// Count by category
|
|
312
|
+
byCategory[pattern.category] = (byCategory[pattern.category] || 0) + 1;
|
|
313
|
+
|
|
314
|
+
// Track total usage
|
|
315
|
+
totalUsage += pattern.metadata.usageCount;
|
|
316
|
+
|
|
317
|
+
// Track most used
|
|
318
|
+
if (!mostUsed || pattern.metadata.usageCount > mostUsed.metadata.usageCount) {
|
|
319
|
+
mostUsed = pattern;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
totalPatterns: patterns.length,
|
|
325
|
+
byCategory,
|
|
326
|
+
totalUsage,
|
|
327
|
+
mostUsed: mostUsed ? { id: mostUsed.id, count: mostUsed.metadata.usageCount } : null
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Associate trigger with pattern
|
|
333
|
+
* @param {string} patternId - Pattern ID
|
|
334
|
+
* @param {string} triggerId - Trigger ID
|
|
335
|
+
*/
|
|
336
|
+
async addTrigger(patternId, triggerId) {
|
|
337
|
+
const pattern = this.patterns.get(patternId);
|
|
338
|
+
if (pattern && !pattern.triggers.includes(triggerId)) {
|
|
339
|
+
pattern.triggers.push(triggerId);
|
|
340
|
+
await this.save();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Remove trigger from pattern
|
|
346
|
+
* @param {string} patternId - Pattern ID
|
|
347
|
+
* @param {string} triggerId - Trigger ID
|
|
348
|
+
*/
|
|
349
|
+
async removeTrigger(patternId, triggerId) {
|
|
350
|
+
const pattern = this.patterns.get(patternId);
|
|
351
|
+
if (pattern) {
|
|
352
|
+
pattern.triggers = pattern.triggers.filter(t => t !== triggerId);
|
|
353
|
+
await this.save();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Create a new PatternRegistry instance for the given workspace
|
|
360
|
+
* @param {string} workspacePath - Path to workspace root
|
|
361
|
+
* @returns {PatternRegistry}
|
|
362
|
+
*/
|
|
363
|
+
export function createRegistry(workspacePath) {
|
|
364
|
+
return new PatternRegistry(workspacePath);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get pattern registry for current working directory
|
|
369
|
+
* @returns {PatternRegistry}
|
|
370
|
+
*/
|
|
371
|
+
export function getRegistry() {
|
|
372
|
+
return createRegistry(process.cwd());
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default { PatternRegistry, createRegistry, getRegistry };
|