pan-wizard 3.26.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/hygiene.md +14 -8
  7. package/commands/pan/milestone-audit.md +10 -4
  8. package/commands/pan/preview.md +2 -2
  9. package/hooks/dist/pan-cost-logger.js +69 -5
  10. package/hooks/dist/pan-stop-guard.js +32 -1
  11. package/hooks/dist/pan-trace-logger.js +35 -2
  12. package/package.json +5 -2
  13. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  14. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  15. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  16. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  17. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  18. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  19. package/pan-wizard-core/bin/lib/constants.cjs +49 -0
  20. package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
  21. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  22. package/pan-wizard-core/bin/lib/cost.cjs +113 -11
  23. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  24. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  25. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  26. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
  28. package/pan-wizard-core/bin/lib/init.cjs +98 -13
  29. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  31. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  32. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  33. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  34. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  35. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  37. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  40. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  41. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  42. package/pan-wizard-core/bin/lib/verify.cjs +26 -5
  43. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  44. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  45. package/pan-wizard-core/mcp/server.cjs +92 -8
  46. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  47. package/pan-wizard-core/references/model-profiles.md +2 -2
  48. package/pan-wizard-core/workflows/health.md +1 -0
  49. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  50. package/pan-zcode/README.md +1 -1
  51. package/scripts/build-agent-plugin.js +220 -0
  52. package/scripts/build-plugin.js +48 -3
  53. package/scripts/generate-skills-docs.py +1 -1
  54. package/scripts/release-check.js +58 -12
package/bin/install.js CHANGED
@@ -46,7 +46,7 @@ const pkg = require('../package.json');
46
46
  // phrased by capability, not by name; these are the "switch to this" examples
47
47
  // that keep the advice actionable. Nothing in PAN gates on these values.
48
48
  const RECOMMENDED_MODELS = {
49
- flagship: 'claude-fable-5',
49
+ flagship: 'claude-fable-5-1',
50
50
  reasoningTier: 'claude-opus-5 / claude-opus-4-8',
51
51
  };
52
52
 
@@ -565,21 +565,15 @@ function copyCommandsAsUnifiedSkills(srcDir, skillsDir, prefix, pathPrefix, core
565
565
  fs.mkdirSync(skillDir, { recursive: true });
566
566
 
567
567
  let content = fs.readFileSync(srcPath, 'utf8');
568
- // Core + agent-definition references → shared .agents/ copies (specific,
569
- // before the generic rewrites); everything else .claude-scoped → the
570
- // installing runtime. Agent refs point at the canonical reference copies
571
- // shipped with the shared core — the runtime's own agents dir may carry
572
- // a different format (Codex TOML, Copilot .agent.md).
573
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
574
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
575
- content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
576
- content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
577
- content = content.replace(/~\/\.claude\//g, pathPrefix);
578
- content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
579
- // Not every runtime puts a `pan-tools` bin on PATH — invoke via node.
580
- const panToolsPath = `${corePrefix}pan-wizard-core/bin/pan-tools.cjs`;
581
- content = content.replace(/\bpan-tools\b(?=\s+[a-z])/g, `node ${panToolsPath}`);
582
- content = processAttribution(content, getCommitAttribution(runtime));
568
+ // The path rewrite lives in install-lib (rewriteUnifiedSkillCommandContent)
569
+ // because the Agent Plugins bundle builder runs the SAME function — one
570
+ // converter, several call sites, never a second copy (ADR-0028, ADR-0045).
571
+ content = lib.rewriteUnifiedSkillCommandContent(content, {
572
+ corePrefix,
573
+ pathPrefix,
574
+ projectDirPrefix: `./${getDirName(runtime)}/`,
575
+ attribution: getCommitAttribution(runtime),
576
+ });
583
577
  content = convertClaudeCommandToUnifiedSkill(content, skillName);
584
578
 
585
579
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
@@ -670,17 +664,14 @@ function copySharedCore(srcDir, destDir, corePrefix, runtimePathPrefix, runtime)
670
664
  recurse(srcPath, destPath);
671
665
  } else if (entry.name.endsWith('.md')) {
672
666
  try {
673
- let content = fs.readFileSync(srcPath, 'utf8');
674
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
675
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
676
- // Agent-definition refs → the canonical reference copies in the
677
- // shared core (runtime agents dirs carry runtime-specific formats).
678
- content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
679
- content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
680
- content = content.replace(/~\/\.claude\//g, runtimePathPrefix);
681
- content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
682
- content = processAttribution(content, getCommitAttribution(runtime));
683
- content = convertSlashCommandsToCopilotSkillMentions(content);
667
+ // Shared with the Agent Plugins bundle builder (install-lib) — see
668
+ // rewriteSharedCoreMarkdown for the rewrite order and rationale.
669
+ const content = lib.rewriteSharedCoreMarkdown(fs.readFileSync(srcPath, 'utf8'), {
670
+ corePrefix,
671
+ pathPrefix: runtimePathPrefix,
672
+ projectDirPrefix: `./${dirName}/`,
673
+ attribution: getCommitAttribution(runtime),
674
+ });
684
675
  fs.writeFileSync(destPath, content);
685
676
  } catch (err) {
686
677
  pushInstallWarning('copySharedCore(md)', destPath, err);
@@ -728,21 +719,13 @@ function stripInternalFromLearningsIndex(indexPath) {
728
719
  if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
729
720
  return;
730
721
  }
731
- if (!parsed || !Array.isArray(parsed.topics)) return;
732
-
733
- const kept = parsed.topics.filter(t => t && t.scope !== 'internal');
734
- if (kept.length === parsed.topics.length) return; // nothing internal to drop
735
-
736
- parsed.topics = kept;
737
- if (parsed.totals && typeof parsed.totals === 'object') {
738
- parsed.totals.topics = kept.length;
739
- parsed.totals.patterns = kept.reduce((n, t) => n + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0);
740
- parsed.totals.size_bytes = kept.reduce((n, t) => n + (t.size_bytes || 0), 0);
741
- parsed.totals.size_tokens_est = kept.reduce((n, t) => n + (t.size_tokens_est || 0), 0);
742
- }
722
+ // The transform is pure and shared with the bundle builders (install-lib):
723
+ // null means "not an index" or "nothing internal to drop" — both no-ops here.
724
+ const stripped = lib.stripInternalLearningsTopics(parsed);
725
+ if (!stripped) return;
743
726
 
744
727
  try {
745
- fs.writeFileSync(indexPath, JSON.stringify(parsed, null, 2) + '\n');
728
+ fs.writeFileSync(indexPath, JSON.stringify(stripped, null, 2) + '\n');
746
729
  } catch (err) {
747
730
  pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
748
731
  }
@@ -2230,10 +2213,8 @@ function install(isGlobal, runtime = 'claude') {
2230
2213
  fs.mkdirSync(agentsRefDir, { recursive: true });
2231
2214
  const agentsSrc = path.join(src, 'agents');
2232
2215
  for (const f of fs.readdirSync(agentsSrc).filter(n => n.endsWith('.md'))) {
2233
- let content = fs.readFileSync(path.join(agentsSrc, f), 'utf8');
2234
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
2235
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
2236
- content = convertSlashCommandsToCopilotSkillMentions(content);
2216
+ // Shared with the Agent Plugins bundle builder (install-lib).
2217
+ const content = lib.rewriteAgentReferenceCopy(fs.readFileSync(path.join(agentsSrc, f), 'utf8'), corePrefix);
2237
2218
  fs.writeFileSync(path.join(agentsRefDir, f), content);
2238
2219
  }
2239
2220
  } catch (e) {
@@ -92,7 +92,7 @@ Every cap the conductor enforces applies to the campaign, scaled up:
92
92
  | `--push` | off | Push approved merges to origin (still human-gated). |
93
93
  | `--clean-seal` | off | One clean build + full verification after the last item (commands from config). |
94
94
  | `--schedule` | off | Arm a self-resuming campaign at this cadence (`hourly`/`daily`/`weekly`/`Nh`/`Nd`) instead of running once — writes the schedule descriptor (ADR-0034). Pair with `--daily-budget`. |
95
- | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `budget.enforce`/`enforce_budget` is set. |
95
+ | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `enforce_budget: true` is set by hand in the schedule descriptor (`schedule.json`); no flag or config key sets it. |
96
96
  | `--dry-run` | off | Plan + squad delegation preview only; STOP. |
97
97
  | `--continue` / `--stop` / `--status` | — | Resume / halt / report from `.planning/orchestration/` + focus-auto state. |
98
98
 
@@ -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>
@@ -34,9 +34,9 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
34
34
  **What it does:**
35
35
  1. `pan-tools preview phase <N>` returns `{files_mentioned, test_files_mentioned, risk_signals, risk_score, plans[], status}`.
36
36
  2. Spawn `pan-previewer` with the payload as `<preview_input>`.
37
- 3. Agent writes `.planning/phases/<N>/preview.md` with files touched / tests at risk / migration steps / risk assessment / bottom line.
37
+ 3. Agent writes `.planning/phases/<NN-slug>/preview.md` with files touched / tests at risk / migration steps / risk assessment / bottom line.
38
38
 
39
- **Output:** `.planning/phases/<N>/preview.md`
39
+ **Output:** `.planning/phases/<NN-slug>/preview.md`
40
40
 
41
41
  ### `phases` — Cross-phase dependency graph
42
42
 
@@ -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.28.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"
@@ -70,6 +70,9 @@
70
70
  "test:e2e": "node scripts/run-tests.cjs tests/scenarios",
71
71
  "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
72
72
  "test:watch": "node scripts/run-tests.cjs --watch tests tests/scenarios",
73
- "build:plugin": "node scripts/build-plugin.js"
73
+ "build:plugin": "node scripts/build-plugin.js",
74
+ "build:agent-plugin": "node scripts/build-agent-plugin.js",
75
+ "harness": "node harness/src/run.cjs --tier 0",
76
+ "harness:model": "node harness/src/run.cjs --tier 2"
74
77
  }
75
78
  }
@@ -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');