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