pan-wizard 3.30.0 → 3.31.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 (42) hide show
  1. package/README.md +2 -2
  2. package/bin/install-lib.cjs +129 -33
  3. package/bin/install.js +16 -9
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/verify-phase.md +8 -9
  6. package/hooks/dist/pan-check-update.js +53 -2
  7. package/hooks/dist/pan-context-monitor.js +41 -0
  8. package/hooks/dist/pan-cost-logger.js +70 -1
  9. package/hooks/dist/pan-state-reinject.js +158 -0
  10. package/hooks/dist/pan-stop-guard.js +48 -2
  11. package/hooks/dist/pan-trace-logger.js +41 -0
  12. package/package.json +1 -1
  13. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  14. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  15. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  16. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  17. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  18. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  19. package/pan-wizard-core/bin/lib/cost.cjs +132 -43
  20. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  21. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  22. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  23. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  24. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  25. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  26. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  27. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  28. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  29. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  30. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  31. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  32. package/pan-wizard-core/workflows/help.md +2 -2
  33. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  34. package/pan-wizard-core/workflows/progress.md +1 -1
  35. package/pan-wizard-core/workflows/todo-add.md +1 -1
  36. package/pan-wizard-core/workflows/todo-check.md +7 -6
  37. package/scripts/build-agent-plugin.js +4 -0
  38. package/scripts/build-hooks.js +2 -1
  39. package/scripts/build-plugin.js +10 -0
  40. package/scripts/npm-dist-tag.js +47 -0
  41. package/scripts/plugin-path.js +6 -1
  42. package/scripts/test-surface.cjs +1 -0
@@ -25,6 +25,45 @@ const fs = require('fs');
25
25
  const os = require('os');
26
26
  const path = require('path');
27
27
 
28
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
29
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
30
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
31
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
32
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
33
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
34
+ // aside whenever the project's Claude settings register the same script, so the hook
35
+ // runs once, and runs again from here the moment the Claude registration is gone.
36
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
37
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
38
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
39
+ // Copilot copy loads where repository hooks may not. Identical in every hook
40
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
41
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
42
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
43
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
44
+ const claudeDir = ['.', 'claude'].join('');
45
+ try {
46
+ const hooksDir = path.dirname(hookFile);
47
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
48
+ if (typeof projectDir !== 'string' || !projectDir) return false;
49
+ const script = path.basename(hookFile);
50
+ for (const name of ['settings.json', 'settings.local.json']) {
51
+ let settings;
52
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
53
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
54
+ if (!events) continue;
55
+ for (const groups of Object.values(events)) {
56
+ if (!Array.isArray(groups)) continue;
57
+ for (const group of groups) {
58
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
59
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
60
+ }
61
+ }
62
+ }
63
+ } catch { /* fail open: run this copy */ }
64
+ return false;
65
+ }
66
+
28
67
  // Per-user bridge directory inside tmpdir, created 0700 so another user on a
29
68
  // shared host can't pre-plant a symlink at a predictable session path or read
30
69
  // the bridge files. Both hooks derive the same dir from the same uid, so the
@@ -135,6 +174,7 @@ function main() {
135
174
  process.stdin.on('end', () => {
136
175
  try {
137
176
  const data = JSON.parse(input);
177
+ if (deferToClaudeRegistration(data.cwd || process.cwd())) process.exit(0);
138
178
  const sessionId = data.session_id;
139
179
 
140
180
  if (!sessionId) {
@@ -199,6 +239,7 @@ if (require.main === module) {
199
239
  }
200
240
 
201
241
  module.exports = {
242
+ deferToClaudeRegistration,
202
243
  buildContextWarning,
203
244
  bridgeDir,
204
245
  WARNING_THRESHOLD,
@@ -17,6 +17,45 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+
21
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
22
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
23
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
24
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
25
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
26
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
27
+ // aside whenever the project's Claude settings register the same script, so the hook
28
+ // runs once, and runs again from here the moment the Claude registration is gone.
29
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
30
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
31
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
32
+ // Copilot copy loads where repository hooks may not. Identical in every hook
33
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
34
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
35
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
36
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
37
+ const claudeDir = ['.', 'claude'].join('');
38
+ try {
39
+ const hooksDir = path.dirname(hookFile);
40
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
41
+ if (typeof projectDir !== 'string' || !projectDir) return false;
42
+ const script = path.basename(hookFile);
43
+ for (const name of ['settings.json', 'settings.local.json']) {
44
+ let settings;
45
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
46
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
47
+ if (!events) continue;
48
+ for (const groups of Object.values(events)) {
49
+ if (!Array.isArray(groups)) continue;
50
+ for (const group of groups) {
51
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
52
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
53
+ }
54
+ }
55
+ }
56
+ } catch { /* fail open: run this copy */ }
57
+ return false;
58
+ }
20
59
  const crypto = require('crypto');
21
60
 
22
61
  // Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
@@ -564,6 +603,8 @@ function buildCostRecord(data, cwd) {
564
603
  let outputTokens = 0;
565
604
  let cacheRead = 0;
566
605
  let cacheWrite = 0;
606
+ // { h1, m5 } when the source carried the cache-write lifetime split (M13).
607
+ let ttlSplit = null;
567
608
  let durationMs = null;
568
609
  // token_source records WHICH path produced the counts: `agent-transcript` (the
569
610
  // subagent's own conversation file — see resolveAgentTranscript), `transcript`
@@ -621,6 +662,10 @@ function buildCostRecord(data, cwd) {
621
662
  cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
622
663
  clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
623
664
  || rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
665
+ // A clamped write total no longer matches its split, so the split is dropped with it.
666
+ if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
667
+ ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
668
+ }
624
669
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
625
670
  if (!model) model = fromTranscript.model;
626
671
  if (fromTranscript.lineCount > since) {
@@ -676,6 +721,11 @@ function buildCostRecord(data, cwd) {
676
721
  cacheRead = clampPlausible(rawCr);
677
722
  cacheWrite = clampPlausible(rawCw);
678
723
  clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
724
+ const split = {};
725
+ addCacheTtlSplit(split, data.usage);
726
+ if (typeof split.cache_write_1h_tokens === 'number' && cacheWrite === rawCw) {
727
+ ttlSplit = { h1: clampPlausible(split.cache_write_1h_tokens), m5: clampPlausible(split.cache_write_5m_tokens) };
728
+ }
679
729
  }
680
730
 
681
731
  // Backfill command/phase from the active trace session when the payload omits
@@ -705,6 +755,8 @@ function buildCostRecord(data, cwd) {
705
755
  output_tokens: outputTokens,
706
756
  cache_read_tokens: cacheRead,
707
757
  cache_write_tokens: cacheWrite,
758
+ // The lifetime split of cache_write_tokens, present only when measured (M13).
759
+ ...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
708
760
  cost_usd: null,
709
761
  duration_ms: durationMs,
710
762
  phase,
@@ -833,11 +885,27 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
833
885
  totals.output_tokens += extractNumber(usage, 'output_tokens');
834
886
  totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
835
887
  totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
888
+ addCacheTtlSplit(totals, usage);
836
889
  }
837
890
  totals.lineCount = seen;
838
891
  return totals;
839
892
  }
840
893
 
894
+ /**
895
+ * Add a usage record's cache-write lifetime split to `totals` (M13). Claude Code
896
+ * transcripts carry `usage.cache_creation: { ephemeral_5m_input_tokens,
897
+ * ephemeral_1h_input_tokens }` beside the `cache_creation_input_tokens` total;
898
+ * one-hour writes bill at 2x base input against 1.25x for five-minute ones, so
899
+ * the ledger keeps the split. The totals gain the two fields only when some
900
+ * record carried the block — rows from hosts without it stay unchanged.
901
+ */
902
+ function addCacheTtlSplit(totals, usage) {
903
+ const cc = usage && usage.cache_creation;
904
+ if (!cc || typeof cc !== 'object') return;
905
+ totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + extractNumber(cc, 'ephemeral_1h_input_tokens');
906
+ totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
907
+ }
908
+
841
909
  /**
842
910
  * Append record to .planning/metrics/tokens.jsonl. Silently succeeds
843
911
  * even if the file or directory can't be written — hook must not block.
@@ -922,6 +990,7 @@ if (require.main === module) {
922
990
  // fall back to process.cwd() which is the project root when Claude Code
923
991
  // invokes the hook.
924
992
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
993
+ if (deferToClaudeRegistration(cwd)) return;
925
994
  // M62: a global-install hook fires in every repo; don't pollute non-PAN
926
995
  // projects with .planning/ metrics artifacts. The tree must already exist —
927
996
  // see hasPlanningTree (field sweep 2026-09-17).
@@ -934,4 +1003,4 @@ if (require.main === module) {
934
1003
  });
935
1004
  }
936
1005
 
937
- module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
1006
+ module.exports = { deferToClaudeRegistration, buildCostRecord, appendRecord, readUsageFromTranscript, addCacheTtlSplit, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // State Re-injection - SessionStart hook, `compact` matcher (market-ideas queue M10)
3
+ //
4
+ // Compaction replaces the conversation with a summary, and a summary can drop the
5
+ // one line that mattered: which phase is in flight, which plan, where it stopped.
6
+ // The PanLoop boundary-drop class (finding 0) is that failure — an agent that no
7
+ // longer knows it is mid-chain ends the turn or re-plans finished work. This hook
8
+ // runs when the session restarts after a compaction and hands the model PAN's
9
+ // current position, read from the planning tree on disk, as `additionalContext`.
10
+ //
11
+ // Why SessionStart and not PostCompact: Claude Code's PostCompact has no decision
12
+ // control and discards its output ("Used for side effects like logging"), while
13
+ // SessionStart fires again after a compaction with `source: "compact"` and honours
14
+ // `hookSpecificOutput.additionalContext` (code.claude.com/docs/en/hooks, read
15
+ // 2026-09-26). Codex is the same: its PostCompact output schema has no
16
+ // additionalContext, and SessionStart hooks matching `source: "compact"` deliver it
17
+ // to the continuation (codex hooks docs, read 2026-09-26). Registered with the
18
+ // `compact` matcher on both; the source is re-checked here so a registration
19
+ // without the matcher stays inert on every other start.
20
+ //
21
+ // Inert unless the project is a PAN project with work in flight: `.planning/`
22
+ // holds a state.md that names a current phase AND a roadmap.md with an unticked
23
+ // phase. Everywhere else — no planning tree, a focus-only project, a finished
24
+ // milestone — it prints nothing. It never writes a file (field finding: hooks
25
+ // that scaffolded `.planning/` in projects that never ran PAN).
26
+ //
27
+ // Fail-open everywhere: bad stdin, unreadable files — exit 0 silently. A missing
28
+ // reminder costs a re-read; a crashed hook is a failing hook at every start.
29
+ //
30
+ // The block is capped well under the host's 10,000-character limit for
31
+ // additionalContext; each field is truncated on its own.
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ /**
37
+ * Which planning tree this hook acts on.
38
+ *
39
+ * Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
40
+ * (they are standalone and run inside the host runtime). All PAN hooks carry an
41
+ * identical copy — if the CLI is pointed at a track while a hook still writes to
42
+ * `.planning/`, that tree's telemetry lands in the wrong place.
43
+ *
44
+ * Env only — a hook gets no argv. A value that escapes the project root is
45
+ * ignored rather than honoured: a bad value degrades to the default, never
46
+ * writes outside the project.
47
+ */
48
+ function planningDirName() {
49
+ const raw = process.env.PAN_PLANNING_DIR || '';
50
+ if (raw.trim()) {
51
+ const rel = raw.trim().replace(/\\/g, '/');
52
+ const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
53
+ || rel.split('/').includes('..');
54
+ if (!bad) return rel;
55
+ }
56
+ const track = (process.env.PAN_TRACK || '').trim();
57
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
58
+ return `.planning/tracks/${track}`;
59
+ }
60
+ return '.planning';
61
+ }
62
+
63
+ /** Absolute path inside the active planning tree. */
64
+ function planningPath(cwd, ...segments) {
65
+ return path.join(cwd, ...planningDirName().split('/'), ...segments);
66
+ }
67
+
68
+ // Same unticked-phase shape the stop guard and the PanLoop harness use: the
69
+ // checklist line templates/roadmap.md and pan-roadmapper.md emit.
70
+ const UNTICKED_PHASE_RE = /^- \[ \] \*\*Phase (\d+(?:\.\d+)?):/m;
71
+
72
+ const FIELD_MAX = 240;
73
+ const BLOCK_MAX = 2000;
74
+
75
+ /** A `**Name:** value` field from state.md — the shape state.cjs reads and writes. */
76
+ function field(content, name) {
77
+ const m = content.match(new RegExp(`\\*\\*${name}:\\*\\*[ \\t]*(.+)`, 'i'));
78
+ if (!m) return null;
79
+ const v = m[1].trim();
80
+ // Template placeholders ("[X]", "[Phase name]") are not a position.
81
+ if (!v || /^\[.*\]$/.test(v) || /^none$/i.test(v)) return null;
82
+ return v.length > FIELD_MAX ? v.slice(0, FIELD_MAX - 1) + '…' : v;
83
+ }
84
+
85
+ /**
86
+ * Pure: the additionalContext block for this planning tree, or null when there is
87
+ * nothing in flight to restore.
88
+ * @param {object} args
89
+ * @param {string|null} args.stateContent .planning/state.md, or null
90
+ * @param {string|null} args.roadmapContent .planning/roadmap.md, or null
91
+ * @param {string} [args.planningRel] the planning directory, for the pointer line
92
+ * @returns {string|null}
93
+ */
94
+ function buildReinjectContext({ stateContent, roadmapContent, planningRel = '.planning' }) {
95
+ if (typeof stateContent !== 'string' || typeof roadmapContent !== 'string') return null;
96
+ const phase = field(stateContent, 'Current Phase');
97
+ if (!phase) return null;
98
+ const unticked = roadmapContent.match(UNTICKED_PHASE_RE);
99
+ if (!unticked) return null; // every roadmap phase is built — nothing in flight
100
+
101
+ const name = field(stateContent, 'Current Phase Name');
102
+ const plan = field(stateContent, 'Current Plan');
103
+ const plans = field(stateContent, 'Total Plans in Phase');
104
+ const status = field(stateContent, 'Status');
105
+ const stoppedAt = field(stateContent, 'Stopped At');
106
+ const lastDesc = field(stateContent, 'Last Activity Description');
107
+ const resume = field(stateContent, 'Resume File');
108
+
109
+ const lines = ['PAN project state, re-read from disk after context compaction:'];
110
+ let position = `- Current phase: ${phase}${name ? ` (${name})` : ''}`;
111
+ if (plan) position += `, plan ${plan}${plans ? ` of ${plans}` : ''}`;
112
+ lines.push(position);
113
+ if (status) lines.push(`- Status: ${status}`);
114
+ if (stoppedAt) lines.push(`- Stopped at: ${stoppedAt}`);
115
+ else if (lastDesc) lines.push(`- Last activity: ${lastDesc}`);
116
+ if (resume) lines.push(`- Resume file: ${resume}`);
117
+ lines.push(`- First unbuilt roadmap phase: Phase ${unticked[1]}`);
118
+ lines.push(`Re-read ${planningRel}/state.md and the current phase's plan before the next step: continue the work in flight rather than re-planning finished work.`);
119
+ const block = lines.join('\n');
120
+ return block.length > BLOCK_MAX ? block.slice(0, BLOCK_MAX - 1) + '…' : block;
121
+ }
122
+
123
+ function readIfExists(p) {
124
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
125
+ }
126
+
127
+ function main() {
128
+ let input = '';
129
+ process.stdin.setEncoding('utf8');
130
+ process.stdin.on('data', chunk => { input += chunk; });
131
+ process.stdin.on('end', () => {
132
+ try {
133
+ let payload = {};
134
+ try { payload = JSON.parse(input); } catch { /* fail open on bad stdin */ }
135
+ if (!payload || typeof payload !== 'object') payload = {};
136
+ if (payload.source !== 'compact') { process.exit(0); return; }
137
+
138
+ const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
139
+ const context = buildReinjectContext({
140
+ stateContent: readIfExists(planningPath(projectDir, 'state.md')),
141
+ roadmapContent: readIfExists(planningPath(projectDir, 'roadmap.md')),
142
+ planningRel: planningDirName(),
143
+ });
144
+ if (context) {
145
+ process.stdout.write(JSON.stringify({
146
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context },
147
+ }));
148
+ }
149
+ } catch { /* fail open — never break a session start */ }
150
+ process.exit(0);
151
+ });
152
+ }
153
+
154
+ if (require.main === module) {
155
+ main();
156
+ }
157
+
158
+ module.exports = { buildReinjectContext, UNTICKED_PHASE_RE };
@@ -52,6 +52,12 @@
52
52
  // directory: a second stop aimed at the same phase is allowed. No safe marker
53
53
  // directory, or no session id, means no block (fail open, as everywhere else).
54
54
  //
55
+ // Codex and Copilot CLI (M14, 2026-09-26): registered on Codex's `Stop` and
56
+ // Copilot's `agentStop`. Both payloads carry `cwd` and `stop_hook_active` (Copilot
57
+ // keeps that one field snake_case inside its camelCase payload) and both honour the
58
+ // same {decision: 'block', reason} output, so the logic above applies unchanged.
59
+ // Copilot also ends the turn itself after eight consecutive blocks.
60
+ //
55
61
  // Escape hatch: set workflow.stop_guard to false in .planning/config.json to
56
62
  // disable the guard entirely without turning off auto_advance.
57
63
  //
@@ -66,6 +72,45 @@
66
72
  const fs = require('fs');
67
73
  const os = require('os');
68
74
  const path = require('path');
75
+
76
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
77
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
78
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
79
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
80
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
81
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
82
+ // aside whenever the project's Claude settings register the same script, so the hook
83
+ // runs once, and runs again from here the moment the Claude registration is gone.
84
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
85
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
86
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
87
+ // Copilot copy loads where repository hooks may not. Identical in every hook
88
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
89
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
90
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
91
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
92
+ const claudeDir = ['.', 'claude'].join('');
93
+ try {
94
+ const hooksDir = path.dirname(hookFile);
95
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
96
+ if (typeof projectDir !== 'string' || !projectDir) return false;
97
+ const script = path.basename(hookFile);
98
+ for (const name of ['settings.json', 'settings.local.json']) {
99
+ let settings;
100
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
101
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
102
+ if (!events) continue;
103
+ for (const groups of Object.values(events)) {
104
+ if (!Array.isArray(groups)) continue;
105
+ for (const group of groups) {
106
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
107
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
108
+ }
109
+ }
110
+ }
111
+ } catch { /* fail open: run this copy */ }
112
+ return false;
113
+ }
69
114
  const crypto = require('crypto');
70
115
  /**
71
116
  * Which planning tree this hook acts on.
@@ -238,6 +283,7 @@ function main() {
238
283
  if (!payload || typeof payload !== 'object') payload = {};
239
284
 
240
285
  const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
286
+ if (deferToClaudeRegistration(projectDir)) process.exit(0);
241
287
  const planningDir = planningPath(projectDir);
242
288
 
243
289
  let config = null;
@@ -253,7 +299,7 @@ function main() {
253
299
  // Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
254
300
  // one-shot promise on its own (see the header), so a marker does.
255
301
  if (decision && payload.hook_event_name === 'AfterAgent') {
256
- const name = onceMarkerName(payload.session_id, projectDir, decision.reason);
302
+ const name = onceMarkerName(payload.session_id || payload.sessionId, projectDir, decision.reason);
257
303
  if (!claimOnceMarker(hookStateDir(), name)) decision = null;
258
304
  }
259
305
 
@@ -267,4 +313,4 @@ if (require.main === module) {
267
313
  main();
268
314
  }
269
315
 
270
- module.exports = { buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
316
+ module.exports = { deferToClaudeRegistration, buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
@@ -17,6 +17,45 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+
21
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
22
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
23
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
24
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
25
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
26
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
27
+ // aside whenever the project's Claude settings register the same script, so the hook
28
+ // runs once, and runs again from here the moment the Claude registration is gone.
29
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
30
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
31
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
32
+ // Copilot copy loads where repository hooks may not. Identical in every hook
33
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
34
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
35
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
36
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
37
+ const claudeDir = ['.', 'claude'].join('');
38
+ try {
39
+ const hooksDir = path.dirname(hookFile);
40
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
41
+ if (typeof projectDir !== 'string' || !projectDir) return false;
42
+ const script = path.basename(hookFile);
43
+ for (const name of ['settings.json', 'settings.local.json']) {
44
+ let settings;
45
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
46
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
47
+ if (!events) continue;
48
+ for (const groups of Object.values(events)) {
49
+ if (!Array.isArray(groups)) continue;
50
+ for (const group of groups) {
51
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
52
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
53
+ }
54
+ }
55
+ }
56
+ } catch { /* fail open: run this copy */ }
57
+ return false;
58
+ }
20
59
  const crypto = require('crypto');
21
60
  /**
22
61
  * Which planning tree this hook acts on.
@@ -932,6 +971,7 @@ if (require.main === module) {
932
971
  try {
933
972
  const data = JSON.parse(input);
934
973
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
974
+ if (deferToClaudeRegistration(cwd)) return;
935
975
  // M62: a global-install hook fires in every repo; skip non-PAN projects so
936
976
  // we don't create .planning/ optimization + trace artifacts in them. The tree
937
977
  // must already exist — see hasPlanningTree (field sweep 2026-09-17).
@@ -948,6 +988,7 @@ if (require.main === module) {
948
988
  }
949
989
 
950
990
  module.exports = {
991
+ deferToClaudeRegistration,
951
992
  buildTraceEvents,
952
993
  appendTraceEvents,
953
994
  resolveAgentTranscript,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.30.0",
3
+ "version": "3.31.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"
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
6
+ const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveModelDetailed, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
7
7
  const { extractFrontmatter } = require('./frontmatter.cjs');
8
8
  const { PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
9
9
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
@@ -220,7 +220,7 @@ function cmdHistoryDigest(cwd, raw) {
220
220
  * @param {string} [metadataJson] - Optional JSON string with task metadata for complexity routing
221
221
  * @returns {void}
222
222
  */
223
- function cmdResolveModel(cwd, agentType, raw, metadataJson) {
223
+ function cmdResolveModel(cwd, agentType, raw, metadataJson, attempt) {
224
224
  if (!agentType) {
225
225
  error('agent-type required');
226
226
  }
@@ -230,6 +230,11 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
230
230
  try { taskMetadata = JSON.parse(metadataJson); }
231
231
  catch { /* ignore invalid metadata, use static routing */ }
232
232
  }
233
+ // --attempt N (M8): the retry number, 1 for a first try. It rides in the
234
+ // metadata so every resolution rule sees it; the flag wins over a metadata key.
235
+ if (attempt !== undefined) {
236
+ taskMetadata = { ...(taskMetadata && typeof taskMetadata === 'object' ? taskMetadata : {}), attempt };
237
+ }
233
238
 
234
239
  const config = loadConfig(cwd);
235
240
  const profile = config.model_profile || 'balanced';
@@ -243,9 +248,14 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
243
248
  return;
244
249
  }
245
250
 
246
- const model = resolveModelInternal(cwd, agentType, taskMetadata);
251
+ const resolved = resolveModelDetailed(cwd, agentType, taskMetadata);
252
+ const model = resolved.model;
247
253
  const effort = resolveEffortInternal(cwd, agentType);
248
254
  const result = { model, profile, strategy, effort };
255
+ if (taskMetadata && Number.isInteger(taskMetadata.attempt)) {
256
+ result.attempt = taskMetadata.attempt;
257
+ result.escalated_from = resolved.escalated_from;
258
+ }
249
259
  output(result, raw, model);
250
260
  }
251
261
 
@@ -804,7 +814,13 @@ function cmdTodoComplete(cwd, filename, raw) {
804
814
  }
805
815
 
806
816
  const today = new Date().toISOString().split('T')[0];
807
- content = `completed: ${today}\n` + content;
817
+ // /pan:todo-add writes YAML frontmatter; the date goes inside it. Prepending it
818
+ // put `completed:` above the opening `---` and broke the block. A file with no
819
+ // frontmatter keeps the old first-line form.
820
+ const fm = content.match(/^---\r?\n/);
821
+ content = fm
822
+ ? fm[0] + `completed: ${today}` + fm[0].slice(3) + content.slice(fm[0].length)
823
+ : `completed: ${today}\n` + content;
808
824
 
809
825
  try {
810
826
  fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
@@ -84,6 +84,8 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
84
84
  strategy: 'static',
85
85
  provider: 'auto',
86
86
  cascade_quality_gate: true,
87
+ // Failure-tier escalation (M8): how many tiers a retry may climb.
88
+ max_escalations: 1,
87
89
  complexity_thresholds: {
88
90
  downgrade_max: 2,
89
91
  upgrade_min: 6,
@@ -607,7 +607,9 @@ const SKILL_ALIGN_MAX_TASKS = 50; // larger drafts are a planning
607
607
  const SKILL_ALIGN_CONTENT_CAP = 700; // chars of file head scored (≈ objective paragraph)
608
608
 
609
609
  /** Valid conventional commit types */
610
- const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'chore'];
610
+ // `perf` is a task commit type in references/git-integration.md; it was missing
611
+ // here, so `pan-tools commit --type perf` refused a type the docs prescribe.
612
+ const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'perf', 'chore'];
611
613
 
612
614
  /** Default sensitive file patterns for commit safety checks */
613
615
  const DEFAULT_SENSITIVE_PATTERNS = ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'];
@@ -113,9 +113,23 @@ function assessCacheTtl(records, opts = {}) {
113
113
  // last record: its cache writes are not a subagent's re-write after an idle
114
114
  // gap, and the subagent cache lifetime setting does not govern them.
115
115
  .filter(r => !(r && r.token_source === 'session-transcript'))
116
- .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
116
+ // A row that recorded the cache-write lifetime split (M13) says which writes
117
+ // were five-minute ones; only those can be a re-write after a short idle gap —
118
+ // a one-hour write already outlives it. Rows without the split fall back to the
119
+ // gap heuristic on the whole write.
120
+ .map(r => {
121
+ const measured = r && typeof r.cache_write_5m_tokens === 'number';
122
+ return {
123
+ t: r && r.ts ? new Date(r.ts).getTime() : NaN,
124
+ w: measured ? r.cache_write_5m_tokens : (Number(r && r.cache_write_tokens) || 0),
125
+ w1h: measured ? (Number(r.cache_write_1h_tokens) || 0) : 0,
126
+ measured,
127
+ };
128
+ })
117
129
  .filter(r => Number.isFinite(r.t))
118
130
  .sort((a, b) => a.t - b.t);
131
+ const measuredRows = rows.filter(r => r.measured);
132
+ const basis = measuredRows.length === 0 ? 'inferred' : measuredRows.length === rows.length ? 'measured' : 'mixed';
119
133
  let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
120
134
  for (let i = 1; i < rows.length; i++) {
121
135
  if (rows[i].w < minWrite) continue;
@@ -126,14 +140,25 @@ function assessCacheTtl(records, opts = {}) {
126
140
  const recommend = shortIdle >= recommendAt;
127
141
  const setting = 'subagentPromptCacheTtl';
128
142
  const severity = recommend && shortIdleTokens >= (opts.warnTokens ?? TTL_WARN_TOKENS) ? 'warn' : 'info';
143
+ const basisNote = basis === 'measured'
144
+ ? ' (measured: the ledger records each write\'s cache lifetime)'
145
+ : basis === 'mixed'
146
+ ? ' (partly measured: rows written before PAN recorded the cache lifetime are counted from idle gaps alone)'
147
+ : ' (inferred from idle gaps between rows: no row records the cache lifetime)';
129
148
  const advice = recommend
130
- ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.`
149
+ ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.${basisNote}`
131
150
  : null;
132
151
  return {
133
152
  records_considered: rows.length,
134
153
  writes_after_short_idle: shortIdle,
135
154
  tokens_after_short_idle: shortIdleTokens,
136
155
  writes_after_long_idle: longIdle,
156
+ // Where the split came from: 'measured' (every row carries it), 'mixed', or
157
+ // 'inferred' (none does — the gap heuristic alone).
158
+ basis,
159
+ measured_rows: measuredRows.length,
160
+ cache_write_1h_tokens: measuredRows.reduce((n, r) => n + r.w1h, 0),
161
+ cache_write_5m_tokens: measuredRows.reduce((n, r) => n + r.w, 0),
137
162
  recommend,
138
163
  setting,
139
164
  severity,