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.
- package/README.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/hooks/dist/pan-cost-logger.js +69 -5
- package/hooks/dist/pan-stop-guard.js +32 -1
- package/hooks/dist/pan-trace-logger.js +35 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
- package/pan-wizard-core/bin/lib/bus.cjs +0 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
- package/pan-wizard-core/bin/lib/commands.cjs +15 -14
- package/pan-wizard-core/bin/lib/config.cjs +5 -5
- package/pan-wizard-core/bin/lib/constants.cjs +27 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +0 -1
- package/pan-wizard-core/bin/lib/distill.cjs +3 -3
- package/pan-wizard-core/bin/lib/focus.cjs +16 -16
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
- package/pan-wizard-core/bin/lib/init.cjs +90 -13
- package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
- package/pan-wizard-core/bin/lib/memory.cjs +1 -1
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
- package/pan-wizard-core/bin/lib/preview.cjs +0 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
- package/pan-wizard-core/bin/lib/state.cjs +0 -1
- package/pan-wizard-core/bin/lib/template.cjs +1 -1
- package/pan-wizard-core/bin/lib/utils.cjs +39 -11
- package/pan-wizard-core/bin/lib/verify.cjs +4 -3
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- 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 {
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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 {
|
|
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 {
|
|
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',
|
|
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 {
|
|
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
|
|
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:
|
|
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 {
|
|
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:
|
|
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',
|
|
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
|
+
};
|
|
@@ -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 {
|
|
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
|
|