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
@@ -5,11 +5,12 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit } = require('./core.cjs');
8
- const { PLANNING_DIR, PHASES_DIR, CODEBASE_DIR, QUICK_DIR, MILESTONES_DIR, STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PROJECT_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isResearchFile, isContextFile, isVerificationFile, PLAN_SUFFIX, SUMMARY_SUFFIX, CONTEXT_SUFFIX, RESEARCH_SUFFIX, VERIFICATION_SUFFIX, UAT_SUFFIX, MAX_SLUG_LENGTH } = require('./constants.cjs');
9
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey, parsePhaseDir } = require('./utils.cjs');
8
+ const { PHASES_DIR, CODEBASE_DIR, QUICK_DIR, MILESTONES_DIR, STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PROJECT_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isResearchFile, isContextFile, isVerificationFile, PLAN_SUFFIX, SUMMARY_SUFFIX, CONTEXT_SUFFIX, RESEARCH_SUFFIX, VERIFICATION_SUFFIX, UAT_SUFFIX, MAX_SLUG_LENGTH } = require('./constants.cjs');
9
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey, parsePhaseDir, planningRel } = require('./utils.cjs');
10
10
  const { classifyPlanTier } = require('./phase.cjs');
11
11
  const { extractFrontmatter } = require('./frontmatter.cjs');
12
12
  const { detectLanguages } = require('./codebase.cjs');
13
+ const { planningRootRel, describePlanningRoot, planningRoots, withPlanningRoot } = require('./planning-root.cjs');
13
14
 
14
15
  // ---- Git helpers ----
15
16
 
@@ -23,7 +24,7 @@ function ensureGitRepo(cwd) {
23
24
 
24
25
  /** Build a forward-slash relative path under .planning for JSON output */
25
26
  function planningRelPath(...segments) {
26
- return [PLANNING_DIR, ...segments].join('/');
27
+ return planningRel(...segments);
27
28
  }
28
29
 
29
30
  /**
@@ -178,6 +179,9 @@ function cmdInitExecutePhase(cwd, phase, raw, opts) {
178
179
  : null,
179
180
 
180
181
  // Milestone info
182
+ // Which tree this milestone came from — a spliced or wrong-track milestone
183
+ // must never look like a confident answer.
184
+ ...describePlanningRoot(cwd),
181
185
  milestone_version: milestone.version,
182
186
  milestone_name: milestone.name,
183
187
  milestone_slug: generateSlugInternal(milestone.name),
@@ -251,7 +255,10 @@ function cmdInitPlanPhase(cwd, phase, raw) {
251
255
  plan_count: phaseInfo?.plans?.length || 0,
252
256
 
253
257
  // Environment
254
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
258
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
259
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
260
+ // in the output rather than silently producing plausible results.
261
+ ...describePlanningRoot(cwd),
255
262
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
256
263
 
257
264
  // File paths
@@ -322,7 +329,10 @@ function cmdInitNewProject(cwd, raw) {
322
329
  // Existing state
323
330
  project_exists: pathExistsInternal(cwd, planningRelPath(PROJECT_FILE)),
324
331
  has_codebase_map: pathExistsInternal(cwd, planningRelPath(CODEBASE_DIR)),
325
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
332
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
333
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
334
+ // in the output rather than silently producing plausible results.
335
+ ...describePlanningRoot(cwd),
326
336
 
327
337
  // Brownfield detection
328
338
  has_existing_code: hasCode,
@@ -435,7 +445,10 @@ function cmdInitQuick(cwd, description, raw) {
435
445
 
436
446
  // File existence
437
447
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
438
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
448
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
449
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
450
+ // in the output rather than silently producing plausible results.
451
+ ...describePlanningRoot(cwd),
439
452
 
440
453
  };
441
454
 
@@ -464,7 +477,10 @@ function cmdInitResume(cwd, raw) {
464
477
  state_exists: pathExistsInternal(cwd, planningRelPath(STATE_FILE)),
465
478
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
466
479
  project_exists: pathExistsInternal(cwd, planningRelPath(PROJECT_FILE)),
467
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
480
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
481
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
482
+ // in the output rather than silently producing plausible results.
483
+ ...describePlanningRoot(cwd),
468
484
 
469
485
  // File paths
470
486
  state_path: planningRelPath(STATE_FILE),
@@ -572,7 +588,10 @@ function cmdInitPhaseOp(cwd, phase, raw) {
572
588
 
573
589
  // File existence
574
590
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
575
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
591
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
592
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
593
+ // in the output rather than silently producing plausible results.
594
+ ...describePlanningRoot(cwd),
576
595
 
577
596
  // File paths
578
597
  state_path: planningRelPath(STATE_FILE),
@@ -610,7 +629,10 @@ function cmdInitTodos(cwd, area, raw) {
610
629
  area_filter: area || null,
611
630
  pending_dir: planningRelPath('todos/pending'),
612
631
  completed_dir: planningRelPath('todos/completed'),
613
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
632
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
633
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
634
+ // in the output rather than silently producing plausible results.
635
+ ...describePlanningRoot(cwd),
614
636
  todos_dir_exists: pathExistsInternal(cwd, planningRelPath('todos')),
615
637
  pending_dir_exists: pathExistsInternal(cwd, planningRelPath('todos/pending')),
616
638
  };
@@ -624,7 +646,7 @@ function cmdInitTodos(cwd, area, raw) {
624
646
  * @param {boolean} raw - If true, output raw value instead of JSON
625
647
  * @returns {void}
626
648
  */
627
- function cmdInitMilestoneOp(cwd, raw) {
649
+ function buildMilestoneOpPayload(cwd) {
628
650
  const config = loadConfig(cwd);
629
651
  const milestone = getMilestoneInfo(cwd);
630
652
 
@@ -667,10 +689,21 @@ function cmdInitMilestoneOp(cwd, raw) {
667
689
  commit_docs: config.commit_docs,
668
690
 
669
691
  // Current milestone
692
+ // Which tree this milestone came from — a spliced or wrong-track milestone
693
+ // must never look like a confident answer.
694
+ ...describePlanningRoot(cwd),
670
695
  milestone_version: milestone.version,
671
696
  milestone_name: milestone.name,
672
697
  milestone_slug: generateSlugInternal(milestone.name),
673
698
 
699
+ // How the milestone was decided. `milestone_ambiguous` means the roadmap
700
+ // marks more than one milestone current — a planning-state error the audit
701
+ // must surface rather than silently resolve to whichever came first.
702
+ milestone_status: milestone.status,
703
+ milestone_basis: milestone.basis,
704
+ milestone_ambiguous: milestone.ambiguous,
705
+ milestone_candidates: milestone.candidates,
706
+
674
707
  // Phase counts
675
708
  phase_count: phaseCount,
676
709
  completed_phases: completedPhases,
@@ -688,7 +721,44 @@ function cmdInitMilestoneOp(cwd, raw) {
688
721
  phases_dir_exists: pathExistsInternal(cwd, planningRelPath(PHASES_DIR)),
689
722
  };
690
723
 
691
- output(result, raw);
724
+ return result;
725
+ }
726
+
727
+ /**
728
+ * Milestone bootstrap context for one planning tree, or for every tree at once.
729
+ *
730
+ * `--all-tracks` exists because a milestone audit run against the wrong tree
731
+ * produces a confident, plausible, wrong report. Sweeping every tree and
732
+ * labelling each result makes the scope of the audit explicit instead of
733
+ * implicit in whichever directory the command happened to resolve.
734
+ *
735
+ * @param {string} cwd - Project root directory
736
+ * @param {boolean} raw - If true, output raw value instead of JSON
737
+ * @param {Object} [opts] - {allTracks}
738
+ * @returns {void}
739
+ */
740
+ function cmdInitMilestoneOp(cwd, raw, opts = {}) {
741
+ if (!opts.allTracks) {
742
+ output(buildMilestoneOpPayload(cwd), raw);
743
+ return;
744
+ }
745
+
746
+ const roots = planningRoots(cwd, { allTracks: true });
747
+ const tracks = roots.map(root => withPlanningRoot(root.rel, () => ({
748
+ ...buildMilestoneOpPayload(cwd),
749
+ // Authoritative: `track` comes from the root we are sweeping, and must win
750
+ // over anything the spread payload carries.
751
+ track: root.name,
752
+ }), root.name));
753
+
754
+ output({
755
+ all_tracks: true,
756
+ track_count: tracks.length,
757
+ // A milestone the tooling could not resolve unambiguously in ANY tree is
758
+ // worth surfacing at the top level — the audit should stop, not guess.
759
+ ambiguous_tracks: tracks.filter(t => t.milestone_ambiguous).map(t => t.track),
760
+ tracks,
761
+ }, raw);
692
762
  }
693
763
 
694
764
  /**
@@ -726,7 +796,10 @@ function cmdInitMapCodebase(cwd, raw) {
726
796
  has_maps: existingMaps.length > 0,
727
797
 
728
798
  // File existence
729
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
799
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
800
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
801
+ // in the output rather than silently producing plausible results.
802
+ ...describePlanningRoot(cwd),
730
803
  codebase_dir_exists: pathExistsInternal(cwd, planningRelPath(CODEBASE_DIR)),
731
804
 
732
805
  // Language detection
@@ -785,7 +858,7 @@ function scanAllPhases(cwd) {
785
858
  const phaseInfo = {
786
859
  number: phaseNumber,
787
860
  name: phaseName,
788
- directory: toPosix(path.join(PLANNING_DIR, PHASES_DIR, dirName)),
861
+ directory: planningRel(PHASES_DIR, dirName),
789
862
  status,
790
863
  plan_count: plans.length,
791
864
  summary_count: summaries.length,
@@ -833,6 +906,9 @@ function cmdInitProgress(cwd, raw) {
833
906
  commit_docs: config.commit_docs,
834
907
 
835
908
  // Milestone
909
+ // Which tree this milestone came from — a spliced or wrong-track milestone
910
+ // must never look like a confident answer.
911
+ ...describePlanningRoot(cwd),
836
912
  milestone_version: milestone.version,
837
913
  milestone_name: milestone.name,
838
914
 
@@ -873,6 +949,7 @@ module.exports = {
873
949
  cmdInitPhaseOp,
874
950
  cmdInitTodos,
875
951
  cmdInitMilestoneOp,
952
+ buildMilestoneOpPayload,
876
953
  cmdInitMapCodebase,
877
954
  cmdInitProgress,
878
955
  };
@@ -15,7 +15,6 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const { output, error, safeReadFile, toPosix, escapeRegex } = require('./core.cjs');
18
- const { PLANNING_DIR } = require('./constants.cjs');
19
18
  const { planningPath } = require('./utils.cjs');
20
19
  const { listMemoryAgents, readMemory } = require('./memory.cjs');
21
20
 
@@ -22,7 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
  const { output, error } = require('./core.cjs');
25
- const { PLANNING_DIR, CHARS_PER_TOKEN, MEMORY_SELECT_BUDGET_TOKENS, MEMORY_RECENCY_FLOOR, MEMORY_SOFT_CAP_MULT, MEMORY_LOAD_WARN_TOKENS, MEMORY_LOAD_CRIT_TOKENS, MEMORY_LOAD_MAX_FRACTION } = require('./constants.cjs');
25
+ const { CHARS_PER_TOKEN, MEMORY_SELECT_BUDGET_TOKENS, MEMORY_RECENCY_FLOOR, MEMORY_SOFT_CAP_MULT, MEMORY_LOAD_WARN_TOKENS, MEMORY_LOAD_CRIT_TOKENS, MEMORY_LOAD_MAX_FRACTION } = require('./constants.cjs');
26
26
  const { planningPath } = require('./utils.cjs');
27
27
 
28
28
  const MEMORY_DIR = 'memory';
@@ -4,8 +4,8 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { PLANNING_DIR, PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
8
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
7
+ const { PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
8
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible, planningRel } = require('./utils.cjs');
9
9
  const { output, error, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
10
10
  const { extractFrontmatter } = require('./frontmatter.cjs');
11
11
  const { writeStateMd } = require('./state.cjs');
@@ -260,7 +260,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
260
260
 
261
261
  // Auto-commit + tag unless --no-commit or not a git repo
262
262
  if (!options.noCommit && isGitRepo(cwd)) {
263
- execGit(cwd, ['add', PLANNING_DIR + '/']);
263
+ execGit(cwd, ['add', planningRel() + '/']);
264
264
  const commitMsg = `docs: milestone ${version} complete`;
265
265
  const commitResult = execGit(cwd, ['commit', '-m', commitMsg]);
266
266
  if (commitResult.exitCode === 0) {
@@ -9,7 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { output, escapeRegex, execGit } = require('./core.cjs');
12
- const { PLANNING_DIR } = require('./constants.cjs');
12
+ const { planningPath, planningRel } = require('./utils.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
15
15
 
@@ -29,7 +29,7 @@ const IMPACT_LEVELS = ['critical', 'major', 'minor', 'trivial'];
29
29
  // ─── Path helpers ─────────────────────────────────────────────────────────────
30
30
 
31
31
  function getOptimizeDir(cwd) {
32
- return path.join(cwd, PLANNING_DIR, OPTIMIZE_DIR);
32
+ return planningPath(cwd, OPTIMIZE_DIR);
33
33
  }
34
34
 
35
35
  function getTracesDir(cwd) {
@@ -817,7 +817,7 @@ function cmdOptimizeLearn(cwd, opts, raw) {
817
817
 
818
818
  output({
819
819
  session_id: sessionId,
820
- analysis_path: path.join(PLANNING_DIR, OPTIMIZE_DIR, OPT_REPORTS_DIR, reportName).replace(/\\/g, '/'),
820
+ analysis_path: planningRel(OPTIMIZE_DIR, OPT_REPORTS_DIR, reportName),
821
821
  summary: report.summary,
822
822
  top_error_patterns: report.error_patterns.slice(0, 5),
823
823
  top_gap_patterns: report.gap_patterns.slice(0, 5),
@@ -8,8 +8,8 @@ const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, get
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
9
  const { writeStateMd, readStateSafe } = require('./state.cjs');
10
10
  const { enumerateRoadmapPhases } = require('./roadmap.cjs');
11
- const { PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile, isSummaryFile, getPlanId, PHASE_DIR_RE, ARCHIVE_DIR_RE } = require('./constants.cjs');
12
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, parsePhaseDir, fileAccessible } = require('./utils.cjs');
11
+ const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile, isSummaryFile, getPlanId, PHASE_DIR_RE, ARCHIVE_DIR_RE } = require('./constants.cjs');
12
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, parsePhaseDir, fileAccessible, planningRel } = require('./utils.cjs');
13
13
  // Phase removal lives in phase-remove.cjs; re-exported below so consumers of
14
14
  // phase.cjs are unaffected by the decomposition.
15
15
  const { removePhaseFromDisk, renumberDecimalPhases, renumberIntegerPhases, updateRoadmapAfterRemoval, cmdPhaseRemove } = require('./phase-remove.cjs');
@@ -218,7 +218,7 @@ function cmdFindPhase(cwd, phase, raw) {
218
218
 
219
219
  const result = {
220
220
  found: true,
221
- directory: toPosix(path.join(PLANNING_DIR, PHASES_DIR, match)),
221
+ directory: planningRel(PHASES_DIR, match),
222
222
  phase_number: phaseNumber,
223
223
  phase_name: phaseName,
224
224
  plans,
@@ -855,7 +855,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
855
855
  const noCommit = opts && opts.noCommit;
856
856
  if (!noCommit && isGitRepo(cwd)) {
857
857
  const commitMsg = `docs(${normalized}): complete phase — ${phaseInfo.phase_name}`;
858
- execGit(cwd, ['add', PLANNING_DIR + '/']);
858
+ execGit(cwd, ['add', planningRel() + '/']);
859
859
  const commitResult = execGit(cwd, ['commit', '-m', commitMsg]);
860
860
  if (commitResult.exitCode === 0) {
861
861
  const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Planning root — resolves WHICH planning tree a pan-tools invocation acts on.
3
+ *
4
+ * Before this module every path was `path.join(cwd, '.planning', …)`, so a repo
5
+ * holding more than one planning tree (several products merged into one repo, a
6
+ * monorepo, a spike kept beside the mainline) could address exactly one of
7
+ * them. The other trees were not merely awkward to reach — they were
8
+ * unreachable, and commands did not say so. `hygiene scan` reported "clean"
9
+ * while a sibling track sat three times over its trace retention, because it
10
+ * had looked at the wrong directory and had no vocabulary for saying which one.
11
+ *
12
+ * The rule this module encodes: a command may operate on a tree other than
13
+ * `.planning/`, but it must always be able to name the tree it chose. Every
14
+ * resolution carries its `source`, and callers surface it, so a wrong target is
15
+ * visible in the output instead of being indistinguishable from a right one.
16
+ *
17
+ * Resolution precedence, highest first:
18
+ * 1. explicit override — `--planning-dir <path>` / `--track <name>`
19
+ * 2. PAN_PLANNING_DIR env — a project-relative path
20
+ * 3. PAN_TRACK env — a track name under `.planning/tracks/`
21
+ * 4. default — `.planning`
22
+ *
23
+ * Roots are always stored project-relative and POSIX-separated: they are used
24
+ * both to build absolute paths and, verbatim, as the display paths in command
25
+ * output and `git add` arguments.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ /** The planning tree every project has unless told otherwise. */
32
+ const DEFAULT_PLANNING_DIR = '.planning';
33
+
34
+ /** Directory under the default root that holds sibling planning trees. */
35
+ const TRACKS_DIR = 'tracks';
36
+
37
+ /**
38
+ * Files/dirs that mark a directory as a real planning tree rather than an
39
+ * incidental folder. Mirrors the hygiene "spine" test: the phase model, the
40
+ * focus model, or an orchestration campaign.
41
+ */
42
+ const PLANNING_SPINE = [
43
+ 'state.md', 'roadmap.md', 'project.md', 'requirements.md',
44
+ 'phases', 'milestones', 'focus', 'quick', 'orchestration', 'config.json',
45
+ ];
46
+
47
+ /** Explicit override set by the CLI, or null. @type {{rel: string, source: string, track: string|null}|null} */
48
+ let override = null;
49
+
50
+ /**
51
+ * Normalize a project-relative planning path to POSIX form, rejecting anything
52
+ * that escapes the project root.
53
+ *
54
+ * @param {string} input - candidate path, relative to the project root
55
+ * @param {string} label - flag/env name, for error text
56
+ * @returns {string} normalized POSIX-relative path
57
+ * @throws {Error} if absolute, empty, or containing a `..` segment
58
+ */
59
+ function normalizeRoot(input, label) {
60
+ const value = String(input == null ? '' : input).trim();
61
+ if (!value) throw new Error(`${label}: missing value`);
62
+
63
+ // Reject absolute paths on both platforms, plus Windows drive-relative
64
+ // ("C:foo") and UNC forms. Checked inline rather than via a helper: static
65
+ // analysis does not follow guards across function boundaries, and this is
66
+ // the barrier that keeps a planning root inside the project.
67
+ if (value.startsWith('/') || value.startsWith('\\')) {
68
+ throw new Error(`${label}: must be relative to the project root, got absolute path "${value}"`);
69
+ }
70
+ if (/^[A-Za-z]:/.test(value)) {
71
+ throw new Error(`${label}: must be relative to the project root, got drive path "${value}"`);
72
+ }
73
+
74
+ const segments = value.split(/[\\/]+/).filter(s => s && s !== '.');
75
+ if (segments.length === 0) throw new Error(`${label}: missing value`);
76
+ if (segments.includes('..')) {
77
+ throw new Error(`${label}: must stay inside the project root, got "${value}"`);
78
+ }
79
+ return segments.join('/');
80
+ }
81
+
82
+ /**
83
+ * Validate a track name. Track names become a single path segment, so they are
84
+ * restricted to a slug alphabet — this is the barrier against traversal via
85
+ * `--track ../../etc`, and it is deliberately stricter than normalizeRoot.
86
+ *
87
+ * @param {string} name
88
+ * @returns {string} the validated name
89
+ * @throws {Error} if the name is empty or not a plain slug
90
+ */
91
+ function normalizeTrackName(name) {
92
+ const value = String(name == null ? '' : name).trim();
93
+ if (!value) throw new Error('--track: missing value');
94
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
95
+ throw new Error(`--track: "${value}" is not a valid track name (letters, digits, dot, dash, underscore)`);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ /**
101
+ * Project-relative path of a named track's planning tree.
102
+ * @param {string} name - track name
103
+ * @returns {string} e.g. `.planning/tracks/verify`
104
+ */
105
+ function trackRel(name) {
106
+ return [DEFAULT_PLANNING_DIR, TRACKS_DIR, normalizeTrackName(name)].join('/');
107
+ }
108
+
109
+ /**
110
+ * Set the planning root explicitly. Called by the CLI once, before dispatch.
111
+ *
112
+ * @param {Object} opts
113
+ * @param {string} [opts.planningDir] - project-relative path (`--planning-dir`)
114
+ * @param {string} [opts.track] - track name (`--track`)
115
+ * @returns {{rel: string, source: string, track: string|null}|null} the resolution
116
+ */
117
+ function setPlanningRoot({ planningDir, track } = {}) {
118
+ if (planningDir && track) {
119
+ throw new Error('--planning-dir and --track are mutually exclusive');
120
+ }
121
+ if (track) {
122
+ const name = normalizeTrackName(track);
123
+ override = { rel: trackRel(name), source: 'flag:--track', track: name };
124
+ } else if (planningDir) {
125
+ override = { rel: normalizeRoot(planningDir, '--planning-dir'), source: 'flag:--planning-dir', track: null };
126
+ } else {
127
+ override = null;
128
+ }
129
+ return override;
130
+ }
131
+
132
+ /** Drop any explicit override, returning to env/default resolution. */
133
+ function clearPlanningRoot() {
134
+ override = null;
135
+ }
136
+
137
+ /**
138
+ * Run `fn` with the planning root pinned to `rel`, then restore the previous
139
+ * resolution — always, including on throw.
140
+ *
141
+ * This exists for the one job that genuinely needs it: sweeping several trees
142
+ * in a single invocation (`hygiene --all-tracks`). The alternative, threading a
143
+ * root argument through every check, would stop at the module boundary — the
144
+ * checks call into memory.cjs and cost.cjs, which resolve the root themselves.
145
+ * Scoping the ambient root is what makes those downstream readers follow the
146
+ * sweep instead of all reporting on `.planning/`.
147
+ *
148
+ * Synchronous only. Do not await inside `fn`: overlapping scopes would
149
+ * interleave and the restore would land on the wrong value.
150
+ *
151
+ * @param {string} rel - project-relative planning root to pin
152
+ * @param {Function} fn - synchronous callback
153
+ * @param {string|null} [track] - track name this root belongs to, so anything
154
+ * reporting from inside the scope (describePlanningRoot, and every payload
155
+ * that spreads it) names the right tree rather than defaulting to null
156
+ * @returns {*} whatever `fn` returns
157
+ */
158
+ function withPlanningRoot(rel, fn, track = null) {
159
+ const previous = override;
160
+ override = { rel: normalizeRoot(rel, 'planning root'), source: 'scoped', track: track || null };
161
+ try {
162
+ return fn();
163
+ } finally {
164
+ override = previous;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Resolve the active planning root.
170
+ *
171
+ * Env vars are read on every call rather than cached at load, so a host that
172
+ * sets them late (and every test that does) sees the change.
173
+ *
174
+ * @returns {{rel: string, source: string, track: string|null}}
175
+ */
176
+ function resolvePlanningRoot() {
177
+ if (override) return { ...override };
178
+
179
+ const envDir = process.env.PAN_PLANNING_DIR;
180
+ if (envDir && envDir.trim()) {
181
+ return { rel: normalizeRoot(envDir, 'PAN_PLANNING_DIR'), source: 'env:PAN_PLANNING_DIR', track: null };
182
+ }
183
+
184
+ const envTrack = process.env.PAN_TRACK;
185
+ if (envTrack && envTrack.trim()) {
186
+ const name = normalizeTrackName(envTrack);
187
+ return { rel: trackRel(name), source: 'env:PAN_TRACK', track: name };
188
+ }
189
+
190
+ return { rel: DEFAULT_PLANNING_DIR, source: 'default', track: null };
191
+ }
192
+
193
+ /**
194
+ * Active planning root, project-relative and POSIX-separated.
195
+ * This is the value that replaces the old `PLANNING_DIR` constant at call sites.
196
+ * @returns {string}
197
+ */
198
+ function planningRootRel() {
199
+ return resolvePlanningRoot().rel;
200
+ }
201
+
202
+ /**
203
+ * Absolute path of the active planning root.
204
+ * @param {string} cwd - project root
205
+ * @returns {string}
206
+ */
207
+ function planningRootAbs(cwd) {
208
+ return path.join(cwd, ...planningRootRel().split('/'));
209
+ }
210
+
211
+ /**
212
+ * Does this directory look like a planning tree (as opposed to any old folder)?
213
+ * @param {string} abs - absolute directory path
214
+ * @returns {boolean}
215
+ */
216
+ function isPlanningTree(abs) {
217
+ let entries;
218
+ try { entries = fs.readdirSync(abs); } catch { return false; }
219
+ const lower = entries.map(e => e.toLowerCase());
220
+ return PLANNING_SPINE.some(s => lower.includes(s));
221
+ }
222
+
223
+ /**
224
+ * Discover sibling planning trees under `.planning/tracks/`.
225
+ *
226
+ * Only directories that pass isPlanningTree() are returned — an empty or
227
+ * incidental folder under tracks/ is not a track, and silently treating one as
228
+ * a track would reintroduce the very "operated on the wrong thing" failure this
229
+ * module exists to prevent.
230
+ *
231
+ * @param {string} cwd - project root
232
+ * @returns {Array<{name: string, rel: string, abs: string}>} sorted by name
233
+ */
234
+ function discoverTracks(cwd) {
235
+ const tracksAbs = path.join(cwd, DEFAULT_PLANNING_DIR, TRACKS_DIR);
236
+ let entries = [];
237
+ try { entries = fs.readdirSync(tracksAbs, { withFileTypes: true }); } catch { return []; }
238
+
239
+ const tracks = [];
240
+ for (const e of entries) {
241
+ if (!e.isDirectory()) continue;
242
+ let name;
243
+ try { name = normalizeTrackName(e.name); } catch { continue; }
244
+ const abs = path.join(tracksAbs, name);
245
+ if (!isPlanningTree(abs)) continue;
246
+ tracks.push({ name, rel: [DEFAULT_PLANNING_DIR, TRACKS_DIR, name].join('/'), abs });
247
+ }
248
+ tracks.sort((a, b) => a.name.localeCompare(b.name));
249
+ return tracks;
250
+ }
251
+
252
+ /**
253
+ * Every planning root a command should act on.
254
+ *
255
+ * Without `allTracks` this is exactly the resolved root — one tree, the one the
256
+ * user asked for. With it, the default root (when it is a real tree) plus every
257
+ * discovered track, each labelled, so aggregate output can attribute findings.
258
+ *
259
+ * @param {string} cwd - project root
260
+ * @param {Object} [opts]
261
+ * @param {boolean} [opts.allTracks] - include every discovered track
262
+ * @returns {Array<{name: string|null, rel: string, abs: string, source: string}>}
263
+ */
264
+ function planningRoots(cwd, opts = {}) {
265
+ const resolved = resolvePlanningRoot();
266
+
267
+ if (!opts.allTracks) {
268
+ return [{
269
+ name: resolved.track,
270
+ rel: resolved.rel,
271
+ abs: path.join(cwd, ...resolved.rel.split('/')),
272
+ source: resolved.source,
273
+ }];
274
+ }
275
+
276
+ const roots = [];
277
+ const rootAbs = path.join(cwd, DEFAULT_PLANNING_DIR);
278
+ if (isPlanningTree(rootAbs)) {
279
+ roots.push({ name: null, rel: DEFAULT_PLANNING_DIR, abs: rootAbs, source: 'all-tracks' });
280
+ }
281
+ for (const t of discoverTracks(cwd)) {
282
+ roots.push({ name: t.name, rel: t.rel, abs: t.abs, source: 'all-tracks' });
283
+ }
284
+ // A project with no tracks and no root spine still gets one root to act on,
285
+ // so --all-tracks never silently does nothing.
286
+ if (roots.length === 0) {
287
+ roots.push({ name: null, rel: DEFAULT_PLANNING_DIR, abs: rootAbs, source: 'all-tracks' });
288
+ }
289
+ return roots;
290
+ }
291
+
292
+ /**
293
+ * Human/machine-readable description of the active root, for command output.
294
+ * @param {string} cwd - project root
295
+ * @returns {{planning_root: string, track: string|null, planning_root_source: string, planning_root_exists: boolean}}
296
+ */
297
+ function describePlanningRoot(cwd) {
298
+ const resolved = resolvePlanningRoot();
299
+ const abs = path.join(cwd, ...resolved.rel.split('/'));
300
+ let exists = false;
301
+ try { exists = fs.statSync(abs).isDirectory(); } catch { /* absent */ }
302
+ return {
303
+ planning_root: resolved.rel,
304
+ track: resolved.track,
305
+ planning_root_source: resolved.source,
306
+ planning_root_exists: exists,
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ DEFAULT_PLANNING_DIR,
312
+ TRACKS_DIR,
313
+ PLANNING_SPINE,
314
+ normalizeRoot,
315
+ normalizeTrackName,
316
+ trackRel,
317
+ setPlanningRoot,
318
+ clearPlanningRoot,
319
+ withPlanningRoot,
320
+ resolvePlanningRoot,
321
+ planningRootRel,
322
+ planningRootAbs,
323
+ isPlanningTree,
324
+ discoverTracks,
325
+ planningRoots,
326
+ describePlanningRoot,
327
+ };
@@ -22,7 +22,6 @@ const {
22
22
  toPosix,
23
23
  } = require('./core.cjs');
24
24
  const {
25
- PLANNING_DIR,
26
25
  ROADMAP_FILE,
27
26
  STATE_FILE,
28
27
  PHASES_DIR,
@@ -20,7 +20,6 @@
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { output, error, safeReadFile, toPosix } = require('./core.cjs');
23
- const { PLANNING_DIR } = require('./constants.cjs');
24
23
  const { planningPath } = require('./utils.cjs');
25
24
  const { publish } = require('./bus.cjs');
26
25
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isContextFile, isResearchFile, PHASE_HEADER_RE, getPlanId, getSummaryId } = require('./constants.cjs');
7
+ const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isContextFile, isResearchFile, PHASE_HEADER_RE, getPlanId, getSummaryId } = require('./constants.cjs');
8
8
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus } = require('./utils.cjs');
9
9
  const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
10
10