pan-wizard 3.26.0 → 3.28.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 (54) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/hygiene.md +14 -8
  7. package/commands/pan/milestone-audit.md +10 -4
  8. package/commands/pan/preview.md +2 -2
  9. package/hooks/dist/pan-cost-logger.js +69 -5
  10. package/hooks/dist/pan-stop-guard.js +32 -1
  11. package/hooks/dist/pan-trace-logger.js +35 -2
  12. package/package.json +5 -2
  13. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  14. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  15. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  16. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  17. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  18. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  19. package/pan-wizard-core/bin/lib/constants.cjs +49 -0
  20. package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
  21. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  22. package/pan-wizard-core/bin/lib/cost.cjs +113 -11
  23. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  24. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  25. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  26. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
  28. package/pan-wizard-core/bin/lib/init.cjs +98 -13
  29. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  31. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  32. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  33. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  34. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  35. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  37. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  40. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  41. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  42. package/pan-wizard-core/bin/lib/verify.cjs +26 -5
  43. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  44. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  45. package/pan-wizard-core/mcp/server.cjs +92 -8
  46. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  47. package/pan-wizard-core/references/model-profiles.md +2 -2
  48. package/pan-wizard-core/workflows/health.md +1 -0
  49. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  50. package/pan-zcode/README.md +1 -1
  51. package/scripts/build-agent-plugin.js +220 -0
  52. package/scripts/build-plugin.js +48 -3
  53. package/scripts/generate-skills-docs.py +1 -1
  54. package/scripts/release-check.js +58 -12
@@ -5,11 +5,13 @@
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 { detectForeignPlanningTreeAt } = require('./foreign-planning.cjs');
14
+ const { planningRootRel, describePlanningRoot, planningRoots, withPlanningRoot } = require('./planning-root.cjs');
13
15
 
14
16
  // ---- Git helpers ----
15
17
 
@@ -23,7 +25,7 @@ function ensureGitRepo(cwd) {
23
25
 
24
26
  /** Build a forward-slash relative path under .planning for JSON output */
25
27
  function planningRelPath(...segments) {
26
- return [PLANNING_DIR, ...segments].join('/');
28
+ return planningRel(...segments);
27
29
  }
28
30
 
29
31
  /**
@@ -178,6 +180,9 @@ function cmdInitExecutePhase(cwd, phase, raw, opts) {
178
180
  : null,
179
181
 
180
182
  // Milestone info
183
+ // Which tree this milestone came from — a spliced or wrong-track milestone
184
+ // must never look like a confident answer.
185
+ ...describePlanningRoot(cwd),
181
186
  milestone_version: milestone.version,
182
187
  milestone_name: milestone.name,
183
188
  milestone_slug: generateSlugInternal(milestone.name),
@@ -251,7 +256,10 @@ function cmdInitPlanPhase(cwd, phase, raw) {
251
256
  plan_count: phaseInfo?.plans?.length || 0,
252
257
 
253
258
  // Environment
254
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
259
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
260
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
261
+ // in the output rather than silently producing plausible results.
262
+ ...describePlanningRoot(cwd),
255
263
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
256
264
 
257
265
  // File paths
@@ -275,6 +283,13 @@ function cmdInitPlanPhase(cwd, phase, raw) {
275
283
  * @returns {void}
276
284
  */
277
285
  function cmdInitNewProject(cwd, raw) {
286
+ // Never scaffold PAN's files into a .planning/ another tool owns (R15). The error
287
+ // key carries the exit code; the fix is a separate tree via --planning-dir.
288
+ const foreign = detectForeignPlanningTreeAt(cwd);
289
+ if (foreign) {
290
+ output({ error: `planning tree belongs to ${foreign.tool}`, evidence: foreign.evidence, fix: 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)' }, raw);
291
+ return;
292
+ }
278
293
  const config = loadConfig(cwd);
279
294
 
280
295
  // Detect Brave Search API key availability
@@ -322,7 +337,10 @@ function cmdInitNewProject(cwd, raw) {
322
337
  // Existing state
323
338
  project_exists: pathExistsInternal(cwd, planningRelPath(PROJECT_FILE)),
324
339
  has_codebase_map: pathExistsInternal(cwd, planningRelPath(CODEBASE_DIR)),
325
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
340
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
341
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
342
+ // in the output rather than silently producing plausible results.
343
+ ...describePlanningRoot(cwd),
326
344
 
327
345
  // Brownfield detection
328
346
  has_existing_code: hasCode,
@@ -435,7 +453,10 @@ function cmdInitQuick(cwd, description, raw) {
435
453
 
436
454
  // File existence
437
455
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
438
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
456
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
457
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
458
+ // in the output rather than silently producing plausible results.
459
+ ...describePlanningRoot(cwd),
439
460
 
440
461
  };
441
462
 
@@ -464,7 +485,10 @@ function cmdInitResume(cwd, raw) {
464
485
  state_exists: pathExistsInternal(cwd, planningRelPath(STATE_FILE)),
465
486
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
466
487
  project_exists: pathExistsInternal(cwd, planningRelPath(PROJECT_FILE)),
467
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
488
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
489
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
490
+ // in the output rather than silently producing plausible results.
491
+ ...describePlanningRoot(cwd),
468
492
 
469
493
  // File paths
470
494
  state_path: planningRelPath(STATE_FILE),
@@ -572,7 +596,10 @@ function cmdInitPhaseOp(cwd, phase, raw) {
572
596
 
573
597
  // File existence
574
598
  roadmap_exists: pathExistsInternal(cwd, planningRelPath(ROADMAP_FILE)),
575
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
599
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
600
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
601
+ // in the output rather than silently producing plausible results.
602
+ ...describePlanningRoot(cwd),
576
603
 
577
604
  // File paths
578
605
  state_path: planningRelPath(STATE_FILE),
@@ -610,7 +637,10 @@ function cmdInitTodos(cwd, area, raw) {
610
637
  area_filter: area || null,
611
638
  pending_dir: planningRelPath('todos/pending'),
612
639
  completed_dir: planningRelPath('todos/completed'),
613
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
640
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
641
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
642
+ // in the output rather than silently producing plausible results.
643
+ ...describePlanningRoot(cwd),
614
644
  todos_dir_exists: pathExistsInternal(cwd, planningRelPath('todos')),
615
645
  pending_dir_exists: pathExistsInternal(cwd, planningRelPath('todos/pending')),
616
646
  };
@@ -624,7 +654,7 @@ function cmdInitTodos(cwd, area, raw) {
624
654
  * @param {boolean} raw - If true, output raw value instead of JSON
625
655
  * @returns {void}
626
656
  */
627
- function cmdInitMilestoneOp(cwd, raw) {
657
+ function buildMilestoneOpPayload(cwd) {
628
658
  const config = loadConfig(cwd);
629
659
  const milestone = getMilestoneInfo(cwd);
630
660
 
@@ -667,10 +697,21 @@ function cmdInitMilestoneOp(cwd, raw) {
667
697
  commit_docs: config.commit_docs,
668
698
 
669
699
  // Current milestone
700
+ // Which tree this milestone came from — a spliced or wrong-track milestone
701
+ // must never look like a confident answer.
702
+ ...describePlanningRoot(cwd),
670
703
  milestone_version: milestone.version,
671
704
  milestone_name: milestone.name,
672
705
  milestone_slug: generateSlugInternal(milestone.name),
673
706
 
707
+ // How the milestone was decided. `milestone_ambiguous` means the roadmap
708
+ // marks more than one milestone current — a planning-state error the audit
709
+ // must surface rather than silently resolve to whichever came first.
710
+ milestone_status: milestone.status,
711
+ milestone_basis: milestone.basis,
712
+ milestone_ambiguous: milestone.ambiguous,
713
+ milestone_candidates: milestone.candidates,
714
+
674
715
  // Phase counts
675
716
  phase_count: phaseCount,
676
717
  completed_phases: completedPhases,
@@ -688,7 +729,44 @@ function cmdInitMilestoneOp(cwd, raw) {
688
729
  phases_dir_exists: pathExistsInternal(cwd, planningRelPath(PHASES_DIR)),
689
730
  };
690
731
 
691
- output(result, raw);
732
+ return result;
733
+ }
734
+
735
+ /**
736
+ * Milestone bootstrap context for one planning tree, or for every tree at once.
737
+ *
738
+ * `--all-tracks` exists because a milestone audit run against the wrong tree
739
+ * produces a confident, plausible, wrong report. Sweeping every tree and
740
+ * labelling each result makes the scope of the audit explicit instead of
741
+ * implicit in whichever directory the command happened to resolve.
742
+ *
743
+ * @param {string} cwd - Project root directory
744
+ * @param {boolean} raw - If true, output raw value instead of JSON
745
+ * @param {Object} [opts] - {allTracks}
746
+ * @returns {void}
747
+ */
748
+ function cmdInitMilestoneOp(cwd, raw, opts = {}) {
749
+ if (!opts.allTracks) {
750
+ output(buildMilestoneOpPayload(cwd), raw);
751
+ return;
752
+ }
753
+
754
+ const roots = planningRoots(cwd, { allTracks: true });
755
+ const tracks = roots.map(root => withPlanningRoot(root.rel, () => ({
756
+ ...buildMilestoneOpPayload(cwd),
757
+ // Authoritative: `track` comes from the root we are sweeping, and must win
758
+ // over anything the spread payload carries.
759
+ track: root.name,
760
+ }), root.name));
761
+
762
+ output({
763
+ all_tracks: true,
764
+ track_count: tracks.length,
765
+ // A milestone the tooling could not resolve unambiguously in ANY tree is
766
+ // worth surfacing at the top level — the audit should stop, not guess.
767
+ ambiguous_tracks: tracks.filter(t => t.milestone_ambiguous).map(t => t.track),
768
+ tracks,
769
+ }, raw);
692
770
  }
693
771
 
694
772
  /**
@@ -726,7 +804,10 @@ function cmdInitMapCodebase(cwd, raw) {
726
804
  has_maps: existingMaps.length > 0,
727
805
 
728
806
  // File existence
729
- planning_exists: pathExistsInternal(cwd, PLANNING_DIR),
807
+ planning_exists: pathExistsInternal(cwd, planningRootRel()),
808
+ // Which tree this ran against — so a wrong --track/--planning-dir is visible
809
+ // in the output rather than silently producing plausible results.
810
+ ...describePlanningRoot(cwd),
730
811
  codebase_dir_exists: pathExistsInternal(cwd, planningRelPath(CODEBASE_DIR)),
731
812
 
732
813
  // Language detection
@@ -785,7 +866,7 @@ function scanAllPhases(cwd) {
785
866
  const phaseInfo = {
786
867
  number: phaseNumber,
787
868
  name: phaseName,
788
- directory: toPosix(path.join(PLANNING_DIR, PHASES_DIR, dirName)),
869
+ directory: planningRel(PHASES_DIR, dirName),
789
870
  status,
790
871
  plan_count: plans.length,
791
872
  summary_count: summaries.length,
@@ -833,6 +914,9 @@ function cmdInitProgress(cwd, raw) {
833
914
  commit_docs: config.commit_docs,
834
915
 
835
916
  // Milestone
917
+ // Which tree this milestone came from — a spliced or wrong-track milestone
918
+ // must never look like a confident answer.
919
+ ...describePlanningRoot(cwd),
836
920
  milestone_version: milestone.version,
837
921
  milestone_name: milestone.name,
838
922
 
@@ -873,6 +957,7 @@ module.exports = {
873
957
  cmdInitPhaseOp,
874
958
  cmdInitTodos,
875
959
  cmdInitMilestoneOp,
960
+ buildMilestoneOpPayload,
876
961
  cmdInitMapCodebase,
877
962
  cmdInitProgress,
878
963
  };
@@ -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