pan-wizard 3.14.0 → 3.15.1

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,723 @@
1
+ /**
2
+ * Phase report — per-phase graphical HTML deliverable (M1 of the phase-report
3
+ * design; see the design dossier). Produces one self-contained HTML report per
4
+ * phase plus a project-level timeline index, reusing hud.cjs's rendering
5
+ * foundation verbatim so both surfaces look identical.
6
+ *
7
+ * Three layers, mirroring hud.cjs:
8
+ * 1. collectPhaseData / collectIndexData — PURE reads of .planning/, deterministic
9
+ * given an injected `now`, never write/exec, never throw on missing artifacts.
10
+ * 2. renderPhaseHtml / renderIndexHtml — PURE string producers, one self-contained
11
+ * document each; every project-derived value is HTML-escaped via esc().
12
+ * 3. cmdReport — the ONLY side-effecting layer: resolves paths, writes (skipping a
13
+ * write when only the timestamp changed), optionally opens a browser, calls output().
14
+ *
15
+ * Honesty by construction: the reconcile verdict is shown beside the (rubber-stampable)
16
+ * self-reported verification status; status is framed as a current-disk snapshot; and
17
+ * counts are derived from disk at render time (never embedded as drift-prone literals).
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const {
25
+ output, error, safeReadFile, escapeRegex, toPosix,
26
+ } = require('./core.cjs');
27
+ const {
28
+ ROADMAP_FILE, PROJECT_FILE,
29
+ isResearchFile, isContextFile, isVerificationFile,
30
+ getPlanId, getSummaryId,
31
+ } = require('./constants.cjs');
32
+ const {
33
+ planningPath, phasesPath, listPhaseDirs, parsePhaseDir,
34
+ filterPlanFiles, filterSummaryFiles,
35
+ } = require('./utils.cjs');
36
+ const { extractFrontmatter } = require('./frontmatter.cjs');
37
+ const { reconcilePhase } = require('./verify.cjs');
38
+ const {
39
+ HUD_CSS, esc, pill, bar, metricCard, fmtUsd, fmtTokens,
40
+ pipelineStage, STATUS_DOT, MARK_SVG, CHECK_SVG,
41
+ scanPhases, ledgerReliability,
42
+ } = require('./hud.cjs');
43
+ const cost = require('./cost.cjs');
44
+
45
+ const REPORT_SUFFIX = '-report.html';
46
+ const INDEX_FILE = 'report-index.html';
47
+
48
+ // ─── small helpers ─────────────────────────────────────────────────────────────
49
+
50
+ /** Coerce a frontmatter value to a string array (handles scalar, array, undefined). */
51
+ function asArray(v) {
52
+ if (v == null) return [];
53
+ return Array.isArray(v) ? v.filter(x => x != null).map(String) : [String(v)];
54
+ }
55
+
56
+ /** First present key of an object. */
57
+ function pick(obj, ...keys) {
58
+ for (const k of keys) if (obj && obj[k] != null) return obj[k];
59
+ return undefined;
60
+ }
61
+
62
+ /** Normalize a phase number for comparison ("03" and "3" match; "04.1" preserved). */
63
+ function normNum(n) {
64
+ return String(n).trim().replace(/^0+(?=\d)/, '');
65
+ }
66
+
67
+ // ─── data collection (pure) ─────────────────────────────────────────────────────
68
+
69
+ function projectName(cwd) {
70
+ try {
71
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
72
+ if (pkg && pkg.name) return String(pkg.name);
73
+ } catch { /* no package.json */ }
74
+ const proj = safeReadFile(path.join(planningPath(cwd), PROJECT_FILE));
75
+ if (proj) {
76
+ const h = proj.match(/^#\s+(.+)$/m);
77
+ if (h) return h[1].trim();
78
+ }
79
+ return path.basename(cwd.replace(/[\\/]+$/, '')) || 'project';
80
+ }
81
+
82
+ /** Locate a phase directory by number. Returns { dir, dirName, number, name } or null. */
83
+ function resolvePhase(cwd, phaseNumber) {
84
+ const want = normNum(phaseNumber);
85
+ for (const dirName of listPhaseDirs(cwd)) {
86
+ const { number, name } = parsePhaseDir(dirName);
87
+ if (normNum(number) === want) {
88
+ return { dir: path.join(phasesPath(cwd), dirName), dirName, number, name: name ? name.replace(/-/g, ' ') : null };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Extract a phase's goal + success criteria from roadmap.md (pure, no output). */
95
+ function extractPhaseGoal(cwd, phaseNumber) {
96
+ const content = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
97
+ if (!content) return { objective: null, success_criteria: [] };
98
+ const re = new RegExp('#{2,4}\\s*Phase\\s+' + escapeRegex(String(phaseNumber)) + ':\\s*([^\\n]+)', 'i');
99
+ const m = content.match(re);
100
+ if (!m) return { objective: null, success_criteria: [] };
101
+ const start = m.index;
102
+ const rest = content.slice(start + 1);
103
+ const nextH = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
104
+ const end = nextH ? start + 1 + nextH.index : content.length;
105
+ const section = content.slice(start, end);
106
+ const gm = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
107
+ const objective = gm ? gm[1].trim() : null;
108
+ const cm = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
109
+ const success_criteria = cm
110
+ ? cm[1].trim().split('\n').map(l => l.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean).slice(0, 8)
111
+ : [];
112
+ return { objective, success_criteria };
113
+ }
114
+
115
+ function bulletList(body, cap) {
116
+ return (body.match(/^\s*[-*]\s+(.+)$/gm) || [])
117
+ .map(l => l.replace(/^\s*[-*]\s+/, '').trim())
118
+ .filter(t => t && !/^none\b/i.test(t))
119
+ .slice(0, cap || 6);
120
+ }
121
+
122
+ /** Parse a phase's verification.md into structured signals + the reconcile verdict. */
123
+ function parseVerification(cwd, dir, verFile, phaseNumber) {
124
+ const raw = safeReadFile(path.join(dir, verFile)) || '';
125
+ const fm = extractFrontmatter(raw);
126
+ let status = fm.status != null ? String(fm.status) : null;
127
+ if (!status) { const sm = raw.match(/^status:\s*([A-Za-z_-]+)/m); status = sm ? sm[1] : null; }
128
+ const tot = raw.match(/TEST_TOTAL[:\s]+(\d+)/i) || raw.match(/\b(\d+)\s+tests?\b/i);
129
+ const pass = raw.match(/(?:TEST_)?PASS(?:ED)?[:\s]+(\d+)/i);
130
+ const fail = raw.match(/(?:TEST_)?FAIL(?:ED)?[:\s]+(\d+)/i);
131
+ const test_gate = (tot || pass || fail)
132
+ ? { total: tot ? Number(tot[1]) : null, passed: pass ? Number(pass[1]) : null, failed: fail ? Number(fail[1]) : null }
133
+ : null;
134
+ const scoreM = raw.match(/\bscore[:\s]+(\d+)/i);
135
+ const score = scoreM ? Number(scoreM[1]) : null;
136
+ const gapsBlock = raw.match(/#{2,4}\s*Gaps?\b[^\n]*\n([\s\S]*?)(?=\n#{2,4}\s|\n---|$)/i);
137
+ const gaps = gapsBlock ? bulletList(gapsBlock[1]) : [];
138
+ const apBlock = raw.match(/#{2,4}\s*Anti-?patterns?\b[^\n]*\n([\s\S]*?)(?=\n#{2,4}\s|\n---|$)/i);
139
+ const anti_patterns = apBlock ? bulletList(apBlock[1]) : [];
140
+
141
+ let reconcile = { checked: false, ok: true, verdict: 'n/a', contradictions: [] };
142
+ try {
143
+ const rec = reconcilePhase(cwd, phaseNumber);
144
+ if (rec && rec.found) {
145
+ reconcile = {
146
+ checked: (rec.mechanical_signals || 0) > 0,
147
+ ok: !!rec.reconciled,
148
+ verdict: rec.reconciled ? 'confirmed' : 'contradiction',
149
+ contradictions: rec.contradictions || [],
150
+ };
151
+ }
152
+ } catch { /* reconcile is best-effort; never blocks the report */ }
153
+
154
+ return { present: true, status, test_gate, score, gaps, anti_patterns, reconcile };
155
+ }
156
+
157
+ /**
158
+ * Collect one phase's report data. Returns null only when the phase directory
159
+ * does not exist; otherwise always returns a valid, honest object.
160
+ * @param {string} cwd
161
+ * @param {string|number} phaseNumber
162
+ * @param {{now?: Date}} [opts]
163
+ */
164
+ function collectPhaseData(cwd, phaseNumber, opts = {}) {
165
+ const now = opts.now || new Date();
166
+ const rp = resolvePhase(cwd, phaseNumber);
167
+ if (!rp) return null;
168
+
169
+ let files = [];
170
+ try { files = fs.readdirSync(rp.dir); } catch { /* unreadable */ }
171
+
172
+ const planFiles = filterPlanFiles(files);
173
+ const summaryFiles = filterSummaryFiles(files);
174
+ const hasResearch = files.some(isResearchFile);
175
+ const hasContext = files.some(isContextFile);
176
+ const verFile = files.find(isVerificationFile);
177
+ const uatFile = files.find(f => f.endsWith('-uat.md') || f === 'uat.md');
178
+ const recordFile = files.find(f => f.endsWith('-record.md') || f === 'record.md');
179
+
180
+ const summaryIds = new Set(summaryFiles.map(getSummaryId));
181
+ const plans = planFiles.map(f => {
182
+ const fm = extractFrontmatter(safeReadFile(path.join(rp.dir, f)) || '');
183
+ const id = getPlanId(f);
184
+ return {
185
+ id, file: f,
186
+ wave: pick(fm, 'wave') ?? null,
187
+ files_modified: asArray(pick(fm, 'files_modified', 'files-modified')),
188
+ must_haves: asArray(pick(fm, 'must_haves', 'must-haves')),
189
+ requirements: asArray(pick(fm, 'requirements')),
190
+ autonomous: pick(fm, 'autonomous') ?? null,
191
+ hasSummary: summaryIds.has(id),
192
+ };
193
+ });
194
+ const summaries = summaryFiles.map(f => {
195
+ const fm = extractFrontmatter(safeReadFile(path.join(rp.dir, f)) || '');
196
+ const kf = pick(fm, 'key-files', 'key_files') || {};
197
+ return {
198
+ id: getSummaryId(f), file: f,
199
+ subsystem: pick(fm, 'subsystem') != null ? String(pick(fm, 'subsystem')) : null,
200
+ tags: asArray(pick(fm, 'tags')),
201
+ key_files_created: asArray(kf.created),
202
+ key_files_modified: asArray(kf.modified),
203
+ key_decisions: asArray(pick(fm, 'key-decisions', 'key_decisions')),
204
+ requirements_completed: asArray(pick(fm, 'requirements-completed', 'requirements_completed')),
205
+ duration: pick(fm, 'duration') != null ? String(pick(fm, 'duration')) : null,
206
+ completed: pick(fm, 'completed') != null ? String(pick(fm, 'completed')) : null,
207
+ };
208
+ });
209
+
210
+ // whole-roadmap scan (reused, tested) — zip with dir names for stepper/position
211
+ const scan = scanPhases(cwd);
212
+ const dirs = listPhaseDirs(cwd);
213
+ const roadmap = scan.phases.map((p, i) => ({ number: p.number, name: p.name, status: p.status, dirName: dirs[i] }));
214
+ const status = (roadmap.find(p => normNum(p.number) === normNum(rp.number)) || {}).status
215
+ || 'empty';
216
+ const index = roadmap.findIndex(p => normNum(p.number) === normNum(rp.number));
217
+ const total = roadmap.length;
218
+
219
+ const plansDone = Math.min(summaryFiles.length, planFiles.length);
220
+ const goal = extractPhaseGoal(cwd, rp.number);
221
+
222
+ // per-phase requirements — DERIVED (never global scanRequirements)
223
+ const declared = [...new Set(plans.flatMap(p => p.requirements))];
224
+ const completed = new Set(summaries.flatMap(s => s.requirements_completed));
225
+ const requirements = declared.length
226
+ ? declared.map(id => ({ id, done: completed.has(id) }))
227
+ : [...completed].map(id => ({ id, done: true }));
228
+
229
+ const durations = summaries.map(s => s.duration).filter(Boolean);
230
+
231
+ const stage = pipelineStage({ status });
232
+
233
+ return {
234
+ generated_at: now.toISOString(),
235
+ project: projectName(cwd),
236
+ phase: { number: rp.number, name: rp.name, slug: rp.dirName, dir: rp.dir },
237
+ status,
238
+ pipeline: {
239
+ stage,
240
+ steps: ['research', 'plan', 'execute', 'verify'].map((label, i) => {
241
+ const ci = ['research', 'plan', 'execute', 'verify'].indexOf(stage);
242
+ return { label, state: ci < 0 ? 'todo' : i < ci ? 'done' : i === ci ? 'now' : 'todo' };
243
+ }),
244
+ },
245
+ position: { index, total, percent: scan.percent },
246
+ roadmap,
247
+ goal,
248
+ counts: { plans: planFiles.length, summaries: summaryFiles.length, plansDone },
249
+ plans,
250
+ summaries,
251
+ artifacts: {
252
+ context: { present: hasContext },
253
+ research: { present: hasResearch },
254
+ record: { present: !!recordFile },
255
+ },
256
+ verification: verFile ? parseVerification(cwd, rp.dir, verFile, rp.number) : null,
257
+ uat: uatFile ? { present: true } : null,
258
+ requirements,
259
+ timing: { durations },
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Collect the project-level timeline index data. Returns null when there are no
265
+ * phases (a phase-less / focus-auto project — the HUD covers those instead).
266
+ */
267
+ function collectIndexData(cwd, opts = {}) {
268
+ const now = opts.now || new Date();
269
+ const dirs = listPhaseDirs(cwd);
270
+ if (!dirs.length) return null;
271
+ const scan = scanPhases(cwd);
272
+
273
+ const agg = cost.aggregate(cwd);
274
+ const rel = ledgerReliability(agg.totals);
275
+ const tok = (agg.totals.input_tokens || 0) + (agg.totals.output_tokens || 0);
276
+ const spend = {
277
+ reliable: rel.ok,
278
+ usd: rel.ok ? agg.totals.cost_usd : null,
279
+ tokens: tok,
280
+ };
281
+
282
+ const phases = scan.phases.map((p, i) => {
283
+ const dirName = dirs[i];
284
+ const hasReport = (() => {
285
+ try {
286
+ const fp = path.join(phasesPath(cwd), dirName);
287
+ return fs.readdirSync(fp).some(f => f.endsWith(REPORT_SUFFIX));
288
+ } catch { return false; }
289
+ })();
290
+ return {
291
+ number: p.number, name: p.name, slug: dirName, status: p.status,
292
+ stage: pipelineStage({ status: p.status }),
293
+ plansDone: Math.min(p.summaries, p.plans), plansTotal: p.plans,
294
+ has_report: hasReport,
295
+ href: 'phases/' + encodeURIComponent(dirName) + '/' + encodeURIComponent(p.number + REPORT_SUFFIX),
296
+ };
297
+ });
298
+
299
+ const inFlight = phases.filter(p => p.status !== 'complete' && p.status !== 'empty').length;
300
+ const current = (phases.find(p => p.status !== 'complete') || phases[phases.length - 1] || {}).number || null;
301
+
302
+ return {
303
+ generated_at: now.toISOString(),
304
+ project: projectName(cwd),
305
+ aggregate: { total: scan.total, complete: scan.completed, in_flight: inFlight, percent: scan.percent, spend },
306
+ current,
307
+ phases,
308
+ };
309
+ }
310
+
311
+ // ─── rendering (pure) ────────────────────────────────────────────────────────────
312
+
313
+ // Report-only additions on top of HUD_CSS (crumbs, phase-report stepper wrap,
314
+ // and the index timeline). All colours reference the HUD's :root tokens.
315
+ const REPORT_CSS = `
316
+ .crumbs{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:14px;}
317
+ .crumbs a{color:var(--indigo);text-decoration:none;}
318
+ .crumbs a:hover{text-decoration:underline;}
319
+ .crumbs .sep{color:var(--faint);}
320
+ .stepwrap{overflow-x:auto;padding-bottom:4px;}
321
+ .stepwrap .stepper{min-width:max-content;}
322
+ .sdot.now a,.sdot a{color:inherit;text-decoration:none;}
323
+ .reqrow{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px;}
324
+ .tl{display:flex;flex-direction:column;margin-top:4px;}
325
+ .trow{display:grid;grid-template-columns:26px 1fr;gap:14px;}
326
+ .rail{display:flex;flex-direction:column;align-items:center;}
327
+ .rail .rd{width:15px;height:15px;border-radius:50%;flex:none;z-index:1;border:2px solid var(--bg);margin-top:14px;}
328
+ .rail .rl{width:3px;flex:1;min-height:16px;background:var(--faint);}
329
+ .tcard{flex:1;background:var(--panel);border:1px solid var(--border);border-radius:11px;padding:12px 15px;margin-bottom:10px;
330
+ display:flex;justify-content:space-between;align-items:center;gap:12px;text-decoration:none;color:inherit;transition:border-color .15s;}
331
+ a.tcard:hover{border-color:var(--coral);}
332
+ .tcard .tt{font-weight:700;font-size:14px;}
333
+ .tcard .tsub{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-top:4px;font-family:var(--mono);font-size:10.5px;color:var(--muted);}
334
+ .mini{display:inline-flex;gap:5px;font-family:var(--mono);font-size:10px;}
335
+ .mini .s{color:var(--faint);}.mini .s.done{color:var(--green);}.mini .s.on{color:var(--coral);font-weight:700;}
336
+ .pnf{color:var(--text2);font-size:13px;}
337
+ `;
338
+
339
+ function docShell(title, bodyHtml, generatedAt) {
340
+ return `<!DOCTYPE html>
341
+ <html lang="en"><head><meta charset="utf-8">
342
+ <meta name="viewport" content="width=device-width, initial-scale=1">
343
+ <title>${esc(title)}</title>
344
+ <style>${HUD_CSS}${REPORT_CSS}</style></head>
345
+ <body><div class="wrap">
346
+ ${bodyHtml}
347
+ <div class="foot">PanWizard · generated ${esc(generatedAt)} · self-contained snapshot</div>
348
+ </div></body></html>`;
349
+ }
350
+
351
+ function reportTopBar(d) {
352
+ return `
353
+ <div class="topbar">
354
+ <div class="tb-brand">${MARK_SVG}<span class="tb-word"><span class="c">Pan</span>Wizard <span class="hud">PHASE REPORT</span></span></div>
355
+ <div class="tb-meta">
356
+ <a href="../../${esc(INDEX_FILE)}" style="color:var(--indigo);text-decoration:none">↖ all phases</a>
357
+ <span class="sep">·</span><span>phase ${esc(d.position.index >= 0 ? d.position.index + 1 : d.phase.number)} of ${esc(d.position.total)}</span>
358
+ <span class="sep">·</span><span>${esc(d.generated_at)}</span>
359
+ </div>
360
+ </div>`;
361
+ }
362
+
363
+ function reportHero(d) {
364
+ const stage = d.pipeline.stage;
365
+ const ci = ['research', 'plan', 'execute', 'verify'].indexOf(stage);
366
+ const pipe = d.pipeline.steps.map((s, i) =>
367
+ `<span class="pstep ${s.state === 'done' ? 'done' : s.state === 'now' ? 'on' : 'off'}">${esc(s.label)}${i < ci ? ' ✓' : i === ci ? ' ●' : ''}</span>`
368
+ ).join('<span class="pgt">›</span>');
369
+ return `
370
+ <section class="nowbuilding">
371
+ <div class="nbtop">
372
+ <div class="ph dark">phase report</div>
373
+ <div class="nbphase"><span class="nd"></span>${esc(d.status)} · ${esc(stage)}</div>
374
+ </div>
375
+ <div class="nbcard" style="margin-top:14px">
376
+ <div class="nbhead">
377
+ <div class="nbtitle">Phase ${esc(d.phase.number)}${d.phase.name ? ' — ' + esc(d.phase.name) : ''}</div>
378
+ <div class="pipeline">${pipe}</div>
379
+ </div>
380
+ ${d.goal.objective ? `<div class="nbsub">${esc(d.goal.objective)}</div>` : '<div class="nbsub">no roadmap goal recorded</div>'}
381
+ </div>
382
+ </section>`;
383
+ }
384
+
385
+ function reportStepper(d) {
386
+ if (!d.roadmap.length) return '';
387
+ const els = [];
388
+ d.roadmap.forEach((p, i) => {
389
+ const done = p.status === 'complete';
390
+ const active = normNum(p.number) === normNum(d.phase.number);
391
+ const dot = done ? `<span class="sdot done">${CHECK_SVG}</span>`
392
+ : active ? `<span class="sdot now">${esc(p.number)}</span>`
393
+ : `<span class="sdot todo">${esc(p.number)}</span>`;
394
+ const href = './../' + encodeURIComponent(p.dirName) + '/' + encodeURIComponent(p.number + REPORT_SUFFIX);
395
+ const label = (p.name || '').split(' ')[0] || p.number;
396
+ const lab = `<span class="slabel ${active ? 'now' : done ? 'done' : 'todo'}">${esc(label)}</span>`;
397
+ els.push(`<div class="step"><a href="${esc(href)}" style="text-decoration:none;color:inherit;display:flex;flex-direction:column;align-items:center;gap:6px">${dot}${lab}</a></div>`);
398
+ if (i < d.roadmap.length - 1) {
399
+ const next = d.roadmap[i + 1];
400
+ const cls = done && next.status === 'complete' ? 'done' : done && normNum(next.number) === normNum(d.phase.number) ? 'grad' : 'todo';
401
+ els.push(`<span class="sline ${cls}"></span>`);
402
+ }
403
+ });
404
+ return `<section class="panel"><div class="ph">roadmap position</div><div class="stepwrap"><div class="stepper">${els.join('')}</div></div></section>`;
405
+ }
406
+
407
+ function verifPill(status) {
408
+ if (!status) return pill('not verified', 'muted');
409
+ if (/^(pass|passed|verified|complete)/i.test(status)) return pill(status, 'ok');
410
+ if (/gap/i.test(status)) return pill(status, 'warn');
411
+ if (/human|needed|fail|block/i.test(status)) return pill(status, 'danger');
412
+ return pill(status, 'info');
413
+ }
414
+
415
+ function reportMetrics(d) {
416
+ const c = d.counts;
417
+ const req = d.requirements;
418
+ const reqDone = req.filter(r => r.done).length;
419
+ const dur = d.timing.durations.length ? d.timing.durations[0] : null;
420
+ const v = d.verification;
421
+ const verCard = `<div class="metric"><div class="mlabel">Verification</div>`
422
+ + `<div class="mnum" style="font-size:18px;margin-top:8px">${v ? verifPill(v.status) : pill('none yet', 'muted')}</div>`
423
+ + `<div class="msub">${v && v.score != null ? 'score ' + esc(v.score) : ''}${v && v.gaps.length ? (v.score != null ? ' · ' : '') + v.gaps.length + ' gap' + (v.gaps.length > 1 ? 's' : '') : ''}</div></div>`;
424
+ const cards = [
425
+ metricCard({
426
+ label: 'Plans done', value: c.plansDone, unit: ` / ${c.plans}`,
427
+ barPct: c.plans ? Math.round((c.plansDone / c.plans) * 100) : 0, barColor: 'var(--coral)',
428
+ sub: c.plans ? (c.plansDone >= c.plans ? 'all summarised' : (c.plans - c.plansDone) + ' open') : 'no plans yet',
429
+ }),
430
+ req.length
431
+ ? metricCard({ label: 'Requirements', value: reqDone, unit: ` / ${req.length}`, barPct: Math.round((reqDone / req.length) * 100), barColor: 'var(--indigo)', sub: (req.length - reqDone) + ' open' })
432
+ : metricCard({ label: 'Requirements', value: '—', sub: 'none traced' }),
433
+ verCard,
434
+ metricCard({ label: 'Duration', value: dur || '—', sub: dur ? 'from summary' : 'spend n/a · no per-phase ledger' }),
435
+ ];
436
+ return `<section class="panel mission"><div class="metrics">${cards.join('')}</div></section>`;
437
+ }
438
+
439
+ function reportObjective(d) {
440
+ const crit = d.goal.success_criteria;
441
+ const req = d.requirements;
442
+ const critRows = crit.length
443
+ ? crit.map(c => `<div class="row"><span class="rl">${esc(c)}</span></div>`).join('')
444
+ : '<div class="row noborder pnf">no success criteria in roadmap</div>';
445
+ const reqEls = req.length
446
+ ? `<div class="reqrow">${req.map(r => pill(esc(r.id), r.done ? 'ok' : 'muted')).join('')}</div>`
447
+ : '<div class="pnf" style="margin-top:8px">no per-phase requirements traced in plan frontmatter</div>';
448
+ return `
449
+ <section class="panel">
450
+ <div class="ph">objective &amp; success criteria</div>
451
+ ${critRows}
452
+ <div class="row noborder"><span class="rl">Requirements (plan-declared)</span><span class="amono dim">${req.filter(r => r.done).length} / ${req.length} done</span></div>
453
+ ${reqEls}
454
+ <div class="row noborder"><span class="rl">Snapshot</span><span class="amono dim">${d.counts.plans} plan(s) · ${d.counts.summaries} summar${d.counts.summaries === 1 ? 'y' : 'ies'} → ${esc(d.status)}</span></div>
455
+ </section>`;
456
+ }
457
+
458
+ function reportVerification(d) {
459
+ const v = d.verification;
460
+ if (!v) {
461
+ return `<section class="panel"><div class="ph">verification &amp; quality</div><div class="row noborder pnf">Not verified yet — no verification.md in this phase.</div></section>`;
462
+ }
463
+ const tg = v.test_gate;
464
+ const rec = v.reconcile;
465
+ const recPill = !rec.checked ? pill('n/a — no must_haves', 'muted')
466
+ : rec.ok ? pill('confirmed', 'ok') : pill('contradiction', 'danger');
467
+ return `
468
+ <section class="panel">
469
+ <div class="ph">verification &amp; quality</div>
470
+ ${tg ? `<div class="row"><span class="rl">Test gate</span><span class="amono">${tg.total != null ? tg.total + ' total' : ''}${tg.passed != null ? ' · ' + tg.passed + ' pass' : ''}${tg.failed != null ? ' · ' + tg.failed + ' fail' : ''}</span></div>` : ''}
471
+ <div class="row"><span class="rl">Self-reported</span>${verifPill(v.status)}</div>
472
+ <div class="row"><span class="rl">verify reconcile</span>${recPill}</div>
473
+ ${v.score != null ? `<div class="row"><span class="rl">Score</span><span class="amono">${esc(v.score)}</span></div>` : ''}
474
+ <div class="row noborder"><span class="rl">Anti-patterns</span><span class="amono ${v.anti_patterns.length ? 'warnc' : 'okc'}">${v.anti_patterns.length ? esc(v.anti_patterns.length + ' flagged') : 'none'}</span></div>
475
+ ${rec.contradictions.length ? `<div class="open" style="margin-top:8px">${rec.contradictions.map(c => `<div class="amono" style="color:var(--red)">• ${esc(c)}</div>`).join('')}</div>` : ''}
476
+ </section>`;
477
+ }
478
+
479
+ function reportChanges(d) {
480
+ if (!d.summaries.length) return '';
481
+ const tasks = d.summaries.map(s => {
482
+ const files = [...s.key_files_created, ...s.key_files_modified];
483
+ const fileNote = files.length ? `${files.length} file${files.length > 1 ? 's' : ''}` : (s.tags.length ? s.tags.slice(0, 3).join(', ') : 'summary');
484
+ return `<div class="task"><span class="tname"><span class="td"></span>${esc(s.subsystem || s.id || s.file)}</span><span class="tpath">${esc(fileNote)}</span></div>`;
485
+ }).join('');
486
+ const decisions = d.summaries.flatMap(s => s.key_decisions).slice(0, 5);
487
+ return `
488
+ <section class="panel">
489
+ <div class="ph">what changed</div>
490
+ <div class="tasks">${tasks}</div>
491
+ ${decisions.length ? `<div class="open" style="margin-top:12px">${decisions.map(k => `<div class="amono dim">• ${esc(k)}</div>`).join('')}</div>` : ''}
492
+ </section>`;
493
+ }
494
+
495
+ function reportGaps(d) {
496
+ const v = d.verification;
497
+ if (!v || !v.gaps.length) return '';
498
+ return `
499
+ <section class="panel">
500
+ <div class="ph">gaps &amp; blockers</div>
501
+ ${v.gaps.map(g => `<div class="row"><span class="rl">${esc(g)}</span>${pill('open', 'danger')}</div>`).join('')}
502
+ </section>`;
503
+ }
504
+
505
+ function renderPhaseHtml(d) {
506
+ const body = [
507
+ reportTopBar(d),
508
+ reportHero(d),
509
+ reportStepper(d),
510
+ reportMetrics(d),
511
+ `<div class="grid"><div class="gcol">${reportObjective(d)}</div><div class="gcol">${reportVerification(d)}</div></div>`,
512
+ reportChanges(d),
513
+ reportGaps(d),
514
+ ].filter(Boolean).join('\n');
515
+ return docShell(`PanWizard · Phase ${d.phase.number}${d.phase.name ? ' — ' + d.phase.name : ''}`, body, d.generated_at);
516
+ }
517
+
518
+ function indexTopBar(d) {
519
+ return `
520
+ <div class="topbar">
521
+ <div class="tb-brand">${MARK_SVG}<span class="tb-word"><span class="c">Pan</span>Wizard <span class="hud">TIMELINE</span></span></div>
522
+ <div class="tb-meta"><span>pan-tools report index</span><span class="sep">·</span><span>${esc(d.generated_at)}</span></div>
523
+ </div>`;
524
+ }
525
+
526
+ function indexHero(d) {
527
+ const a = d.aggregate;
528
+ const cur = d.phases.find(p => p.number === d.current);
529
+ const spendCard = a.spend.reliable
530
+ ? metricCard({ label: 'Spend', value: fmtUsd(a.spend.usd), sub: fmtTokens(a.spend.tokens) + ' tok' })
531
+ : metricCard({ label: 'Spend', value: '—', sub: fmtTokens(a.spend.tokens) + ' tok · ledger n/a' });
532
+ const cards = [
533
+ metricCard({ label: 'Progress', value: a.percent == null ? '—' : String(a.percent), unit: a.percent == null ? '' : '%', barPct: a.percent, barColor: 'var(--coral)', sub: `${a.complete} / ${a.total} phases` }),
534
+ metricCard({ label: 'Complete', value: a.complete, unit: ` / ${a.total}` }),
535
+ metricCard({ label: 'In flight', value: a.in_flight, sub: cur ? 'phase ' + cur.number : 'idle' }),
536
+ spendCard,
537
+ ].join('');
538
+ return `
539
+ <section class="panel mission">
540
+ <div class="mhead">
541
+ <div>
542
+ <div class="kicker">pan · project timeline</div>
543
+ <div class="title">${esc(d.project)}</div>
544
+ </div>
545
+ <div class="mmeta">${cur ? pill('phase ' + cur.number + ' in flight', 'info') : pill('all phases complete', 'ok')}</div>
546
+ </div>
547
+ <div class="metrics">${cards}</div>
548
+ </section>`;
549
+ }
550
+
551
+ function indexTimeline(d) {
552
+ const rows = d.phases.map((p, i) => {
553
+ const color = STATUS_DOT[p.status] || '#C9C0AE';
554
+ const isLast = i === d.phases.length - 1;
555
+ const railColor = p.status === 'complete' ? 'var(--green)' : 'var(--faint)';
556
+ const stages = ['research', 'plan', 'execute', 'verify'];
557
+ const ci = stages.indexOf(p.stage);
558
+ const mini = stages.map((s, j) => `<span class="s ${j < ci ? 'done' : j === ci ? 'on' : ''}">${esc(s[0].toUpperCase())}</span>`).join('');
559
+ const kind = p.status === 'complete' ? 'ok' : p.status === 'partial' ? 'info' : p.status === 'planned' ? 'info' : 'muted';
560
+ return `
561
+ <div class="trow">
562
+ <div class="rail"><span class="rd" style="background:${color}"></span>${isLast ? '' : `<span class="rl" style="background:${railColor}"></span>`}</div>
563
+ <a class="tcard" href="${esc(p.href)}">
564
+ <div>
565
+ <div class="tt">${esc(p.number)}${p.name ? ' · ' + esc(p.name) : ''}</div>
566
+ <div class="tsub"><span class="mini">${mini}</span><span>${p.plansTotal ? p.plansDone + ' / ' + p.plansTotal + ' plans' : 'no plans'}</span>${p.has_report ? '' : '<span>· report pending</span>'}</div>
567
+ </div>
568
+ ${pill(p.status, kind)}
569
+ </a>
570
+ </div>`;
571
+ }).join('');
572
+ return `<section class="panel"><div class="ph">phases</div><div class="tl">${rows}</div></section>`;
573
+ }
574
+
575
+ function renderIndexHtml(d) {
576
+ const body = [indexTopBar(d), indexHero(d), indexTimeline(d)].join('\n');
577
+ return docShell(`PanWizard · ${d.project} — timeline`, body, d.generated_at);
578
+ }
579
+
580
+ // ─── side effects (the only impure layer) ────────────────────────────────────────
581
+
582
+ /**
583
+ * Strip the volatile generated-at ISO-8601 timestamp so an unchanged report is
584
+ * not rewritten (no git churn). The timestamp is the ONLY non-deterministic part
585
+ * of the output — everything else is a pure function of disk state — so removing
586
+ * it yields a stable body to compare. Rendered timestamps are always raw ISO
587
+ * (never toLocaleString) precisely so this single pattern catches every one.
588
+ */
589
+ function stripVolatile(html) {
590
+ return String(html).replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, '⌀');
591
+ }
592
+
593
+ function writeIfChanged(outPath, html) {
594
+ let existing = null;
595
+ try { if (fs.existsSync(outPath)) existing = fs.readFileSync(outPath, 'utf-8'); } catch { /* treat as new */ }
596
+ if (existing !== null && stripVolatile(existing) === stripVolatile(html)) return { written: false };
597
+ try {
598
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
599
+ fs.writeFileSync(outPath, html, 'utf-8');
600
+ } catch (e) {
601
+ error('Failed to write report: ' + e.message);
602
+ }
603
+ return { written: true };
604
+ }
605
+
606
+ // openInBrowser — byte-identical to hud.cjs (the inline allowlist regex is a
607
+ // CodeQL command-injection barrier that must NOT move into a shared helper;
608
+ // a CI test asserts the two copies never diverge).
609
+ function openInBrowser(filePath) {
610
+ const { execFileSync } = require('child_process');
611
+ // Only open a path we can resolve to an existing regular file, and refuse
612
+ // anything carrying shell/cmd metacharacters — on Windows `start` is a cmd
613
+ // builtin that re-parses its command line, so a crafted --out value must not
614
+ // be able to reach it. The allowlist check is the taint barrier; `resolved`
615
+ // is what actually gets opened.
616
+ let resolved;
617
+ try {
618
+ resolved = path.resolve(filePath);
619
+ if (!fs.statSync(resolved).isFile()) return false;
620
+ } catch {
621
+ return false;
622
+ }
623
+ // Allowlist barrier: only ordinary path characters may reach the opener.
624
+ // Anything outside this set (shell/cmd metacharacters, quotes, newlines) is
625
+ // rejected outright, so a crafted --out value cannot reach Windows `start`.
626
+ if (!/^[A-Za-z0-9 _.:\\/()-]+$/.test(resolved)) return false;
627
+ try {
628
+ if (process.platform === 'win32') {
629
+ execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
630
+ } else if (process.platform === 'darwin') {
631
+ execFileSync('open', [resolved], { stdio: 'ignore' });
632
+ } else {
633
+ execFileSync('xdg-open', [resolved], { stdio: 'ignore' });
634
+ }
635
+ return true;
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Generate phase report(s). Sub-actions: phase <N> | index | all.
643
+ * @param {string} cwd
644
+ * @param {{action?:string, phase?:string, out?:string, open?:boolean, stdout?:boolean, now?:Date}} opts
645
+ * @param {boolean} raw
646
+ */
647
+ function cmdReport(cwd, opts = {}, raw) {
648
+ const action = opts.action || 'phase';
649
+ const now = opts.now;
650
+
651
+ if (action === 'phase') {
652
+ if (!opts.phase) return error('Usage: report phase <N>');
653
+ const data = collectPhaseData(cwd, opts.phase, { now });
654
+ if (!data) return error(`Phase ${opts.phase} not found under .planning/phases/`);
655
+ const html = renderPhaseHtml(data);
656
+ if (opts.stdout) { process.stdout.write(html); return; }
657
+ const outPath = opts.out ? path.resolve(cwd, opts.out) : path.join(data.phase.dir, `${data.phase.number}${REPORT_SUFFIX}`);
658
+ const res = writeIfChanged(outPath, html);
659
+ const opened = opts.open && res.written ? openInBrowser(outPath) : false;
660
+ return output(
661
+ { action, phase: data.phase.number, path: toPosix(outPath), bytes: Buffer.byteLength(html), status: data.status, written: res.written, opened },
662
+ raw,
663
+ `phase ${data.phase.number} report ${res.written ? 'written' : 'unchanged'}: ${toPosix(outPath)}`,
664
+ );
665
+ }
666
+
667
+ if (action === 'index') {
668
+ const data = collectIndexData(cwd, { now });
669
+ if (!data) return error('No phases found — nothing to index (phase-less / focus-auto project). Use `pan-tools hud` instead.');
670
+ const html = renderIndexHtml(data);
671
+ if (opts.stdout) { process.stdout.write(html); return; }
672
+ const outPath = opts.out ? path.resolve(cwd, opts.out) : path.join(planningPath(cwd), INDEX_FILE);
673
+ const res = writeIfChanged(outPath, html);
674
+ const opened = opts.open && res.written ? openInBrowser(outPath) : false;
675
+ return output(
676
+ { action, phases: data.phases.length, path: toPosix(outPath), bytes: Buffer.byteLength(html), written: res.written, opened },
677
+ raw,
678
+ `timeline index ${res.written ? 'written' : 'unchanged'}: ${toPosix(outPath)}`,
679
+ );
680
+ }
681
+
682
+ if (action === 'all') {
683
+ const dirs = listPhaseDirs(cwd);
684
+ if (!dirs.length) return error('No phases found — nothing to report.');
685
+ const reports = [];
686
+ for (const dirName of dirs) {
687
+ const { number } = parsePhaseDir(dirName);
688
+ const data = collectPhaseData(cwd, number, { now });
689
+ if (!data) continue;
690
+ const outPath = path.join(data.phase.dir, `${data.phase.number}${REPORT_SUFFIX}`);
691
+ const res = writeIfChanged(outPath, renderPhaseHtml(data));
692
+ reports.push({ phase: data.phase.number, path: toPosix(outPath), written: res.written });
693
+ }
694
+ const idx = collectIndexData(cwd, { now });
695
+ let index = { path: null, written: false };
696
+ if (idx) {
697
+ const outPath = path.join(planningPath(cwd), INDEX_FILE);
698
+ const res = writeIfChanged(outPath, renderIndexHtml(idx));
699
+ index = { path: toPosix(outPath), written: res.written };
700
+ }
701
+ return output(
702
+ { action, reports, index },
703
+ raw,
704
+ `generated ${reports.length} phase report(s)${index.path ? ' + index' : ''}`,
705
+ );
706
+ }
707
+
708
+ return error('Unknown report action. Available: phase <N>, index, all');
709
+ }
710
+
711
+ module.exports = {
712
+ REPORT_SUFFIX,
713
+ INDEX_FILE,
714
+ collectPhaseData,
715
+ collectIndexData,
716
+ renderPhaseHtml,
717
+ renderIndexHtml,
718
+ cmdReport,
719
+ // exported for focused unit tests
720
+ resolvePhase,
721
+ extractPhaseGoal,
722
+ stripVolatile,
723
+ };