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
package/README.md CHANGED
@@ -659,7 +659,7 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
659
659
  | `/pan:debug [desc]` | Systematic debugging with persistent state |
660
660
  | `/pan:quick [--full]` | Execute ad-hoc task with PAN guarantees (`--full` adds plan-checking and verification) |
661
661
  | `/pan:health [--repair]` | Validate `.planning/` directory integrity; `--repair` auto-fixes detected issues |
662
- | `/pan:hygiene [--apply] [--trace-age-days N]` | Scan for PAN version drift and stale project artifacts (legacy filenames, .tmp orphans, memory bloat, poisoned cost ledgers, trace debris, fragment planning dirs); `--apply` executes the safe fixes — ledgers are quarantined by rename, never deleted |
662
+ | `/pan:hygiene [--apply] [--trace-age-days N] [--all-tracks]` | Scan for PAN version drift and stale project artifacts (legacy filenames, .tmp orphans, memory bloat, poisoned cost ledgers, trace and report debris, cached-context bloat, fragment planning dirs); `--apply` executes the safe fixes — ledgers are quarantined by rename, never deleted, and settled `state.md` history is archived rather than dropped |
663
663
  | `/pan:links [--strict]` | Validate the doc-code link graph: inline `[[<id>]]` refs, `// @pan:` source anchors, `require-code-mention` contracts (ADR-0027, v3.8.0+) |
664
664
  | `/pan:phase-tests [N]` | Generate tests for a completed phase based on UAT criteria |
665
665
  | `/pan:milestone-cleanup` | Archive accumulated phase directories from completed milestones |
@@ -2,7 +2,7 @@
2
2
  name: pan:hygiene
3
3
  group: System
4
4
  description: Scan the project for PAN version drift and stale artifacts (legacy filenames, memory bloat, poisoned ledgers, trace debris) and apply safe cleanups
5
- argument-hint: "[--apply] [--trace-age-days N]"
5
+ argument-hint: "[--apply] [--trace-age-days N] [--all-tracks] [--track <name>]"
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Bash
@@ -17,12 +17,18 @@ Keep a PAN-managed project aligned with the latest PAN version and free of accum
17
17
  ## 1. Scan
18
18
 
19
19
  ```bash
20
- SCAN=$(node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene scan)
20
+ SCAN=$(node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene scan --all-tracks)
21
21
  ```
22
22
 
23
- Parse JSON: `findings[]` (`check`, `severity`, `path`, `detail`, `fixable`), `installs[]`, `latest_version`, `summary`.
23
+ Pass `--all-tracks` by default: a project may hold several planning trees (`.planning/` plus `.planning/tracks/<name>/`), and scanning only the root tree reports "clean" for debris sitting in a sibling track. Use `--track <name>` to scan one specific tree instead.
24
24
 
25
- Display the findings grouped by severity (critical → warn → info). If `summary.total` is 0: report "Project is clean and aligned" and stop.
25
+ Parse JSON: `findings[]` (`check`, `severity`, `path`, `detail`, `fixable`, `track`), `installs[]`, `latest_version`, `roots_scanned[]`, `summary` (including `summary.by_track`).
26
+
27
+ Display the findings grouped by severity (critical → warn → info), labelling each with its `track` when more than one tree was scanned.
28
+
29
+ **Always state which trees were scanned** — read `roots_scanned[]` and name them. If `summary.total` is 0, report "Project is clean and aligned" *together with* the list of trees that were read. A clean verdict without its scope is what let stale debris hide in an unscanned track; never report one without the other.
30
+
31
+ If any root has `planning_root_exists: false`, say so plainly — that is a mistyped `--track`, not a clean tree.
26
32
 
27
33
  ## 2. Version drift (manual remediation)
28
34
 
@@ -41,7 +47,7 @@ Hygiene never runs the installer itself.
41
47
  **Without `--apply` in $ARGUMENTS:** run the dry-run and present what WOULD change:
42
48
 
43
49
  ```bash
44
- node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene clean
50
+ node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene clean --all-tracks
45
51
  ```
46
52
 
47
53
  Then ask the user (AskUserQuestion, header "Apply fixes", options: "Apply safe fixes" / "Skip") unless running headless — in auto/headless contexts, report the dry-run only and stop.
@@ -49,14 +55,14 @@ Then ask the user (AskUserQuestion, header "Apply fixes", options: "Apply safe f
49
55
  **With `--apply` (or after user confirmation):**
50
56
 
51
57
  ```bash
52
- node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene clean --apply
58
+ node ~/.claude/pan-wizard-core/bin/pan-tools.cjs hygiene clean --all-tracks --apply
53
59
  ```
54
60
 
55
- Safe fixes are: lowercase renames of legacy planning filenames, deletion of aged .tmp orphans, memory-log compaction, poisoned-ledger quarantine (rename in place — never deleted), and pruning of trace sessions past retention (newest 5 always kept). Pass through `--trace-age-days N` if provided.
61
+ Safe fixes are: lowercase renames of legacy planning filenames, deletion of aged .tmp orphans, memory-log compaction, poisoned-ledger quarantine (rename in place — never deleted), pruning of trace sessions **and optimization reports** past retention (newest 5 of each always kept), and **state.md compaction** (`compact-state`) — settled history is archived to `state-history.md` so it stops being re-read into every agent call. Nothing is deleted by any of these: the ledger is renamed, and state history is written to the archive before state.md is rewritten. Each fix is applied inside its own tree's scope, so a track's debris is cleaned in that track. Pass through `--trace-age-days N` and any `--track <name>` if provided.
56
62
 
57
63
  ## 4. Report
58
64
 
59
- Summarize: fixes executed / failed / left manual, plus the installer command if version drift remains. Recommend re-running `/pan:hygiene` after the installer to confirm alignment.
65
+ Summarize: the trees scanned, fixes executed / failed / left manual (attributed per track), plus the installer command if version drift remains. If a `cache-context` finding appeared, state the per-call token cost it represents — that block is re-read on **every** agent call, so it is the project's largest recurring expense. Recommend re-running `/pan:hygiene` after the installer to confirm alignment.
60
66
 
61
67
  </process>
62
68
 
@@ -2,7 +2,7 @@
2
2
  name: pan:milestone-audit
3
3
  group: Milestone
4
4
  description: Audit milestone completion against original intent before archiving
5
- argument-hint: "[version]"
5
+ argument-hint: "[version] [--track <name>] [--all-tracks]"
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Glob
@@ -26,9 +26,11 @@ Version: $ARGUMENTS (optional — defaults to current milestone)
26
26
 
27
27
  Core planning files are resolved in-workflow (`init milestone-op`) and loaded only as needed.
28
28
 
29
- **Completed Work:**
30
- Glob: .planning/phases/*/*-summary.md
31
- Glob: .planning/phases/*/*-verification.md
29
+ **Planning tree:** the project may hold several. `init milestone-op` reports the `planning_root` it resolved; use that root for every glob below rather than assuming `.planning/`. Pass `--track <name>` to audit a specific tree, or run `init milestone-op --all-tracks` first to see every tree's milestone state.
30
+
31
+ **Completed Work:** (relative to the resolved `planning_root`)
32
+ Glob: {planning_root}/phases/*/*-summary.md
33
+ Glob: {planning_root}/phases/*/*-verification.md
32
34
  </context>
33
35
 
34
36
  <citation_requirement>
@@ -57,4 +59,8 @@ Do not trust summary files at face value. If a verification.md says "all tests p
57
59
  <process>
58
60
  Execute the audit-milestone workflow from @~/.claude/pan-wizard-core/workflows/milestone-audit.md end-to-end.
59
61
  Preserve all workflow gates (scope determination, verification reading, integration check, requirements coverage, routing).
62
+
63
+ Two gates are non-negotiable because they guard against auditing the wrong thing:
64
+ - **State the resolved `planning_root` in the report.** An audit that does not name the tree it read cannot be checked.
65
+ - **Stop if `milestone_ambiguous` is true.** More than one milestone marked current is a roadmap defect for the owner to fix; auditing one of them silently is how a report ends up describing a milestone that does not exist.
60
66
  </process>
@@ -20,6 +20,38 @@ const crypto = require('crypto');
20
20
  // Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
21
21
  const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
22
22
 
23
+ /**
24
+ * Which planning tree this hook writes to.
25
+ *
26
+ * Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which the hook cannot
27
+ * require (hooks are standalone and run inside the host runtime). Without this
28
+ * the CLI could be pointed at `--track verify` while the cost hook kept writing
29
+ * to `.planning/`, so a track's telemetry landed in the wrong tree.
30
+ *
31
+ * Env only — a hook gets no argv. Values that escape the project root are
32
+ * ignored rather than honoured; a bad value must degrade to the default, never
33
+ * write outside the project.
34
+ */
35
+ function planningDirName() {
36
+ const raw = process.env.PAN_PLANNING_DIR || '';
37
+ if (raw.trim()) {
38
+ const rel = raw.trim().replace(/\\/g, '/');
39
+ const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
40
+ || rel.split('/').includes('..');
41
+ if (!bad) return rel;
42
+ }
43
+ const track = (process.env.PAN_TRACK || '').trim();
44
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
45
+ return `.planning/tracks/${track}`;
46
+ }
47
+ return '.planning';
48
+ }
49
+
50
+ /** Absolute path inside the active planning tree. */
51
+ function planningPath(cwd, ...segments) {
52
+ return path.join(cwd, ...planningDirName().split('/'), ...segments);
53
+ }
54
+
23
55
  // M62: only instrument actual PAN projects. A global-install hook fires in EVERY
24
56
  // repo the user opens; without this gate it silently creates .planning/ metrics
25
57
  // artifacts in non-PAN repos. A project counts as PAN if it already has a
@@ -30,7 +62,7 @@ const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'
30
62
  function isPanProject(cwd) {
31
63
  try {
32
64
  if (!cwd) return false;
33
- if (fs.existsSync(path.join(cwd, '.planning'))) return true;
65
+ if (fs.existsSync(planningPath(cwd))) return true;
34
66
  for (const d of PAN_RUNTIME_DIRS) {
35
67
  if (fs.existsSync(path.join(cwd, d, 'pan-file-manifest.json'))) return true;
36
68
  if (fs.existsSync(path.join(cwd, d, 'pan-wizard-core'))) return true;
@@ -71,7 +103,7 @@ function tierForModel(model) {
71
103
  // Never throws — returns {} on any miss.
72
104
  function readActiveSessionMeta(cwd) {
73
105
  try {
74
- const optDir = path.join(cwd, '.planning', 'optimization');
106
+ const optDir = planningPath(cwd, 'optimization');
75
107
  const sid = fs.readFileSync(path.join(optDir, 'current-session'), 'utf-8').trim();
76
108
  if (!sid) return {};
77
109
  const meta = JSON.parse(fs.readFileSync(path.join(optDir, 'traces', sid, 'session.json'), 'utf-8'));
@@ -81,6 +113,35 @@ function readActiveSessionMeta(cwd) {
81
113
  }
82
114
  }
83
115
 
116
+ /**
117
+ * Current phase from state.md — the fallback when no optimizer trace is running.
118
+ *
119
+ * Phase attribution used to come ONLY from the active trace session, and
120
+ * tracing is off by default, so in normal use every ledger row carried
121
+ * `phase: null`. A field ledger had 121 rows and 100% of them were unattributed,
122
+ * which makes "which phase got expensive" unanswerable from PAN's own telemetry
123
+ * — exactly the question a slowdown raises.
124
+ *
125
+ * state.md is authoritative for the current phase and is present whenever the
126
+ * phase model is in use. Frontmatter first (cheap, canonical), then the
127
+ * `**Current Phase:**` body field that `extractFieldsFromState` reads.
128
+ */
129
+ function readCurrentPhase(cwd) {
130
+ try {
131
+ const content = fs.readFileSync(planningPath(cwd, 'state.md'), 'utf-8');
132
+ const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
133
+ if (fm) {
134
+ const m = fm[1].match(/^\s*current_phase\s*:\s*["']?([^"'\r\n]+)["']?\s*$/mi);
135
+ if (m && m[1].trim() && m[1].trim() !== 'null') return m[1].trim();
136
+ }
137
+ const body = content.match(/\*\*Current Phase:\*\*\s*(.+)/i);
138
+ if (body && body[1].trim()) return body[1].trim();
139
+ return null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
84
145
  // Duration of a transcript slice from its first→last record timestamp. Returns
85
146
  // null when either bound is absent/unparseable — never a fabricated 0.
86
147
  function durationFromSpan(firstTs, lastTs) {
@@ -97,7 +158,7 @@ function durationFromSpan(firstTs, lastTs) {
97
158
  // cache-read into the billions/trillions and stamps it onto every subagent record
98
159
  // (field report 2026-06). Stored next to tokens.jsonl; best-effort, never blocks.
99
160
  function cursorFilePath(cwd) {
100
- return path.join(cwd, '.planning', METRICS_DIR, CURSOR_FILE);
161
+ return planningPath(cwd, METRICS_DIR, CURSOR_FILE);
101
162
  }
102
163
  function readCursor(cwd) {
103
164
  try {
@@ -373,7 +434,10 @@ function buildCostRecord(data, cwd) {
373
434
  // them (real SubagentStop payloads carry neither); tier is derived from the model.
374
435
  const sessionMeta = readActiveSessionMeta(cwd);
375
436
  const command = data.command || sessionMeta.command || null;
376
- const phase = data.phase || sessionMeta.phase || null;
437
+ // The trace session is only present while the optimizer is running (off by
438
+ // default), so state.md is the fallback that makes phase attribution work in
439
+ // ordinary use instead of only under tracing.
440
+ const phase = data.phase || sessionMeta.phase || readCurrentPhase(cwd) || null;
377
441
 
378
442
  const record = {
379
443
  v: SCHEMA_V,
@@ -513,7 +577,7 @@ function appendRecord(cwd, record) {
513
577
  // differs from the real row it follows, so the dedup never fired (M61).
514
578
  if (record.__emptySlice) return false;
515
579
  try {
516
- const dir = path.join(cwd, '.planning', METRICS_DIR);
580
+ const dir = planningPath(cwd, METRICS_DIR);
517
581
  fs.mkdirSync(dir, { recursive: true });
518
582
  const file = path.join(dir, TOKENS_FILE);
519
583
  // Idempotency guard: a re-fired SubagentStop must not double-log. Skip the
@@ -52,6 +52,37 @@
52
52
 
53
53
  const fs = require('fs');
54
54
  const path = require('path');
55
+ /**
56
+ * Which planning tree this hook acts on.
57
+ *
58
+ * Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
59
+ * (they are standalone and run inside the host runtime). All PAN hooks carry an
60
+ * identical copy — if the CLI is pointed at a track while a hook still writes to
61
+ * `.planning/`, that tree's telemetry lands in the wrong place.
62
+ *
63
+ * Env only — a hook gets no argv. A value that escapes the project root is
64
+ * ignored rather than honoured: a bad value degrades to the default, never
65
+ * writes outside the project.
66
+ */
67
+ function planningDirName() {
68
+ const raw = process.env.PAN_PLANNING_DIR || '';
69
+ if (raw.trim()) {
70
+ const rel = raw.trim().replace(/\\/g, '/');
71
+ const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
72
+ || rel.split('/').includes('..');
73
+ if (!bad) return rel;
74
+ }
75
+ const track = (process.env.PAN_TRACK || '').trim();
76
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
77
+ return `.planning/tracks/${track}`;
78
+ }
79
+ return '.planning';
80
+ }
81
+
82
+ /** Absolute path inside the active planning tree. */
83
+ function planningPath(cwd, ...segments) {
84
+ return path.join(cwd, ...planningDirName().split('/'), ...segments);
85
+ }
55
86
 
56
87
  // Unticked phase line, exactly as templates/roadmap.md and pan-roadmapper.md
57
88
  // emit it. Fixture doctrine: this shape is pinned against the shipped template
@@ -135,7 +166,7 @@ function main() {
135
166
  if (!payload || typeof payload !== 'object') payload = {};
136
167
 
137
168
  const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
138
- const planningDir = path.join(projectDir, '.planning');
169
+ const planningDir = planningPath(projectDir);
139
170
 
140
171
  let config = null;
141
172
  try { config = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8')); } catch { /* no project / bad config -> allow */ }
@@ -18,6 +18,37 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const crypto = require('crypto');
21
+ /**
22
+ * Which planning tree this hook acts on.
23
+ *
24
+ * Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
25
+ * (they are standalone and run inside the host runtime). All PAN hooks carry an
26
+ * identical copy — if the CLI is pointed at a track while a hook still writes to
27
+ * `.planning/`, that tree's telemetry lands in the wrong place.
28
+ *
29
+ * Env only — a hook gets no argv. A value that escapes the project root is
30
+ * ignored rather than honoured: a bad value degrades to the default, never
31
+ * writes outside the project.
32
+ */
33
+ function planningDirName() {
34
+ const raw = process.env.PAN_PLANNING_DIR || '';
35
+ if (raw.trim()) {
36
+ const rel = raw.trim().replace(/\\/g, '/');
37
+ const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
38
+ || rel.split('/').includes('..');
39
+ if (!bad) return rel;
40
+ }
41
+ const track = (process.env.PAN_TRACK || '').trim();
42
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
43
+ return `.planning/tracks/${track}`;
44
+ }
45
+ return '.planning';
46
+ }
47
+
48
+ /** Absolute path inside the active planning tree. */
49
+ function planningPath(cwd, ...segments) {
50
+ return path.join(cwd, ...planningDirName().split('/'), ...segments);
51
+ }
21
52
 
22
53
  // Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
23
54
  const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
@@ -32,7 +63,7 @@ const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'
32
63
  function isPanProject(cwd) {
33
64
  try {
34
65
  if (!cwd) return false;
35
- if (fs.existsSync(path.join(cwd, '.planning'))) return true;
66
+ if (fs.existsSync(planningPath(cwd))) return true;
36
67
  for (const d of PAN_RUNTIME_DIRS) {
37
68
  if (fs.existsSync(path.join(cwd, d, 'pan-file-manifest.json'))) return true;
38
69
  if (fs.existsSync(path.join(cwd, d, 'pan-wizard-core'))) return true;
@@ -43,7 +74,9 @@ function isPanProject(cwd) {
43
74
  }
44
75
  }
45
76
 
46
- const PLANNING_DIR = '.planning';
77
+ // Resolved per call via planningDirName() so a track-scoped run traces into
78
+ // its own tree; kept as a name for the code paths that only need the label.
79
+ const PLANNING_DIR = planningDirName();
47
80
  const OPTIMIZE_DIR = 'optimization';
48
81
  const TRACES_DIR = 'traces';
49
82
  const CURRENT_SESSION_FILE = 'current-session';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.26.0",
3
+ "version": "3.27.0",
4
4
  "description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -30,7 +30,6 @@
30
30
  const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { output, error, safeReadFile, toPosix, findPhaseInternal } = require('./core.cjs');
33
- const { PLANNING_DIR } = require('./constants.cjs');
34
33
  const { planningPath } = require('./utils.cjs');
35
34
 
36
35
  const BRIDGE_DIR = 'bridge';
@@ -26,7 +26,6 @@
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
28
  const { output, error } = require('./core.cjs');
29
- const { PLANNING_DIR } = require('./constants.cjs');
30
29
  const { planningPath } = require('./utils.cjs');
31
30
 
32
31
  const BUS_DIR = 'bus';
@@ -13,7 +13,8 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { output, error } = require('./core.cjs');
16
- const { PLANNING_DIR, VERIFY_RESERVE_FRACTION } = require('./constants.cjs');
16
+ const { VERIFY_RESERVE_FRACTION } = require('./constants.cjs');
17
+ const { planningPath } = require('./utils.cjs');
17
18
 
18
19
  const ORCH_DIR = 'orchestration';
19
20
  const SCHEDULE_FILE = 'schedule.json';
@@ -21,7 +22,7 @@ const HISTORY_CAP = 50;
21
22
  const DAY_MS = 86400000;
22
23
 
23
24
  function schedulePath(cwd) {
24
- return path.join(cwd, PLANNING_DIR, ORCH_DIR, SCHEDULE_FILE);
25
+ return planningPath(cwd, ORCH_DIR, SCHEDULE_FILE);
25
26
  }
26
27
 
27
28
  /**
@@ -9,8 +9,8 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { getArchivedPhaseDirs, output, error } = require('./core.cjs');
11
11
  const { extractFrontmatter } = require('./frontmatter.cjs');
12
- const { PLANNING_DIR, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, isSummaryFile } = require('./constants.cjs');
13
- const { phasesPath } = require('./utils.cjs');
12
+ const { PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, isSummaryFile } = require('./constants.cjs');
13
+ const { phasesPath, planningPath } = require('./utils.cjs');
14
14
 
15
15
  /**
16
16
  * Scan all phase directories (archived + current) and read summary frontmatter.
@@ -78,7 +78,7 @@ function collectPhaseSummaries(cwd) {
78
78
  * @returns {Array<{id: string, title: string, wrong: string, right: string, context: string|null, date: string|null}>}
79
79
  */
80
80
  function readErrorPatterns(cwd) {
81
- const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
81
+ const filePath = planningPath(cwd, PATTERNS_FILE);
82
82
  let content;
83
83
  try {
84
84
  content = fs.readFileSync(filePath, 'utf-8');
@@ -141,7 +141,7 @@ function appendErrorPattern(cwd, pattern) {
141
141
  return { error: "Pattern requires 'wrong' and 'right' fields" };
142
142
  }
143
143
 
144
- const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
144
+ const filePath = planningPath(cwd, PATTERNS_FILE);
145
145
  const existing = readErrorPatterns(cwd);
146
146
 
147
147
  // Determine next ID
@@ -198,7 +198,7 @@ function appendSessionSummary(cwd, summary) {
198
198
  return { error: "Summary requires 'phase' field" };
199
199
  }
200
200
 
201
- const filePath = path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE);
201
+ const filePath = planningPath(cwd, SESSION_HISTORY_FILE);
202
202
  const date = summary.date || new Date().toISOString().split('T')[0];
203
203
 
204
204
  const entry = [
@@ -308,7 +308,7 @@ function formatLearningEntry(learning) {
308
308
  * @returns {void}
309
309
  */
310
310
  function cmdLearningsExtract(cwd, raw) {
311
- const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
311
+ const learningsPath = planningPath(cwd, LEARNINGS_FILE);
312
312
  const newLearnings = [];
313
313
  const today = new Date().toISOString().split('T')[0];
314
314
 
@@ -445,7 +445,7 @@ function cmdLearningsExtract(cwd, raw) {
445
445
  * @returns {void}
446
446
  */
447
447
  function cmdLearningsList(cwd, raw) {
448
- const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
448
+ const learningsPath = planningPath(cwd, LEARNINGS_FILE);
449
449
 
450
450
  let content;
451
451
  try {
@@ -484,7 +484,7 @@ function cmdLearningsList(cwd, raw) {
484
484
  * @returns {void}
485
485
  */
486
486
  function cmdLearningsPrune(cwd, opts, raw) {
487
- const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
487
+ const learningsPath = planningPath(cwd, LEARNINGS_FILE);
488
488
 
489
489
  if (!opts || (opts.days == null && opts.id == null)) {
490
490
  error('Prune requires --days N or --id LEARN-NNN');
@@ -5,10 +5,11 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
7
7
  const { extractFrontmatter } = require('./frontmatter.cjs');
8
- const { PLANNING_DIR, PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
9
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles } = require('./utils.cjs');
8
+ const { PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
9
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
10
10
  const { estimateTokens } = require('./context-budget.cjs');
11
11
  const { collectPhaseSummaries, readErrorPatterns, appendErrorPattern, appendSessionSummary, parseLearnings, formatLearningEntry, cmdLearningsExtract, cmdLearningsList, cmdLearningsPrune } = require('./commands-learnings.cjs');
12
+ const { planningRootRel } = require('./planning-root.cjs');
12
13
 
13
14
  /**
14
15
  * Generate a URL-safe slug from text by lowercasing and replacing non-alphanumeric chars.
@@ -381,7 +382,7 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
381
382
  output({ committed: false, hash: null, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
382
383
  return;
383
384
  }
384
- if (isGitIgnored(cwd, PLANNING_DIR)) {
385
+ if (isGitIgnored(cwd, planningRootRel())) {
385
386
  // No error key, exit 0: .planning/ is gitignored - also the user's choice.
386
387
  output({ committed: false, hash: null, reason: 'skipped_gitignored' }, raw, 'skipped');
387
388
  return;
@@ -395,7 +396,7 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
395
396
  const preStaged = new Set(stagedFiles(cwd));
396
397
 
397
398
  // Stage files
398
- const filesToStage = files && files.length > 0 ? files : [PLANNING_DIR + '/'];
399
+ const filesToStage = files && files.length > 0 ? files : [planningRel() + '/'];
399
400
  for (const file of filesToStage) execGit(cwd, ['add', file]);
400
401
 
401
402
  // Safety checks
@@ -614,7 +615,7 @@ function renderProgressBar(percent, width) {
614
615
  */
615
616
  function cmdProgressRender(cwd, format, raw) {
616
617
  const phasesDir = phasesPath(cwd);
617
- const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
618
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
618
619
  const milestone = getMilestoneInfo(cwd);
619
620
 
620
621
  const phases = [];
@@ -693,9 +694,9 @@ function cmdProgressRender(cwd, format, raw) {
693
694
  * Compute and output a composite health score from progress, context budget, and staleness.
694
695
  */
695
696
  function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries, percent }, raw) {
696
- const stateContent = safeReadFile(path.join(cwd, PLANNING_DIR, STATE_FILE));
697
- const roadmapContent = safeReadFile(path.join(cwd, PLANNING_DIR, ROADMAP_FILE));
698
- const projectContent = safeReadFile(path.join(cwd, PLANNING_DIR, PROJECT_FILE));
697
+ const stateContent = safeReadFile(planningPath(cwd, STATE_FILE));
698
+ const roadmapContent = safeReadFile(planningPath(cwd, ROADMAP_FILE));
699
+ const projectContent = safeReadFile(planningPath(cwd, PROJECT_FILE));
699
700
 
700
701
  const stateTokens = estimateTokens(stateContent);
701
702
  const roadmapTokens = estimateTokens(roadmapContent);
@@ -742,7 +743,7 @@ function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries
742
743
  // Read session history count
743
744
  let sessionCount = 0;
744
745
  try {
745
- const sessionContent = fs.readFileSync(path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE), 'utf-8');
746
+ const sessionContent = fs.readFileSync(planningPath(cwd, SESSION_HISTORY_FILE), 'utf-8');
746
747
  sessionCount = (sessionContent.match(/^### Session — /gm) || []).length;
747
748
  } catch { /* file doesn't exist */ }
748
749
 
@@ -782,8 +783,8 @@ function cmdTodoComplete(cwd, filename, raw) {
782
783
  error('filename required for todo complete');
783
784
  }
784
785
 
785
- const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
786
- const completedDir = path.join(cwd, PLANNING_DIR, 'todos', 'completed');
786
+ const pendingDir = planningPath(cwd, 'todos', 'pending');
787
+ const completedDir = planningPath(cwd, 'todos', 'completed');
787
788
  const sourcePath = path.join(pendingDir, filename);
788
789
 
789
790
  let content;
@@ -870,14 +871,14 @@ function cmdScaffold(cwd, type, options, raw) {
870
871
  }
871
872
  const slug = generateSlugInternal(name);
872
873
  const dirName = `${padded}-${slug}`;
873
- const phasesParent = path.join(cwd, PLANNING_DIR, PHASES_DIR);
874
+ const phasesParent = planningPath(cwd, PHASES_DIR);
874
875
  try {
875
876
  fs.mkdirSync(phasesParent, { recursive: true });
876
877
  fs.mkdirSync(path.join(phasesParent, dirName), { recursive: true });
877
878
  } catch (e) {
878
879
  error(`Failed to create phase directory: ${e.message}`);
879
880
  }
880
- output({ created: true, directory: `${PLANNING_DIR}/${PHASES_DIR}/${dirName}` }, raw, `${PLANNING_DIR}/${PHASES_DIR}/${dirName}`);
881
+ output({ created: true, directory: planningRel(PHASES_DIR, dirName) }, raw, planningRel(PHASES_DIR, dirName));
881
882
  return;
882
883
  }
883
884
  default:
@@ -972,7 +973,7 @@ function cmdBatchCommit(cwd, items, raw) {
972
973
  }
973
974
 
974
975
  // Stage .planning/ only
975
- execGit(cwd, ['add', PLANNING_DIR + '/']);
976
+ execGit(cwd, ['add', planningRel() + '/']);
976
977
 
977
978
  // Check if there's anything to commit
978
979
  const statusResult = execGit(cwd, ['diff', '--cached', '--name-only']);
@@ -7,11 +7,11 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const { output, error, safeReadFile, toPosix, findPhaseInternal } = require('./core.cjs');
9
9
  const {
10
- PLANNING_DIR, CONFIG_FILE, PROJECT_FILE, STANDARDS_FILE,
10
+ CONFIG_FILE, PROJECT_FILE, STANDARDS_FILE,
11
11
  STANDARDS_CATALOG, STANDARDS_CATEGORIES, STANDARDS_RECOMMENDATIONS,
12
12
  PHASE_KEYWORDS_TO_STANDARDS, STANDARDS_EXTERNAL_TOOLS,
13
13
  } = require('./constants.cjs');
14
- const { readJsonFile, planningPath, fileAccessible, hasBraveSearchKey } = require('./utils.cjs');
14
+ const { readJsonFile, planningPath, fileAccessible, hasBraveSearchKey, planningRel } = require('./utils.cjs');
15
15
 
16
16
  /**
17
17
  * Count checked checklist items in a standards section.
@@ -119,7 +119,7 @@ function cmdConfigEnsureSection(cwd, raw) {
119
119
 
120
120
  try {
121
121
  fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
122
- output({ created: true, path: PLANNING_DIR + '/' + CONFIG_FILE }, raw, 'created');
122
+ output({ created: true, path: planningRel(CONFIG_FILE) }, raw, 'created');
123
123
  } catch (err) {
124
124
  error('Failed to create config.json: ' + err.message);
125
125
  }
@@ -354,7 +354,7 @@ function cmdStandardsSelect(cwd, standardId, raw) {
354
354
  output({
355
355
  added: standardId,
356
356
  project_standards: currentIds,
357
- standards_file: toPosix(PLANNING_DIR + '/' + STANDARDS_FILE),
357
+ standards_file: toPosix(planningRel(STANDARDS_FILE)),
358
358
  }, raw, 'Added ' + STANDARDS_CATALOG[standardId].name);
359
359
  }
360
360
 
@@ -398,7 +398,7 @@ function cmdStandardsRemove(cwd, standardId, raw) {
398
398
  output({
399
399
  removed: standardId,
400
400
  project_standards: newIds,
401
- standards_file: toPosix(PLANNING_DIR + '/' + STANDARDS_FILE),
401
+ standards_file: toPosix(planningRel(STANDARDS_FILE)),
402
402
  }, raw, 'Removed ' + standardId);
403
403
  }
404
404
 
@@ -572,11 +572,31 @@ const MEMORY_LOAD_WARN_TOKENS = 4000; // memory-budget telemetry: warn thres
572
572
  const MEMORY_LOAD_CRIT_TOKENS = 8000; // memory-budget telemetry: critical threshold (absolute tokens)
573
573
  const MEMORY_LOAD_MAX_FRACTION = 0.15; // memory-budget telemetry: max fraction of median agent input
574
574
 
575
+ // Cached prompt context (ADR-0044). The files in CACHEABLE_CONTEXT_FILES are
576
+ // re-read into EVERY agent call, so their combined size is the single largest
577
+ // recurring cost in a PAN project — cache reads dominate token traffic by roughly
578
+ // two orders of magnitude over generation. These thresholds mirror the
579
+ // MEMORY_LOAD_* pattern above: measured, classified, and surfaced.
580
+ const CACHE_BLOCK_WARN_TOKENS = 15000; // cached context block: warn threshold (absolute tokens)
581
+ const CACHE_BLOCK_CRIT_TOKENS = 25000; // cached context block: critical threshold
582
+ const CACHE_FILE_WARN_TOKENS = 6000; // any SINGLE cacheable file past this is the one to fix
583
+
584
+ // state.md compaction (ADR-0044). state.md is the largest cacheable file in
585
+ // practice because its section writers only ever append; closed history keeps
586
+ // being re-read months after it stopped being actionable.
587
+ const STATE_HISTORY_FILE = 'state-history.md';
588
+ const STATE_COMPACT_KEEP_DAYS = 30; // dated sections newer than this stay in state.md
589
+
575
590
  // Hygiene — project cleanup + version alignment (docs/FIELD-HARVEST-2026-07.md follow-ups).
576
591
  const HYGIENE_TRACE_RETENTION_DAYS = 30; // trace sessions older than this are prunable…
577
592
  const HYGIENE_TRACE_KEEP_MIN = 5; // …but always keep this many newest sessions
593
+ const HYGIENE_REPORT_KEEP_MIN = 5; // …same shape for optimization/reports/
578
594
  const HYGIENE_LEDGER_SUSPECT_RATIO = 0.5; // ledger "poisoned" when suspect fraction ≥ this…
579
595
  const HYGIENE_LEDGER_MIN_RECORDS = 20; // …and it has at least this many records
596
+ // …OR when the suspect rows, however few, carry this share of the token MASS.
597
+ // A count-only gate passes a ledger whose 24% bad rows hold 90% of the tokens —
598
+ // statistically fine, arithmetically useless. Mass is what aggregates read.
599
+ const HYGIENE_LEDGER_SUSPECT_MASS_RATIO = 0.5;
580
600
  const HYGIENE_TMP_AGE_MS = 60 * 60 * 1000; // .tmp orphans older than 1h are deletable
581
601
 
582
602
  // Skill-Aligned Decomposition pass (ADR-0038): planner draft ↔ skill-surface alignment.
@@ -772,8 +792,15 @@ module.exports = {
772
792
  MEMORY_LOAD_CRIT_TOKENS,
773
793
  MEMORY_LOAD_MAX_FRACTION,
774
794
  // Hygiene
795
+ CACHE_BLOCK_WARN_TOKENS,
796
+ CACHE_BLOCK_CRIT_TOKENS,
797
+ CACHE_FILE_WARN_TOKENS,
798
+ STATE_HISTORY_FILE,
799
+ STATE_COMPACT_KEEP_DAYS,
775
800
  HYGIENE_TRACE_RETENTION_DAYS,
776
801
  HYGIENE_TRACE_KEEP_MIN,
802
+ HYGIENE_REPORT_KEEP_MIN,
803
+ HYGIENE_LEDGER_SUSPECT_MASS_RATIO,
777
804
  HYGIENE_LEDGER_SUSPECT_RATIO,
778
805
  HYGIENE_LEDGER_MIN_RECORDS,
779
806
  HYGIENE_TMP_AGE_MS,