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.
- package/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/commands/pan/preview.md +2 -2
- 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 +5 -2
- 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 +49 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +113 -11
- 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/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
- package/pan-wizard-core/bin/lib/init.cjs +98 -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 +26 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/generate-skills-docs.py +1 -1
- 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 {
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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 {
|
|
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
|
|