pan-wizard 3.26.0 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/commands/pan/hygiene.md +14 -8
  3. package/commands/pan/milestone-audit.md +10 -4
  4. package/hooks/dist/pan-cost-logger.js +69 -5
  5. package/hooks/dist/pan-stop-guard.js +32 -1
  6. package/hooks/dist/pan-trace-logger.js +35 -2
  7. package/package.json +1 -1
  8. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  9. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  10. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  11. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  12. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  13. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  14. package/pan-wizard-core/bin/lib/constants.cjs +27 -0
  15. package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
  16. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  17. package/pan-wizard-core/bin/lib/cost.cjs +0 -1
  18. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  19. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  20. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  21. package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
  22. package/pan-wizard-core/bin/lib/init.cjs +90 -13
  23. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  24. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  25. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  26. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  27. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  28. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  29. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  31. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  32. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  33. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  34. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  35. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  36. package/pan-wizard-core/bin/lib/verify.cjs +4 -3
  37. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  38. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  39. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
@@ -0,0 +1,339 @@
1
+ /**
2
+ * state compact — move closed history out of state.md.
3
+ *
4
+ * state.md sits in CACHEABLE_CONTEXT_FILES, so it is re-read into EVERY agent
5
+ * call. Its section writers only ever append (`sectionBody.trimEnd() + entry`),
6
+ * so the file can only grow: a field project reached 54 KB, of which 30 KB was
7
+ * a June session log, a resolved milestone audit, and three phase closures —
8
+ * roughly 7k tokens of settled history re-read on every call for months. Prompt
9
+ * cache reads outweigh generated tokens by about two orders of magnitude in a
10
+ * PAN project, which makes this file the single largest recurring cost.
11
+ *
12
+ * The rule this module encodes: PAN already knows how to bound its other
13
+ * append-only store (`memory compact`); state.md gets the same treatment.
14
+ *
15
+ * Safety, in order of importance:
16
+ * 1. Nothing is ever deleted. Sections are appended to state-history.md
17
+ * FIRST, and only then removed from state.md.
18
+ * 2. `state get <section>` can read ANY heading by name, so a section is
19
+ * archived only when it is unambiguously historical — a dated heading
20
+ * past the retention window, or one that says "closure". Everything else
21
+ * stays, including anything unrecognised.
22
+ * 3. A protected list guards the headings PAN itself reads and writes, so
23
+ * they survive even if a title were to match the historical patterns.
24
+ * 4. Dry-run by default. `--apply` is required to touch either file.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { output, error } = require('./core.cjs');
30
+ const {
31
+ STATE_FILE,
32
+ STATE_HISTORY_FILE,
33
+ STATE_COMPACT_KEEP_DAYS,
34
+ CHARS_PER_TOKEN,
35
+ } = require('./constants.cjs');
36
+ const { planningPath, planningRel } = require('./utils.cjs');
37
+
38
+ /**
39
+ * Headings PAN's own tooling reads or writes. Never archived, whatever else
40
+ * matches — `state.cjs` parses Decisions/Blockers/Session by name, and the
41
+ * remainder are the live working set a resumed session depends on.
42
+ */
43
+ const PROTECTED_HEADINGS = [
44
+ /^decisions/i,
45
+ /^accumulated/i,
46
+ /^blockers/i,
47
+ /^concerns/i,
48
+ /^session\b/i,
49
+ /^next action/i,
50
+ /^phase progress/i,
51
+ /^project reference/i,
52
+ /^source authority/i,
53
+ /^toolchain/i,
54
+ /^metrics/i,
55
+ /^current/i,
56
+ /^status/i,
57
+ ];
58
+
59
+ /** Marker left in state.md where an archived section used to be. */
60
+ const POINTER_PREFIX = '_Archived to ';
61
+
62
+ /**
63
+ * Body fields that `state.cjs::extractFieldsFromState` reads to REGENERATE the
64
+ * frontmatter on every write. It takes the FIRST `**Field:**` match anywhere in
65
+ * the body, so removing a section that carries one can silently change which
66
+ * value wins — a compaction that quietly rewrites `status:` or `Current Phase`
67
+ * would be exactly the kind of invisible damage this tool must not do.
68
+ *
69
+ * A section carrying any of these is therefore never archived, regardless of
70
+ * how historical its heading looks.
71
+ */
72
+ const FRONTMATTER_SOURCE_FIELDS = [
73
+ 'Current Phase', 'Current Phase Name', 'Current Plan', 'Total Phases',
74
+ 'Total Plans in Phase', 'Status', 'Progress', 'Last Activity',
75
+ 'Last Activity Description', 'Stopped At', 'Stopped at', 'Paused At',
76
+ ];
77
+
78
+ const FRONTMATTER_SOURCE_RE = new RegExp(
79
+ '\\*\\*(?:' + FRONTMATTER_SOURCE_FIELDS.map(f => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + '):\\*\\*',
80
+ 'i'
81
+ );
82
+
83
+ /** A heading that opens with an ISO date: "## 2026-06-04 — Post-milestone session". */
84
+ const DATED_HEADING_RE = /^\s*(\d{4}-\d{2}-\d{2})\b/;
85
+
86
+ /** A heading recording a closed unit of work: "## Phase 3 closure" / "## Phase 3 closed". */
87
+ const CLOSURE_HEADING_RE = /\b(closure|closures|closed)\b/i;
88
+
89
+ /**
90
+ * Split state.md into its preamble and top-level (H2) sections.
91
+ *
92
+ * Only `## ` headings split the file; deeper headings belong to their parent
93
+ * section, because `state.cjs` writes Decisions/Blockers as `###` nested inside
94
+ * an H2 and splitting on those would tear a live section in half.
95
+ *
96
+ * @param {string} content - full state.md text
97
+ * @returns {{preamble: string, sections: Array<{title: string, text: string, start: number}>}}
98
+ */
99
+ function splitSections(content) {
100
+ const lines = String(content || '').split(/\r?\n/);
101
+ const heads = [];
102
+ lines.forEach((line, i) => {
103
+ if (/^##(?!#)\s+/.test(line)) heads.push({ i, title: line.replace(/^##\s+/, '').trim() });
104
+ });
105
+
106
+ const preamble = lines.slice(0, heads.length ? heads[0].i : lines.length).join('\n');
107
+ const sections = heads.map((h, k) => {
108
+ const end = k + 1 < heads.length ? heads[k + 1].i : lines.length;
109
+ return { title: h.title, text: lines.slice(h.i, end).join('\n'), start: h.i + 1 };
110
+ });
111
+ return { preamble, sections };
112
+ }
113
+
114
+ /**
115
+ * Decide whether one section is settled history.
116
+ *
117
+ * @param {{title: string}} section
118
+ * @param {number} keepDays - dated sections newer than this stay
119
+ * @param {number} now - epoch ms, injectable for tests
120
+ * @returns {{archive: boolean, reason: string}}
121
+ */
122
+ function classifySection(section, keepDays, now) {
123
+ const title = section.title || '';
124
+ const text = section.text || '';
125
+
126
+ // Already compacted: the section is now just a pointer at its archived body.
127
+ // Without this a closure heading would be re-archived on every run, appending
128
+ // the same pointer to state-history.md forever.
129
+ if (text.includes(POINTER_PREFIX)) {
130
+ return { archive: false, reason: 'already archived — only a pointer remains' };
131
+ }
132
+
133
+ if (PROTECTED_HEADINGS.some(re => re.test(title))) {
134
+ return { archive: false, reason: 'protected — PAN reads or writes this section' };
135
+ }
136
+
137
+ if (FRONTMATTER_SOURCE_RE.test(section.text || '')) {
138
+ return { archive: false, reason: 'carries a field the frontmatter is rebuilt from — moving it could change state.md metadata' };
139
+ }
140
+
141
+ const dated = title.match(DATED_HEADING_RE);
142
+ if (dated) {
143
+ const t = Date.parse(dated[1]);
144
+ if (!Number.isFinite(t)) return { archive: false, reason: 'unparseable date — left in place' };
145
+ const ageDays = Math.floor((now - t) / (24 * 3600 * 1000));
146
+ if (ageDays > keepDays) {
147
+ return { archive: true, reason: `dated section ${ageDays}d old (keep ${keepDays}d)` };
148
+ }
149
+ return { archive: false, reason: `dated section ${ageDays}d old — within the ${keepDays}d window` };
150
+ }
151
+
152
+ if (CLOSURE_HEADING_RE.test(title)) {
153
+ return { archive: true, reason: 'closure record — the work it describes is finished' };
154
+ }
155
+
156
+ return { archive: false, reason: 'not recognised as history — left in place' };
157
+ }
158
+
159
+ /**
160
+ * Rebuild state.md with the named sections replaced by a pointer.
161
+ *
162
+ * Shared by the planner and the applier so a dry-run reports the exact bytes
163
+ * `--apply` will produce, rather than an estimate that could disagree with it.
164
+ *
165
+ * @param {string} content - current state.md text
166
+ * @param {Set<string>} toArchive - section titles to replace
167
+ * @param {string} stamp - YYYY-MM-DD recorded in each pointer
168
+ * @returns {string} the rebuilt file
169
+ */
170
+ function rebuildState(content, toArchive, stamp) {
171
+ const { preamble, sections } = splitSections(content);
172
+ const parts = [preamble.trimEnd()];
173
+ for (const s of sections) {
174
+ parts.push(toArchive.has(s.title)
175
+ ? `## ${s.title}\n\n${POINTER_PREFIX}[${STATE_HISTORY_FILE}](${STATE_HISTORY_FILE}) on ${stamp}._`
176
+ : s.text.trimEnd());
177
+ }
178
+ return parts.filter(Boolean).join('\n\n') + '\n';
179
+ }
180
+
181
+ /**
182
+ * Plan a compaction without touching anything.
183
+ *
184
+ * @param {string} cwd - project root
185
+ * @param {Object} [opts] - {keepDays, now}
186
+ * @returns {Object} plan with per-section verdicts and before/after sizes
187
+ */
188
+ function planStateCompaction(cwd, opts = {}) {
189
+ const keepDays = Number.isFinite(Number(opts.keepDays)) && opts.keepDays !== null && opts.keepDays !== undefined
190
+ ? Number(opts.keepDays)
191
+ : STATE_COMPACT_KEEP_DAYS;
192
+ const now = opts.now || Date.now();
193
+ const statePath = planningPath(cwd, STATE_FILE);
194
+
195
+ let content;
196
+ try {
197
+ content = fs.readFileSync(statePath, 'utf-8');
198
+ } catch {
199
+ return { found: false, path: planningRel(STATE_FILE), sections: [], archivable: [], keep_days: keepDays };
200
+ }
201
+
202
+ const { preamble, sections } = splitSections(content);
203
+ const verdicts = sections.map(s => {
204
+ const c = classifySection(s, keepDays, now);
205
+ return { title: s.title, bytes: s.text.length, archive: c.archive, reason: c.reason };
206
+ });
207
+
208
+ let archivable = verdicts.filter(v => v.archive);
209
+ const bytesBefore = content.length;
210
+ // Build the post-compaction text for real rather than estimating it. An
211
+ // estimate that ignores pointer overhead can claim a saving on a file where
212
+ // the pointers cost more than the sections they replace — a dry-run that
213
+ // overstates its own benefit is worse than no dry-run.
214
+ const stamp = new Date(now).toISOString().slice(0, 10);
215
+ let bytesAfter = rebuildState(content, new Set(archivable.map(v => v.title)), stamp).length;
216
+
217
+ // The point of compaction is a smaller re-read on every agent call. On a file
218
+ // whose history sections are tiny, the pointers left behind cost more than the
219
+ // bodies they replace — churning state.md to make it BIGGER helps nobody, so
220
+ // stand down and say why.
221
+ if (archivable.length > 0 && bytesAfter >= bytesBefore) {
222
+ for (const v of verdicts) {
223
+ if (!v.archive) continue;
224
+ v.archive = false;
225
+ v.reason = 'archiving it would not shrink state.md — the pointer costs more than the section';
226
+ }
227
+ archivable = [];
228
+ bytesAfter = bytesBefore;
229
+ }
230
+
231
+ return {
232
+ found: true,
233
+ path: planningRel(STATE_FILE),
234
+ history_path: planningRel(STATE_HISTORY_FILE),
235
+ keep_days: keepDays,
236
+ sections: verdicts,
237
+ archivable,
238
+ bytes_before: bytesBefore,
239
+ bytes_after: bytesAfter,
240
+ tokens_before: Math.ceil(bytesBefore / CHARS_PER_TOKEN),
241
+ tokens_after: Math.ceil(bytesAfter / CHARS_PER_TOKEN),
242
+ tokens_saved_per_call: Math.max(0, Math.ceil((bytesBefore - bytesAfter) / CHARS_PER_TOKEN)),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Apply a compaction. Archive is written BEFORE state.md is rewritten, so an
248
+ * interruption can only ever leave a duplicate — never a loss.
249
+ *
250
+ * @param {string} cwd - project root
251
+ * @param {Object} [opts] - {keepDays, now, apply}
252
+ * @returns {Object} result
253
+ */
254
+ function compactState(cwd, opts = {}) {
255
+ const plan = planStateCompaction(cwd, opts);
256
+ if (!plan.found) return { ...plan, applied: false, dry_run: !opts.apply };
257
+ if (!opts.apply || plan.archivable.length === 0) {
258
+ return { ...plan, applied: false, dry_run: !opts.apply, archived: [] };
259
+ }
260
+
261
+ const statePath = planningPath(cwd, STATE_FILE);
262
+ const historyPath = planningPath(cwd, STATE_HISTORY_FILE);
263
+ const content = fs.readFileSync(statePath, 'utf-8');
264
+ const { sections } = splitSections(content);
265
+ const now = opts.now || Date.now();
266
+ const stamp = new Date(now).toISOString().slice(0, 10);
267
+
268
+ const toArchive = new Set(plan.archivable.map(v => v.title));
269
+ const archivedText = sections.filter(s => toArchive.has(s.title)).map(s => s.text.trimEnd()).join('\n\n');
270
+
271
+ // 1. Append to history FIRST, so an interruption can only ever duplicate.
272
+ //
273
+ // The preamble is created with an exclusive open rather than an
274
+ // existsSync check. Check-then-write is a time-of-check/time-of-use race
275
+ // (CWE-367): two compactions running together would each see "absent" and
276
+ // each prepend a preamble. `wx` makes creation atomic — EEXIST simply
277
+ // means someone else won, which is the outcome we wanted anyway.
278
+ const preamble = `# State history\n\nSections compacted out of ${STATE_FILE} by \`pan-tools state compact\`.\n`
279
+ + 'They are kept verbatim and are no longer re-read into agent context.\n';
280
+ try {
281
+ fs.writeFileSync(historyPath, preamble, { flag: 'wx', encoding: 'utf-8' });
282
+ } catch (e) {
283
+ if (e.code !== 'EEXIST') throw e;
284
+ }
285
+ fs.appendFileSync(historyPath,
286
+ `\n\n<!-- compacted from ${STATE_FILE} on ${stamp} -->\n${archivedText}\n`, 'utf-8');
287
+
288
+ // 2. Only now rewrite state.md, replacing each archived section with a pointer.
289
+ const next = rebuildState(content, toArchive, stamp);
290
+
291
+ const { writeStateMd } = require('./state.cjs');
292
+ writeStateMd(statePath, next, cwd);
293
+
294
+ return {
295
+ ...plan,
296
+ applied: true,
297
+ dry_run: false,
298
+ archived: plan.archivable.map(v => v.title),
299
+ bytes_after: next.length,
300
+ tokens_after: Math.ceil(next.length / CHARS_PER_TOKEN),
301
+ tokens_saved_per_call: Math.max(0, Math.ceil((plan.bytes_before - next.length) / CHARS_PER_TOKEN)),
302
+ };
303
+ }
304
+
305
+ /** CLI wrapper for `state compact`. */
306
+ function cmdStateCompact(cwd, opts, raw) {
307
+ const result = compactState(cwd, opts);
308
+ if (!result.found) {
309
+ error(`${result.path} not found — nothing to compact`);
310
+ return;
311
+ }
312
+ if (!raw) return output(result, false);
313
+
314
+ const lines = [];
315
+ lines.push(result.applied
316
+ ? `state compact — APPLIED (${result.archived.length} section(s) archived)`
317
+ : `state compact — DRY-RUN (pass --apply to execute)`);
318
+ lines.push(` ${result.path}: ${(result.bytes_before / 1024).toFixed(1)} KB (~${result.tokens_before} tokens) re-read on every agent call`);
319
+ for (const s of result.sections) {
320
+ lines.push(` ${s.archive ? '→' : '·'} ${(s.bytes / 1024).toFixed(1).padStart(6)} KB ${s.title.slice(0, 54)}`);
321
+ lines.push(` ${s.reason}`);
322
+ }
323
+ if (result.archivable.length === 0) {
324
+ lines.push(' Nothing to compact — no settled history past the retention window.');
325
+ } else {
326
+ lines.push(` → ${result.history_path}`);
327
+ lines.push(` after: ~${result.tokens_after} tokens (saves ~${result.tokens_saved_per_call} tokens per agent call)`);
328
+ }
329
+ output(result, true, lines.join('\n'));
330
+ }
331
+
332
+ module.exports = {
333
+ splitSections,
334
+ classifySection,
335
+ planStateCompaction,
336
+ compactState,
337
+ cmdStateCompact,
338
+ PROTECTED_HEADINGS,
339
+ };
@@ -8,7 +8,6 @@ const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error }
8
8
  const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
  const { withFileLock, writeFileAtomic } = require('./lock.cjs');
10
10
  const {
11
- PLANNING_DIR,
12
11
  STATE_FILE,
13
12
  ROADMAP_FILE,
14
13
  CONFIG_FILE,
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const {
8
- PLANNING_DIR, PHASES_DIR, PLAN_SUFFIX, SUMMARY_SUFFIX, VERIFICATION_SUFFIX,
8
+ PHASES_DIR, PLAN_SUFFIX, SUMMARY_SUFFIX, VERIFICATION_SUFFIX,
9
9
  SIMPLE_TASK_THRESHOLD, SIMPLE_FILE_THRESHOLD, COMPLEX_TASK_THRESHOLD, COMPLEX_FILE_THRESHOLD,
10
10
  } = require('./constants.cjs');
11
11
  const { planningPath, phasesPath } = require('./utils.cjs');
@@ -9,13 +9,13 @@ const fs = require('fs');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const {
12
- PLANNING_DIR,
13
12
  PHASES_DIR,
14
13
  MILESTONES_DIR,
15
14
  isPlanFile,
16
15
  isSummaryFile,
17
16
  PHASE_DIR_RE,
18
17
  } = require('./constants.cjs');
18
+ const { planningRootRel, planningRootAbs } = require('./planning-root.cjs');
19
19
 
20
20
  // ─── File utilities ──────────────────────────────────────────────────────────
21
21
 
@@ -44,30 +44,57 @@ function removeQuotes(str) {
44
44
  // ─── Phase directory utilities ───────────────────────────────────────────────
45
45
 
46
46
  /**
47
- * Build the absolute path to the .planning directory.
47
+ * Build an absolute path inside the ACTIVE planning root.
48
+ *
49
+ * The root is `.planning/` by default but may be a track (`--track verify`) or
50
+ * any project-relative tree (`--planning-dir`). Every absolute planning path in
51
+ * the codebase goes through here, which is what makes a non-default root
52
+ * reachable at all — see planning-root.cjs.
53
+ *
48
54
  * @param {string} cwd - Project root directory
49
- * @returns {string} Absolute path to .planning/
55
+ * @param {...string} segments - Path segments below the planning root
56
+ * @returns {string} Absolute path
57
+ */
58
+ function planningPath(cwd, ...segments) {
59
+ return path.join(planningRootAbs(cwd), ...segments.filter(s => s != null && s !== ''));
60
+ }
61
+
62
+ /**
63
+ * Build a project-relative, POSIX-separated path inside the active planning
64
+ * root — the display form used in command output, findings, and `git add`
65
+ * arguments. Kept in lockstep with planningPath() so what a command reports is
66
+ * the tree it actually touched.
67
+ *
68
+ * @param {...string} segments - Path segments below the planning root
69
+ * @returns {string} e.g. `.planning/tracks/verify/state.md`
50
70
  */
51
- function planningPath(cwd) {
52
- return path.join(cwd, PLANNING_DIR);
71
+ function planningRel(...segments) {
72
+ const parts = [planningRootRel()];
73
+ for (const s of segments) {
74
+ if (s == null || s === '') continue;
75
+ for (const seg of String(s).split(/[\\/]+/)) {
76
+ if (seg && seg !== '.') parts.push(seg);
77
+ }
78
+ }
79
+ return parts.join('/');
53
80
  }
54
81
 
55
82
  /**
56
- * Build the absolute path to the phases directory.
83
+ * Build the absolute path to the phases directory of the active planning root.
57
84
  * @param {string} cwd - Project root directory
58
- * @returns {string} Absolute path to .planning/phases/
85
+ * @returns {string} Absolute path to <planning-root>/phases/
59
86
  */
60
87
  function phasesPath(cwd) {
61
- return path.join(cwd, PLANNING_DIR, PHASES_DIR);
88
+ return planningPath(cwd, PHASES_DIR);
62
89
  }
63
90
 
64
91
  /**
65
- * Build the absolute path to the milestones directory.
92
+ * Build the absolute path to the milestones directory of the active planning root.
66
93
  * @param {string} cwd - Project root directory
67
- * @returns {string} Absolute path to .planning/milestones/
94
+ * @returns {string} Absolute path to <planning-root>/milestones/
68
95
  */
69
96
  function milestonesPath(cwd) {
70
- return path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
97
+ return planningPath(cwd, MILESTONES_DIR);
71
98
  }
72
99
 
73
100
  /**
@@ -159,6 +186,7 @@ module.exports = {
159
186
  readJsonFile,
160
187
  removeQuotes,
161
188
  planningPath,
189
+ planningRel,
162
190
  phasesPath,
163
191
  milestonesPath,
164
192
  listPhaseDirs,
@@ -9,7 +9,7 @@ const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInt
9
9
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
10
  const { writeStateMd, readStateSafe } = require('./state.cjs');
11
11
  const {
12
- PLANNING_DIR, PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
12
+ PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
13
13
  isPlanFile, isSummaryFile, isVerificationFile, PHASE_HEADER_RE, PHASE_DIR_RE, ARCHIVE_DIR_RE, FIELD_VALUE_RE,
14
14
  PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
15
15
  BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
@@ -21,6 +21,7 @@ const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDrif
21
21
  const { collectVerificationStats, countRoadmapPhases, groupGapPatterns, cmdRetro } = require('./verify-retro.cjs');
22
22
  const { detectInstalledRuntimes, validateRuntimeInstall, cmdValidateDeployment } = require('./verify-deploy.cjs');
23
23
  const { cmdPreflight, cmdDepsValidate } = require('./verify-preflight.cjs');
24
+ const { planningRootRel } = require('./planning-root.cjs');
24
25
 
25
26
  /**
26
27
  * Spot-check files mentioned in summary content.
@@ -715,7 +716,7 @@ function cmdValidateConsistency(cwd, raw) {
715
716
  */
716
717
  function checkPlanningDirExists(cwd, addIssue) {
717
718
  if (!fileAccessible(planningPath(cwd))) {
718
- addIssue('error', 'E001', PLANNING_DIR + '/ directory not found', 'Run /pan:new-project to initialize');
719
+ addIssue('error', 'E001', planningRootRel() + '/ directory not found', 'Run /pan:new-project to initialize');
719
720
  return false;
720
721
  }
721
722
  return true;
@@ -998,7 +999,7 @@ function repairIssues(cwd, repairs) {
998
999
  const milestone = getMilestoneInfo(cwd);
999
1000
  let stateContent = '# Session State\n\n';
1000
1001
  stateContent += '## Project Reference\n\n';
1001
- stateContent += `See: ${PLANNING_DIR}/${PROJECT_FILE}\n\n`;
1002
+ stateContent += `See: ${planningRootRel()}/${PROJECT_FILE}\n\n`;
1002
1003
  stateContent += '## Position\n\n';
1003
1004
  stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
1004
1005
  stateContent += '**Current phase:** (determining...)\n';
@@ -19,7 +19,6 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { output, error, safeReadFile, isGitRepo, execGit, toPosix, findPhaseInternal } = require('./core.cjs');
22
- const { PLANNING_DIR } = require('./constants.cjs');
23
22
  const { planningPath } = require('./utils.cjs');
24
23
 
25
24
  const COUNTERFACTUALS_DIR = 'counterfactuals';
@@ -128,7 +128,7 @@
128
128
  * init verify-work <phase> All context for verify-work workflow
129
129
  * init phase-op <phase> Generic phase operation context
130
130
  * init todos [area] All context for todo workflows
131
- * init milestone-op All context for milestone operations
131
+ * init milestone-op [--all-tracks] All context for milestone operations
132
132
  * init map-codebase All context for map-codebase workflow
133
133
  * init progress All context for progress workflow
134
134
  *
@@ -219,6 +219,8 @@ const docLint = require('./lib/doc-lint.cjs');
219
219
  const learnLint = require('./lib/learn-lint.cjs');
220
220
  const learnIndex = require('./lib/learn-index.cjs');
221
221
  const links = require('./lib/links.cjs');
222
+ const { setPlanningRoot, describePlanningRoot } = require('./lib/planning-root.cjs');
223
+ const stateCompact = require('./lib/state-compact.cjs');
222
224
 
223
225
  /**
224
226
  * Get the value following a flag in the args array.
@@ -233,6 +235,30 @@ function getArgValue(args, flag, defaultVal = null) {
233
235
  return args[idx + 1];
234
236
  }
235
237
 
238
+ /**
239
+ * Read a global `--flag value` / `--flag=value` pair and REMOVE it from args,
240
+ * so per-command parsers never see it. Returns null when the flag is absent.
241
+ *
242
+ * @param {string[]} args - CLI arguments (mutated)
243
+ * @param {string} flag - Flag name (e.g. '--track')
244
+ * @returns {string|null} The flag's value, or null
245
+ */
246
+ function takeFlagValue(args, flag) {
247
+ const eqArg = args.find(a => a.startsWith(`${flag}=`));
248
+ if (eqArg) {
249
+ const value = eqArg.slice(flag.length + 1).trim();
250
+ if (!value) error(`Missing value for ${flag}`);
251
+ args.splice(args.indexOf(eqArg), 1);
252
+ return value;
253
+ }
254
+ const idx = args.indexOf(flag);
255
+ if (idx === -1) return null;
256
+ const value = args[idx + 1];
257
+ if (!value || value.startsWith('--')) error(`Missing value for ${flag}`);
258
+ args.splice(idx, 2);
259
+ return value;
260
+ }
261
+
236
262
  /**
237
263
  * Parse JSON string or call error() with a descriptive message.
238
264
  * @param {string} raw - Raw JSON string
@@ -270,6 +296,22 @@ async function main() {
270
296
  error(`Invalid --cwd: ${cwd}`);
271
297
  }
272
298
 
299
+ // Which planning tree do we act on? `--cwd` moves the PROJECT root; these
300
+ // move the PLANNING root inside it, so a repo holding several planning trees
301
+ // can address any of them. Parsed here, before dispatch, because the answer
302
+ // has to be settled once for every path the command will build.
303
+ const planningDirFlag = takeFlagValue(args, '--planning-dir');
304
+ const trackFlag = takeFlagValue(args, '--track');
305
+ const allTracksIndex = args.indexOf('--all-tracks');
306
+ const allTracks = allTracksIndex !== -1;
307
+ if (allTracks) args.splice(allTracksIndex, 1);
308
+
309
+ try {
310
+ setPlanningRoot({ planningDir: planningDirFlag, track: trackFlag });
311
+ } catch (e) {
312
+ error(e.message);
313
+ }
314
+
273
315
  const rawIndex = args.indexOf('--raw');
274
316
  const raw = rawIndex !== -1;
275
317
  if (rawIndex !== -1) args.splice(rawIndex, 1);
@@ -282,7 +324,13 @@ async function main() {
282
324
 
283
325
  const command = args[0];
284
326
 
285
- const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>]\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
327
+ const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>] [--track <name> | --planning-dir <path>] [--all-tracks]\n'
328
+ + '\nPlanning root (which .planning tree to act on):\n'
329
+ + ' --track <name> act on .planning/tracks/<name>/ instead of .planning/\n'
330
+ + ' --planning-dir <path> act on an arbitrary project-relative planning tree\n'
331
+ + ' --all-tracks (hygiene) act on the root tree AND every discovered track\n'
332
+ + ' env: PAN_TRACK, PAN_PLANNING_DIR (flags win)\n'
333
+ + '\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
286
334
 
287
335
  if (!command) {
288
336
  error(USAGE);
@@ -317,6 +365,11 @@ async function main() {
317
365
  }
318
366
  }
319
367
  state.cmdStatePatch(cwd, patches, raw);
368
+ } else if (subcommand === 'compact') {
369
+ stateCompact.cmdStateCompact(cwd, {
370
+ apply: args.includes('--apply'),
371
+ keepDays: getArgValue(args, '--keep-days'),
372
+ }, raw);
320
373
  } else if (subcommand === 'advance-plan') {
321
374
  state.cmdStateAdvancePlan(cwd, raw);
322
375
  } else if (subcommand === 'record-metric') {
@@ -765,7 +818,7 @@ async function main() {
765
818
  init.cmdInitTodos(cwd, args[2], raw);
766
819
  break;
767
820
  case 'milestone-op':
768
- init.cmdInitMilestoneOp(cwd, raw);
821
+ init.cmdInitMilestoneOp(cwd, raw, { allTracks });
769
822
  break;
770
823
  case 'map-codebase':
771
824
  init.cmdInitMapCodebase(cwd, raw);
@@ -1087,13 +1140,14 @@ async function main() {
1087
1140
  const hygieneOpts = {
1088
1141
  traceAgeDays: getArgValue(args, '--trace-age-days'),
1089
1142
  apply: args.includes('--apply'),
1143
+ allTracks,
1090
1144
  };
1091
1145
  if (subcommand === 'scan') {
1092
1146
  hygiene.cmdHygieneScan(cwd, hygieneOpts, raw);
1093
1147
  } else if (subcommand === 'clean') {
1094
1148
  hygiene.cmdHygieneClean(cwd, hygieneOpts, raw);
1095
1149
  } else {
1096
- error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N]');
1150
+ error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N] [--all-tracks] [--track <name>]');
1097
1151
  }
1098
1152
  break;
1099
1153
  }