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.
- package/README.md +31 -9
- package/agents/pan-conductor.md +189 -0
- package/agents/pan-counterfactual.md +112 -0
- package/agents/pan-debugger.md +15 -1
- package/agents/pan-distiller.md +82 -0
- package/agents/pan-document_code.md +21 -0
- package/agents/pan-executor.md +16 -0
- package/agents/pan-hardener.md +113 -0
- package/agents/pan-integration-checker.md +2 -0
- package/agents/pan-knowledge.md +81 -0
- package/agents/pan-meta-reviewer.md +91 -0
- package/agents/pan-optimizer.md +242 -0
- package/agents/pan-plan-checker.md +2 -0
- package/agents/pan-previewer.md +98 -0
- package/agents/pan-project-researcher.md +4 -4
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +2 -0
- package/bin/install-lib.cjs +197 -0
- package/bin/install.js +2048 -1959
- package/commands/pan/cost.md +132 -0
- package/commands/pan/exec-phase.md +15 -0
- package/commands/pan/focus-auto.md +168 -3
- package/commands/pan/focus-exec.md +21 -1
- package/commands/pan/focus-scan.md +6 -0
- package/commands/pan/git.md +223 -0
- package/commands/pan/knowledge.md +129 -0
- package/commands/pan/learn.md +61 -0
- package/commands/pan/map-codebase.md +15 -0
- package/commands/pan/mcp-bridge.md +145 -0
- package/commands/pan/milestone-done.md +9 -0
- package/commands/pan/optimize.md +86 -0
- package/commands/pan/plan-phase.md +11 -0
- package/commands/pan/preview.md +114 -0
- package/commands/pan/profile.md +37 -0
- package/commands/pan/review-deep.md +128 -0
- package/commands/pan/verify-phase.md +11 -0
- package/commands/pan/what-if.md +146 -0
- package/hooks/dist/pan-cost-logger.js +102 -0
- package/hooks/dist/pan-statusline.js +154 -108
- package/hooks/dist/pan-trace-logger.js +197 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
- package/pan-wizard-core/bin/lib/bus.cjs +251 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1 -0
- package/pan-wizard-core/bin/lib/constants.cjs +44 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
- package/pan-wizard-core/bin/lib/core.cjs +91 -6
- package/pan-wizard-core/bin/lib/cost.cjs +359 -0
- package/pan-wizard-core/bin/lib/distill.cjs +510 -0
- package/pan-wizard-core/bin/lib/focus.cjs +108 -3
- package/pan-wizard-core/bin/lib/git.cjs +407 -0
- package/pan-wizard-core/bin/lib/init.cjs +5 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
- package/pan-wizard-core/bin/lib/memory.cjs +252 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
- package/pan-wizard-core/bin/lib/phase.cjs +40 -13
- package/pan-wizard-core/bin/lib/preview.cjs +480 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
- package/pan-wizard-core/bin/lib/state.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify.cjs +34 -1
- package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
- package/pan-wizard-core/bin/pan-tools.cjs +317 -4
- package/pan-wizard-core/templates/playbook.md +53 -0
- package/pan-wizard-core/templates/preview-report.md +93 -0
- package/pan-wizard-core/templates/roadmap.md +24 -24
- package/pan-wizard-core/templates/state.md +12 -9
- package/pan-wizard-core/workflows/exec-phase.md +97 -0
- package/pan-wizard-core/workflows/learn.md +91 -0
- package/pan-wizard-core/workflows/optimize.md +139 -0
- package/pan-wizard-core/workflows/plan-phase.md +28 -1
- package/pan-wizard-core/workflows/quick.md +7 -0
- package/pan-wizard-core/workflows/verify-phase.md +16 -0
- 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(
|
|
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(
|
|
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(
|
|
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]
|
|
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
|
|
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
|
+
};
|