pan-wizard 3.34.0 → 3.35.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 (76) hide show
  1. package/README.md +6 -6
  2. package/agents/pan-conductor.md +2 -2
  3. package/agents/pan-debugger.md +3 -1
  4. package/agents/pan-design-checker.md +1 -0
  5. package/agents/pan-executor.md +18 -1
  6. package/agents/pan-experiment-runner.md +1 -1
  7. package/agents/pan-hardener.md +1 -1
  8. package/agents/pan-meta-reviewer.md +1 -1
  9. package/agents/pan-phase-researcher.md +2 -2
  10. package/agents/pan-plan-checker.md +9 -8
  11. package/agents/pan-planner.md +3 -1
  12. package/agents/pan-project-researcher.md +1 -1
  13. package/agents/pan-research-synthesizer.md +1 -1
  14. package/agents/pan-reviewer.md +3 -2
  15. package/agents/pan-verifier.md +1 -1
  16. package/bin/install-lib.cjs +119 -16
  17. package/bin/install.js +34 -62
  18. package/commands/pan/cost.md +0 -4
  19. package/commands/pan/dashboard.md +0 -3
  20. package/commands/pan/design-phase.md +2 -2
  21. package/commands/pan/hud.md +1 -5
  22. package/commands/pan/knowledge.md +0 -1
  23. package/commands/pan/mcp-bridge.md +1 -3
  24. package/commands/pan/optimize.md +2 -0
  25. package/commands/pan/phase-tests.md +1 -1
  26. package/commands/pan/preview.md +1 -2
  27. package/commands/pan/report.md +1 -5
  28. package/commands/pan/research-phase.md +4 -4
  29. package/commands/pan/review-deep.md +0 -2
  30. package/commands/pan/what-if.md +1 -2
  31. package/hooks/dist/pan-context-monitor.js +12 -3
  32. package/hooks/dist/pan-guard.js +244 -0
  33. package/hooks/dist/pan-statusline.js +5 -2
  34. package/package.json +1 -1
  35. package/pan-wizard-core/bin/lib/commands.cjs +24 -2
  36. package/pan-wizard-core/bin/lib/config.cjs +5 -0
  37. package/pan-wizard-core/bin/lib/findings.cjs +126 -19
  38. package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
  39. package/pan-wizard-core/bin/lib/init.cjs +6 -0
  40. package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
  41. package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
  42. package/pan-wizard-core/bin/lib/phase.cjs +24 -0
  43. package/pan-wizard-core/bin/lib/preview.cjs +32 -31
  44. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  45. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  46. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  47. package/pan-wizard-core/bin/lib/verify-scope.cjs +6 -5
  48. package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
  49. package/pan-wizard-core/bin/pan-tools.cjs +13 -4
  50. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  51. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  52. package/pan-wizard-core/opencode/pan-wizard.js +36 -8
  53. package/pan-wizard-core/references/model-profiles.md +1 -1
  54. package/pan-wizard-core/references/planning-config.md +3 -3
  55. package/pan-wizard-core/references/verdict-contract.md +6 -1
  56. package/pan-wizard-core/templates/config.json +2 -6
  57. package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
  58. package/pan-wizard-core/templates/phase-prompt.md +2 -0
  59. package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
  60. package/pan-wizard-core/templates/preview-report.md +1 -1
  61. package/pan-wizard-core/templates/state.md +4 -4
  62. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  63. package/pan-wizard-core/workflows/discuss-phase.md +2 -1
  64. package/pan-wizard-core/workflows/exec-phase.md +51 -40
  65. package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
  66. package/pan-wizard-core/workflows/new-project.md +8 -16
  67. package/pan-wizard-core/workflows/plan-phase.md +22 -8
  68. package/pan-wizard-core/workflows/progress.md +1 -1
  69. package/pan-wizard-core/workflows/quick.md +2 -2
  70. package/pan-wizard-core/workflows/todo-check.md +3 -2
  71. package/pan-wizard-core/workflows/transition.md +4 -0
  72. package/pan-wizard-core/workflows/verify-phase.md +6 -6
  73. package/scripts/build-hooks.js +1 -0
  74. package/scripts/generate-skills-docs.py +2 -2
  75. package/scripts/prompt-lint.cjs +167 -0
  76. package/scripts/test-surface.cjs +1 -0
package/bin/install.js CHANGED
@@ -369,13 +369,11 @@ function getCommitAttribution(runtime) {
369
369
  const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
370
370
  result = lib.commitAttributionFromSettings(settings);
371
371
  } else if (runtime === 'copilot') {
372
- // Copilot CLI: user-editable settings live in settings.json; config.json is
373
- // legacy (auto-migrated by the CLI, now internal state) — fall back for old installs
372
+ // Copilot CLI: user settings live in settings.json only. config.json is the CLI's
373
+ // internal state, and Copilot 1.0.93 ignores user settings there (R48), so a
374
+ // stale `attribution` left in it must not decide PAN's attribution.
374
375
  const copilotDir = getGlobalDir('copilot', explicitConfigDir);
375
- let config = readSettings(path.join(copilotDir, 'settings.json')) || {}; // unusable file = no info (read-only probe)
376
- if (!config.attribution) {
377
- config = readSettings(path.join(copilotDir, 'config.json')) || {}; // unusable file = no info (read-only probe)
378
- }
376
+ const config = readSettings(path.join(copilotDir, 'settings.json')) || {}; // unusable file = no info (read-only probe)
379
377
  if (!config.attribution || config.attribution.commit === undefined) {
380
378
  result = undefined;
381
379
  } else if (config.attribution.commit === '') {
@@ -1222,7 +1220,7 @@ function uninstall(isGlobal, runtime = 'claude') {
1222
1220
  // 4. Remove PAN hooks (scripts + Copilot CLI hooks config file)
1223
1221
  const hooksDir = path.join(targetDir, 'hooks');
1224
1222
  if (fs.existsSync(hooksDir)) {
1225
- const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan-state-reinject.js', 'pan.json'];
1223
+ const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-guard.js', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan-state-reinject.js', 'pan.json'];
1226
1224
  let hookCount = 0;
1227
1225
  for (const hook of panHooks) {
1228
1226
  const hookPath = path.join(hooksDir, hook);
@@ -1576,35 +1574,12 @@ function uninstall(isGlobal, runtime = 'claude') {
1576
1574
  // CodeQL js/file-system-race. Same idiom as the hooks' metrics read.
1577
1575
  try {
1578
1576
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1579
- let modified = false;
1580
-
1581
- // Remove PAN permission entries
1582
- if (config.permission) {
1583
- for (const permType of ['read', 'external_directory']) {
1584
- if (config.permission[permType]) {
1585
- const keys = Object.keys(config.permission[permType]);
1586
- for (const key of keys) {
1587
- // Match `pan-wizard` rather than `pan-wizard-core` so the malformed
1588
- // key earlier versions wrote (`~/.config/opencode/pan-wizard/*`) is
1589
- // cleaned too. Without this, machines that ran any global OpenCode
1590
- // install before the glob was fixed keep that entry forever — the
1591
- // uninstaller could never match it, which is what made it residue
1592
- // rather than a cosmetic typo.
1593
- if (key.includes('pan-wizard')) {
1594
- delete config.permission[permType][key];
1595
- modified = true;
1596
- }
1597
- }
1598
- // Clean up empty objects
1599
- if (Object.keys(config.permission[permType]).length === 0) {
1600
- delete config.permission[permType];
1601
- }
1602
- }
1603
- }
1604
- if (Object.keys(config.permission).length === 0) {
1605
- delete config.permission;
1606
- }
1607
- }
1577
+ let modified;
1578
+
1579
+ // Remove PAN permission entries, v1 `permission` keys and OpenCode 2
1580
+ // `permissions[]` rules alike (R47). Keys matching `pan-wizard` rather than
1581
+ // `pan-wizard-core` also clean the malformed key earlier versions wrote.
1582
+ ({ modified } = lib.stripOpencodePermissions(config));
1608
1583
 
1609
1584
  if (modified) {
1610
1585
  // If stripping PAN permissions emptied the config, remove the file
@@ -1718,6 +1693,18 @@ function uninstall(isGlobal, runtime = 'claude') {
1718
1693
  console.log(` ${yellow}⚠${reset} No PAN files found to remove.`);
1719
1694
  }
1720
1695
 
1696
+ // A local install's runtime directory that the removal left empty goes too (X1).
1697
+ // Never a global one (~/.claude and the like are the host's own homes), and never a
1698
+ // directory that still holds anything: `.github` usually holds the project's files.
1699
+ if (!isGlobal) {
1700
+ try {
1701
+ if (fs.readdirSync(targetDir).length === 0) {
1702
+ fs.rmdirSync(targetDir);
1703
+ console.log(` ${green}✓${reset} Removed empty ${locationLabel}`);
1704
+ }
1705
+ } catch { /* already gone or unreadable: leave it */ }
1706
+ }
1707
+
1721
1708
  console.log(`
1722
1709
  ${green}Done!${reset} PAN has been uninstalled from ${runtimeLabel}.
1723
1710
  Your other files and settings have been preserved.
@@ -1760,10 +1747,6 @@ function configureOpencodePermissions(isGlobal = true) {
1760
1747
  }
1761
1748
  }
1762
1749
 
1763
- // Ensure permission structure exists
1764
- if (!config.permission) {
1765
- config.permission = {};
1766
- }
1767
1750
 
1768
1751
  // Build the PAN path using the actual config directory
1769
1752
  // Use ~ shorthand if it's in the default location, otherwise use full path
@@ -1780,25 +1763,9 @@ function configureOpencodePermissions(isGlobal = true) {
1780
1763
  ? '~/.config/opencode/pan-wizard-core/*'
1781
1764
  : `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
1782
1765
 
1783
- let modified = false;
1784
-
1785
- // Configure read permission
1786
- if (!config.permission.read || typeof config.permission.read !== 'object') {
1787
- config.permission.read = {};
1788
- }
1789
- if (config.permission.read[panPath] !== 'allow') {
1790
- config.permission.read[panPath] = 'allow';
1791
- modified = true;
1792
- }
1793
-
1794
- // Configure external_directory permission (the safety guard for paths outside project)
1795
- if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1796
- config.permission.external_directory = {};
1797
- }
1798
- if (config.permission.external_directory[panPath] !== 'allow') {
1799
- config.permission.external_directory[panPath] = 'allow';
1800
- modified = true;
1801
- }
1766
+ // v1 `permission` object in a new or v1 file; the native `permissions[]` array
1767
+ // in a file the user already converted to OpenCode 2, never both (R47).
1768
+ const { modified } = lib.addOpencodePermissions(config, panPath);
1802
1769
 
1803
1770
  if (!modified) {
1804
1771
  return; // Already configured
@@ -2785,6 +2752,10 @@ function install(isGlobal, runtime = 'claude') {
2785
2752
  const stateReinjectCommand = isGlobal
2786
2753
  ? buildHookCommand(targetDir, 'pan-state-reinject.js')
2787
2754
  : 'node ' + dirName + '/hooks/pan-state-reinject.js';
2755
+ // The guard names its host: payloads and the way to deny differ per runtime (A7).
2756
+ const guardCommand = (isGlobal
2757
+ ? buildHookCommand(targetDir, 'pan-guard.js')
2758
+ : 'node ' + dirName + '/hooks/pan-guard.js') + ` --host ${runtime}`;
2788
2759
  // Gemini and Copilot (M33): a marker before the compaction, the block on the next
2789
2760
  // tool result. The inject mode names the host, whose output shapes differ.
2790
2761
  const stateReinjectMarkCommand = `${stateReinjectCommand} --mark`;
@@ -2798,7 +2769,7 @@ function install(isGlobal, runtime = 'claude') {
2798
2769
  if (!isOpencode) {
2799
2770
  const entrypoints = lib.verifyHookEntrypoints([
2800
2771
  updateCheckCommand, contextMonitorCommand, statuslineCommand, costLoggerCommand,
2801
- traceLoggerCommand, stopGuardCommand, stateReinjectCommand, stateReinjectMarkCommand, stateReinjectInjectCommand,
2772
+ traceLoggerCommand, stopGuardCommand, stateReinjectCommand, stateReinjectMarkCommand, stateReinjectInjectCommand, guardCommand,
2802
2773
  ], process.cwd());
2803
2774
  if (!entrypoints.ok) {
2804
2775
  console.error(`\n ${red}✖ Hook verification FAILED — these hook commands would run a script that is not there:${reset}`);
@@ -2818,7 +2789,7 @@ function install(isGlobal, runtime = 'claude') {
2818
2789
  let existing = null;
2819
2790
  try { existing = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); } catch { /* absent or invalid — start fresh */ }
2820
2791
  const merged = lib.mergeCodexHooksConfig(existing, {
2821
- updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand,
2792
+ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand, guardCommand,
2822
2793
  });
2823
2794
  fs.writeFileSync(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n');
2824
2795
  // Print the path we actually wrote. The hardcoded '.codex/hooks.json' was
@@ -2836,7 +2807,7 @@ function install(isGlobal, runtime = 'claude') {
2836
2807
  // from any legacy config.json registration.
2837
2808
  if (isCopilot) {
2838
2809
  const hooksConfigPath = path.join(targetDir, 'hooks', 'pan.json');
2839
- const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectMarkCommand, stateReinjectInjectCommand });
2810
+ const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectMarkCommand, stateReinjectInjectCommand, guardCommand });
2840
2811
  try {
2841
2812
  fs.mkdirSync(path.dirname(hooksConfigPath), { recursive: true });
2842
2813
  fs.writeFileSync(hooksConfigPath, JSON.stringify(hooksConfig, null, 2) + '\n');
@@ -2930,6 +2901,7 @@ function install(isGlobal, runtime = 'claude') {
2930
2901
  // unbuilt phases — fail-open and inert outside PAN projects.
2931
2902
  const registrations = [
2932
2903
  { slot: 'sessionStart', hook: 'pan-check-update', command: updateCheckCommand, label: 'update check hook' },
2904
+ { slot: 'preToolUse', hook: 'pan-guard', command: guardCommand, label: 'pre-tool guard hook' },
2933
2905
  { slot: 'postToolUse', hook: 'pan-context-monitor', command: contextMonitorCommand, label: 'context window monitor hook' },
2934
2906
  { slot: 'subagentStop', hook: 'pan-cost-logger', command: costLoggerCommand, label: 'cost logger hook' },
2935
2907
  { slot: 'subagentStop', hook: 'pan-trace-logger', command: traceLoggerCommand, label: 'trace logger hook' },
@@ -16,10 +16,6 @@ Reads `.planning/metrics/tokens.jsonl` — an append-only log where each line is
16
16
  Default output is JSON for piping. Use `--format table` for human-readable tables or `--format chart` for an ASCII bar chart of daily spend; both print text, with or without `--raw`.
17
17
  </objective>
18
18
 
19
- <execution_context>
20
- @~/.claude/pan-wizard-core/bin/lib/cost.cjs
21
- </execution_context>
22
-
23
19
  <subcommands>
24
20
 
25
21
  ### `report` (default)
@@ -20,6 +20,3 @@ pan-tools hud [--out <file>] [--open] [--stdout]
20
20
  See **`/pan:hud`** for the full panel list, flags, JSON result shape, and runtime compatibility. The two commands are interchangeable.
21
21
  </objective>
22
22
 
23
- <execution_context>
24
- @~/.claude/pan-wizard-core/bin/lib/hud.cjs
25
- </execution_context>
@@ -53,7 +53,7 @@ ELSE (default):
53
53
  1. **Validate phase** — error if the phase number is missing or not in `roadmap.md`.
54
54
  2. **Prime context** — resolve the phase directory; gather `{phase}-context.md`, `{phase}-research.md`, and the phase boundary from `roadmap.md`.
55
55
  3. **Design** — spawn `pan-designer` with a `<files_to_read>` block (context, research, roadmap boundary, `CLAUDE.md`). It fills in `templates/design.md` at the `phase` tier (`spike` if `--spike`).
56
- 4. **Verify (reflexion loop)** — spawn `pan-design-checker` with the drafted design + the boundary source:
56
+ 4. **Verify (reflexion loop)** — spawn `pan-design-checker` with the drafted design + the boundary source, plus an `<open_findings>` block holding `pan-tools findings list --phase {phase} --agent pan-design-checker --status open` (empty on the first check):
57
57
  - If it PASSES → done.
58
58
  - If it finds gaps (iteration 1) → `pan-designer` revises (address genuine gaps only), re-check.
59
59
  - If it finds gaps (iteration 2) → final revision, record remaining gaps as caveats.
@@ -62,7 +62,7 @@ ELSE (default):
62
62
  ```bash
63
63
  node ~/.claude/pan-wizard-core/bin/pan-tools.cjs findings record --phase "{phase}" --agent pan-design-checker --file "{phase_dir}/{padded_phase}-design-check.md" --raw
64
64
  ```
65
- A re-check is the next attempt and closes the gaps it no longer reports. When the final iteration still has gaps, record them as the caveats they are before writing the design:
65
+ A re-check is the next attempt. It answers each earlier gap in `resolutions`, and the record closes the ones answered `resolved` with evidence; a gap it neither reports nor answers stays open. When the final iteration still has gaps, record them as the caveats they are before writing the design:
66
66
  ```bash
67
67
  node ~/.claude/pan-wizard-core/bin/pan-tools.cjs findings dispose --phase "{phase}" --agent pan-design-checker --open --as deferred --reason "design caveat after 2 revision iterations"
68
68
  ```
@@ -16,10 +16,6 @@ It is a *view*, not a new source of truth: every panel aggregates state PAN alre
16
16
  Default output is `.planning/hud.html`. Open it in any browser. Re-run any time for a fresh snapshot.
17
17
  </objective>
18
18
 
19
- <execution_context>
20
- @~/.claude/pan-wizard-core/bin/lib/hud.cjs
21
- </execution_context>
22
-
23
19
  <usage>
24
20
 
25
21
  ```
@@ -62,7 +58,7 @@ Panels render only when they have data — a plain (non-army) project still gets
62
58
  | **Requirements & quality** | Requirements done/open + last verification artifacts | `requirements.md`, phase `*-verification.md` / `*-uat.md` |
63
59
  | **Recent activity** | Last commits (the army's committed output) | `git log` |
64
60
 
65
- *(army)* panels appear only when a campaign is scheduled or army worktrees exist — graceful degradation per ADR-0035. The *(fallback)* **Planning activity** panel appears only when there is no phase/roadmap layout, so it fills the gap for focus-auto and imported projects instead of leaving a bare page. Telemetry shows an honest advisory (never a fabricated `$0.00`) when the cost ledger is poisoned or every record is unpriced.
61
+ *(army)* panels appear only when a campaign is scheduled or army worktrees exist — graceful degradation per ADR-0035. The *(fallback)* **Planning activity** panel appears only when there is no phase/roadmap layout, so it fills the gap for focus-auto and imported projects instead of leaving a bare page. Telemetry shows an honest advisory (never a fabricated zero-dollar total) when the cost ledger is poisoned or every record is unpriced.
66
62
 
67
63
  </panels>
68
64
 
@@ -23,7 +23,6 @@ Consolidates Spec B v1's X-3 converse + X-6 teach + X-10 explain into one comman
23
23
  </objective>
24
24
 
25
25
  <execution_context>
26
- @~/.claude/pan-wizard-core/bin/lib/knowledge.cjs
27
26
  @~/.claude/agents/pan-knowledge.md
28
27
  @~/.claude/pan-wizard-core/templates/playbook.md
29
28
  </execution_context>
@@ -15,9 +15,7 @@ Surface Model Context Protocol (MCP) tools visible to the host runtime and recom
15
15
  Reduced scope from Spec B v1's X-7: **discovery and recommendation only**. Auto-injection of MCP tools into planner context and auto-invocation from executor agents are deliberately deferred and still unscheduled (the v3.5 target passed without them). This kept v3.3 narrow and avoids coupling PAN to Claude Code's MCP schema stability.
16
16
  </objective>
17
17
 
18
- <execution_context>
19
- @~/.claude/pan-wizard-core/bin/lib/bridge.cjs
20
- </execution_context>
18
+ Each subcommand runs `node ~/.claude/pan-wizard-core/bin/pan-tools.cjs bridge <list|recommend|cache> [flags]`. The CLI verb is `bridge`, not `mcp-bridge`: `pan-tools mcp-bridge` is an unknown command.
21
19
 
22
20
  <subcommands>
23
21
 
@@ -111,3 +111,5 @@ List all trace sessions, most recent first.
111
111
  Each iteration puts what the last run lacked where the next run's agents read it: fewer repeated errors and gaps.
112
112
 
113
113
  **See also:** `/pan:learn`, `/pan:exec-phase`
114
+
115
+ Follow the workflow at `~/.claude/pan-wizard-core/workflows/optimize.md`. Each subcommand runs `node ~/.claude/pan-wizard-core/bin/pan-tools.cjs optimize <subcommand> [flags]` — e.g. `optimize apply --report <path>`, `optimize revert --last`, `optimize trace init --description "..."`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pan:phase-tests
3
3
  group: Phase Management
4
- description: Generate tests for a completed phase based on UAT criteria and implementation
4
+ description: Generate tests for a completed phase from its summaries, context decisions, verification report and implementation
5
5
  argument-hint: "<phase> [additional instructions]"
6
6
  allowed-tools:
7
7
  - Read
@@ -19,7 +19,6 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
19
19
  </objective>
20
20
 
21
21
  <execution_context>
22
- @~/.claude/pan-wizard-core/bin/lib/preview.cjs
23
22
  @~/.claude/pan-wizard-core/templates/preview-report.md
24
23
  </execution_context>
25
24
 
@@ -74,7 +73,7 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
74
73
 
75
74
  **Before committing to a milestone date externally:** run `/pan:preview milestone`. Look at `confidence_pct` and `sample_size`. If sample is <3, don't promise a date.
76
75
 
77
- **Before running phases in parallel:** run `/pan:preview phases`. Parallel batches from the data layer are based on declared `depends_on` only; `hidden_coupling_count > 0` means there are cross-phase references the author should promote to explicit deps before parallelizing.
76
+ **Before running phases in parallel:** run `/pan:preview phases`. Parallel batches from the data layer are based on declared dependencies only — the roadmap's `**Depends on:** Phase N` line and any `phase:N` entry in a plan's `depends_on`; `hidden_coupling_count > 0` means there are cross-phase references the author should promote to explicit deps (on the roadmap's Depends on line) before parallelizing.
78
77
 
79
78
  </workflow>
80
79
 
@@ -13,13 +13,9 @@ Render **self-contained HTML reports** — no server, no network, no external CS
13
13
 
14
14
  Like `pan:hud`, these are *views*, not a new source of truth: every value is read from what PAN already tracks on disk (the phase's `plan`/`summary`/`verification` artifacts and their frontmatter, `roadmap.md`, and the cost ledger). The command only writes the rendered file(s), so it can never corrupt planning data. Reports reuse the HUD's visual language, so they look like part of the same product.
15
15
 
16
- Reports are honest by construction: the `verify reconcile` verdict is shown beside the (rubber-stampable) self-reported verification status, status is framed as a current-disk snapshot, and any spend is gated so a poisoned or unpriced ledger never renders a fake `$0`.
16
+ Reports are honest by construction: the `verify reconcile` verdict is shown beside the (rubber-stampable) self-reported verification status, status is framed as a current-disk snapshot, and any spend is gated so a poisoned or unpriced ledger never renders a fake zero-dollar spend.
17
17
  </objective>
18
18
 
19
- <execution_context>
20
- @~/.claude/pan-wizard-core/bin/lib/phase-report.cjs
21
- </execution_context>
22
-
23
19
  <usage>
24
20
 
25
21
  ```
@@ -137,8 +137,8 @@ Write to: {phase_dir}/{padded_phase}-research.md
137
137
 
138
138
  ```
139
139
  Task(
140
- prompt="First, read ~/.claude/agents/pan-phase-researcher.md for your role and instructions.\n\n" + filled_prompt,
141
- subagent_type="general-purpose",
140
+ prompt=filled_prompt,
141
+ subagent_type="pan-phase-researcher",
142
142
  model="{researcher_model}",
143
143
  description="Research Phase {phase}"
144
144
  )
@@ -171,8 +171,8 @@ Continue research for Phase {phase_number}: {phase_name}
171
171
 
172
172
  ```
173
173
  Task(
174
- prompt="First, read ~/.claude/agents/pan-phase-researcher.md for your role and instructions.\n\n" + continuation_prompt,
175
- subagent_type="general-purpose",
174
+ prompt=continuation_prompt,
175
+ subagent_type="pan-phase-researcher",
176
176
  model="{researcher_model}",
177
177
  description="Continue research Phase {phase}"
178
178
  )
@@ -24,8 +24,6 @@ Consolidates Spec B v1's X-4 (self-review) + X-12 (harden) into a single command
24
24
  </objective>
25
25
 
26
26
  <execution_context>
27
- @~/.claude/pan-wizard-core/bin/lib/review-deep.cjs
28
- @~/.claude/pan-wizard-core/bin/lib/bus.cjs
29
27
  @~/.claude/agents/pan-hardener.md
30
28
  @~/.claude/agents/pan-meta-reviewer.md
31
29
  </execution_context>
@@ -20,7 +20,6 @@ Unchanged from Spec B v1's X-9. Already narrow enough to stand alone.
20
20
  </objective>
21
21
 
22
22
  <execution_context>
23
- @~/.claude/pan-wizard-core/bin/lib/whatif.cjs
24
23
  @~/.claude/agents/pan-counterfactual.md
25
24
  </execution_context>
26
25
 
@@ -80,7 +79,7 @@ This writes `.planning/counterfactuals/<phase>-<slug>.md`. The file belongs to t
80
79
  pan-tools whatif cleanup --worktree <path> --branch <name> --force
81
80
  ```
82
81
 
83
- Removes the worktree directory and deletes the counterfactual branch. Best-effort: warnings are surfaced but don't block.
82
+ Removes the worktree directory and deletes the counterfactual branch. Pass the `worktree.worktree_path` and `worktree.branch` that `whatif prepare` returned, unchanged. Cleanup refuses (exit 1, nothing removed) a branch outside `pan-whatif/` and a worktree that is not checked out on that branch, so it can never delete your own work. Otherwise best-effort: warnings are surfaced but don't block.
84
83
 
85
84
  ### Stage 5 — Confirm
86
85
 
@@ -429,14 +429,23 @@ function buildContextWarning(metrics, warnState, nowSeconds, opts = {}) {
429
429
  };
430
430
  }
431
431
 
432
- /** Claude Code's settings for a project, highest precedence first: local, project, user. */
433
- function readClaudeSettings(projectDir, homeDir = os.homedir()) {
432
+ /**
433
+ * Claude Code's settings for a project, highest precedence first: local, project, user.
434
+ * The user file lives in CLAUDE_CONFIG_DIR when that is set, as Claude Code reads it.
435
+ * A given homeDir is a test seam and ignores CLAUDE_CONFIG_DIR (as the statusline and
436
+ * hygiene do), unless configDir is passed too.
437
+ */
438
+ function readClaudeSettings(projectDir, homeArg, configArg) {
439
+ const seam = homeArg !== undefined;
440
+ const homeDir = seam ? homeArg : os.homedir();
441
+ const configDir = configArg !== undefined ? configArg : (seam ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
434
442
  // Assembled, not a literal: the installer rewrites quoted .claude literals in hook
435
443
  // copies, and these are Claude Code's own settings (only it writes the bridge).
436
444
  const dir = ['.', 'claude'].join('');
437
445
  const files = [];
438
446
  if (typeof projectDir === 'string' && projectDir) files.push(path.join(projectDir, dir, 'settings.local.json'), path.join(projectDir, dir, 'settings.json'));
439
- if (homeDir) files.push(path.join(homeDir, dir, 'settings.json'));
447
+ if (configDir) files.push(path.join(configDir, 'settings.json'));
448
+ else if (homeDir) files.push(path.join(homeDir, dir, 'settings.json'));
440
449
  const out = [];
441
450
  for (const f of files) {
442
451
  try { out.push(JSON.parse(fs.readFileSync(f, 'utf8'))); } catch { /* absent or unreadable */ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // PAN guard — a pre-tool hook (program A7).
4
+ //
5
+ // Registered on the hosts with a pre-tool event that can deny a call: Claude Code
6
+ // and Codex (`PreToolUse`), Gemini CLI (`BeforeTool`) and Copilot CLI (`preToolUse`).
7
+ // OpenCode registers no hooks. The command carries `--host <runtime>`, because the
8
+ // payload and the way to deny differ per host.
9
+ //
10
+ // Rules, over the files a tool call is about to write:
11
+ // secret_write the written text holds a credential (known token shapes,
12
+ // or a long key=value secret)
13
+ // state_write a direct write to PAN's own state: state.md, findings.jsonl,
14
+ // the token ledger (pan-tools owns those)
15
+ // undeclared_test_edit a test file the phase in flight does not declare in any
16
+ // unfinished plan's files_modified
17
+ //
18
+ // Mode, from the planning tree's config.json `hooks.guard`:
19
+ // off nothing
20
+ // report (default) every match is appended to <planning>/guard.jsonl, the call proceeds
21
+ // enforce as report, and a secret_write is denied; the other rules still only report
22
+ // until the writers of PAN's state go through pan-tools verbs (A7b)
23
+ //
24
+ // Inert outside a PAN project. Fail-open on anything it cannot parse; never fail-open
25
+ // on a matched deny. Never writes outside the planning tree.
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const TOKEN_RE = /\b(?:sk-ant-[A-Za-z0-9_-]{10,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----)/;
31
+ const SECRET_KV_RE = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd)\b["']?\s*[:=]\s*["']([^"'\s]{12,})["']/i;
32
+ const TEST_PATH_RE = /(^|\/)(tests?|__tests__|specs?)\//i;
33
+ const TEST_FILE_RE = /(\.(test|spec)\.[a-z0-9]+|(^|\/)test_[^/]+\.py|_test\.(py|go)|Tests?\.(java|kt|cs))$/i;
34
+ const MODES = ['off', 'report', 'enforce'];
35
+
36
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
37
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
38
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
39
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
40
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
41
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
42
+ // aside whenever the project's Claude settings register the same script, so the hook
43
+ // runs once, and runs again from here the moment the Claude registration is gone.
44
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
45
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
46
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
47
+ // Copilot copy loads where repository hooks may not. Identical in every hook
48
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
49
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
50
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
51
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
52
+ const claudeDir = ['.', 'claude'].join('');
53
+ try {
54
+ const hooksDir = path.dirname(hookFile);
55
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
56
+ if (typeof projectDir !== 'string' || !projectDir) return false;
57
+ const script = path.basename(hookFile);
58
+ for (const name of ['settings.json', 'settings.local.json']) {
59
+ let settings;
60
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
61
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
62
+ if (!events) continue;
63
+ for (const groups of Object.values(events)) {
64
+ if (!Array.isArray(groups)) continue;
65
+ for (const group of groups) {
66
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
67
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
68
+ }
69
+ }
70
+ }
71
+ } catch { /* fail open: run this copy */ }
72
+ return false;
73
+ }
74
+
75
+
76
+ function planningDirName() {
77
+ const raw = process.env.PAN_PLANNING_DIR || '';
78
+ if (raw.trim()) {
79
+ const rel = raw.trim().replace(/\\/g, '/');
80
+ const bad = rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || rel.split('/').includes('..');
81
+ if (!bad) return rel;
82
+ }
83
+ const track = (process.env.PAN_TRACK || '').trim();
84
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) return `.planning/tracks/${track}`;
85
+ return '.planning';
86
+ }
87
+
88
+ function readJson(p) {
89
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
90
+ }
91
+
92
+ /** A value that is a placeholder, not a credential. */
93
+ function isPlaceholder(v) {
94
+ return /^(?:\$\{|\$[A-Z_]|process\.env|os\.environ|<|xxx|your[_-]|changeme|example|dummy|placeholder|redacted|\*+$)/i.test(v);
95
+ }
96
+
97
+ function hasSecret(text) {
98
+ if (typeof text !== 'string' || !text) return false;
99
+ if (TOKEN_RE.test(text)) return true;
100
+ const m = text.match(SECRET_KV_RE);
101
+ return !!(m && !isPlaceholder(m[2]) && /\d/.test(m[2]) && /[A-Za-z]/.test(m[2]));
102
+ }
103
+
104
+ /**
105
+ * The tool call as `{tool, writes: [{path, text}], command}`, whatever the host.
106
+ * Claude/Codex/Gemini send `tool_name` + `tool_input`; Copilot sends `toolName` +
107
+ * `toolArgs` (a JSON string).
108
+ */
109
+ function normalise(payload) {
110
+ const p = payload && typeof payload === 'object' ? payload : {};
111
+ const tool = String(p.tool_name || p.toolName || p.tool || '');
112
+ let input = p.tool_input || p.toolArgs || p.input || {};
113
+ if (typeof input === 'string') { try { input = JSON.parse(input); } catch { input = { command: input }; } }
114
+ if (!input || typeof input !== 'object') input = {};
115
+ const writes = [];
116
+ const file = input.file_path || input.filePath || input.path || input.notebook_path || input.absolute_path;
117
+ const texts = [input.content, input.new_string, input.newString, input.new_source, input.file_text, input.text]
118
+ .concat(Array.isArray(input.edits) ? input.edits.map((e) => e && (e.new_string || e.newString)) : [])
119
+ .filter((t) => typeof t === 'string');
120
+ if (typeof file === 'string' && file && (texts.length || /write|edit|create|replace|notebook/i.test(tool))) {
121
+ writes.push({ path: file, text: texts.join('\n') });
122
+ }
123
+ const command = typeof input.command === 'string' ? input.command : (Array.isArray(input.command) ? input.command.join(' ') : '');
124
+ // Codex edits files through apply_patch: the patch names its files and carries the text.
125
+ if (/\*\*\* (?:Add|Update) File: /.test(command)) {
126
+ let cur = null;
127
+ for (const line of command.split(/\r?\n/)) {
128
+ const m = line.match(/^\*\*\* (?:Add|Update) File: (.+)$/);
129
+ if (m) { cur = { path: m[1].trim(), text: '' }; writes.push(cur); continue; }
130
+ if (cur && line.startsWith('+')) cur.text += line.slice(1) + '\n';
131
+ }
132
+ }
133
+ return { tool, writes, command };
134
+ }
135
+
136
+ /** Project-relative POSIX path, or null when the path is outside the project. */
137
+ function rel(projectDir, p) {
138
+ const abs = path.resolve(projectDir, p);
139
+ const r = path.relative(projectDir, abs).replace(/\\/g, '/');
140
+ return r.startsWith('..') || path.isAbsolute(r) ? null : r;
141
+ }
142
+
143
+ function isTestFile(r) {
144
+ return TEST_PATH_RE.test(r) || TEST_FILE_RE.test(r);
145
+ }
146
+
147
+ /** files_modified of the unfinished plans of the phase state.md names, or null when none is in flight. */
148
+ function declaredFiles(projectDir, planningRel) {
149
+ let state;
150
+ try { state = fs.readFileSync(path.join(projectDir, planningRel, 'state.md'), 'utf8'); } catch { return null; }
151
+ const m = state.match(/\*\*Current Phase:\*\*[ \t]*([0-9]+(?:\.[0-9]+)?)/i) || state.match(/^Phase:[ \t]*([0-9]+(?:\.[0-9]+)?)/m);
152
+ if (!m) return null;
153
+ const want = String(parseFloat(m[1]));
154
+ const phasesDir = path.join(projectDir, planningRel, 'phases');
155
+ let dir;
156
+ try { dir = fs.readdirSync(phasesDir).find((d) => { const n = d.match(/^(\d+(?:\.\d+)?)-/); return n && String(parseFloat(n[1])) === want; }); } catch { return null; }
157
+ if (!dir) return null;
158
+ const full = path.join(phasesDir, dir);
159
+ const names = fs.readdirSync(full);
160
+ const done = new Set(names.filter((n) => n.endsWith('-summary.md')).map((n) => n.replace(/-summary\.md$/, '')));
161
+ const out = new Set();
162
+ let plans = 0;
163
+ for (const n of names.filter((x) => x.endsWith('-plan.md'))) {
164
+ if (done.has(n.replace(/-plan\.md$/, ''))) continue;
165
+ plans++;
166
+ const text = fs.readFileSync(path.join(full, n), 'utf8').replace(/\r\n/g, '\n');
167
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
168
+ if (!fm) continue;
169
+ const inline = fm[1].match(/^files_modified:[ \t]*\[([^\]]*)\]/m);
170
+ if (inline) inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
171
+ const block = fm[1].match(/^files_modified:[ \t]*\n((?:[ \t]+-.*\n?)+)/m);
172
+ if (block) block[1].split('\n').map((l) => l.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
173
+ }
174
+ return plans ? out : null;
175
+ }
176
+
177
+ /** The rule matches for one normalised call. */
178
+ function evaluate(call, projectDir, planningRel) {
179
+ const hits = [];
180
+ const declared = call.writes.length ? declaredFiles(projectDir, planningRel) : null;
181
+ const ownState = new Set([`${planningRel}/state.md`, `${planningRel}/findings.jsonl`, `${planningRel}/metrics/tokens.jsonl`]);
182
+ for (const w of call.writes) {
183
+ const r = rel(projectDir, w.path);
184
+ if (hasSecret(w.text)) hits.push({ rule: 'secret_write', path: r || w.path });
185
+ if (!r) continue;
186
+ if (ownState.has(r)) hits.push({ rule: 'state_write', path: r });
187
+ if (declared && isTestFile(r) && !declared.has(r)) hits.push({ rule: 'undeclared_test_edit', path: r });
188
+ }
189
+ return hits;
190
+ }
191
+
192
+ function decide(hits, mode) {
193
+ if (mode !== 'enforce') return null;
194
+ return hits.find((h) => h.rule === 'secret_write') || null;
195
+ }
196
+
197
+ /** The host whose deny format this call needs: Copilot by payload shape, else the --host flag. */
198
+ function hostOf(payload, flagHost) {
199
+ const p = payload && typeof payload === 'object' ? payload : {};
200
+ if (typeof p.toolName === 'string' && p.tool_name === undefined) return 'copilot';
201
+ return flagHost;
202
+ }
203
+
204
+ function main() {
205
+ const flagHost = (process.argv[process.argv.indexOf('--host') + 1] || 'claude').toLowerCase();
206
+ let raw = '';
207
+ try { raw = fs.readFileSync(0, 'utf8'); } catch { return; }
208
+ let payload;
209
+ try { payload = JSON.parse(raw); } catch { return; } // fail open
210
+ const projectDir = (payload && (payload.cwd || payload.workspace || payload.project_dir)) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
211
+ if (deferToClaudeRegistration(projectDir)) return; // the Claude registration covers this call under Copilot
212
+ // The payload names the host better than the registration does: Copilot sends its own
213
+ // camelCase payload (toolName, toolArgs) even to a hook registered in .claude/settings.json,
214
+ // and denies only on the JSON decision, so a Copilot payload gets Copilot's deny whatever
215
+ // --host says. The flag decides for the snake_case hosts (Claude Code, Codex, Gemini).
216
+ const host = hostOf(payload, flagHost);
217
+ const planningRel = planningDirName();
218
+ const planning = path.join(projectDir, planningRel);
219
+ if (!fs.existsSync(planning)) return; // not a PAN project
220
+ const cfg = readJson(path.join(planning, 'config.json')) || {};
221
+ const mode = MODES.includes(cfg.hooks && cfg.hooks.guard) ? cfg.hooks.guard : 'report';
222
+ if (mode === 'off') return;
223
+ let hits = [];
224
+ try { hits = evaluate(normalise(payload), projectDir, planningRel); } catch { return; } // fail open
225
+ if (!hits.length) return;
226
+ const deny = decide(hits, mode);
227
+ try {
228
+ const ts = new Date().toISOString();
229
+ const lines = hits.map((h) => JSON.stringify({ ts, host, mode, rule: h.rule, path: h.path, tool: normalise(payload).tool, action: deny && h === deny ? 'denied' : 'reported' }));
230
+ fs.appendFileSync(path.join(planning, 'guard.jsonl'), lines.join('\n') + '\n');
231
+ } catch { /* the log is best effort; a deny below still stands */ }
232
+ if (!deny) return;
233
+ const reason = `PAN guard: this write puts a credential into ${deny.path}. Keep secrets out of the repository (an environment variable or a local, ignored file), then retry. (hooks.guard: enforce)`;
234
+ if (host === 'copilot') {
235
+ process.stdout.write(JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason }) + '\n');
236
+ return;
237
+ }
238
+ process.stderr.write(reason + '\n');
239
+ process.exitCode = 2;
240
+ }
241
+
242
+ if (require.main === module) main();
243
+
244
+ module.exports = { normalise, evaluate, decide, hasSecret, declaredFiles, isTestFile, hostOf, deferToClaudeRegistration };