pan-wizard 3.13.1 → 3.15.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 (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +4 -5
  3. package/commands/pan/audit-deployment.md +384 -384
  4. package/commands/pan/focus-auto.md +683 -683
  5. package/commands/pan/focus-doc-audit.md +530 -530
  6. package/commands/pan/focus-drift-walking.md +525 -525
  7. package/commands/pan/git.md +1 -1
  8. package/commands/pan/hud.md +3 -2
  9. package/commands/pan/report.md +70 -0
  10. package/hooks/dist/pan-check-update.js +62 -62
  11. package/hooks/dist/pan-context-monitor.js +134 -122
  12. package/hooks/dist/pan-statusline.js +7 -1
  13. package/package.json +5 -5
  14. package/pan-wizard-core/bin/lib/config.cjs +14 -1
  15. package/pan-wizard-core/bin/lib/core.cjs +6 -2
  16. package/pan-wizard-core/bin/lib/doc-lint.cjs +86 -1
  17. package/pan-wizard-core/bin/lib/focus.cjs +48 -2
  18. package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -442
  19. package/pan-wizard-core/bin/lib/hud.cjs +202 -17
  20. package/pan-wizard-core/bin/lib/knowledge.cjs +2 -2
  21. package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
  22. package/pan-wizard-core/bin/lib/phase-remove.cjs +1 -1
  23. package/pan-wizard-core/bin/lib/phase-report.cjs +723 -0
  24. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  25. package/pan-wizard-core/bin/lib/review-deep.cjs +3 -1
  26. package/pan-wizard-core/bin/lib/utils.cjs +171 -171
  27. package/pan-wizard-core/bin/lib/verify.cjs +172 -61
  28. package/pan-wizard-core/bin/pan-tools.cjs +1499 -1463
  29. package/pan-wizard-core/references/checkpoints.md +776 -776
  30. package/pan-wizard-core/references/continuation-format.md +249 -249
  31. package/pan-wizard-core/references/questioning.md +145 -145
  32. package/pan-wizard-core/references/tdd.md +263 -263
  33. package/pan-wizard-core/references/ui-brand.md +160 -160
  34. package/pan-wizard-core/templates/config.json +38 -38
  35. package/pan-wizard-core/workflows/exec-phase.md +14 -0
  36. package/scripts/build-hooks.js +51 -51
  37. package/scripts/git-hooks/pre-commit +0 -0
  38. package/scripts/release-check.js +53 -47
  39. package/scripts/run-tests.cjs +44 -0
@@ -11,7 +11,11 @@
11
11
  * Graceful degradation: army-only panels (command stack, campaign, harness,
12
12
  * worktrees) render only when a campaign is scheduled or army worktrees
13
13
  * exist. A plain PAN project still gets mission, roadmap, telemetry,
14
- * requirements/quality and activity panels.
14
+ * requirements/quality and activity panels. A project with no phase/roadmap
15
+ * layout (focus-auto / autonomous-loop / imported repos) gets a "planning
16
+ * activity" fallback panel summarising its .planning/ markdown instead of a
17
+ * bare page. Telemetry never quotes a dollar figure it can't stand behind: a
18
+ * poisoned or wholly-unpriced ledger degrades to an honest advisory.
15
19
  *
16
20
  * collectHudData() and renderHud() are pure given their inputs (a `now` Date
17
21
  * is injected for testability); cmdHud() is the only side-effecting wrapper.
@@ -136,6 +140,79 @@ function scanVerification(cwd) {
136
140
  return found;
137
141
  }
138
142
 
143
+ /** Humanize a file mtime as an age relative to `now` ("3d ago"). */
144
+ function relAge(mtimeMs, now) {
145
+ const ref = now instanceof Date ? now.getTime() : Number(now);
146
+ const diff = ref - Number(mtimeMs);
147
+ if (!isFinite(diff)) return '';
148
+ if (diff < 60000) return 'just now';
149
+ const min = Math.floor(diff / 60000);
150
+ if (min < 60) return `${min}m ago`;
151
+ const h = Math.floor(min / 60);
152
+ if (h < 24) return `${h}h ago`;
153
+ const days = Math.floor(h / 24);
154
+ if (days < 30) return `${days}d ago`;
155
+ const mo = Math.floor(days / 30);
156
+ return `${mo}mo ago`;
157
+ }
158
+
159
+ /**
160
+ * Fallback signal for projects that don't use the phase/state/roadmap layout
161
+ * (focus-auto / autonomous-loop projects, imported repos, ad-hoc `.planning/`
162
+ * trees): surface whatever markdown lives under `.planning/` so the HUD isn't
163
+ * a dead shell. Only `.md` docs are stat-ed — a focus-auto ledger can hold
164
+ * tens of thousands of JSON artifacts, and we never stat those. The walk is
165
+ * depth- and entry-bounded so a pathological tree can't stall the render.
166
+ * Returns null when there are no docs to show.
167
+ */
168
+ function scanPlanningActivity(cwd, now) {
169
+ const root = planningPath(cwd);
170
+ let topEntries;
171
+ try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
172
+
173
+ const docs = []; // { rel, folder, mtime }
174
+ const byFolder = {}; // folder name -> md count
175
+ let visited = 0;
176
+ const MAX_ENTRIES = 40000; // backstop against pathological trees
177
+ const MAX_DEPTH = 3;
178
+
179
+ function record(fp, folder) {
180
+ let st;
181
+ try { st = fs.statSync(fp); } catch { return; }
182
+ byFolder[folder] = (byFolder[folder] || 0) + 1;
183
+ docs.push({ rel: toPosix(path.relative(root, fp)), folder, mtime: st.mtimeMs });
184
+ }
185
+
186
+ function walk(dir, folder, depth) {
187
+ if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return;
188
+ let ents = [];
189
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
190
+ for (const e of ents) {
191
+ if (visited++ > MAX_ENTRIES) return;
192
+ if (e.isDirectory()) walk(path.join(dir, e.name), folder, depth + 1);
193
+ else if (e.name.endsWith('.md')) record(path.join(dir, e.name), folder);
194
+ }
195
+ }
196
+
197
+ for (const e of topEntries) {
198
+ if (visited++ > MAX_ENTRIES) break;
199
+ if (e.isDirectory()) walk(path.join(root, e.name), e.name, 1);
200
+ else if (e.name.endsWith('.md')) record(path.join(root, e.name), '(root)');
201
+ }
202
+
203
+ if (!docs.length) return null;
204
+ docs.sort((a, b) => b.mtime - a.mtime);
205
+ const folders = Object.keys(byFolder)
206
+ .map(f => ({ folder: f, count: byFolder[f] }))
207
+ .sort((a, b) => b.count - a.count);
208
+ const recent = docs.slice(0, 8).map(d => ({
209
+ name: d.rel.split('/').pop(),
210
+ folder: d.folder,
211
+ when: relAge(d.mtime, now),
212
+ }));
213
+ return { doc_count: docs.length, folder_count: folders.length, folders, recent };
214
+ }
215
+
139
216
  function recentCommits(cwd, limit) {
140
217
  if (!isGitRepo(cwd)) return [];
141
218
  const r = execGit(cwd, ['log', '-n', String(limit || 8), '--pretty=%h\x1f%s\x1f%cr']);
@@ -278,6 +355,7 @@ function collectHudData(cwd, opts = {}) {
278
355
  telemetry,
279
356
  requirements: scanRequirements(cwd),
280
357
  quality: scanVerification(cwd),
358
+ planning_activity: scanPlanningActivity(cwd, now),
281
359
  activity: recentCommits(cwd, 8),
282
360
  };
283
361
  }
@@ -343,6 +421,36 @@ function fmtTokens(n) {
343
421
  return String(v);
344
422
  }
345
423
 
424
+ /**
425
+ * Assess whether a cost ledger is trustworthy enough to show dollar figures.
426
+ * Two failure modes are treated as "don't quote a number":
427
+ * - legacy: more records were quarantined as implausible than survived (the
428
+ * pre-v3.12.4 transcript-oversum bug) — reset advised.
429
+ * - unresolved: every surviving record lacks a resolvable model→rate, so the
430
+ * computed spend is a misleading $0 even though real tokens were spent.
431
+ * Returns { ok:true } when figures are safe to display.
432
+ */
433
+ function ledgerReliability(totals) {
434
+ const t = totals || {};
435
+ const calls = t.calls || 0;
436
+ const suspect = t.suspect_excluded || 0;
437
+ const unknown = t.cost_unknown || 0;
438
+ if (suspect > calls) {
439
+ const total = suspect + calls;
440
+ return {
441
+ ok: false, kind: 'legacy',
442
+ message: `${suspect} of ${total} cost records are implausible (the pre-v3.12.4 telemetry capture bug). Reset the ledger with <b>pan-tools cost clear</b> — records captured after the fix are accurate.`,
443
+ };
444
+ }
445
+ if (calls > 0 && unknown >= calls) {
446
+ return {
447
+ ok: false, kind: 'unresolved',
448
+ message: `None of ${calls} cost records carry a resolvable model→rate, so spend can't be priced (it would read $0). Set <b>cost.rates</b> in .planning/config.json, or record a model on each call, to price them. Token volume below is still accurate.`,
449
+ };
450
+ }
451
+ return { ok: true };
452
+ }
453
+
346
454
  function relDue(nextIso, nowIso) {
347
455
  const a = new Date(nextIso).getTime();
348
456
  const b = new Date(nowIso).getTime();
@@ -403,22 +511,29 @@ function renderMission(d) {
403
511
  const req = d.requirements;
404
512
  const tok = d.telemetry.totals.input_tokens + d.telemetry.totals.output_tokens;
405
513
  const cache = d.telemetry.cache_hit_rate_pct;
514
+ const rel = ledgerReliability(d.telemetry.totals);
515
+ const spendCard = rel.ok
516
+ ? metricCard({
517
+ label: 'Spend', value: fmtUsd(d.telemetry.totals.cost_usd),
518
+ sub: `${fmtTokens(tok)} tok${cache == null ? '' : ' · ' + cache + '% cache'}`,
519
+ })
520
+ : metricCard({
521
+ label: 'Spend', value: '—',
522
+ sub: rel.kind === 'unresolved' ? `${fmtTokens(tok)} tok · cost n/a` : 'ledger unreliable',
523
+ });
406
524
  const cards = [
407
525
  metricCard({
408
526
  label: 'Progress', value: prog.percent == null ? '—' : String(prog.percent), unit: prog.percent == null ? '' : '%',
409
527
  barPct: prog.percent, barColor: 'var(--coral)', sub: prog.total ? `${prog.completed} / ${prog.total} phases` : 'no phases',
410
528
  }),
411
529
  metricCard({
412
- label: 'Phase', value: st.current_phase || prog.completed || 0, unit: prog.total ? ` / ${prog.total}` : '',
530
+ label: 'Phase', value: st.current_phase || (prog.total ? prog.completed : '—'), unit: prog.total ? ` / ${prog.total}` : '',
413
531
  sub: st.current_phase_name || '',
414
532
  }),
415
533
  req
416
534
  ? metricCard({ label: 'Requirements', value: req.done, unit: ` / ${req.total}`, barPct: Math.round((req.done / req.total) * 100), barColor: 'var(--indigo)', sub: `${req.total - req.done} open` })
417
535
  : metricCard({ label: 'Requirements', value: '—', sub: 'none tracked' }),
418
- metricCard({
419
- label: 'Spend', value: fmtUsd(d.telemetry.totals.cost_usd),
420
- sub: `${fmtTokens(tok)} tok${cache == null ? '' : ' · ' + cache + '% cache'}`,
421
- }),
536
+ spendCard,
422
537
  ].join('');
423
538
  return `
424
539
  <section class="panel mission">
@@ -567,6 +682,32 @@ function renderRoadmap(d) {
567
682
  </section>`;
568
683
  }
569
684
 
685
+ // Graceful degradation: projects without a phase/state/roadmap layout still
686
+ // have real signal under .planning/ (focus-auto designs, ADRs, findings). Show
687
+ // it — but only when there's no roadmap, so standard projects are unchanged.
688
+ function renderPlanningActivity(d) {
689
+ const a = d.planning_activity;
690
+ if (!a || d.roadmap.length) return '';
691
+ const maxCount = a.folders.reduce((m, f) => Math.max(m, f.count), 0) || 1;
692
+ const folderBars = a.folders.slice(0, 7).map(f => {
693
+ const pct = Math.round((f.count / maxCount) * 100);
694
+ return `<div class="sqbar"><div class="sqbar-h"><span>${esc(f.folder)}</span><span>${esc(f.count)}</span></div>`
695
+ + `<div class="bar"><span style="width:${pct}%;background:var(--indigo)"></span></div></div>`;
696
+ }).join('');
697
+ const recent = a.recent.map(r =>
698
+ `<div class="row"><span class="amono"><span class="dot" style="background:var(--coral)"></span>${esc(r.name)}</span>`
699
+ + `<span class="amono dim">${esc(r.folder)} · ${esc(r.when)}</span></div>`
700
+ ).join('');
701
+ return `
702
+ <section class="panel">
703
+ <div class="ph">planning activity</div>
704
+ <div class="row"><span class="rl">Documents</span><span class="amono">${esc(a.doc_count)} across ${esc(a.folder_count)} folder${a.folder_count === 1 ? '' : 's'}</span></div>
705
+ <div class="sqbars" style="margin-top:8px">${folderBars}</div>
706
+ <div class="ph" style="margin-top:18px">recently updated</div>
707
+ ${recent}
708
+ </section>`;
709
+ }
710
+
570
711
  function renderHarness(d) {
571
712
  if (!d.army_active) return '';
572
713
  const h = d.harness;
@@ -586,15 +727,20 @@ function renderHarness(d) {
586
727
 
587
728
  function renderTelemetry(d) {
588
729
  const t = d.telemetry;
589
- // A ledger where most records are implausible is the pre-v3.12.4 capture bug —
590
- // don't present salvaged numbers as if trustworthy; tell the user to reset it.
591
- if (t.totals.suspect_excluded > t.totals.calls) {
592
- const total = t.totals.suspect_excluded + t.totals.calls;
730
+ // Don't present salvaged or uncomputable numbers as if trustworthy — a
731
+ // poisoned or unpriced ledger gets an honest advisory instead of a fake $0.
732
+ const rel = ledgerReliability(t.totals);
733
+ if (!rel.ok) {
734
+ const label = rel.kind === 'legacy' ? 'legacy ledger — unreliable' : 'cost unresolved';
735
+ const tokLine = rel.kind === 'unresolved'
736
+ ? `<div class="row" style="margin-top:10px"><span class="rl">Tokens processed</span><span class="amono">${fmtTokens(t.totals.input_tokens + t.totals.output_tokens)} · ${t.totals.calls} calls</span></div>`
737
+ : '';
593
738
  return `
594
739
  <section class="panel">
595
740
  <div class="ph">telemetry</div>
596
- <div class="row noborder"><span class="rl">Status</span>${pill('legacy ledger — unreliable', 'warn')}</div>
597
- <div class="amono dim" style="margin-top:10px;line-height:1.6;">${t.totals.suspect_excluded} of ${total} cost records are implausible (the pre-v3.12.4 telemetry capture bug). Reset the ledger with <b>pan-tools cost clear</b> — records captured after the fix are accurate.</div>
741
+ <div class="row noborder"><span class="rl">Status</span>${pill(label, 'warn')}</div>
742
+ <div class="amono dim" style="margin-top:10px;line-height:1.6;">${rel.message}</div>
743
+ ${tokLine}
598
744
  </section>`;
599
745
  }
600
746
  const keys = Object.keys(t.by_squad).sort((a, b) => t.by_squad[b].cost - t.by_squad[a].cost);
@@ -794,13 +940,16 @@ body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font);fon
794
940
  function renderHud(d) {
795
941
  const leftCol = [renderCampaign(d), renderRoadmap(d)].filter(Boolean).join('');
796
942
  const rightCol = [renderHarness(d), renderTelemetry(d)].filter(Boolean).join('');
797
- const grid = (leftCol || rightCol)
943
+ // Two columns only when both have content; otherwise render full-width so a
944
+ // lone panel (e.g. telemetry on a non-army project) doesn't float in half.
945
+ const grid = (leftCol && rightCol)
798
946
  ? `<div class="grid"><div class="gcol">${leftCol}</div><div class="gcol">${rightCol}</div></div>`
799
- : '';
947
+ : (leftCol || rightCol);
800
948
  const body = [
801
949
  renderTopBar(d),
802
950
  renderMission(d),
803
951
  renderNowBuilding(d),
952
+ renderPlanningActivity(d),
804
953
  renderCommandStack(d),
805
954
  grid,
806
955
  renderWorktrees(d),
@@ -822,13 +971,29 @@ ${body}
822
971
 
823
972
  function openInBrowser(filePath) {
824
973
  const { execFileSync } = require('child_process');
974
+ // Only open a path we can resolve to an existing regular file, and refuse
975
+ // anything carrying shell/cmd metacharacters — on Windows `start` is a cmd
976
+ // builtin that re-parses its command line, so a crafted --out value must not
977
+ // be able to reach it. The allowlist check is the taint barrier; `resolved`
978
+ // is what actually gets opened.
979
+ let resolved;
980
+ try {
981
+ resolved = path.resolve(filePath);
982
+ if (!fs.statSync(resolved).isFile()) return false;
983
+ } catch {
984
+ return false;
985
+ }
986
+ // Allowlist barrier: only ordinary path characters may reach the opener.
987
+ // Anything outside this set (shell/cmd metacharacters, quotes, newlines) is
988
+ // rejected outright, so a crafted --out value cannot reach Windows `start`.
989
+ if (!/^[A-Za-z0-9 _.:\\/()-]+$/.test(resolved)) return false;
825
990
  try {
826
991
  if (process.platform === 'win32') {
827
- execFileSync('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore' });
992
+ execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
828
993
  } else if (process.platform === 'darwin') {
829
- execFileSync('open', [filePath], { stdio: 'ignore' });
994
+ execFileSync('open', [resolved], { stdio: 'ignore' });
830
995
  } else {
831
- execFileSync('xdg-open', [filePath], { stdio: 'ignore' });
996
+ execFileSync('xdg-open', [resolved], { stdio: 'ignore' });
832
997
  }
833
998
  return true;
834
999
  } catch {
@@ -867,6 +1032,7 @@ function cmdHud(cwd, opts = {}, raw) {
867
1032
  const sections = [
868
1033
  'mission',
869
1034
  data.roadmap.length && 'now-building',
1035
+ (!data.roadmap.length && data.planning_activity) && 'planning-activity',
870
1036
  data.army_active && 'command-stack',
871
1037
  data.campaign && 'campaign',
872
1038
  data.army_active && 'safety-harness',
@@ -893,7 +1059,26 @@ module.exports = {
893
1059
  scanPhases,
894
1060
  scanRequirements,
895
1061
  scanVerification,
1062
+ scanPlanningActivity,
1063
+ ledgerReliability,
896
1064
  buildArmy,
897
1065
  telemetryBySquad,
898
1066
  esc,
1067
+ // shared rendering foundation — reused verbatim by phase-report.cjs so both
1068
+ // surfaces look identical. Exporting these adds NO logic and cannot change
1069
+ // HUD output (a golden snapshot test guards that). openInBrowser is
1070
+ // deliberately NOT shared: its inline allowlist is a CodeQL taint barrier
1071
+ // that must stay a literal, so phase-report.cjs carries its own byte-copy.
1072
+ HUD_CSS,
1073
+ pill,
1074
+ bar,
1075
+ metricCard,
1076
+ fmtUsd,
1077
+ fmtTokens,
1078
+ relAge,
1079
+ pipelineStage,
1080
+ STATUS_KIND,
1081
+ STATUS_DOT,
1082
+ MARK_SVG,
1083
+ CHECK_SVG,
899
1084
  };
@@ -14,7 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
- const { output, error, safeReadFile, toPosix } = require('./core.cjs');
17
+ const { output, error, safeReadFile, toPosix, escapeRegex } = require('./core.cjs');
18
18
  const { PLANNING_DIR } = require('./constants.cjs');
19
19
  const { planningPath } = require('./utils.cjs');
20
20
  const { listMemoryAgents, readMemory } = require('./memory.cjs');
@@ -56,7 +56,7 @@ function scoreRelevance(question, content) {
56
56
  const body = content.toLowerCase();
57
57
  let score = 0;
58
58
  for (const w of words) {
59
- const count = (body.match(new RegExp(`\\b${w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g')) || []).length;
59
+ const count = (body.match(new RegExp(`\\b${escapeRegex(w)}\\b`, 'g')) || []).length;
60
60
  score += count;
61
61
  }
62
62
  return score;
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output } = require('./core.cjs');
11
+ const { output, escapeRegex } = require('./core.cjs');
12
12
  const { PLANNING_DIR } = require('./constants.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
@@ -1052,7 +1052,7 @@ function unpromotePattern(patternId, opts) {
1052
1052
  // Strip the pattern's body section. Pattern body is a `## P-<id> — ...` heading
1053
1053
  // followed by content until the next `## ` or end-of-file.
1054
1054
  const headingRe = new RegExp(
1055
- `\\n## ${patternId.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
1055
+ `\\n## ${escapeRegex(patternId)}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
1056
1056
  ''
1057
1057
  );
1058
1058
  const newBody = parsed.body.replace(headingRe, '');
@@ -47,7 +47,7 @@ function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
47
47
  const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
48
48
 
49
49
  // Find sibling decimals with higher numbers than the removed one
50
- const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
50
+ const decPattern = new RegExp(`^${escapeRegex(String(baseInt))}\\.(\\d+)-(.+)$`);
51
51
  const toRename = [];
52
52
  for (const dir of dirs) {
53
53
  const decMatch = dir.match(decPattern);