pan-wizard 3.34.0 → 3.36.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 (110) hide show
  1. package/README.md +16 -10
  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-designer.md +1 -1
  6. package/agents/pan-executor.md +43 -1
  7. package/agents/pan-experiment-runner.md +1 -1
  8. package/agents/pan-hardener.md +3 -1
  9. package/agents/pan-meta-reviewer.md +1 -1
  10. package/agents/pan-phase-researcher.md +16 -2
  11. package/agents/pan-plan-checker.md +37 -9
  12. package/agents/pan-planner.md +14 -2
  13. package/agents/pan-previewer.md +2 -0
  14. package/agents/pan-project-researcher.md +9 -1
  15. package/agents/pan-research-synthesizer.md +1 -1
  16. package/agents/pan-reviewer.md +8 -2
  17. package/agents/pan-verifier.md +3 -3
  18. package/bin/install-lib.cjs +119 -16
  19. package/bin/install.js +34 -62
  20. package/commands/pan/cost.md +21 -5
  21. package/commands/pan/dashboard.md +0 -3
  22. package/commands/pan/design-phase.md +2 -2
  23. package/commands/pan/hud.md +1 -5
  24. package/commands/pan/knowledge.md +0 -1
  25. package/commands/pan/mcp-bridge.md +1 -3
  26. package/commands/pan/optimize.md +2 -0
  27. package/commands/pan/phase-tests.md +1 -1
  28. package/commands/pan/preview.md +2 -3
  29. package/commands/pan/report.md +1 -5
  30. package/commands/pan/research-phase.md +4 -4
  31. package/commands/pan/review-deep.md +0 -2
  32. package/commands/pan/what-if.md +1 -2
  33. package/hooks/dist/pan-context-monitor.js +12 -3
  34. package/hooks/dist/pan-guard.js +244 -0
  35. package/hooks/dist/pan-statusline.js +5 -2
  36. package/package.json +1 -1
  37. package/pan-wizard-core/bin/lib/commands.cjs +24 -2
  38. package/pan-wizard-core/bin/lib/config.cjs +13 -1
  39. package/pan-wizard-core/bin/lib/core.cjs +9 -4
  40. package/pan-wizard-core/bin/lib/cost.cjs +146 -0
  41. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  42. package/pan-wizard-core/bin/lib/findings.cjs +239 -23
  43. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  44. package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
  45. package/pan-wizard-core/bin/lib/init.cjs +6 -0
  46. package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
  47. package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
  48. package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
  49. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  50. package/pan-wizard-core/bin/lib/phase.cjs +61 -9
  51. package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
  52. package/pan-wizard-core/bin/lib/preview.cjs +35 -31
  53. package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
  54. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  55. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  56. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  57. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  58. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  59. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  60. package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
  61. package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
  62. package/pan-wizard-core/bin/lib/verify.cjs +27 -27
  63. package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
  64. package/pan-wizard-core/bin/pan-tools.cjs +106 -9
  65. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  66. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  67. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  68. package/pan-wizard-core/opencode/pan-wizard.js +36 -8
  69. package/pan-wizard-core/references/design-methodology.md +1 -1
  70. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  71. package/pan-wizard-core/references/model-profiles.md +1 -1
  72. package/pan-wizard-core/references/planning-config.md +3 -3
  73. package/pan-wizard-core/references/verdict-contract.md +6 -1
  74. package/pan-wizard-core/templates/config.json +6 -6
  75. package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
  76. package/pan-wizard-core/templates/design.md +5 -4
  77. package/pan-wizard-core/templates/phase-prompt.md +9 -0
  78. package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
  79. package/pan-wizard-core/templates/preview-report.md +1 -1
  80. package/pan-wizard-core/templates/research-project/architecture.md +6 -0
  81. package/pan-wizard-core/templates/research-project/features.md +6 -0
  82. package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
  83. package/pan-wizard-core/templates/research-project/stack.md +6 -0
  84. package/pan-wizard-core/templates/research.md +10 -0
  85. package/pan-wizard-core/templates/state.md +4 -4
  86. package/pan-wizard-core/templates/summary-complex.md +5 -1
  87. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  88. package/pan-wizard-core/templates/summary-standard.md +5 -1
  89. package/pan-wizard-core/templates/summary.md +8 -1
  90. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  91. package/pan-wizard-core/workflows/discuss-phase.md +2 -1
  92. package/pan-wizard-core/workflows/exec-phase.md +92 -45
  93. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  94. package/pan-wizard-core/workflows/help.md +103 -44
  95. package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
  96. package/pan-wizard-core/workflows/new-project.md +8 -16
  97. package/pan-wizard-core/workflows/plan-phase.md +40 -8
  98. package/pan-wizard-core/workflows/progress.md +1 -1
  99. package/pan-wizard-core/workflows/quick.md +2 -2
  100. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  101. package/pan-wizard-core/workflows/settings.md +17 -0
  102. package/pan-wizard-core/workflows/todo-check.md +3 -2
  103. package/pan-wizard-core/workflows/transition.md +4 -0
  104. package/pan-wizard-core/workflows/verify-phase.md +40 -47
  105. package/scripts/build-help.cjs +106 -0
  106. package/scripts/build-hooks.js +1 -0
  107. package/scripts/generate-skills-docs.py +2 -2
  108. package/scripts/mutation-probe.cjs +7 -80
  109. package/scripts/prompt-lint.cjs +167 -0
  110. package/scripts/test-surface.cjs +169 -6
@@ -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 };
@@ -13,7 +13,7 @@ const os = require('os');
13
13
  *
14
14
  * @param {Object} data - Parsed stdin JSON from Claude Code.
15
15
  * @param {Object} [deps] - Optional dep injection for testing.
16
- * {fs, path, homeDir, tmpDir} — defaults to real modules + OS paths.
16
+ * {fs, path, homeDir, tmpDir, configDir} — defaults to real modules + OS paths (configDir: CLAUDE_CONFIG_DIR, ignored when homeDir is given).
17
17
  * @returns {string} The statusline content.
18
18
  */
19
19
  function buildStatuslineOutput(data, deps) {
@@ -155,7 +155,10 @@ function buildStatuslineOutput(data, deps) {
155
155
 
156
156
  // Current task from todos
157
157
  let task = '';
158
- const todosDir = pathMod.join(homeDir, '.claude', 'todos');
158
+ // Claude Code keeps its todos in CLAUDE_CONFIG_DIR when that is set (a given
159
+ // homeDir is a test seam and ignores it).
160
+ const configDir = d.configDir !== undefined ? d.configDir : (d.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
161
+ const todosDir = configDir ? pathMod.join(configDir, 'todos') : pathMod.join(homeDir, '.claude', 'todos');
159
162
  let todosExists = false;
160
163
  try { todosExists = fsMod.statSync(todosDir).isDirectory(); } catch { /* missing */ }
161
164
  if (session && todosExists) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.34.0",
3
+ "version": "3.36.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"
@@ -556,6 +556,21 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
556
556
  output(fullResult, raw);
557
557
  }
558
558
 
559
+ /**
560
+ * Map a --freshness value to Brave's `freshness` parameter, which accepts only
561
+ * pd/pw/pm/py or a YYYY-MM-DDtoYYYY-MM-DD range. Returns null for anything else.
562
+ * @param {string} value - day|week|month|year, or a value Brave accepts as is
563
+ * @returns {string|null}
564
+ */
565
+ function braveFreshness(value) {
566
+ const v = String(value || '').trim().toLowerCase();
567
+ const named = { day: 'pd', week: 'pw', month: 'pm', year: 'py' };
568
+ if (named[v]) return named[v];
569
+ if (/^p[dwmy]$/.test(v)) return v;
570
+ if (/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/.test(v)) return v;
571
+ return null;
572
+ }
573
+
559
574
  /**
560
575
  * Search the web using Brave Search API and return structured results.
561
576
  *
@@ -588,6 +603,12 @@ async function cmdWebsearch(query, options, raw) {
588
603
  return;
589
604
  }
590
605
 
606
+ const freshness = options.freshness ? braveFreshness(options.freshness) : null;
607
+ if (options.freshness && !freshness) {
608
+ output({ available: false, error: `Invalid --freshness "${options.freshness}": use day, week, month, year, pd, pw, pm, py or YYYY-MM-DDtoYYYY-MM-DD` }, raw, '');
609
+ return;
610
+ }
611
+
591
612
  const params = new URLSearchParams({
592
613
  q: query,
593
614
  count: String(options.limit || 10),
@@ -596,8 +617,8 @@ async function cmdWebsearch(query, options, raw) {
596
617
  text_decorations: 'false'
597
618
  });
598
619
 
599
- if (options.freshness) {
600
- params.set('freshness', options.freshness);
620
+ if (freshness) {
621
+ params.set('freshness', freshness);
601
622
  }
602
623
 
603
624
  try {
@@ -1161,6 +1182,7 @@ module.exports = {
1161
1182
  cmdSummaryExtract,
1162
1183
  summaryOneLiner,
1163
1184
  cmdWebsearch,
1185
+ braveFreshness,
1164
1186
  cmdProgressRender,
1165
1187
  cmdTodoComplete,
1166
1188
  cmdScaffold,
@@ -53,10 +53,21 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
53
53
  verifier: true,
54
54
  nyquist_validation: false,
55
55
  phase_record_compact: false,
56
+ // V1: the phase gate. `enforce` (the default since V1E) refuses; `warn` reports
57
+ // refusals and completes.
58
+ phase_gate: 'enforce',
59
+ // V5: the mutation report on a phase's diff. `report` lists surviving mutants and
60
+ // never refuses; `off` skips it; `gate` is reserved for V5G.
61
+ mutation_gate: 'report',
56
62
  // M2: opt-in per-phase HTML reports at the verify→complete gate.
57
63
  phase_reports: { enabled: false, open: false, theme: 'auto', index: true },
58
64
  },
59
65
  parallelization: true,
66
+ // A7: the pre-tool guard. `report` logs to <planning>/guard.jsonl; `enforce` also
67
+ // denies a write that puts a credential in a file; `off` disables it.
68
+ hooks: { guard: 'report' },
69
+ // Spend per phase, in API-equivalent dollars. It only warns (80% and over); 0 = off.
70
+ cost: { phase_budget_usd: 20 },
60
71
  brave_search: hasBraveSearch,
61
72
  budget: {
62
73
  default_points: 50,
@@ -97,6 +108,7 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
97
108
  ...userDefaults,
98
109
  workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) },
99
110
  budget: { ...hardcoded.budget, ...(userDefaults.budget || {}) },
111
+ cost: { ...hardcoded.cost, ...(userDefaults.cost || {}) },
100
112
  commit: { ...hardcoded.commit, ...(userDefaults.commit || {}) },
101
113
  execution: { ...hardcoded.execution, ...(userDefaults.execution || {}) },
102
114
  routing: { ...hardcoded.routing, ...(userDefaults.routing || {}) },
@@ -114,7 +126,7 @@ const CONFIG_KEYS_BEYOND_DEFAULTS = Object.freeze({
114
126
  top: Object.freeze(['research', 'plan_checker', 'verifier', 'nyquist_validation', 'mode', 'depth', 'planning', 'git',
115
127
  'focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']),
116
128
  sections: Object.freeze({
117
- workflow: Object.freeze(['auto_advance', 'stop_guard']),
129
+ workflow: Object.freeze(['auto_advance', 'stop_guard', 'test_command', 'test_file_command']),
118
130
  planning: Object.freeze(['commit_docs', 'search_gitignored']),
119
131
  git: Object.freeze(['branching_strategy', 'phase_branch_template', 'milestone_branch_template']),
120
132
  }),
@@ -559,23 +559,28 @@ function isGitRepo(cwd) {
559
559
  }
560
560
 
561
561
  /**
562
- * Execute a git command safely with proper argument escaping.
562
+ * Execute a git command safely with proper argument escaping. stdout is trimmed;
563
+ * `keepLeading` trims only whitespace at its end, for output whose first column means
564
+ * something (`git status --porcelain` starts an unstaged edit with a space, which a
565
+ * full trim cuts off the first line).
563
566
  * @param {string} cwd - Working directory for git
564
567
  * @param {string[]} args - Git arguments (e.g., ['add', 'file.md'])
568
+ * @param {{keepLeading?: boolean}} [opts]
565
569
  * @returns {{exitCode: number, stdout: string, stderr: string}}
566
570
  */
567
- function execGit(cwd, args) {
571
+ function execGit(cwd, args, opts = {}) {
572
+ const clean = (s) => (opts.keepLeading ? String(s).replace(/\s+$/, '') : String(s).trim());
568
573
  try {
569
574
  const stdout = execFileSync('git', args, {
570
575
  cwd,
571
576
  stdio: 'pipe',
572
577
  encoding: 'utf-8',
573
578
  });
574
- return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
579
+ return { exitCode: 0, stdout: clean(stdout), stderr: '' };
575
580
  } catch (err) {
576
581
  return {
577
582
  exitCode: err.status ?? 1,
578
- stdout: (err.stdout ?? '').toString().trim(),
583
+ stdout: clean((err.stdout ?? '').toString()),
579
584
  stderr: (err.stderr ?? '').toString().trim(),
580
585
  };
581
586
  }
@@ -664,6 +664,146 @@ function aggregate(cwd, opts) {
664
664
  };
665
665
  }
666
666
 
667
+ // ─── Per-phase budget ───────────────────────────────────────────────────────
668
+ // `cost.phase_budget_usd` (default $20, `0`/`false` = off). It only WARNS: `near` from
669
+ // PHASE_BUDGET_WARN_PCT of the budget, `over` once spend reaches it. No run stops on it.
670
+ // On a subscription the dollars are API-equivalent: what the tokens would cost at the
671
+ // configured rates.
672
+
673
+ const DEFAULT_PHASE_BUDGET_USD = 20;
674
+ const PHASE_BUDGET_WARN_PCT = 80;
675
+
676
+ function phaseBudgetUsd(cwd) {
677
+ let v;
678
+ try { v = loadConfig(cwd)?.cost?.phase_budget_usd; } catch { /* defaults */ }
679
+ if (v === false || v === 0 || v === '0') return 0;
680
+ const n = Number(v);
681
+ return v != null && Number.isFinite(n) && n > 0 ? n : DEFAULT_PHASE_BUDGET_USD;
682
+ }
683
+
684
+ /**
685
+ * A phase's spend against its budget.
686
+ * @returns {{phase, budget_usd, spent_usd, pct, status: 'off'|'ok'|'near'|'over', mode: 'warn', warn_at_pct, unpriced}}
687
+ */
688
+ function phaseBudget(cwd, phase) {
689
+ const budget = phaseBudgetUsd(cwd);
690
+ const rates = effectiveRates(loadConfig(cwd));
691
+ const key = samePhaseKey(phase);
692
+ let spent = 0;
693
+ let unpriced = 0;
694
+ for (const r of readRecords(cwd)) {
695
+ if (samePhaseKey(r.phase) !== key || isSuspectRecord(r) || isEmptyRecord(r)) continue;
696
+ const usd = typeof r.cost_usd === 'number' ? r.cost_usd : computeCost(r, rates);
697
+ if (typeof usd === 'number') spent += usd;
698
+ else unpriced++;
699
+ }
700
+ spent = round4(spent);
701
+ const pct = budget > 0 ? Math.round((spent / budget) * 1000) / 10 : null;
702
+ let status = 'off';
703
+ if (budget > 0) status = pct >= 100 ? 'over' : pct >= PHASE_BUDGET_WARN_PCT ? 'near' : 'ok';
704
+ return { phase: String(phase), budget_usd: budget, spent_usd: spent, pct, status, mode: 'warn', warn_at_pct: PHASE_BUDGET_WARN_PCT, unpriced };
705
+ }
706
+
707
+ function cmdCostBudget(cwd, phase, raw) {
708
+ if (!phase) error('Usage: cost budget --phase <N>');
709
+ const r = phaseBudget(cwd, phase);
710
+ output(r, raw, r.status);
711
+ }
712
+
713
+ // ─── Cost forecast (program P6) ─────────────────────────────────────────────
714
+ // What a plan and a phase will cost, as p10/p50/p90 dollars, from what this project's
715
+ // agents have cost so far. Fewer than FORECAST_MIN_SAMPLES observations is no
716
+ // forecast: the result says `insufficient` rather than extrapolating from two runs.
717
+
718
+ const FORECAST_MIN_SAMPLES = 5;
719
+ const FORECAST_ITERATIONS = 2000;
720
+ const PLAN_AGENT = 'pan-executor';
721
+
722
+ const round4 = (x) => Math.round(x * 10000) / 10000;
723
+
724
+ /** Linear interpolation between the closest ranks (R type 7). */
725
+ function quantile(sorted, q) {
726
+ const h = (sorted.length - 1) * q;
727
+ const lo = Math.floor(h);
728
+ return sorted[lo] + (sorted[Math.min(lo + 1, sorted.length - 1)] - sorted[lo]) * (h - lo);
729
+ }
730
+
731
+ function quantiles(values) {
732
+ const s = [...values].sort((a, b) => a - b);
733
+ return { p10: round4(quantile(s, 0.1)), p50: round4(quantile(s, 0.5)), p90: round4(quantile(s, 0.9)) };
734
+ }
735
+
736
+ /** mulberry32: a seeded PRNG, so a forecast is reproducible. */
737
+ function prng(seed) {
738
+ let a = (Number(seed) >>> 0) || 1;
739
+ return () => {
740
+ a = (a + 0x6d2b79f5) >>> 0;
741
+ let t = a;
742
+ t = Math.imul(t ^ (t >>> 15), t | 1);
743
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
744
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
745
+ };
746
+ }
747
+
748
+ const samePhaseKey = (p) => String(p == null ? '' : p).replace(/^0+(?=\d)/, '');
749
+
750
+ /**
751
+ * Forecast a plan and a phase.
752
+ *
753
+ * - Per plan: the cost of each pan-executor call at one tier (the tier the latest
754
+ * executor call ran at, unless `tier` is given). Tiers differ several-fold in price,
755
+ * so they are never pooled.
756
+ * - Per phase: for each past phase, the cost of its other agents (planner, checkers,
757
+ * verifier, reviewer …); a phase is resampled as one of those plus one executor
758
+ * draw per plan, `FORECAST_ITERATIONS` times, seeded.
759
+ *
760
+ * @param {string} cwd
761
+ * @param {{plans?: number, tier?: string, excludePhase?: string, seed?: number}} [opts]
762
+ */
763
+ function costForecast(cwd, opts = {}) {
764
+ const rates = effectiveRates(loadConfig(cwd));
765
+ const exclude = opts.excludePhase != null ? samePhaseKey(opts.excludePhase) : null;
766
+ const rows = [];
767
+ for (const r of readRecords(cwd)) {
768
+ if (isSuspectRecord(r) || isEmptyRecord(r)) continue;
769
+ if (exclude != null && samePhaseKey(r.phase) === exclude) continue;
770
+ const usd = typeof r.cost_usd === 'number' ? r.cost_usd : computeCost(r, rates);
771
+ if (typeof usd === 'number') rows.push({ ...r, usd });
772
+ }
773
+ const executor = rows.filter((r) => r.agent === PLAN_AGENT);
774
+ const tier = opts.tier || (executor.length ? executor[executor.length - 1].tier || null : null);
775
+ const planCosts = executor.filter((r) => (r.tier || null) === tier).map((r) => r.usd);
776
+ const parsedPlans = Number.parseInt(opts.plans, 10);
777
+ const plans = Number.isFinite(parsedPlans) && parsedPlans >= 0 ? parsedPlans : 1;
778
+ const insufficient = (n) => ({ status: 'insufficient', n, needed: FORECAST_MIN_SAMPLES });
779
+
780
+ const perPlan = planCosts.length >= FORECAST_MIN_SAMPLES ? { status: 'ok', n: planCosts.length, ...quantiles(planCosts) } : insufficient(planCosts.length);
781
+
782
+ // A past phase is one the executor ran in; its overhead is everything else it spent.
783
+ const ranIn = new Set(executor.filter((r) => r.phase != null).map((r) => samePhaseKey(r.phase)));
784
+ const overhead = new Map([...ranIn].map((p) => [p, 0]));
785
+ for (const r of rows) {
786
+ const p = samePhaseKey(r.phase);
787
+ if (r.agent !== PLAN_AGENT && overhead.has(p)) overhead.set(p, overhead.get(p) + r.usd);
788
+ }
789
+ const overheads = [...overhead.values()];
790
+ let phase;
791
+ if (perPlan.status !== 'ok') phase = { ...insufficient(planCosts.length), n_phases: overheads.length, reason: 'too few executor calls at this tier' };
792
+ else if (overheads.length < FORECAST_MIN_SAMPLES) phase = { ...insufficient(overheads.length), n_phases: overheads.length, reason: 'too few past phases' };
793
+ else {
794
+ const rand = prng(opts.seed == null ? 1 : opts.seed);
795
+ const pick = (arr) => arr[Math.floor(rand() * arr.length)];
796
+ const sims = [];
797
+ for (let i = 0; i < FORECAST_ITERATIONS; i++) {
798
+ let usd = pick(overheads);
799
+ for (let k = 0; k < plans; k++) usd += pick(planCosts);
800
+ sims.push(usd);
801
+ }
802
+ phase = { status: 'ok', n_phases: overheads.length, ...quantiles(sims) };
803
+ }
804
+ return { agent: PLAN_AGENT, tier, plans, min_samples: FORECAST_MIN_SAMPLES, per_plan: perPlan, phase };
805
+ }
806
+
667
807
  /**
668
808
  * Render aggregation as a human-readable table.
669
809
  * @param {Object} agg - from aggregate()
@@ -900,6 +1040,12 @@ function cmdModelsCheck(raw) {
900
1040
 
901
1041
  module.exports = {
902
1042
  computeCost,
1043
+ phaseBudget,
1044
+ cmdCostBudget,
1045
+ DEFAULT_PHASE_BUDGET_USD,
1046
+ costForecast,
1047
+ quantiles,
1048
+ FORECAST_MIN_SAMPLES,
903
1049
  appendRecord,
904
1050
  readRecords,
905
1051
  aggregate,
@@ -343,11 +343,13 @@ function scanDocFlags(cwd, opts = {}) {
343
343
  const docDirs = opts.docDirs || ['docs'];
344
344
  const collected = [];
345
345
  for (const d of docDirs) {
346
- const abs = path.join(cwd, d);
346
+ const abs = path.resolve(cwd, d);
347
347
  if (!fs.existsSync(abs)) continue;
348
348
  for (const f of walkMarkdownFiles(abs)) {
349
349
  if (f.readError) continue;
350
- collected.push({ rel: path.join(d, f.relativePath).replace(/\\/g, '/'), content: f.content });
350
+ // Relative to the project root whether --doc-dir was given relative or
351
+ // absolute, so the frozen-doc allowlist sees project segments only.
352
+ collected.push({ rel: path.relative(cwd, path.join(abs, f.relativePath)).replace(/\\/g, '/'), content: f.content });
351
353
  }
352
354
  }
353
355
  for (const rel of (opts.files || [])) {
@@ -379,7 +381,7 @@ function scanDocFlags(cwd, opts = {}) {
379
381
 
380
382
  function cmdDocLintFlags(cwd, opts = {}, raw) {
381
383
  for (const d of opts.docDirs || []) {
382
- const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
384
+ const abs = path.resolve(cwd, d);
383
385
  if (fs.existsSync(abs)) requireDirectory(abs);
384
386
  }
385
387
  const r = scanDocFlags(cwd, opts);