pan-wizard 3.7.10 → 3.10.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 (54) hide show
  1. package/README.md +24 -2
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/links.md +102 -0
  27. package/commands/pan/profile.md +2 -0
  28. package/hooks/dist/pan-cost-logger.js +22 -7
  29. package/package.json +5 -4
  30. package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
  31. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  32. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  33. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  34. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  35. package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
  36. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  37. package/pan-wizard-core/bin/lib/links.cjs +549 -0
  38. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  39. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  40. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  41. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  42. package/pan-wizard-core/bin/lib/runner.cjs +6 -0
  43. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  44. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  45. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  46. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  47. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  48. package/pan-wizard-core/bin/lib/verify.cjs +33 -797
  49. package/pan-wizard-core/bin/pan-tools.cjs +35 -1
  50. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  51. package/scripts/build-plugin.js +105 -0
  52. package/scripts/git-hooks/pre-commit +40 -0
  53. package/scripts/install-git-hooks.js +64 -0
  54. package/scripts/release-check.js +13 -2
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Commands / Learnings — error patterns, session history, and session-learnings
3
+ * extraction (LEARN-NNN lifecycle), plus the shared phase-summary collector.
4
+ * Extracted from commands.cjs (IMPROVEMENT-TODO P2 module decomposition);
5
+ * commands.cjs re-exports the public pieces, so consumers are unaffected.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { getArchivedPhaseDirs, output, error } = require('./core.cjs');
11
+ const { extractFrontmatter } = require('./frontmatter.cjs');
12
+ const { PLANNING_DIR, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, isSummaryFile } = require('./constants.cjs');
13
+ const { phasesPath } = require('./utils.cjs');
14
+
15
+ /**
16
+ * Scan all phase directories (archived + current) and read summary frontmatter.
17
+ * Returns an array of { phaseNum, dirName, frontmatter } objects for each summary found.
18
+ *
19
+ * Algorithm overview:
20
+ * 1. Collect archived phase dirs from milestone archives (oldest milestones first)
21
+ * 2. Collect current phase dirs from .planning/phases/
22
+ * 3. For each directory, read all *-summary.md files and extract frontmatter
23
+ *
24
+ * @param {string} cwd - Working directory path
25
+ * @returns {{ allPhaseDirs: Array, summaries: Array<{phaseNum: string, dirName: string, frontmatter: Object}> }}
26
+ */
27
+ function collectPhaseSummaries(cwd) {
28
+ const phasesDir = phasesPath(cwd);
29
+
30
+ // Collect all phase directories: archived + current
31
+ const allPhaseDirs = [];
32
+
33
+ // Add archived phases first (oldest milestones first)
34
+ const archived = getArchivedPhaseDirs(cwd);
35
+ for (const archiveEntry of archived) {
36
+ allPhaseDirs.push({ name: archiveEntry.name, fullPath: archiveEntry.fullPath, milestone: archiveEntry.milestone });
37
+ }
38
+
39
+ // Add current phases
40
+ try {
41
+ const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
42
+ .filter(entry => entry.isDirectory())
43
+ .map(entry => entry.name)
44
+ .sort();
45
+ for (const dirName of currentDirs) {
46
+ allPhaseDirs.push({ name: dirName, fullPath: path.join(phasesDir, dirName), milestone: null });
47
+ }
48
+ } catch { /* phases dir missing or unreadable */ }
49
+
50
+ const summaries = [];
51
+
52
+ for (const { name: dirName, fullPath: dirPath } of allPhaseDirs) {
53
+ let summaryFiles;
54
+ try {
55
+ summaryFiles = fs.readdirSync(dirPath).filter(filename => isSummaryFile(filename));
56
+ } catch { continue; }
57
+
58
+ for (const summaryFile of summaryFiles) {
59
+ try {
60
+ const content = fs.readFileSync(path.join(dirPath, summaryFile), 'utf-8');
61
+ const frontmatter = extractFrontmatter(content);
62
+ const phaseNum = frontmatter.phase || dirName.split('-')[0];
63
+
64
+ summaries.push({ phaseNum, dirName, frontmatter });
65
+ } catch {
66
+ // Skip malformed summary files (broken YAML, unreadable)
67
+ }
68
+ }
69
+ }
70
+
71
+ return { allPhaseDirs, summaries };
72
+ }
73
+
74
+ /**
75
+ * Read error patterns from .planning/patterns.md.
76
+ * Parses PAT-NNN entries into structured objects.
77
+ * @param {string} cwd - Working directory path
78
+ * @returns {Array<{id: string, title: string, wrong: string, right: string, context: string|null, date: string|null}>}
79
+ */
80
+ function readErrorPatterns(cwd) {
81
+ const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
82
+ let content;
83
+ try {
84
+ content = fs.readFileSync(filePath, 'utf-8');
85
+ } catch {
86
+ return [];
87
+ }
88
+
89
+ if (!content || !content.trim()) {
90
+ return [];
91
+ }
92
+
93
+ const patterns = [];
94
+ // Split on PAT-NNN headers
95
+ const sections = content.split(/^### (PAT-\d+):\s*/m);
96
+ // sections[0] = preamble, then alternating [id, body, id, body, ...]
97
+ for (let i = 1; i < sections.length; i += 2) {
98
+ const id = sections[i];
99
+ const body = sections[i + 1] || '';
100
+
101
+ // Title is the first line of the body
102
+ const lines = body.split('\n');
103
+ const title = lines[0] ? lines[0].trim() : '';
104
+ const rest = lines.slice(1).join('\n');
105
+
106
+ const wrongMatch = rest.match(/\*\*Wrong:\*\*\s*(.+)/);
107
+ const rightMatch = rest.match(/\*\*Right:\*\*\s*(.+)/);
108
+ const contextMatch = rest.match(/\*\*Context:\*\*\s*(.+)/);
109
+ const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
110
+
111
+ // Skip entries missing required fields
112
+ if (!wrongMatch || !rightMatch) continue;
113
+
114
+ patterns.push({
115
+ id,
116
+ title,
117
+ wrong: wrongMatch ? wrongMatch[1].trim() : null,
118
+ right: rightMatch ? rightMatch[1].trim() : null,
119
+ context: contextMatch ? contextMatch[1].trim() : null,
120
+ date: dateMatch ? dateMatch[1].trim() : null,
121
+ });
122
+ }
123
+
124
+ return patterns;
125
+ }
126
+
127
+ /**
128
+ * Append a new error pattern entry to .planning/patterns.md.
129
+ * Auto-increments the PAT-NNN ID. Creates file if missing.
130
+ * @param {string} cwd - Working directory path
131
+ * @param {Object} pattern - Pattern to append
132
+ * @param {string} pattern.wrong - What went wrong
133
+ * @param {string} pattern.right - What is correct
134
+ * @param {string} [pattern.title] - Short title
135
+ * @param {string} [pattern.context] - Additional context
136
+ * @param {string} [pattern.date] - Date string (defaults to today)
137
+ * @returns {{ id: string } | { error: string }}
138
+ */
139
+ function appendErrorPattern(cwd, pattern) {
140
+ if (!pattern || !pattern.wrong || !pattern.right) {
141
+ return { error: "Pattern requires 'wrong' and 'right' fields" };
142
+ }
143
+
144
+ const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
145
+ const existing = readErrorPatterns(cwd);
146
+
147
+ // Determine next ID
148
+ let maxNum = 0;
149
+ for (const p of existing) {
150
+ const m = p.id.match(/PAT-(\d+)/);
151
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
152
+ }
153
+ const nextId = `PAT-${String(maxNum + 1).padStart(3, '0')}`;
154
+
155
+ const date = pattern.date || new Date().toISOString().split('T')[0];
156
+ const title = pattern.title || 'Untitled';
157
+
158
+ const entry = [
159
+ '',
160
+ `### ${nextId}: ${title}`,
161
+ `**Wrong:** ${pattern.wrong}`,
162
+ `**Right:** ${pattern.right}`,
163
+ pattern.context ? `**Context:** ${pattern.context}` : null,
164
+ `**Date:** ${date}`,
165
+ '',
166
+ ].filter(line => line !== null).join('\n');
167
+
168
+ try {
169
+ let existingContent = '';
170
+ try {
171
+ existingContent = fs.readFileSync(filePath, 'utf-8');
172
+ } catch {
173
+ // File doesn't exist — create with header
174
+ existingContent = '# Error Patterns\n';
175
+ }
176
+ fs.writeFileSync(filePath, existingContent.trimEnd() + '\n' + entry, 'utf-8');
177
+ return { id: nextId };
178
+ } catch (e) {
179
+ return { error: `Failed to write pattern: ${e.message}` };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Append a session summary to .planning/session-history.md.
185
+ * Creates file with header if missing. Keeps last 20 entries.
186
+ * @param {string} cwd - Working directory path
187
+ * @param {Object} summary - Session summary
188
+ * @param {string} summary.phase - Phase identifier
189
+ * @param {number} [summary.plans_executed] - Plans executed
190
+ * @param {number} [summary.tests_before] - Test count before
191
+ * @param {number} [summary.tests_after] - Test count after
192
+ * @param {string} [summary.key_decisions] - Key decisions made
193
+ * @param {string} [summary.date] - Date string (defaults to today)
194
+ * @returns {{ appended: boolean } | { error: string }}
195
+ */
196
+ function appendSessionSummary(cwd, summary) {
197
+ if (!summary || !summary.phase) {
198
+ return { error: "Summary requires 'phase' field" };
199
+ }
200
+
201
+ const filePath = path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE);
202
+ const date = summary.date || new Date().toISOString().split('T')[0];
203
+
204
+ const entry = [
205
+ `### Session — ${date}`,
206
+ `- **Phase:** ${summary.phase}`,
207
+ summary.plans_executed != null ? `- **Plans Executed:** ${summary.plans_executed}` : null,
208
+ summary.tests_before != null ? `- **Tests Before:** ${summary.tests_before}` : null,
209
+ summary.tests_after != null ? `- **Tests After:** ${summary.tests_after}` : null,
210
+ summary.key_decisions ? `- **Key Decisions:** ${summary.key_decisions}` : null,
211
+ '',
212
+ ].filter(line => line !== null).join('\n');
213
+
214
+ try {
215
+ let content = '';
216
+ try {
217
+ content = fs.readFileSync(filePath, 'utf-8');
218
+ } catch {
219
+ content = '# Session History\n\n';
220
+ }
221
+
222
+ content = content.trimEnd() + '\n\n' + entry;
223
+
224
+ // Keep last 20 entries — split on session headers, trim oldest
225
+ const SESSION_HEADER_RE = /^### Session — /m;
226
+ const parts = content.split(SESSION_HEADER_RE);
227
+ // parts[0] = header, parts[1..N] = session entries
228
+ if (parts.length > 21) { // header + 20 entries
229
+ const header = parts[0];
230
+ const kept = parts.slice(parts.length - 20);
231
+ content = header.trimEnd() + '\n\n' + kept.map(p => '### Session — ' + p).join('');
232
+ }
233
+
234
+ fs.writeFileSync(filePath, content, 'utf-8');
235
+ return { appended: true };
236
+ } catch (e) {
237
+ return { error: `Failed to write session summary: ${e.message}` };
238
+ }
239
+ }
240
+
241
+ // ---- Session Learnings ---------------------------------------------------------
242
+
243
+ /**
244
+ * Parse learnings.md into structured entries.
245
+ * Each learning has: id, type, title, detail, files (optional), date.
246
+ * @param {string} content - Raw content of learnings.md
247
+ * @returns {Array<{id: string, type: string, title: string, detail: string, files: string[], date: string|null}>}
248
+ */
249
+ function parseLearnings(content) {
250
+ if (!content || !content.trim()) return [];
251
+
252
+ const learnings = [];
253
+ const sections = content.split(/^### (LEARN-\d+):\s*/m);
254
+ // sections[0] = preamble, then alternating [id, body, ...]
255
+ for (let i = 1; i < sections.length; i += 2) {
256
+ const id = sections[i];
257
+ const body = sections[i + 1] || '';
258
+
259
+ const lines = body.split('\n');
260
+ const title = lines[0] ? lines[0].trim() : '';
261
+ const rest = lines.slice(1).join('\n');
262
+
263
+ const typeMatch = rest.match(/\*\*Type:\*\*\s*(.+)/);
264
+ const detailMatch = rest.match(/\*\*Detail:\*\*\s*(.+)/);
265
+ const filesMatch = rest.match(/\*\*Files:\*\*\s*(.+)/);
266
+ const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
267
+
268
+ learnings.push({
269
+ id,
270
+ type: typeMatch ? typeMatch[1].trim() : 'unknown',
271
+ title,
272
+ detail: detailMatch ? detailMatch[1].trim() : '',
273
+ files: filesMatch ? filesMatch[1].trim().split(/,\s*/) : [],
274
+ date: dateMatch ? dateMatch[1].trim() : null,
275
+ });
276
+ }
277
+
278
+ return learnings;
279
+ }
280
+
281
+ /**
282
+ * Format a learning entry as markdown text.
283
+ * @param {Object} learning - Learning entry
284
+ * @returns {string}
285
+ */
286
+ function formatLearningEntry(learning) {
287
+ const lines = [
288
+ `### ${learning.id}: ${learning.title}`,
289
+ `**Type:** ${learning.type}`,
290
+ `**Detail:** ${learning.detail}`,
291
+ ];
292
+ if (learning.files && learning.files.length > 0) {
293
+ lines.push(`**Files:** ${learning.files.join(', ')}`);
294
+ }
295
+ if (learning.date) {
296
+ lines.push(`**Date:** ${learning.date}`);
297
+ }
298
+ lines.push('');
299
+ return lines.join('\n');
300
+ }
301
+
302
+ /**
303
+ * Extract learnings from session summaries and error patterns.
304
+ * Reads session history + error patterns, extracts file co-change patterns
305
+ * and error resolutions, writes to .planning/learnings.md.
306
+ * @param {string} cwd - Working directory path
307
+ * @param {boolean} raw - If true, output raw count instead of JSON
308
+ * @returns {void}
309
+ */
310
+ function cmdLearningsExtract(cwd, raw) {
311
+ const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
312
+ const newLearnings = [];
313
+ const today = new Date().toISOString().split('T')[0];
314
+
315
+ // Read existing learnings to get next ID and avoid duplicates
316
+ let existingContent = '';
317
+ try { existingContent = fs.readFileSync(learningsPath, 'utf-8'); } catch { /* new file */ }
318
+ const existing = parseLearnings(existingContent);
319
+ let maxNum = 0;
320
+ for (const l of existing) {
321
+ const m = l.id.match(/LEARN-(\d+)/);
322
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
323
+ }
324
+
325
+ // Existing detail strings for dedup
326
+ const existingDetails = new Set(existing.map(l => l.detail));
327
+
328
+ // 1. Extract error resolutions from patterns.md
329
+ const patterns = readErrorPatterns(cwd);
330
+ for (const pat of patterns) {
331
+ const detail = `${pat.wrong} -> ${pat.right}`;
332
+ if (existingDetails.has(detail)) continue;
333
+ existingDetails.add(detail);
334
+ maxNum++;
335
+ newLearnings.push({
336
+ id: `LEARN-${String(maxNum).padStart(3, '0')}`,
337
+ type: 'error-resolution',
338
+ title: pat.title || 'Error pattern',
339
+ detail,
340
+ files: [],
341
+ date: pat.date || today,
342
+ });
343
+ }
344
+
345
+ // 2. Extract file co-change patterns from summary frontmatters
346
+ const { summaries } = collectPhaseSummaries(cwd);
347
+ const fileCoChanges = new Map(); // file -> Set of co-changed files
348
+
349
+ for (const { frontmatter } of summaries) {
350
+ const keyFiles = Array.isArray(frontmatter['key-files']) ? frontmatter['key-files'] : [];
351
+ if (keyFiles.length < 2) continue;
352
+ for (const file of keyFiles) {
353
+ if (!fileCoChanges.has(file)) fileCoChanges.set(file, new Set());
354
+ for (const other of keyFiles) {
355
+ if (other !== file) fileCoChanges.get(file).add(other);
356
+ }
357
+ }
358
+ }
359
+
360
+ // Emit co-change learnings for files that appear together 2+ times
361
+ const emittedPairs = new Set();
362
+ for (const [file, coFiles] of fileCoChanges) {
363
+ for (const coFile of coFiles) {
364
+ const pair = [file, coFile].sort().join(' + ');
365
+ if (emittedPairs.has(pair)) continue;
366
+ emittedPairs.add(pair);
367
+
368
+ // Count co-occurrences
369
+ let count = 0;
370
+ for (const { frontmatter } of summaries) {
371
+ const kf = frontmatter['key-files'] || [];
372
+ if (kf.includes(file) && kf.includes(coFile)) count++;
373
+ }
374
+ if (count < 2) continue;
375
+
376
+ const detail = `${file} and ${coFile} changed together ${count} times`;
377
+ if (existingDetails.has(detail)) continue;
378
+ existingDetails.add(detail);
379
+ maxNum++;
380
+ newLearnings.push({
381
+ id: `LEARN-${String(maxNum).padStart(3, '0')}`,
382
+ type: 'co-change',
383
+ title: `Co-change: ${path.basename(file)} + ${path.basename(coFile)}`,
384
+ detail,
385
+ files: [file, coFile],
386
+ date: today,
387
+ });
388
+ }
389
+ }
390
+
391
+ // 3. Extract successful patterns from summaries
392
+ for (const { frontmatter } of summaries) {
393
+ const patterns_established = frontmatter['patterns-established'] || [];
394
+ for (const pattern of patterns_established) {
395
+ const detail = String(pattern);
396
+ if (existingDetails.has(detail)) continue;
397
+ existingDetails.add(detail);
398
+ maxNum++;
399
+ newLearnings.push({
400
+ id: `LEARN-${String(maxNum).padStart(3, '0')}`,
401
+ type: 'pattern',
402
+ title: detail.length > 60 ? detail.substring(0, 57) + '...' : detail,
403
+ detail,
404
+ files: [],
405
+ date: today,
406
+ });
407
+ }
408
+ }
409
+
410
+ // Write new learnings to file
411
+ if (newLearnings.length > 0) {
412
+ let content = existingContent;
413
+ if (!content || !content.trim()) {
414
+ content = '# Session Learnings\n\n';
415
+ }
416
+
417
+ for (const learning of newLearnings) {
418
+ content = content.trimEnd() + '\n\n' + formatLearningEntry(learning);
419
+ }
420
+
421
+ try {
422
+ fs.mkdirSync(path.dirname(learningsPath), { recursive: true });
423
+ fs.writeFileSync(learningsPath, content, 'utf-8');
424
+ } catch (e) {
425
+ error('Failed to write learnings: ' + e.message);
426
+ }
427
+ }
428
+
429
+ const result = {
430
+ extracted: newLearnings.length,
431
+ total: existing.length + newLearnings.length,
432
+ by_type: {
433
+ 'error-resolution': newLearnings.filter(l => l.type === 'error-resolution').length,
434
+ 'co-change': newLearnings.filter(l => l.type === 'co-change').length,
435
+ 'pattern': newLearnings.filter(l => l.type === 'pattern').length,
436
+ },
437
+ };
438
+ output(result, raw, `Extracted ${newLearnings.length} new learnings (${result.total} total)`);
439
+ }
440
+
441
+ /**
442
+ * List all learnings from .planning/learnings.md.
443
+ * @param {string} cwd - Working directory path
444
+ * @param {boolean} raw - If true, output raw formatted list instead of JSON
445
+ * @returns {void}
446
+ */
447
+ function cmdLearningsList(cwd, raw) {
448
+ const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
449
+
450
+ let content;
451
+ try {
452
+ content = fs.readFileSync(learningsPath, 'utf-8');
453
+ } catch {
454
+ output({ learnings: [], count: 0 }, raw, 'No learnings found');
455
+ return;
456
+ }
457
+
458
+ const learnings = parseLearnings(content);
459
+ const result = {
460
+ learnings,
461
+ count: learnings.length,
462
+ by_type: {},
463
+ };
464
+
465
+ for (const l of learnings) {
466
+ result.by_type[l.type] = (result.by_type[l.type] || 0) + 1;
467
+ }
468
+
469
+ if (raw) {
470
+ const lines = learnings.map(l => `${l.id} [${l.type}] ${l.title}`);
471
+ output(result, true, lines.join('\n') || 'No learnings found');
472
+ } else {
473
+ output(result, false);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Prune learnings by age (--days) or by ID (--id).
479
+ * @param {string} cwd - Working directory path
480
+ * @param {Object} opts - Prune options
481
+ * @param {number|null} opts.days - Remove entries older than N days
482
+ * @param {string|null} opts.id - Remove specific entry by ID
483
+ * @param {boolean} raw - If true, output raw count instead of JSON
484
+ * @returns {void}
485
+ */
486
+ function cmdLearningsPrune(cwd, opts, raw) {
487
+ const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
488
+
489
+ if (!opts || (opts.days == null && opts.id == null)) {
490
+ error('Prune requires --days N or --id LEARN-NNN');
491
+ }
492
+
493
+ let content;
494
+ try {
495
+ content = fs.readFileSync(learningsPath, 'utf-8');
496
+ } catch {
497
+ output({ pruned: 0, remaining: 0 }, raw, 'No learnings file found');
498
+ return;
499
+ }
500
+
501
+ const learnings = parseLearnings(content);
502
+ const before = learnings.length;
503
+ let kept;
504
+
505
+ if (opts.id) {
506
+ kept = learnings.filter(l => l.id !== opts.id);
507
+ } else if (opts.days != null) {
508
+ const cutoff = new Date();
509
+ cutoff.setDate(cutoff.getDate() - opts.days);
510
+ const cutoffStr = cutoff.toISOString().split('T')[0];
511
+ kept = learnings.filter(l => !l.date || l.date >= cutoffStr);
512
+ } else {
513
+ kept = learnings;
514
+ }
515
+
516
+ const pruned = before - kept.length;
517
+
518
+ // Rewrite file
519
+ let newContent = '# Session Learnings\n';
520
+ for (const learning of kept) {
521
+ newContent += '\n' + formatLearningEntry(learning);
522
+ }
523
+
524
+ try {
525
+ fs.writeFileSync(learningsPath, newContent, 'utf-8');
526
+ } catch (e) {
527
+ error('Failed to write learnings: ' + e.message);
528
+ }
529
+
530
+ const result = { pruned, remaining: kept.length };
531
+ output(result, raw, `Pruned ${pruned} learnings (${kept.length} remaining)`);
532
+ }
533
+
534
+ module.exports = {
535
+ collectPhaseSummaries,
536
+ readErrorPatterns,
537
+ appendErrorPattern,
538
+ appendSessionSummary,
539
+ parseLearnings,
540
+ formatLearningEntry,
541
+ cmdLearningsExtract,
542
+ cmdLearningsList,
543
+ cmdLearningsPrune,
544
+ };