claude-autopm 1.28.0 → 1.30.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/README.md +75 -15
- package/autopm/.claude/scripts/pm/analytics.js +425 -0
- package/autopm/.claude/scripts/pm/sync-batch.js +337 -0
- package/lib/README-FILTER-SEARCH.md +285 -0
- package/lib/analytics-engine.js +689 -0
- package/lib/batch-processor-integration.js +366 -0
- package/lib/batch-processor.js +278 -0
- package/lib/burndown-chart.js +415 -0
- package/lib/conflict-history.js +316 -0
- package/lib/conflict-resolver.js +330 -0
- package/lib/dependency-analyzer.js +466 -0
- package/lib/filter-engine.js +414 -0
- package/lib/query-parser.js +322 -0
- package/lib/visual-diff.js +297 -0
- package/package.json +5 -4
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterEngine - Apply filters and search to PRD/Epic/Task collections
|
|
3
|
+
*
|
|
4
|
+
* Loads markdown files with YAML frontmatter and applies powerful filtering
|
|
5
|
+
* and search capabilities.
|
|
6
|
+
*
|
|
7
|
+
* @example Basic Usage
|
|
8
|
+
* ```javascript
|
|
9
|
+
* const FilterEngine = require('./lib/filter-engine');
|
|
10
|
+
* const engine = new FilterEngine({ basePath: '.claude' });
|
|
11
|
+
*
|
|
12
|
+
* // Load and filter PRDs
|
|
13
|
+
* const activePRDs = await engine.loadAndFilter('prds', { status: 'active' });
|
|
14
|
+
*
|
|
15
|
+
* // Search across content
|
|
16
|
+
* const results = await engine.searchAll('authentication', {
|
|
17
|
+
* types: ['prds', 'epics', 'tasks']
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @example Advanced Filtering
|
|
22
|
+
* ```javascript
|
|
23
|
+
* const engine = new FilterEngine();
|
|
24
|
+
*
|
|
25
|
+
* // Load files
|
|
26
|
+
* const files = await engine.loadFiles('.claude/prds');
|
|
27
|
+
*
|
|
28
|
+
* // Apply multiple filters (AND logic)
|
|
29
|
+
* const filtered = await engine.filter(files, {
|
|
30
|
+
* status: 'active',
|
|
31
|
+
* priority: 'high',
|
|
32
|
+
* 'created-after': '2025-01-01'
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // Full-text search
|
|
36
|
+
* const searchResults = await engine.search(files, 'OAuth2');
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @module FilterEngine
|
|
40
|
+
* @version 1.0.0
|
|
41
|
+
* @since v1.28.0
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs').promises;
|
|
45
|
+
const fsSync = require('fs');
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const yaml = require('yaml');
|
|
48
|
+
|
|
49
|
+
class FilterEngine {
|
|
50
|
+
/**
|
|
51
|
+
* Create FilterEngine instance
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} options - Configuration options
|
|
54
|
+
* @param {string} options.basePath - Base path for file operations (default: '.claude')
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.basePath = options.basePath || '.claude';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load markdown files with frontmatter from directory
|
|
62
|
+
*
|
|
63
|
+
* @param {string} directory - Directory to load files from
|
|
64
|
+
* @returns {Promise<Array>} - Array of { path, frontmatter, content }
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const files = await engine.loadFiles('.claude/prds');
|
|
68
|
+
* // Returns: [
|
|
69
|
+
* // {
|
|
70
|
+
* // path: '/path/to/prd-001.md',
|
|
71
|
+
* // frontmatter: { id: 'prd-001', title: 'API', status: 'active' },
|
|
72
|
+
* // content: '# API\nContent here...'
|
|
73
|
+
* // }
|
|
74
|
+
* // ]
|
|
75
|
+
*/
|
|
76
|
+
async loadFiles(directory) {
|
|
77
|
+
const files = [];
|
|
78
|
+
|
|
79
|
+
// Check if directory exists
|
|
80
|
+
try {
|
|
81
|
+
await fs.access(directory);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Read directory
|
|
87
|
+
const entries = await fs.readdir(directory);
|
|
88
|
+
|
|
89
|
+
// Filter for markdown files
|
|
90
|
+
const mdFiles = entries.filter(entry => entry.endsWith('.md'));
|
|
91
|
+
|
|
92
|
+
// Load each file
|
|
93
|
+
for (const filename of mdFiles) {
|
|
94
|
+
const filePath = path.join(directory, filename);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
98
|
+
const parsed = this.parseFrontmatter(content);
|
|
99
|
+
|
|
100
|
+
files.push({
|
|
101
|
+
path: filePath,
|
|
102
|
+
frontmatter: parsed.frontmatter,
|
|
103
|
+
content: parsed.content
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Skip files that can't be read
|
|
107
|
+
console.error(`Error loading ${filePath}:`, error.message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse frontmatter from markdown content
|
|
116
|
+
*
|
|
117
|
+
* @param {string} content - Markdown content with YAML frontmatter
|
|
118
|
+
* @returns {Object} - { frontmatter: Object, content: string }
|
|
119
|
+
* @private
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* const parsed = engine.parseFrontmatter(`---
|
|
123
|
+
* id: prd-001
|
|
124
|
+
* title: API
|
|
125
|
+
* ---
|
|
126
|
+
* # Content
|
|
127
|
+
* `);
|
|
128
|
+
* // Returns: {
|
|
129
|
+
* // frontmatter: { id: 'prd-001', title: 'API' },
|
|
130
|
+
* // content: '# Content\n'
|
|
131
|
+
* // }
|
|
132
|
+
*/
|
|
133
|
+
parseFrontmatter(content) {
|
|
134
|
+
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
135
|
+
const match = content.match(frontmatterRegex);
|
|
136
|
+
|
|
137
|
+
if (!match) {
|
|
138
|
+
return {
|
|
139
|
+
frontmatter: {},
|
|
140
|
+
content: content
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const [, frontmatterText, bodyContent] = match;
|
|
145
|
+
|
|
146
|
+
let frontmatter = {};
|
|
147
|
+
try {
|
|
148
|
+
frontmatter = yaml.parse(frontmatterText) || {};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// Malformed YAML - return empty frontmatter
|
|
151
|
+
frontmatter = {};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
frontmatter,
|
|
156
|
+
content: bodyContent.trimStart() // Remove leading newline
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Apply filters to file collection
|
|
162
|
+
*
|
|
163
|
+
* Uses AND logic - all filters must match for a file to be included.
|
|
164
|
+
*
|
|
165
|
+
* @param {Array} files - Array of files from loadFiles()
|
|
166
|
+
* @param {Object} filters - Filter criteria
|
|
167
|
+
* @returns {Promise<Array>} - Filtered array
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* const filtered = await engine.filter(files, {
|
|
171
|
+
* status: 'active',
|
|
172
|
+
* priority: 'high',
|
|
173
|
+
* 'created-after': '2025-01-01'
|
|
174
|
+
* });
|
|
175
|
+
*/
|
|
176
|
+
async filter(files, filters) {
|
|
177
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Separate search from other filters
|
|
186
|
+
const { search, ...otherFilters } = filters;
|
|
187
|
+
|
|
188
|
+
let results = files;
|
|
189
|
+
|
|
190
|
+
// Apply field-based filters first
|
|
191
|
+
results = this.applyFieldFilters(results, otherFilters);
|
|
192
|
+
|
|
193
|
+
// Apply search if specified
|
|
194
|
+
if (search) {
|
|
195
|
+
results = await this.search(results, search);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Apply field-based filters (status, priority, dates, etc.)
|
|
203
|
+
*
|
|
204
|
+
* @param {Array} files - Files to filter
|
|
205
|
+
* @param {Object} filters - Filter criteria (without search)
|
|
206
|
+
* @returns {Array} - Filtered files
|
|
207
|
+
* @private
|
|
208
|
+
*/
|
|
209
|
+
applyFieldFilters(files, filters) {
|
|
210
|
+
return files.filter(file => {
|
|
211
|
+
const { frontmatter } = file;
|
|
212
|
+
|
|
213
|
+
// Check each filter
|
|
214
|
+
for (const [filterName, filterValue] of Object.entries(filters)) {
|
|
215
|
+
// Handle date filters
|
|
216
|
+
if (filterName === 'created-after') {
|
|
217
|
+
if (!this.isDateAfter(frontmatter.created, filterValue)) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
} else if (filterName === 'created-before') {
|
|
221
|
+
if (!this.isDateBefore(frontmatter.created, filterValue)) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
} else if (filterName === 'updated-after') {
|
|
225
|
+
if (!this.isDateAfter(frontmatter.updated, filterValue)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
} else if (filterName === 'updated-before') {
|
|
229
|
+
if (!this.isDateBefore(frontmatter.updated, filterValue)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
// Simple field match
|
|
234
|
+
if (frontmatter[filterName] !== filterValue) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return true;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if date1 is after date2
|
|
246
|
+
*
|
|
247
|
+
* @param {string} date1 - First date (YYYY-MM-DD)
|
|
248
|
+
* @param {string} date2 - Second date (YYYY-MM-DD)
|
|
249
|
+
* @returns {boolean} - True if date1 is after date2
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
isDateAfter(date1, date2) {
|
|
253
|
+
if (!date1 || !date2) return false;
|
|
254
|
+
return new Date(date1) >= new Date(date2);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check if date1 is before date2
|
|
259
|
+
*
|
|
260
|
+
* @param {string} date1 - First date (YYYY-MM-DD)
|
|
261
|
+
* @param {string} date2 - Second date (YYYY-MM-DD)
|
|
262
|
+
* @returns {boolean} - True if date1 is before date2
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
isDateBefore(date1, date2) {
|
|
266
|
+
if (!date1 || !date2) return false;
|
|
267
|
+
return new Date(date1) <= new Date(date2);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Full-text search in files (content and frontmatter)
|
|
272
|
+
*
|
|
273
|
+
* Case-insensitive search with match context.
|
|
274
|
+
*
|
|
275
|
+
* @param {Array} files - Files to search
|
|
276
|
+
* @param {string} query - Search query
|
|
277
|
+
* @returns {Promise<Array>} - Matching files with match context
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* const results = await engine.search(files, 'authentication');
|
|
281
|
+
* // Returns files with matches, including:
|
|
282
|
+
* // {
|
|
283
|
+
* // path: '...',
|
|
284
|
+
* // frontmatter: {...},
|
|
285
|
+
* // content: '...',
|
|
286
|
+
* // matches: [
|
|
287
|
+
* // { context: 'OAuth2 authentication system', line: 5 }
|
|
288
|
+
* // ]
|
|
289
|
+
* // }
|
|
290
|
+
*/
|
|
291
|
+
async search(files, query) {
|
|
292
|
+
if (!query || query.trim() === '') {
|
|
293
|
+
return files;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const lowerQuery = query.toLowerCase();
|
|
297
|
+
const results = [];
|
|
298
|
+
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const matches = [];
|
|
301
|
+
let found = false;
|
|
302
|
+
|
|
303
|
+
// Search in frontmatter
|
|
304
|
+
const frontmatterText = JSON.stringify(file.frontmatter).toLowerCase();
|
|
305
|
+
if (frontmatterText.includes(lowerQuery)) {
|
|
306
|
+
found = true;
|
|
307
|
+
matches.push({
|
|
308
|
+
context: 'Found in metadata',
|
|
309
|
+
line: 0
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Search in content
|
|
314
|
+
const lines = file.content.split('\n');
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i];
|
|
317
|
+
if (line.toLowerCase().includes(lowerQuery)) {
|
|
318
|
+
found = true;
|
|
319
|
+
matches.push({
|
|
320
|
+
context: line.trim(),
|
|
321
|
+
line: i + 1
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (found) {
|
|
327
|
+
results.push({
|
|
328
|
+
...file,
|
|
329
|
+
matches
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Load and filter in one operation
|
|
339
|
+
*
|
|
340
|
+
* Convenience method that combines loadFiles() and filter().
|
|
341
|
+
*
|
|
342
|
+
* @param {string} type - Type of files (prds/epics/tasks)
|
|
343
|
+
* @param {Object} filters - Filter criteria
|
|
344
|
+
* @returns {Promise<Array>} - Filtered files
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* const activePRDs = await engine.loadAndFilter('prds', { status: 'active' });
|
|
348
|
+
*/
|
|
349
|
+
async loadAndFilter(type, filters) {
|
|
350
|
+
const directory = path.join(this.basePath, type);
|
|
351
|
+
const files = await this.loadFiles(directory);
|
|
352
|
+
return this.filter(files, filters);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Search across multiple file types
|
|
357
|
+
*
|
|
358
|
+
* @param {string} query - Search query
|
|
359
|
+
* @param {Object} options - Search options
|
|
360
|
+
* @param {string[]} options.types - Types to search (default: ['prds', 'epics', 'tasks'])
|
|
361
|
+
* @returns {Promise<Array>} - Search results from all types
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* const results = await engine.searchAll('authentication', {
|
|
365
|
+
* types: ['prds', 'epics']
|
|
366
|
+
* });
|
|
367
|
+
*/
|
|
368
|
+
async searchAll(query, options = {}) {
|
|
369
|
+
const types = options.types || ['prds', 'epics', 'tasks'];
|
|
370
|
+
const allResults = [];
|
|
371
|
+
|
|
372
|
+
for (const type of types) {
|
|
373
|
+
const directory = path.join(this.basePath, type);
|
|
374
|
+
const files = await this.loadFiles(directory);
|
|
375
|
+
const results = await this.search(files, query);
|
|
376
|
+
allResults.push(...results);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return allResults;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Filter files by date range
|
|
384
|
+
*
|
|
385
|
+
* @param {string} type - Type of files (prds/epics/tasks)
|
|
386
|
+
* @param {Object} options - Date range options
|
|
387
|
+
* @param {string} options.field - Date field to filter on (created/updated)
|
|
388
|
+
* @param {string} options.after - Start date (YYYY-MM-DD)
|
|
389
|
+
* @param {string} options.before - End date (YYYY-MM-DD)
|
|
390
|
+
* @returns {Promise<Array>} - Filtered files
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* const recentPRDs = await engine.filterByDateRange('prds', {
|
|
394
|
+
* field: 'created',
|
|
395
|
+
* after: '2025-01-01',
|
|
396
|
+
* before: '2025-12-31'
|
|
397
|
+
* });
|
|
398
|
+
*/
|
|
399
|
+
async filterByDateRange(type, options) {
|
|
400
|
+
const { field, after, before } = options;
|
|
401
|
+
|
|
402
|
+
const filters = {};
|
|
403
|
+
if (after) {
|
|
404
|
+
filters[`${field}-after`] = after;
|
|
405
|
+
}
|
|
406
|
+
if (before) {
|
|
407
|
+
filters[`${field}-before`] = before;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return this.loadAndFilter(type, filters);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = FilterEngine;
|