claude-autopm 1.30.0 → 1.31.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.
@@ -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;