pan-wizard 2.9.1 → 3.5.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 (75) hide show
  1. package/README.md +31 -9
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-distiller.md +82 -0
  6. package/agents/pan-document_code.md +21 -0
  7. package/agents/pan-executor.md +16 -0
  8. package/agents/pan-hardener.md +113 -0
  9. package/agents/pan-integration-checker.md +2 -0
  10. package/agents/pan-knowledge.md +81 -0
  11. package/agents/pan-meta-reviewer.md +91 -0
  12. package/agents/pan-optimizer.md +242 -0
  13. package/agents/pan-plan-checker.md +2 -0
  14. package/agents/pan-previewer.md +98 -0
  15. package/agents/pan-project-researcher.md +4 -4
  16. package/agents/pan-reviewer.md +2 -0
  17. package/agents/pan-verifier.md +2 -0
  18. package/bin/install-lib.cjs +197 -0
  19. package/bin/install.js +2048 -1959
  20. package/commands/pan/cost.md +132 -0
  21. package/commands/pan/exec-phase.md +15 -0
  22. package/commands/pan/focus-auto.md +168 -3
  23. package/commands/pan/focus-exec.md +21 -1
  24. package/commands/pan/focus-scan.md +6 -0
  25. package/commands/pan/git.md +223 -0
  26. package/commands/pan/knowledge.md +129 -0
  27. package/commands/pan/learn.md +61 -0
  28. package/commands/pan/map-codebase.md +15 -0
  29. package/commands/pan/mcp-bridge.md +145 -0
  30. package/commands/pan/milestone-done.md +9 -0
  31. package/commands/pan/optimize.md +86 -0
  32. package/commands/pan/plan-phase.md +11 -0
  33. package/commands/pan/preview.md +114 -0
  34. package/commands/pan/profile.md +37 -0
  35. package/commands/pan/review-deep.md +128 -0
  36. package/commands/pan/verify-phase.md +11 -0
  37. package/commands/pan/what-if.md +146 -0
  38. package/hooks/dist/pan-cost-logger.js +102 -0
  39. package/hooks/dist/pan-statusline.js +154 -108
  40. package/hooks/dist/pan-trace-logger.js +197 -0
  41. package/package.json +1 -1
  42. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  43. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  44. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  45. package/pan-wizard-core/bin/lib/commands.cjs +1 -0
  46. package/pan-wizard-core/bin/lib/constants.cjs +44 -1
  47. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  48. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  49. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  50. package/pan-wizard-core/bin/lib/distill.cjs +510 -0
  51. package/pan-wizard-core/bin/lib/focus.cjs +108 -3
  52. package/pan-wizard-core/bin/lib/git.cjs +407 -0
  53. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  54. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  55. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  56. package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
  57. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  58. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  59. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  60. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  61. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  62. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  63. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  64. package/pan-wizard-core/bin/pan-tools.cjs +317 -4
  65. package/pan-wizard-core/templates/playbook.md +53 -0
  66. package/pan-wizard-core/templates/preview-report.md +93 -0
  67. package/pan-wizard-core/templates/roadmap.md +24 -24
  68. package/pan-wizard-core/templates/state.md +12 -9
  69. package/pan-wizard-core/workflows/exec-phase.md +97 -0
  70. package/pan-wizard-core/workflows/learn.md +91 -0
  71. package/pan-wizard-core/workflows/optimize.md +139 -0
  72. package/pan-wizard-core/workflows/plan-phase.md +28 -1
  73. package/pan-wizard-core/workflows/quick.md +7 -0
  74. package/pan-wizard-core/workflows/verify-phase.md +16 -0
  75. package/scripts/build-hooks.js +3 -1
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Review-Deep — security + cross-check review data layer (Spec B v2 Y-2, v3.2).
3
+ *
4
+ * Orchestration sequence:
5
+ * 1. pan-reviewer (already shipped) — convention/style findings
6
+ * 2. pan-hardener (new, this wave) — OWASP Top 10 + STRIDE audit
7
+ * 3. pan-meta-reviewer (new) — flags things (1) and (2) missed
8
+ *
9
+ * This module provides the DATA LAYER only:
10
+ * - parseReviewFindings(markdown) — extract structured findings from
11
+ * either a reviewer/hardener/meta-reviewer markdown output
12
+ * - mergeReviews(reviewer, hardener, meta) — merge the three findings
13
+ * sets into one consolidated list + conflict table
14
+ * - writeDeepReview(cwd, phaseNum, payload) — serialize the merged output
15
+ * to .planning/reviews/<N>/deep-review.md
16
+ *
17
+ * Agents publish to `review-handoff` channel via bus.cjs for audit trail.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { output, error, safeReadFile, toPosix } = require('./core.cjs');
23
+ const { PLANNING_DIR } = require('./constants.cjs');
24
+ const { planningPath } = require('./utils.cjs');
25
+ const { publish } = require('./bus.cjs');
26
+
27
+ const REVIEWS_DIR = 'reviews';
28
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
29
+ const SEVERITY_WEIGHT = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
30
+
31
+ function reviewsDir(cwd) {
32
+ return path.join(planningPath(cwd), REVIEWS_DIR);
33
+ }
34
+
35
+ /**
36
+ * Parse structured findings from reviewer/hardener/meta-reviewer markdown.
37
+ *
38
+ * Expected format: each finding is a bullet under a `## Findings` heading
39
+ * with the shape:
40
+ * - **[SEVERITY] category** — description. File: `path:line` — rationale.
41
+ *
42
+ * Recognized severities (case-insensitive): critical, high, medium, low, info.
43
+ * Missing severity defaults to `info`.
44
+ *
45
+ * @param {string} content - Full markdown content
46
+ * @param {string} source - Label for finding.source (e.g. "reviewer", "hardener")
47
+ * @returns {Array<Object>}
48
+ */
49
+ function parseReviewFindings(content, source) {
50
+ if (typeof content !== 'string' || !content) return [];
51
+ const findings = [];
52
+ const lines = content.split('\n');
53
+ let inFindings = false;
54
+ for (const line of lines) {
55
+ if (/^##\s+Findings\s*$/i.test(line)) { inFindings = true; continue; }
56
+ if (inFindings && /^##\s+/.test(line)) { inFindings = false; continue; }
57
+ if (!inFindings) continue;
58
+
59
+ const m = line.match(/^-\s+(?:\*\*\[(critical|high|medium|low|info)\]\s*([^*]*?)\*\*\s*[-—:]\s*)?(.+)$/i);
60
+ if (!m) continue;
61
+ const severity = (m[1] || 'info').toLowerCase();
62
+ const category = (m[2] || 'general').trim();
63
+ const rest = m[3].trim();
64
+
65
+ // Optional `File: path:line — rationale.`
66
+ const fileMatch = rest.match(/File:\s*`?([^:`\s]+)(?::(\d+))?`?\s*[-—]?\s*(.*)$/i);
67
+ const description = fileMatch ? rest.slice(0, fileMatch.index).trim().replace(/[.\s]+$/, '') : rest;
68
+ const file = fileMatch ? fileMatch[1] : null;
69
+ const lineNum = fileMatch && fileMatch[2] ? Number(fileMatch[2]) : null;
70
+ const rationale = fileMatch ? (fileMatch[3] || null) : null;
71
+
72
+ findings.push({
73
+ source,
74
+ severity,
75
+ category,
76
+ description,
77
+ file,
78
+ line: lineNum,
79
+ rationale,
80
+ });
81
+ }
82
+ return findings;
83
+ }
84
+
85
+ /**
86
+ * Merge findings from the three reviewers into a consolidated list plus
87
+ * a conflict table. A "conflict" is when the meta-reviewer explicitly
88
+ * disputes a reviewer/hardener finding (meta source mentions `dispute` or
89
+ * `overstated`) or adds a finding that reviewer/hardener missed.
90
+ *
91
+ * @param {Array|string} reviewer - reviewer findings array OR markdown content
92
+ * @param {Array|string} hardener - hardener findings array OR markdown content
93
+ * @param {Array|string} [meta] - optional meta-reviewer findings
94
+ * @returns {Object} Merged payload
95
+ */
96
+ function mergeReviews(reviewer, hardener, meta) {
97
+ const r = Array.isArray(reviewer) ? reviewer : parseReviewFindings(reviewer || '', 'reviewer');
98
+ const h = Array.isArray(hardener) ? hardener : parseReviewFindings(hardener || '', 'hardener');
99
+ const m = Array.isArray(meta) ? meta : parseReviewFindings(meta || '', 'meta-reviewer');
100
+
101
+ const findings = [...r, ...h, ...m].sort((a, b) => {
102
+ const wa = SEVERITY_WEIGHT[a.severity] ?? 0;
103
+ const wb = SEVERITY_WEIGHT[b.severity] ?? 0;
104
+ if (wa !== wb) return wb - wa;
105
+ return (a.file || '').localeCompare(b.file || '');
106
+ });
107
+
108
+ // Conflicts: any meta-reviewer finding whose description contains keywords
109
+ // suggesting disagreement, OR any meta finding on a file+line the other
110
+ // sources didn't flag.
111
+ const conflicts = [];
112
+ for (const mf of m) {
113
+ // "missed" is genuinely ambiguous — a meta describing a finding as
114
+ // "missed issue" is an addition, not a dispute. Restrict dispute keywords
115
+ // to ones that explicitly signal disagreement with a prior finding.
116
+ const kw = /\b(dispute|overstated|incorrectly|false\s*positive|overrated|underrated)\b/i;
117
+ if (kw.test(mf.description)) {
118
+ conflicts.push({
119
+ type: 'meta_dispute',
120
+ finding: mf,
121
+ });
122
+ continue;
123
+ }
124
+ // Missed: meta raises something reviewer+hardener didn't on same file.
125
+ if (mf.file) {
126
+ const othersFoundThisFile = [...r, ...h].some(x => x.file === mf.file && x.line === mf.line);
127
+ if (!othersFoundThisFile) {
128
+ conflicts.push({
129
+ type: 'meta_addition',
130
+ finding: mf,
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ const coverage = {
137
+ total: findings.length,
138
+ by_source: {
139
+ reviewer: r.length,
140
+ hardener: h.length,
141
+ meta_reviewer: m.length,
142
+ },
143
+ by_severity: SEVERITIES.reduce((acc, s) => { acc[s] = findings.filter(f => f.severity === s).length; return acc; }, {}),
144
+ };
145
+
146
+ // Verdict: highest-severity finding drives the verdict.
147
+ let verdict;
148
+ if (coverage.by_severity.critical > 0) verdict = 'block';
149
+ else if (coverage.by_severity.high > 0) verdict = 'review_required';
150
+ else if (coverage.by_severity.medium > 0) verdict = 'fix_before_merge';
151
+ else if (coverage.by_severity.low > 0) verdict = 'ok_with_minor';
152
+ else verdict = 'ok';
153
+
154
+ return { findings, conflicts, coverage, verdict };
155
+ }
156
+
157
+ /**
158
+ * Write the merged deep-review report to .planning/reviews/<phase>/deep-review.md.
159
+ * Returns the written path.
160
+ *
161
+ * @param {string} cwd - Project root
162
+ * @param {string} phaseNum - Phase number (e.g. "07")
163
+ * @param {Object} payload - mergeReviews() output
164
+ * @param {Object} [opts] - {timestamp, audit_channel}
165
+ * @returns {{written: true, file: string}|{error: string}}
166
+ */
167
+ function writeDeepReview(cwd, phaseNum, payload, opts) {
168
+ if (!phaseNum) return { error: 'phaseNum required' };
169
+ const targetDir = path.join(reviewsDir(cwd), String(phaseNum));
170
+ try {
171
+ fs.mkdirSync(targetDir, { recursive: true });
172
+ } catch (e) {
173
+ return { error: `Failed to create ${targetDir}: ${e.message}` };
174
+ }
175
+
176
+ const lines = [];
177
+ lines.push('---');
178
+ lines.push('type: deep-review');
179
+ lines.push(`phase: ${phaseNum}`);
180
+ lines.push(`generated: ${opts?.timestamp || new Date().toISOString()}`);
181
+ lines.push(`verdict: ${payload.verdict}`);
182
+ lines.push('---');
183
+ lines.push('');
184
+ lines.push(`# Deep Review — Phase ${phaseNum}`);
185
+ lines.push('');
186
+ lines.push(`**Verdict:** ${payload.verdict}`);
187
+ lines.push('');
188
+ lines.push('## Coverage');
189
+ lines.push(`- Total findings: ${payload.coverage.total}`);
190
+ lines.push(`- By source: reviewer=${payload.coverage.by_source.reviewer}, hardener=${payload.coverage.by_source.hardener}, meta=${payload.coverage.by_source.meta_reviewer}`);
191
+ lines.push(`- By severity: ${SEVERITIES.map(s => `${s}=${payload.coverage.by_severity[s]}`).join(', ')}`);
192
+ lines.push('');
193
+
194
+ if (payload.findings.length > 0) {
195
+ lines.push('## Findings');
196
+ lines.push('');
197
+ lines.push('| Severity | Source | Category | Description | File |');
198
+ lines.push('|----------|--------|----------|-------------|------|');
199
+ for (const f of payload.findings) {
200
+ const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
201
+ const desc = f.description.replace(/\|/g, '\\|');
202
+ lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
203
+ }
204
+ lines.push('');
205
+ } else {
206
+ lines.push('## Findings');
207
+ lines.push('');
208
+ lines.push('_No findings — all three reviewers returned clean._');
209
+ lines.push('');
210
+ }
211
+
212
+ if (payload.conflicts.length > 0) {
213
+ lines.push('## Conflicts & additions from meta-reviewer');
214
+ lines.push('');
215
+ for (const c of payload.conflicts) {
216
+ const locLine = c.finding.file ? ` at \`${c.finding.file}${c.finding.line ? `:${c.finding.line}` : ''}\`` : '';
217
+ lines.push(`- **${c.type}** — ${c.finding.description}${locLine}`);
218
+ }
219
+ lines.push('');
220
+ }
221
+
222
+ const file = path.join(targetDir, 'deep-review.md');
223
+ try {
224
+ fs.writeFileSync(file, lines.join('\n'), 'utf-8');
225
+ } catch (e) {
226
+ return { error: `Failed to write ${file}: ${e.message}` };
227
+ }
228
+
229
+ // Audit trail on the review-handoff bus channel (best-effort).
230
+ if (opts?.audit_channel !== false) {
231
+ try {
232
+ publish(cwd, 'review-handoff', {
233
+ phase: phaseNum,
234
+ verdict: payload.verdict,
235
+ finding_count: payload.coverage.total,
236
+ conflict_count: payload.conflicts.length,
237
+ file: toPosix(path.relative(cwd, file)),
238
+ }, { source: 'pan-meta-reviewer' });
239
+ } catch { /* non-blocking */ }
240
+ }
241
+
242
+ return { written: true, file: toPosix(path.relative(cwd, file)) };
243
+ }
244
+
245
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
246
+
247
+ function cmdReviewDeepMerge(cwd, phaseNum, opts, raw) {
248
+ if (!phaseNum) error('Usage: review-deep merge <phase> --reviewer-file X --hardener-file Y [--meta-file Z]');
249
+ const reviewerContent = opts.reviewerFile ? safeReadFile(opts.reviewerFile) : '';
250
+ const hardenerContent = opts.hardenerFile ? safeReadFile(opts.hardenerFile) : '';
251
+ const metaContent = opts.metaFile ? safeReadFile(opts.metaFile) : '';
252
+ if (!reviewerContent && !hardenerContent && !metaContent) {
253
+ output({ error: 'No input files provided or readable' }, raw);
254
+ return;
255
+ }
256
+ const payload = mergeReviews(reviewerContent, hardenerContent, metaContent);
257
+ const result = writeDeepReview(cwd, phaseNum, payload);
258
+ if (result.error) { output(result, raw); return; }
259
+ output({ ...result, verdict: payload.verdict, coverage: payload.coverage, conflicts: payload.conflicts.length }, raw);
260
+ }
261
+
262
+ function cmdReviewDeepAnalyze(cwd, phaseNum, opts, raw) {
263
+ // Returns the merged payload WITHOUT writing a file. Useful for piping.
264
+ if (!phaseNum) error('Usage: review-deep analyze <phase> --reviewer-file X --hardener-file Y [--meta-file Z]');
265
+ const reviewerContent = opts.reviewerFile ? safeReadFile(opts.reviewerFile) : '';
266
+ const hardenerContent = opts.hardenerFile ? safeReadFile(opts.hardenerFile) : '';
267
+ const metaContent = opts.metaFile ? safeReadFile(opts.metaFile) : '';
268
+ output(mergeReviews(reviewerContent, hardenerContent, metaContent), raw);
269
+ }
270
+
271
+ module.exports = {
272
+ parseReviewFindings,
273
+ mergeReviews,
274
+ writeDeepReview,
275
+ cmdReviewDeepMerge,
276
+ cmdReviewDeepAnalyze,
277
+ SEVERITIES,
278
+ SEVERITY_WEIGHT,
279
+ REVIEWS_DIR,
280
+ };
@@ -68,7 +68,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
68
68
  const section = content.slice(headerIndex, sectionEnd).trim();
69
69
 
70
70
  // Extract goal if present
71
- const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
71
+ const goalMatch = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
72
72
  const goal = goalMatch ? goalMatch[1].trim() : null;
73
73
 
74
74
  // Extract success criteria as structured array
@@ -122,11 +122,11 @@ function enumerateRoadmapPhases(content) {
122
122
  const section = content.slice(sectionStart, sectionEnd);
123
123
 
124
124
  // Extract goal from the section
125
- const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
125
+ const goalMatch = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
126
126
  const goal = goalMatch ? goalMatch[1].trim() : null;
127
127
 
128
128
  // Extract dependency info from the section
129
- const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i);
129
+ const dependsMatch = section.match(/(?:\*\*Depends on:\*\*|\*\*Depends on\*\*:)\s*([^\n]+)/i);
130
130
  const dependsOn = dependsMatch ? dependsMatch[1].trim() : null;
131
131
 
132
132
  phases.push({
@@ -366,7 +366,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
366
366
 
367
367
  // Update plan count in phase detail section
368
368
  const planCountPattern = new RegExp(
369
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
369
+ `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
370
370
  'i'
371
371
  );
372
372
  const planCountText = isComplete
@@ -574,10 +574,10 @@ function parseSessionFromState(content) {
574
574
  resume_file: null,
575
575
  };
576
576
 
577
- const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
577
+ const sessionMatch = content.match(/##\s*Session[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
578
578
  if (sessionMatch) {
579
579
  const sessionSection = sessionMatch[1];
580
- const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
580
+ const lastDateMatch = sessionSection.match(/\*\*(?:Last Date|Last session):\*\*\s*(.+)/i);
581
581
  const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
582
582
  const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
583
583
 
@@ -1847,7 +1847,7 @@ function groupGapPatterns(patterns) {
1847
1847
  * @param {string} cwd - Working directory
1848
1848
  * @param {boolean} raw - Raw output flag
1849
1849
  */
1850
- function cmdRetro(cwd, raw) {
1850
+ function cmdRetro(cwd, raw, args) {
1851
1851
  const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
1852
1852
  const roadmapContent = safeReadFile(roadmapPath);
1853
1853
  if (!roadmapContent) {
@@ -1880,6 +1880,36 @@ function cmdRetro(cwd, raw) {
1880
1880
  common_gap_patterns: gapGroups,
1881
1881
  };
1882
1882
 
1883
+ // E-4: optional memory write. Top gap patterns become lessons for pan-planner
1884
+ // (they surface what plans routinely miss). First-try rate deltas feed
1885
+ // pan-verifier memory.
1886
+ const argsList = Array.isArray(args) ? args : [];
1887
+ if (argsList.includes('--write-memory')) {
1888
+ const { appendMemory } = require('./memory.cjs');
1889
+ const lessons_written = { 'pan-planner': 0, 'pan-verifier': 0 };
1890
+ const maxIdx = argsList.indexOf('--max');
1891
+ const maxLessons = maxIdx !== -1 && argsList[maxIdx + 1]
1892
+ ? Math.max(1, Math.min(10, Number(argsList[maxIdx + 1]) || 3))
1893
+ : 3;
1894
+
1895
+ // Top N gap patterns → planner memory as single-line lessons.
1896
+ const top = gapGroups.slice(0, maxLessons);
1897
+ for (const g of top) {
1898
+ const lesson = `Recurring plan gap (${g.count}x across phases): "${g.pattern}" — factor into plan-checker inputs`;
1899
+ const r = appendMemory(cwd, 'pan-planner', lesson);
1900
+ if (r.appended) lessons_written['pan-planner'] += 1;
1901
+ }
1902
+
1903
+ // Low first-try rate → verifier memory.
1904
+ if (verification.total >= 3 && result.first_try_rate_pct != null && result.first_try_rate_pct < 60) {
1905
+ const lesson = `First-try verification rate ${result.first_try_rate_pct}% over ${verification.total} runs — tighten verification criteria and pre-exec checks`;
1906
+ const r = appendMemory(cwd, 'pan-verifier', lesson);
1907
+ if (r.appended) lessons_written['pan-verifier'] += 1;
1908
+ }
1909
+
1910
+ result.memory = { wrote: lessons_written, max: maxLessons };
1911
+ }
1912
+
1883
1913
  const rawLines = [
1884
1914
  `Phases: ${phases.completed}/${phases.planned} completed (${phases.decimal_phases} gap closures)`,
1885
1915
  `Estimation accuracy: ${estimationAccuracy}%`,
@@ -1890,6 +1920,9 @@ function cmdRetro(cwd, raw) {
1890
1920
  rawLines.push('Common gap patterns:');
1891
1921
  for (const g of gapGroups) rawLines.push(` - ${g.pattern} (${g.count}x)`);
1892
1922
  }
1923
+ if (result.memory) {
1924
+ rawLines.push(`Memory: wrote ${result.memory.wrote['pan-planner']} planner + ${result.memory.wrote['pan-verifier']} verifier lessons`);
1925
+ }
1893
1926
 
1894
1927
  output(result, raw, rawLines.join('\n'));
1895
1928
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Whatif — counterfactual phase exploration (Spec B v2 Y-4, v3.3).
3
+ *
4
+ * Creates an isolated git worktree, lets an agent replay a phase with a
5
+ * different premise, emits a comparison report, and cleans up.
6
+ *
7
+ * The module has two concerns:
8
+ * 1. **Data layer** (pure, testable without git): context gathering,
9
+ * report generation, scenario normalization.
10
+ * 2. **Worktree lifecycle** (shell-out): createWorktree, cleanupWorktree.
11
+ * Exercised only on real git repos; testable via scenario tests that
12
+ * git-init a temp project.
13
+ *
14
+ * Default worktree location: `<cwd>/../pan-whatif-<phase>-<scenario-slug>-<ts>`
15
+ * (sibling of the main repo, not inside, to avoid `.gitignore` games).
16
+ * Override via opts.worktree_root.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { output, error, safeReadFile, isGitRepo, execGit, toPosix, findPhaseInternal } = require('./core.cjs');
22
+ const { PLANNING_DIR } = require('./constants.cjs');
23
+ const { planningPath } = require('./utils.cjs');
24
+
25
+ const COUNTERFACTUALS_DIR = 'counterfactuals';
26
+ const BRANCH_PREFIX = 'pan-whatif/';
27
+ const SCENARIO_SLUG_MAX = 50;
28
+
29
+ // ─── Data layer ─────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Turn a free-text scenario into a filesystem/branch-safe slug.
33
+ * Lowercase, alphanumerics + hyphens only, bounded length.
34
+ *
35
+ * @param {string} scenario
36
+ * @returns {string}
37
+ */
38
+ function scenarioSlug(scenario) {
39
+ if (typeof scenario !== 'string') return 'scenario';
40
+ const slug = scenario
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .slice(0, SCENARIO_SLUG_MAX);
45
+ return slug || 'scenario';
46
+ }
47
+
48
+ /**
49
+ * Gather context the counterfactual agent needs: phase plan, goal, the
50
+ * stated alternative scenario, and (optional) the completed summary so
51
+ * the agent can compare "what actually happened" vs "what would have happened".
52
+ *
53
+ * @param {string} cwd - Project root
54
+ * @param {string|number} phaseNum - Phase identifier
55
+ * @param {string} scenario - Free-text alternative premise
56
+ * @returns {Object} Context payload
57
+ */
58
+ function buildCounterfactualContext(cwd, phaseNum, scenario) {
59
+ if (!scenario || !scenario.trim()) {
60
+ return { error: 'scenario required (free-text alternative premise)' };
61
+ }
62
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
63
+ if (!phaseInfo || !phaseInfo.found) {
64
+ return { error: `Phase ${phaseNum} not found in .planning/phases/` };
65
+ }
66
+
67
+ const phaseDir = path.join(cwd, phaseInfo.directory);
68
+ const plans = (phaseInfo.plans || []).sort();
69
+ const summaries = (phaseInfo.summaries || []).sort();
70
+
71
+ const planTexts = plans.map(f => ({
72
+ file: f,
73
+ bytes: Buffer.byteLength(safeReadFile(path.join(phaseDir, f)) || '', 'utf-8'),
74
+ }));
75
+ const summaryTexts = summaries.map(f => ({
76
+ file: f,
77
+ bytes: Buffer.byteLength(safeReadFile(path.join(phaseDir, f)) || '', 'utf-8'),
78
+ }));
79
+
80
+ return {
81
+ phase: String(phaseNum),
82
+ phase_name: phaseInfo.name || null,
83
+ directory: toPosix(phaseInfo.directory),
84
+ scenario,
85
+ slug: scenarioSlug(scenario),
86
+ plans: planTexts,
87
+ summaries: summaryTexts,
88
+ has_executed: summaries.length > 0,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Serialize a counterfactual comparison to a markdown report.
94
+ *
95
+ * @param {string} cwd - Project root
96
+ * @param {string} phaseNum - Phase number
97
+ * @param {string} scenario - Original scenario text
98
+ * @param {Object} comparison - {summary, differences, recommendations, risks}
99
+ * @param {Object} [opts] - {timestamp}
100
+ * @returns {{written: true, file: string}|{error: string}}
101
+ */
102
+ function writeCounterfactualReport(cwd, phaseNum, scenario, comparison, opts) {
103
+ if (!phaseNum) return { error: 'phaseNum required' };
104
+ if (!scenario || !scenario.trim()) return { error: 'scenario required' };
105
+
106
+ const dir = path.join(planningPath(cwd), COUNTERFACTUALS_DIR);
107
+ try {
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ } catch (e) {
110
+ return { error: `Failed to create ${dir}: ${e.message}` };
111
+ }
112
+
113
+ const slug = scenarioSlug(scenario);
114
+ const filename = `${phaseNum}-${slug}.md`;
115
+ const file = path.join(dir, filename);
116
+
117
+ const lines = [];
118
+ lines.push('---');
119
+ lines.push('type: counterfactual');
120
+ lines.push(`phase: ${phaseNum}`);
121
+ lines.push(`scenario_slug: ${slug}`);
122
+ lines.push(`generated: ${opts?.timestamp || new Date().toISOString()}`);
123
+ lines.push('---');
124
+ lines.push('');
125
+ lines.push(`# What-if: Phase ${phaseNum} — ${scenario}`);
126
+ lines.push('');
127
+ lines.push('## Summary');
128
+ lines.push('');
129
+ lines.push(comparison?.summary || '_(agent did not produce a summary)_');
130
+ lines.push('');
131
+
132
+ if (Array.isArray(comparison?.differences) && comparison.differences.length > 0) {
133
+ lines.push('## Differences from actual plan');
134
+ lines.push('');
135
+ for (const d of comparison.differences) {
136
+ lines.push(`- ${d}`);
137
+ }
138
+ lines.push('');
139
+ }
140
+
141
+ if (Array.isArray(comparison?.recommendations) && comparison.recommendations.length > 0) {
142
+ lines.push('## Recommendations');
143
+ lines.push('');
144
+ for (const r of comparison.recommendations) {
145
+ lines.push(`- ${r}`);
146
+ }
147
+ lines.push('');
148
+ }
149
+
150
+ if (Array.isArray(comparison?.risks) && comparison.risks.length > 0) {
151
+ lines.push('## Risks');
152
+ lines.push('');
153
+ for (const risk of comparison.risks) {
154
+ lines.push(`- ${risk}`);
155
+ }
156
+ lines.push('');
157
+ }
158
+
159
+ if (comparison?.verdict) {
160
+ lines.push('## Bottom line');
161
+ lines.push('');
162
+ lines.push(`**${comparison.verdict}**`);
163
+ lines.push('');
164
+ }
165
+
166
+ try {
167
+ fs.writeFileSync(file, lines.join('\n'), 'utf-8');
168
+ } catch (e) {
169
+ return { error: `Failed to write ${file}: ${e.message}` };
170
+ }
171
+
172
+ return { written: true, file: toPosix(path.relative(cwd, file)) };
173
+ }
174
+
175
+ // ─── Worktree lifecycle (git shell-out) ────────────────────────────────────
176
+
177
+ /**
178
+ * Create an isolated git worktree for counterfactual replay.
179
+ *
180
+ * @param {string} cwd - Main project root
181
+ * @param {string} phaseNum - Phase number (used in branch name)
182
+ * @param {string} scenario - Free-text scenario (slugified for paths)
183
+ * @param {Object} [opts] - {worktree_root, base}
184
+ * @returns {{worktree_path: string, branch: string, base: string}|{error: string}}
185
+ */
186
+ function createWorktree(cwd, phaseNum, scenario, opts) {
187
+ if (!isGitRepo(cwd)) {
188
+ return { error: 'Not a git repo — what-if requires git worktree support' };
189
+ }
190
+ const slug = scenarioSlug(scenario);
191
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
192
+ const branch = `${BRANCH_PREFIX}${phaseNum}-${slug}-${ts.slice(0, 15)}`;
193
+ const worktreeRoot = opts?.worktree_root
194
+ || path.join(path.dirname(cwd), `pan-whatif-${phaseNum}-${slug}-${ts.slice(0, 15)}`);
195
+
196
+ // Base ref: current HEAD by default. Callers can override (e.g. to branch
197
+ // off main for a clean comparison).
198
+ const base = opts?.base || 'HEAD';
199
+
200
+ const result = execGit(cwd, ['worktree', 'add', '-b', branch, worktreeRoot, base]);
201
+ if (result.exitCode !== 0) {
202
+ return { error: `git worktree add failed: ${result.stderr}` };
203
+ }
204
+
205
+ return {
206
+ worktree_path: toPosix(worktreeRoot),
207
+ branch,
208
+ base,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Remove a worktree + its branch. Best-effort: errors are surfaced but
214
+ * don't block subsequent cleanups.
215
+ *
216
+ * @param {string} cwd - Main project root (for the cleanup git call)
217
+ * @param {string} worktreePath - Path returned by createWorktree
218
+ * @param {string} branch - Branch name returned by createWorktree
219
+ * @param {Object} [opts] - {force: boolean}
220
+ * @returns {{removed: true, warnings: string[]}|{error: string}}
221
+ */
222
+ function cleanupWorktree(cwd, worktreePath, branch, opts) {
223
+ if (!isGitRepo(cwd)) {
224
+ return { error: 'Not a git repo' };
225
+ }
226
+ const warnings = [];
227
+ const force = opts?.force === true;
228
+
229
+ const rmArgs = ['worktree', 'remove'];
230
+ if (force) rmArgs.push('--force');
231
+ rmArgs.push(worktreePath);
232
+ const removeResult = execGit(cwd, rmArgs);
233
+ if (removeResult.exitCode !== 0) {
234
+ warnings.push(`worktree remove: ${removeResult.stderr}`);
235
+ }
236
+
237
+ // Branch cleanup — only if branch still exists.
238
+ if (branch) {
239
+ const branchCheck = execGit(cwd, ['branch', '--list', branch]);
240
+ if (branchCheck.exitCode === 0 && branchCheck.stdout.trim()) {
241
+ const deleteResult = execGit(cwd, ['branch', '-D', branch]);
242
+ if (deleteResult.exitCode !== 0) {
243
+ warnings.push(`branch delete: ${deleteResult.stderr}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ return { removed: true, warnings };
249
+ }
250
+
251
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
252
+
253
+ function cmdWhatifPrepare(cwd, phaseNum, scenario, raw) {
254
+ // Returns context + creates worktree. Called before spawning agent.
255
+ if (!scenario) error('Usage: whatif prepare <phase> <scenario>');
256
+ const ctx = buildCounterfactualContext(cwd, phaseNum, scenario);
257
+ if (ctx.error) { output(ctx, raw); return; }
258
+ const wt = createWorktree(cwd, phaseNum, scenario);
259
+ if (wt.error) { output({ ...ctx, worktree_error: wt.error }, raw); return; }
260
+ output({ ...ctx, worktree: wt }, raw);
261
+ }
262
+
263
+ function cmdWhatifReport(cwd, phaseNum, scenario, comparisonJson, raw) {
264
+ if (!scenario) error('Usage: whatif report <phase> <scenario> --comparison <json>');
265
+ let comparison = {};
266
+ if (comparisonJson) {
267
+ try { comparison = JSON.parse(comparisonJson); }
268
+ catch (e) { error(`Invalid --comparison JSON: ${e.message}`); }
269
+ }
270
+ output(writeCounterfactualReport(cwd, phaseNum, scenario, comparison), raw);
271
+ }
272
+
273
+ function cmdWhatifCleanup(cwd, worktreePath, branch, force, raw) {
274
+ if (!worktreePath) error('Usage: whatif cleanup --worktree <path> --branch <name> [--force]');
275
+ output(cleanupWorktree(cwd, worktreePath, branch, { force: Boolean(force) }), raw);
276
+ }
277
+
278
+ module.exports = {
279
+ scenarioSlug,
280
+ buildCounterfactualContext,
281
+ writeCounterfactualReport,
282
+ createWorktree,
283
+ cleanupWorktree,
284
+ cmdWhatifPrepare,
285
+ cmdWhatifReport,
286
+ cmdWhatifCleanup,
287
+ COUNTERFACTUALS_DIR,
288
+ BRANCH_PREFIX,
289
+ };