pan-wizard 3.26.0 → 3.27.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/README.md +1 -1
  2. package/commands/pan/hygiene.md +14 -8
  3. package/commands/pan/milestone-audit.md +10 -4
  4. package/hooks/dist/pan-cost-logger.js +69 -5
  5. package/hooks/dist/pan-stop-guard.js +32 -1
  6. package/hooks/dist/pan-trace-logger.js +35 -2
  7. package/package.json +1 -1
  8. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  9. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  10. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  11. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  12. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  13. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  14. package/pan-wizard-core/bin/lib/constants.cjs +27 -0
  15. package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
  16. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  17. package/pan-wizard-core/bin/lib/cost.cjs +0 -1
  18. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  19. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  20. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  21. package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
  22. package/pan-wizard-core/bin/lib/init.cjs +90 -13
  23. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  24. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  25. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  26. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  27. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  28. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  29. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  31. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  32. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  33. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  34. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  35. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  36. package/pan-wizard-core/bin/lib/verify.cjs +4 -3
  37. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  38. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  39. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
@@ -182,12 +182,40 @@ function cmdContextBudget(cwd, raw) {
182
182
  const eligiblePct = totalTokens > 0
183
183
  ? Math.round((cacheTokens / totalTokens) * 1000) / 10
184
184
  : 0;
185
+ // The cached block is re-read into EVERY agent call, so its size is the
186
+ // project's largest recurring cost. This used to be measured and reported
187
+ // with no threshold attached, which meant a block that had grown to ~28k
188
+ // tokens of mostly closed history looked exactly like a healthy one.
189
+ // Classifying it is what turns the measurement into a signal.
190
+ const { CACHE_BLOCK_WARN_TOKENS, CACHE_BLOCK_CRIT_TOKENS, CACHE_FILE_WARN_TOKENS } = require('./constants.cjs');
191
+ const largest = cached.blocks
192
+ .map(b => ({ path: b.path, tokens: Math.ceil((b.content || '').length / 4) }))
193
+ .sort((a, b) => b.tokens - a.tokens);
194
+
195
+ let cacheStatus = 'ok';
196
+ if (cached.blocks.length === 0) cacheStatus = 'absent';
197
+ else if (cacheTokens >= CACHE_BLOCK_CRIT_TOKENS) cacheStatus = 'critical';
198
+ else if (cacheTokens >= CACHE_BLOCK_WARN_TOKENS) cacheStatus = 'warn';
199
+
200
+ const advice = cacheStatus === 'absent'
201
+ ? 'no cacheable context files — every agent call re-sends its context uncached'
202
+ : cacheStatus === 'ok'
203
+ ? null
204
+ : `cached context is re-read on every agent call; largest file ${largest[0].path} (~${largest[0].tokens} tokens)`
205
+ + (largest[0].path.endsWith('state.md') ? ' — run `pan-tools state compact`' : '');
206
+
185
207
  cache = {
186
208
  block_count: cached.blocks.length,
187
209
  block_paths: cached.blocks.map(b => b.path),
210
+ block_tokens: largest,
188
211
  total_bytes: cached.total_bytes,
189
212
  total_tokens: cacheTokens,
190
213
  eligible_pct: eligiblePct,
214
+ status: cacheStatus,
215
+ warn_tokens: CACHE_BLOCK_WARN_TOKENS,
216
+ crit_tokens: CACHE_BLOCK_CRIT_TOKENS,
217
+ file_warn_tokens: CACHE_FILE_WARN_TOKENS,
218
+ advice,
191
219
  sha: cached.sha,
192
220
  };
193
221
  } catch {
@@ -7,7 +7,6 @@ const path = require('path');
7
7
  const os = require('os');
8
8
  const { execFileSync } = require('child_process');
9
9
  const {
10
- PLANNING_DIR,
11
10
  PHASES_DIR,
12
11
  MILESTONES_DIR,
13
12
  ROADMAP_FILE,
@@ -22,8 +21,8 @@ const {
22
21
  isVerificationFile,
23
22
  getPlanId,
24
23
  getSummaryId,
25
- MILESTONE_VERSION_RE,
26
24
  } = require('./constants.cjs');
25
+ const { planningPath, planningRel } = require('./utils.cjs');
27
26
 
28
27
  // ─── Multi-Model Routing ─────────────────────────────────────────────────────
29
28
 
@@ -331,7 +330,7 @@ function safeReadFile(filePath) {
331
330
  * plan_checker, verifier, parallelization, brave_search
332
331
  */
333
332
  function loadConfig(cwd) {
334
- const configPath = path.join(cwd, PLANNING_DIR, 'config.json');
333
+ const configPath = planningPath(cwd, 'config.json');
335
334
  const defaults = {
336
335
  model_profile: 'balanced',
337
336
  commit_docs: true,
@@ -391,6 +390,11 @@ function loadConfig(cwd) {
391
390
  // Cost dashboard config: `cost.rates` per-model overrides (surfaced so the
392
391
  // documented override actually reaches cost.cjs — it was dropped before).
393
392
  cost: parsed.cost || {},
393
+ // Prompt-cache config: `cache.extra_files` lets a project add its own
394
+ // stable documents to the cached context block. Needed because the
395
+ // built-in list is the phase-model spine, so a focus-model project had
396
+ // an empty block and therefore no prompt caching at all.
397
+ cache: parsed.cache || {},
394
398
  // ADR-0031: project build/verification commands. null = not configured
395
399
  // (focus-auto --clean-seal then asks or skips rather than guessing).
396
400
  build: parsed.build || null,
@@ -408,6 +412,7 @@ function loadConfig(cwd) {
408
412
  effort_overrides: {},
409
413
  routing: { strategy: 'static', provider: 'auto' },
410
414
  cost: {},
415
+ cache: {},
411
416
  build: null,
412
417
  verification: null,
413
418
  concurrency: { serial_build: false },
@@ -595,18 +600,18 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
595
600
  function findPhaseInternal(cwd, phase) {
596
601
  if (!phase) return null;
597
602
 
598
- const phasesDir = path.join(cwd, PLANNING_DIR, PHASES_DIR);
603
+ const phasesDir = planningPath(cwd, PHASES_DIR);
599
604
  const normalized = normalizePhaseName(phase);
600
605
 
601
606
  // Two-phase search strategy:
602
607
  // 1. Search the active phases directory (.planning/phases/) first.
603
608
  // 2. If not found, search archived milestone directories (.planning/milestones/v*-phases/)
604
609
  // in reverse order (newest archive first) so the most recent match wins.
605
- const current = searchPhaseInDir(phasesDir, path.join(PLANNING_DIR, PHASES_DIR), normalized);
610
+ const current = searchPhaseInDir(phasesDir, planningRel(PHASES_DIR), normalized);
606
611
  if (current) return current;
607
612
 
608
613
  // Search archived milestone phases (newest first)
609
- const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
614
+ const milestonesDir = planningPath(cwd, MILESTONES_DIR);
610
615
  try {
611
616
  const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
612
617
  const archiveDirs = milestoneEntries
@@ -620,7 +625,7 @@ function findPhaseInternal(cwd, phase) {
620
625
  if (!vm) continue;
621
626
  const version = vm[1];
622
627
  const archivePath = path.join(milestonesDir, archiveName);
623
- const relBase = path.join(PLANNING_DIR, MILESTONES_DIR, archiveName);
628
+ const relBase = planningRel(MILESTONES_DIR, archiveName);
624
629
  const result = searchPhaseInDir(archivePath, relBase, normalized);
625
630
  if (result) {
626
631
  result.archived = version;
@@ -633,7 +638,7 @@ function findPhaseInternal(cwd, phase) {
633
638
  }
634
639
 
635
640
  function getArchivedPhaseDirs(cwd) {
636
- const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
641
+ const milestonesDir = planningPath(cwd, MILESTONES_DIR);
637
642
  const results = [];
638
643
 
639
644
  try {
@@ -657,7 +662,7 @@ function getArchivedPhaseDirs(cwd) {
657
662
  results.push({
658
663
  name: dir,
659
664
  milestone: version,
660
- basePath: path.join(PLANNING_DIR, MILESTONES_DIR, archiveName),
665
+ basePath: planningRel(MILESTONES_DIR, archiveName),
661
666
  fullPath: path.join(archivePath, dir),
662
667
  });
663
668
  }
@@ -677,7 +682,7 @@ function getArchivedPhaseDirs(cwd) {
677
682
  */
678
683
  function getRoadmapPhaseInternal(cwd, phaseNum) {
679
684
  if (!phaseNum) return null;
680
- const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
685
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
681
686
 
682
687
  try {
683
688
  const content = fs.readFileSync(roadmapPath, 'utf-8');
@@ -927,22 +932,150 @@ function generateSlugInternal(text) {
927
932
  }
928
933
 
929
934
  /**
930
- * Extract current milestone version and name from roadmap.md.
935
+ * Match a milestone HEADING — a markdown heading line that carries a version.
936
+ *
937
+ * Anchored to line start and allowing `#{1,6}`, because the previous pattern
938
+ * (`/## .*v\d+\.\d+.../`, unanchored) matched inside `### ` headings AND inside
939
+ * body prose, which is how a version could be read out of a sentence.
940
+ */
941
+ const MILESTONE_HEADING_RE = /^[ \t]{0,3}#{1,6}[ \t]+(.*\bv\d+(?:\.\d+)+\b.*?)[ \t]*$/;
942
+
943
+ /** Collapsed shipped milestones live in `<summary>` lines, not headings. */
944
+ const MILESTONE_SUMMARY_RE = /^[ \t]*<summary>(.*\bv\d+(?:\.\d+)+\b.*?)<\/summary>[ \t]*$/;
945
+
946
+ /** Status markers PAN's own roadmap template emits, plus their prose forms. */
947
+ const MILESTONE_STATUS_MARKERS = [
948
+ { status: 'shipped', re: /✅|\bshipped\b|\bcomplete[d]?\b|\bdone\b/i },
949
+ // `current` is matched as a word anywhere in the heading, not as the whole
950
+ // parenthetical: real roadmaps write "(current, phases 1–10)", and requiring
951
+ // an exact "(current)" silently missed the marker and fell through to
952
+ // positional guessing — the failure mode this resolver exists to remove.
953
+ { status: 'current', re: /🚧|\bcurrent\b|\bin[-\s]progress\b|\bactive\b/i },
954
+ { status: 'planned', re: /📋|\bplanned\b|\bupcoming\b|\bfuture\b/i },
955
+ ];
956
+
957
+ /**
958
+ * Parse every milestone heading in a roadmap into {version, name, status}.
959
+ *
960
+ * Version and name are taken from THE SAME heading — the whole point. Reading
961
+ * them with two independent whole-document regexes let a version from one
962
+ * milestone pair with a name from another and produce a milestone that does not
963
+ * exist, with nothing in the output to suggest anything had gone wrong.
964
+ *
965
+ * @param {string} roadmap - full roadmap.md text
966
+ * @returns {Array<{version: string, name: string, status: string, line: number, heading: string}>}
967
+ */
968
+ function parseMilestoneHeadings(roadmap) {
969
+ const out = [];
970
+ const lines = String(roadmap || '').split(/\r?\n/);
971
+
972
+ lines.forEach((line, i) => {
973
+ const m = line.match(MILESTONE_HEADING_RE) || line.match(MILESTONE_SUMMARY_RE);
974
+ if (!m) return;
975
+ const heading = m[1];
976
+
977
+ const versionMatch = heading.match(/\bv(\d+(?:\.\d+)+)\b/);
978
+ if (!versionMatch) return;
979
+
980
+ let status = 'unknown';
981
+ for (const marker of MILESTONE_STATUS_MARKERS) {
982
+ if (marker.re.test(heading)) { status = marker.status; break; }
983
+ }
984
+ // A <summary> heading is a collapsed, already-shipped milestone even when
985
+ // it carries no explicit marker.
986
+ if (status === 'unknown' && MILESTONE_SUMMARY_RE.test(line)) status = 'shipped';
987
+
988
+ out.push({
989
+ version: `v${versionMatch[1]}`,
990
+ name: extractMilestoneName(heading, versionMatch[0]),
991
+ status,
992
+ line: i + 1,
993
+ heading: heading.trim(),
994
+ });
995
+ });
996
+
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * Reduce a milestone heading to its bare name.
1002
+ * "### 🚧 Milestone v4.1 — Full Platform (phases 1–12)" → "Full Platform"
1003
+ *
1004
+ * @param {string} heading - heading text with the leading #'s already stripped
1005
+ * @param {string} versionToken - the matched version, e.g. "v4.1"
1006
+ * @returns {string} the name, or '' when the heading carries none
1007
+ */
1008
+ function extractMilestoneName(heading, versionToken) {
1009
+ let name = heading;
1010
+ name = name.split(versionToken).slice(1).join(versionToken); // everything after the version
1011
+ name = name.replace(/\([^)]*\)/g, ' '); // "(current)", "(phases 1–12)"
1012
+ name = name.replace(/<\/?[^>]+>/g, ' '); // stray inline tags
1013
+ name = name.replace(/[*_`]+/g, ''); // markdown emphasis
1014
+ name = name.replace(/\p{Extended_Pictographic}️?/gu, ' '); // ✅ 🚧 📋 status glyphs
1015
+ name = name.replace(/^[\s:—–\-–]+/, '').replace(/[\s:—–\-–]+$/, '');
1016
+ // "Shipped: 2025-11-25" style trailers add nothing to the name.
1017
+ name = name.replace(/\b(shipped|completed?|done)\b[:\s]*\d{4}-\d{2}-\d{2}\s*$/i, '').trim();
1018
+ return name.trim();
1019
+ }
1020
+
1021
+ /**
1022
+ * Pick the CURRENT milestone from parsed headings.
1023
+ *
1024
+ * Order of preference:
1025
+ * 1. a heading explicitly marked in-progress (🚧 / "(current)" / "in progress")
1026
+ * 2. the first heading that is not shipped — work not yet done
1027
+ * 3. the last shipped heading — everything is done, so the newest is current
1028
+ *
1029
+ * @param {Array} headings - from parseMilestoneHeadings()
1030
+ * @returns {{milestone: Object|null, ambiguous: boolean, basis: string}}
1031
+ */
1032
+ function selectCurrentMilestone(headings) {
1033
+ if (!headings.length) return { milestone: null, ambiguous: false, basis: 'none' };
1034
+
1035
+ const current = headings.filter(h => h.status === 'current');
1036
+ if (current.length) {
1037
+ // More than one milestone marked current is a planning-state error, not
1038
+ // something to resolve silently — report it alongside the pick.
1039
+ return { milestone: current[0], ambiguous: current.length > 1, basis: 'marked-current' };
1040
+ }
1041
+
1042
+ const unshipped = headings.find(h => h.status !== 'shipped');
1043
+ if (unshipped) return { milestone: unshipped, ambiguous: false, basis: 'first-unshipped' };
1044
+
1045
+ return { milestone: headings[headings.length - 1], ambiguous: false, basis: 'last-shipped' };
1046
+ }
1047
+
1048
+ /**
1049
+ * Extract the current milestone's version and name from roadmap.md.
1050
+ *
1051
+ * Both values come from a single heading — see parseMilestoneHeadings() for why
1052
+ * that constraint is the whole fix.
1053
+ *
931
1054
  * @param {string} cwd - Project root directory
932
- * @returns {{version: string, name: string}} Milestone info (defaults: v1.0, "milestone")
1055
+ * @returns {{version: string, name: string, status: string, basis: string, ambiguous: boolean, candidates: number}}
1056
+ * Milestone info (defaults: v1.0, "milestone")
933
1057
  */
934
1058
  function getMilestoneInfo(cwd) {
1059
+ const fallback = { version: 'v1.0', name: 'milestone', status: 'unknown', basis: 'default', ambiguous: false, candidates: 0 };
1060
+ let roadmap;
935
1061
  try {
936
- const roadmap = fs.readFileSync(path.join(cwd, PLANNING_DIR, ROADMAP_FILE), 'utf-8');
937
- const versionMatch = roadmap.match(MILESTONE_VERSION_RE);
938
- const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/);
939
- return {
940
- version: versionMatch ? versionMatch[0] : 'v1.0',
941
- name: nameMatch ? nameMatch[1].trim() : 'milestone',
942
- };
1062
+ roadmap = fs.readFileSync(planningPath(cwd, ROADMAP_FILE), 'utf-8');
943
1063
  } catch {
944
- return { version: 'v1.0', name: 'milestone' };
1064
+ return fallback;
945
1065
  }
1066
+
1067
+ const headings = parseMilestoneHeadings(roadmap);
1068
+ const { milestone, ambiguous, basis } = selectCurrentMilestone(headings);
1069
+ if (!milestone) return { ...fallback, basis: 'no-milestone-heading' };
1070
+
1071
+ return {
1072
+ version: milestone.version,
1073
+ name: milestone.name || 'milestone',
1074
+ status: milestone.status,
1075
+ basis,
1076
+ ambiguous,
1077
+ candidates: headings.length,
1078
+ };
946
1079
  }
947
1080
 
948
1081
  /**
@@ -952,7 +1085,7 @@ function getMilestoneInfo(cwd) {
952
1085
  * @returns {{ count: number, todos: Array<{file: string, created: string, title: string, area: string, path: string}> }}
953
1086
  */
954
1087
  function scanPendingTodos(cwd, area) {
955
- const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
1088
+ const pendingDir = planningPath(cwd, 'todos', 'pending');
956
1089
  let count = 0;
957
1090
  const todos = [];
958
1091
 
@@ -974,7 +1107,7 @@ function scanPendingTodos(cwd, area) {
974
1107
  created: createdMatch ? createdMatch[1].trim() : 'unknown',
975
1108
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
976
1109
  area: todoArea,
977
- path: path.join(PLANNING_DIR, 'todos', 'pending', file),
1110
+ path: planningRel('todos', 'pending', file),
978
1111
  });
979
1112
  } catch { /* skip unreadable file */ }
980
1113
  }
@@ -1004,17 +1137,44 @@ function scanPendingTodos(cwd, area) {
1004
1137
  * @returns {{blocks: Array<{path: string, content: string, cache: true}>, total_bytes: number, sha: string}}
1005
1138
  */
1006
1139
  function buildCachedContext(cwd) {
1007
- const { PLANNING_DIR, CACHEABLE_CONTEXT_FILES } = require('./constants.cjs');
1140
+ const { CACHEABLE_CONTEXT_FILES } = require('./constants.cjs');
1008
1141
  const crypto = require('crypto');
1009
1142
  const blocks = [];
1010
1143
  let totalBytes = 0;
1011
1144
  const hasher = crypto.createHash('sha256');
1012
1145
 
1013
- for (const file of CACHEABLE_CONTEXT_FILES) {
1014
- const abs = path.join(cwd, PLANNING_DIR, file);
1146
+ // The built-in list is the PHASE-model spine. A focus-model project has none
1147
+ // of those files, so its cached block came out empty and it silently received
1148
+ // no prompt caching at all. `cache.extra_files` lets such a project name its
1149
+ // own stable documents rather than PAN inventing a convention it doesn't
1150
+ // otherwise define.
1151
+ //
1152
+ // Entries are planning-root-relative, must stay inside it, and are appended
1153
+ // after the built-ins so the prefix stays byte-stable for projects that set
1154
+ // nothing — changing the prefix would invalidate every existing cache key.
1155
+ const extra = [];
1156
+ try {
1157
+ const configured = loadConfig(cwd)?.cache?.extra_files;
1158
+ if (Array.isArray(configured)) {
1159
+ for (const entry of configured) {
1160
+ if (typeof entry !== 'string' || !entry.trim()) continue;
1161
+ const rel = entry.trim().replace(/\\/g, '/');
1162
+ // Inline literal guard: a cache entry must not escape the planning root
1163
+ // or reach an absolute path. Checked here rather than via a helper
1164
+ // because static analysis does not follow guards across functions.
1165
+ if (rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)) continue;
1166
+ if (rel.split('/').includes('..')) continue;
1167
+ if (CACHEABLE_CONTEXT_FILES.includes(rel) || extra.includes(rel)) continue;
1168
+ extra.push(rel);
1169
+ }
1170
+ }
1171
+ } catch { /* unreadable config — built-ins only */ }
1172
+
1173
+ for (const file of [...CACHEABLE_CONTEXT_FILES, ...extra]) {
1174
+ const abs = planningPath(cwd, file);
1015
1175
  try {
1016
1176
  const content = fs.readFileSync(abs, 'utf-8');
1017
- blocks.push({ path: toPosix(path.join(PLANNING_DIR, file)), content, cache: true });
1177
+ blocks.push({ path: planningRel(file), content, cache: true });
1018
1178
  totalBytes += Buffer.byteLength(content, 'utf-8');
1019
1179
  hasher.update(file + '\0' + content + '\0');
1020
1180
  } catch {
@@ -1091,6 +1251,10 @@ module.exports = {
1091
1251
  pathExistsInternal,
1092
1252
  generateSlugInternal,
1093
1253
  getMilestoneInfo,
1254
+ parseMilestoneHeadings,
1255
+ selectCurrentMilestone,
1256
+ extractMilestoneName,
1257
+ MILESTONE_HEADING_RE,
1094
1258
  toPosix,
1095
1259
  buildCachedContext,
1096
1260
  scanPendingTodos,
@@ -37,7 +37,6 @@
37
37
  const fs = require('fs');
38
38
  const path = require('path');
39
39
  const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
40
- const { PLANNING_DIR } = require('./constants.cjs');
41
40
  const { planningPath } = require('./utils.cjs');
42
41
 
43
42
  const METRICS_DIR = 'metrics';
@@ -3,9 +3,9 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { output, error, safeReadFile } = require('./core.cjs');
6
+ const { planningRel } = require('./utils.cjs');
6
7
 
7
- const PLANNING_DIR = '.planning';
8
- const MEMORY_DIR = path.join(PLANNING_DIR, 'memory');
8
+ const MEMORY_DIR = planningRel('memory');
9
9
  const PATTERNS_FILE = 'distill-patterns.md';
10
10
 
11
11
  const SAFETY_TIERS = { SAFE: 'safe', REVIEW: 'review_required', RISKY: 'risky' };
@@ -307,7 +307,7 @@ function loadFiles(filePaths, cwd) {
307
307
  const out = {};
308
308
  for (const f of filePaths) {
309
309
  const content = safeReadFile(f);
310
- if (content) out[path.relative(cwd, f).replace(/\\/g, '/')] = content;
310
+ if (content) out[path.relative(cwd, f)] = content;
311
311
  }
312
312
  return out;
313
313
  }
@@ -10,7 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { output, EXIT_OK, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex, normalizePhaseName } = require('./core.cjs');
12
12
  const {
13
- PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
13
+ PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
14
14
  FOCUS_MODES, FOCUS_TIERS, FOCUS_DIR,
15
15
  BUDGET_LIMIT_BUGFIX, BUDGET_LIMIT_FULL, STABILITY_RATIO, FEATURE_RATIO,
16
16
  DIMINISHING_RETURNS_THRESHOLD,
@@ -22,7 +22,7 @@ const {
22
22
  const { extractFrontmatter, extractPriorityEffort } = require('./frontmatter.cjs');
23
23
  const { enumerateRoadmapPhases } = require('./roadmap.cjs');
24
24
  const { readErrorPatterns } = require('./commands.cjs');
25
- const { planningPath, listPhaseDirs, classifyPhaseStatus, filterPlanFiles, filterSummaryFiles } = require('./utils.cjs');
25
+ const { planningPath, listPhaseDirs, classifyPhaseStatus, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
26
26
 
27
27
  // ─── Scan helpers ───────────────────────────────────────────────────────────
28
28
 
@@ -37,11 +37,11 @@ function collectWorkItems(cwd) {
37
37
  const sources = { phases: 0, todos: 0, patterns: 0 };
38
38
 
39
39
  // 1. Phase-based items from ROADMAP + plan.md frontmatter
40
- const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
40
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
41
41
  const roadmapContent = safeReadFile(roadmapPath);
42
42
  if (roadmapContent) {
43
43
  const phases = enumerateRoadmapPhases(roadmapContent);
44
- const phasesDir = path.join(cwd, PLANNING_DIR, PHASES_DIR);
44
+ const phasesDir = planningPath(cwd, PHASES_DIR);
45
45
  let dirs;
46
46
  try { dirs = fs.readdirSync(phasesDir); } catch { dirs = []; }
47
47
 
@@ -89,7 +89,7 @@ function collectWorkItems(cwd) {
89
89
  effort,
90
90
  points: EFFORT_POINTS[effort] || 4,
91
91
  status,
92
- file: toPosix(path.join(PLANNING_DIR, PHASES_DIR, dirName)),
92
+ file: planningRel(PHASES_DIR, dirName),
93
93
  });
94
94
  sources.phases++;
95
95
  }
@@ -107,7 +107,7 @@ function collectWorkItems(cwd) {
107
107
  effort: 'S',
108
108
  points: EFFORT_POINTS.S,
109
109
  status: 'pending',
110
- file: toPosix(path.join(PLANNING_DIR, 'todos', 'pending', todo.file)),
110
+ file: planningRel('todos', 'pending', todo.file),
111
111
  });
112
112
  sources.todos++;
113
113
  }
@@ -124,7 +124,7 @@ function collectWorkItems(cwd) {
124
124
  effort: 'S',
125
125
  points: EFFORT_POINTS.S,
126
126
  status: 'active',
127
- file: toPosix(path.join(PLANNING_DIR, PATTERNS_FILE)),
127
+ file: planningRel(PATTERNS_FILE),
128
128
  });
129
129
  sources.patterns++;
130
130
  }
@@ -384,7 +384,7 @@ function cmdFocusPlan(cwd, raw, ...args) {
384
384
  const { batch, allocated, remaining } = allocateBudget(sorted, budget, mode);
385
385
 
386
386
  // Write batch file
387
- const focusDir = path.join(cwd, PLANNING_DIR, FOCUS_DIR);
387
+ const focusDir = planningPath(cwd, FOCUS_DIR);
388
388
  try { fs.mkdirSync(focusDir, { recursive: true }); } catch { /* exists */ }
389
389
 
390
390
  const date = new Date().toISOString().split('T')[0];
@@ -574,7 +574,7 @@ function cmdFocusSync(cwd, raw, ...args) {
574
574
  * @returns {Object|null} Parsed batch data or null
575
575
  */
576
576
  function readLatestBatch(cwd) {
577
- const focusDir = path.join(cwd, PLANNING_DIR, FOCUS_DIR);
577
+ const focusDir = planningPath(cwd, FOCUS_DIR);
578
578
  let files;
579
579
  try {
580
580
  files = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
@@ -642,7 +642,7 @@ function cmdFocusExec(cwd, raw, ...args) {
642
642
  full: full.length,
643
643
  },
644
644
  items: batch.batch,
645
- batch_file: toPosix(path.join(PLANNING_DIR, FOCUS_DIR, `batch-${batch.date}.json`)),
645
+ batch_file: planningRel(FOCUS_DIR, `batch-${batch.date}.json`),
646
646
  };
647
647
 
648
648
  output(result, raw);
@@ -672,7 +672,7 @@ function categoryFilter(items, category) {
672
672
  * @returns {object|null} Parsed auto-run state or null
673
673
  */
674
674
  function readAutoRun(cwd) {
675
- const filePath = path.join(cwd, PLANNING_DIR, FOCUS_DIR, AUTO_RUN_FILE);
675
+ const filePath = planningPath(cwd, FOCUS_DIR, AUTO_RUN_FILE);
676
676
  const content = safeReadFile(filePath);
677
677
  if (!content) return null;
678
678
  try {
@@ -689,7 +689,7 @@ function readAutoRun(cwd) {
689
689
  * @returns {boolean} true on success
690
690
  */
691
691
  function writeAutoRun(cwd, data) {
692
- const dirPath = path.join(cwd, PLANNING_DIR, FOCUS_DIR);
692
+ const dirPath = planningPath(cwd, FOCUS_DIR);
693
693
  try {
694
694
  fs.mkdirSync(dirPath, { recursive: true });
695
695
  fs.writeFileSync(path.join(dirPath, AUTO_RUN_FILE), JSON.stringify(data, null, 2));
@@ -929,9 +929,9 @@ function focusAutoCheckpointCommit(cwd, cycle, run) {
929
929
  // Enabled projects: refresh the HTML reports before staging so the committed
930
930
  // .planning/ snapshot reflects this cycle.
931
931
  maybeRenderPhaseReports(cwd);
932
- const status = execGit(cwd, ['status', '--porcelain', PLANNING_DIR + '/']);
932
+ const status = execGit(cwd, ['status', '--porcelain', planningRel() + '/']);
933
933
  if (status.exitCode !== 0 || !status.stdout) return null;
934
- execGit(cwd, ['add', PLANNING_DIR + '/']);
934
+ execGit(cwd, ['add', planningRel() + '/']);
935
935
  const msg = `docs: focus-auto cycle ${cycle.cycle} — ${cycle.items_completed} items completed`;
936
936
  const commitResult = execGit(cwd, ['commit', '-m', msg]);
937
937
  if (commitResult.exitCode !== 0) return null;
@@ -1057,11 +1057,11 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
1057
1057
  };
1058
1058
 
1059
1059
  if (hasFlag('--dry-run')) {
1060
- return output({ dry_run: true, ...runData, run_file: toPosix(path.join(PLANNING_DIR, FOCUS_DIR, AUTO_RUN_FILE)) }, raw);
1060
+ return output({ dry_run: true, ...runData, run_file: planningRel(FOCUS_DIR, AUTO_RUN_FILE) }, raw);
1061
1061
  }
1062
1062
 
1063
1063
  writeAutoRun(cwd, runData);
1064
- output({ ...runData, run_file: toPosix(path.join(PLANNING_DIR, FOCUS_DIR, AUTO_RUN_FILE)) }, raw);
1064
+ output({ ...runData, run_file: planningRel(FOCUS_DIR, AUTO_RUN_FILE) }, raw);
1065
1065
  }
1066
1066
 
1067
1067
  function cmdFocusAuto(cwd, raw, ...args) {
@@ -30,7 +30,7 @@ const {
30
30
  execGit, isGitRepo, escapeRegex, toPosix,
31
31
  } = require('./core.cjs');
32
32
  const {
33
- PLANNING_DIR, STATE_FILE, PROJECT_FILE, REQUIREMENTS_FILE, PAUSE_FILE,
33
+ STATE_FILE, PROJECT_FILE, REQUIREMENTS_FILE, PAUSE_FILE,
34
34
  } = require('./constants.cjs');
35
35
  const {
36
36
  planningPath, phasesPath, listPhaseDirs, parsePhaseDir,