pan-wizard 3.30.0 → 3.32.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 (75) hide show
  1. package/README.md +30 -26
  2. package/agents/pan-design-checker.md +18 -0
  3. package/agents/pan-optimizer.md +5 -1
  4. package/agents/pan-plan-checker.md +23 -0
  5. package/agents/pan-reviewer.md +19 -0
  6. package/agents/pan-verifier.md +23 -0
  7. package/bin/install-lib.cjs +129 -33
  8. package/bin/install.js +16 -9
  9. package/commands/pan/army.md +4 -4
  10. package/commands/pan/design-phase.md +8 -0
  11. package/commands/pan/exec-phase.md +1 -1
  12. package/commands/pan/git.md +2 -2
  13. package/commands/pan/learn.md +2 -0
  14. package/commands/pan/optimize.md +14 -2
  15. package/commands/pan/review-deep.md +5 -5
  16. package/commands/pan/verify-phase.md +8 -9
  17. package/hooks/dist/pan-check-update.js +53 -2
  18. package/hooks/dist/pan-context-monitor.js +42 -1
  19. package/hooks/dist/pan-cost-logger.js +132 -11
  20. package/hooks/dist/pan-state-reinject.js +158 -0
  21. package/hooks/dist/pan-stop-guard.js +50 -4
  22. package/hooks/dist/pan-trace-logger.js +324 -18
  23. package/package.json +1 -1
  24. package/pan-wizard-core/bin/lib/commands.cjs +22 -5
  25. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  26. package/pan-wizard-core/bin/lib/constants.cjs +31 -1
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  28. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  29. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  30. package/pan-wizard-core/bin/lib/cost.cjs +135 -46
  31. package/pan-wizard-core/bin/lib/findings.cjs +381 -0
  32. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  33. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  34. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  35. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  36. package/pan-wizard-core/bin/lib/init.cjs +4 -1
  37. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  38. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  39. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  40. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  41. package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
  42. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  43. package/pan-wizard-core/bin/lib/state.cjs +93 -24
  44. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  45. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  46. package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
  47. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  48. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  49. package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
  50. package/pan-wizard-core/bin/lib/verify.cjs +25 -2
  51. package/pan-wizard-core/bin/pan-tools.cjs +69 -6
  52. package/pan-wizard-core/learnings/README.md +10 -7
  53. package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
  54. package/pan-wizard-core/references/verdict-contract.md +101 -0
  55. package/pan-wizard-core/templates/verification-report.md +16 -0
  56. package/pan-wizard-core/workflows/exec-phase.md +73 -47
  57. package/pan-wizard-core/workflows/health.md +2 -0
  58. package/pan-wizard-core/workflows/help.md +2 -2
  59. package/pan-wizard-core/workflows/learn.md +4 -2
  60. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  61. package/pan-wizard-core/workflows/optimize.md +14 -1
  62. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  63. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  64. package/pan-wizard-core/workflows/progress.md +1 -1
  65. package/pan-wizard-core/workflows/todo-add.md +1 -1
  66. package/pan-wizard-core/workflows/todo-check.md +7 -6
  67. package/pan-wizard-core/workflows/verify-phase.md +3 -13
  68. package/pan-zcode/README.md +3 -3
  69. package/scripts/build-agent-plugin.js +4 -0
  70. package/scripts/build-hooks.js +2 -1
  71. package/scripts/build-plugin.js +10 -0
  72. package/scripts/git-hooks/pre-commit +2 -1
  73. package/scripts/npm-dist-tag.js +47 -0
  74. package/scripts/plugin-path.js +6 -1
  75. package/scripts/test-surface.cjs +1 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pan:verify-phase
3
3
  group: Phase Lifecycle
4
- description: Validate built features through conversational UAT with test suite gate
4
+ description: Re-run goal-backward verification of a phase with a test-suite gate and list its gaps
5
5
  argument-hint: "[phase number, e.g., '4']"
6
6
  allowed-tools:
7
7
  - Read
@@ -13,24 +13,23 @@ allowed-tools:
13
13
  - Task
14
14
  ---
15
15
  <objective>
16
- Validate built features through conversational testing with persistent state.
16
+ Verify that the phase delivered what it promised, not just that its tasks completed.
17
17
 
18
- Purpose: Confirm what Claude built actually works from user's perspective. One test at a time, plain text responses, no interrogation. When issues are found, automatically diagnose, plan fixes, and prepare for execution.
18
+ Purpose: goal-backward verification — what must be TRUE for the phase goal, what must EXIST for that, what must be WIRED — checked against the codebase, behind a test-suite gate.
19
19
 
20
- Output: {phase_num}-uat.md tracking all test results. If issues found: diagnosed gaps, verified fix plans ready for /pan:exec-phase
20
+ Output: {phase_num}-verification.md with a status (passed, gaps_found or human_needed). Gaps found are listed for `/pan:plan-phase {phase_num} --gaps`.
21
21
  </objective>
22
22
 
23
23
  <execution_context>
24
24
  @~/.claude/pan-wizard-core/workflows/verify-phase.md
25
- @~/.claude/pan-wizard-core/templates/uat.md
26
25
  </execution_context>
27
26
 
28
27
  <context>
29
28
  Phase: $ARGUMENTS (optional)
30
29
  - If provided: Test specific phase (e.g., "4")
31
- - If not provided: Check for active sessions or prompt for phase
30
+ - If not provided: prompt for the phase
32
31
 
33
- Context files are resolved inside the workflow (`init verify-work`) and delegated via `<files_to_read>` blocks.
32
+ Context files are resolved inside the workflow (`init phase-op`).
34
33
  </context>
35
34
 
36
35
  <investigate_before_judging>
@@ -87,6 +86,6 @@ See [plan-phase.md](plan-phase.md) or [exec-phase.md](exec-phase.md) for the ful
87
86
  </cache_priming>
88
87
 
89
88
  <process>
90
- Execute the verify-work workflow from @~/.claude/pan-wizard-core/workflows/verify-phase.md end-to-end.
91
- Preserve all workflow gates (session management, test presentation, diagnosis, fix planning, routing).
89
+ Execute the verify-phase workflow from @~/.claude/pan-wizard-core/workflows/verify-phase.md end-to-end.
90
+ Preserve all workflow gates (the test-suite gate, the truth/artifact/wiring checks, status determination, the verification report).
92
91
  </process>
@@ -12,6 +12,45 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+
16
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
17
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
18
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
19
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
20
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
21
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
22
+ // aside whenever the project's Claude settings register the same script, so the hook
23
+ // runs once, and runs again from here the moment the Claude registration is gone.
24
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
25
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
26
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
27
+ // Copilot copy loads where repository hooks may not. Identical in every hook
28
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
29
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
30
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
31
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
32
+ const claudeDir = ['.', 'claude'].join('');
33
+ try {
34
+ const hooksDir = path.dirname(hookFile);
35
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
36
+ if (typeof projectDir !== 'string' || !projectDir) return false;
37
+ const script = path.basename(hookFile);
38
+ for (const name of ['settings.json', 'settings.local.json']) {
39
+ let settings;
40
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
41
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
42
+ if (!events) continue;
43
+ for (const groups of Object.values(events)) {
44
+ if (!Array.isArray(groups)) continue;
45
+ for (const group of groups) {
46
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
47
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
48
+ }
49
+ }
50
+ }
51
+ } catch { /* fail open: run this copy */ }
52
+ return false;
53
+ }
15
54
  const os = require('os');
16
55
  const { spawn } = require('child_process');
17
56
 
@@ -128,9 +167,17 @@ function main() {
128
167
  const cacheDir = path.join(homeDir, '.claude', 'cache');
129
168
  const cacheFile = path.join(cacheDir, 'pan-update-check.json');
130
169
 
131
- // VERSION file locations (check project first, then global)
170
+ // VERSION file locations: the project's own install first; then the core this
171
+ // hook copy ships with, which sits beside it in every layout (<config>/hooks next
172
+ // to <config>/pan-wizard-core, and the same under a plugin root); then the global
173
+ // install. The plugin case is why the middle one exists: the two config-dir paths
174
+ // only ever find an install, so under a plugin host the installed version read as
175
+ // 0.0.0 (3.28.0 review, LOW).
132
176
  const projectVersionFile = path.join(cwd, '.claude', 'pan-wizard-core', 'VERSION');
133
- const globalVersionFile = path.join(homeDir, '.claude', 'pan-wizard-core', 'VERSION');
177
+ const ownVersionFile = path.join(__dirname, '..', 'pan-wizard-core', 'VERSION');
178
+ const globalVersionFile = fs.existsSync(ownVersionFile)
179
+ ? ownVersionFile
180
+ : path.join(homeDir, '.claude', 'pan-wizard-core', 'VERSION');
134
181
 
135
182
  // Ensure cache directory exists
136
183
  try {
@@ -153,6 +200,9 @@ function main() {
153
200
  return;
154
201
  }
155
202
 
203
+ // Copilot also runs the Claude registration of this hook in a two-runtime project.
204
+ if (deferToClaudeRegistration(cwd)) return;
205
+
156
206
  // Parent mode: run the check in a detached background child so the 10s
157
207
  // `npm view` never blocks SessionStart. windowsHide prevents a console flash.
158
208
  const child = spawn(process.execPath, [__filename, '--run-check'], {
@@ -168,6 +218,7 @@ if (require.main === module) {
168
218
  }
169
219
 
170
220
  module.exports = {
221
+ deferToClaudeRegistration,
171
222
  parseVersion,
172
223
  compareVersions,
173
224
  isUpdateAvailable,
@@ -5,7 +5,7 @@
5
5
  // context limits (the statusline only shows the user).
6
6
  //
7
7
  // How it works:
8
- // 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json
8
+ // 1. The statusline hook writes metrics to <os tmpdir>/pan-hooks-<uid>/claude-ctx-{session_id}.json
9
9
  // 2. This hook reads those metrics after each tool use
10
10
  // 3. When remaining context drops below thresholds, it injects a warning
11
11
  // as additionalContext, which the agent sees in its conversation
@@ -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).
@@ -107,9 +146,11 @@ const CURSOR_FILE = '.cost-cursor.json';
107
146
  // the per-invocation `event_sig` discriminator; v4 added `agent_id` and the
108
147
  // `agent-transcript` token source. Kept as a literal in each hook —
109
148
  // they are standalone zero-dep scripts that can't import from pan-wizard-core, so
110
- // the two hooks must stay in sync by hand. No constant in pan-wizard-core mirrors
111
- // it: the readers there take a row field by field rather than switching on its
112
- // version, so an added field is additive for them.
149
+ // each hook's copy is kept by hand (in lockstep through v4; the trace logger's events
150
+ // have since moved to 5 on their own). cost-rebuild.cjs in
151
+ // pan-wizard-core mirrors it for the rows it rebuilds; the readers there take a row
152
+ // field by field rather than switching on its version, so an added field is additive
153
+ // for them.
113
154
  const SCHEMA_V = 4;
114
155
 
115
156
  // Reverse-map a resolved model id to its cost tier so the "By tier" dashboard
@@ -417,10 +458,13 @@ const MAX_CURSOR_KEYS = 512;
417
458
  // marker phantom (an over-count) and from N29 (concurrent same-type siblings).
418
459
  //
419
460
  // Returns null when the payload cannot be serialized — callers then fail OPEN
420
- // (record, never mark).
461
+ // (record, never mark). SHA-256 cut to the 40 hex characters the SHA-1 it replaced
462
+ // produced, so persisted `event_sig` values keep their shape: a dedup key, not a
463
+ // security boundary, but CodeQL rightly flags SHA-1 over a payload that carries a
464
+ // session id (2026-09-29).
421
465
  function eventSignature(data) {
422
466
  try {
423
- return crypto.createHash('sha1').update(JSON.stringify(data)).digest('hex');
467
+ return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 40);
424
468
  } catch { return null; }
425
469
  }
426
470
 
@@ -520,6 +564,27 @@ function resolveAgentTranscript(data) {
520
564
  }
521
565
  }
522
566
 
567
+ // Host-internal agents (measured 2026-09-28). Claude Code runs helper agents of its own:
568
+ // the compaction summariser, and one at almost every turn end of an interactive session.
569
+ // It fires SubagentStop for them with an `agent_id`, an EMPTY `agent_type`, and an
570
+ // `agent_transcript_path` it never writes. That shape was captured from a real `/compact`
571
+ // on Claude Code 2.1.280 (tests/fixtures/hooks/subagent-stop-internal-claude.json). Both
572
+ // loggers booked each as an `unknown` zero-token spawn: 181 rows in three field projects
573
+ // in eleven days, skewing optimize learn's agent stats and the ledger's spawn counts.
574
+ // The host documents `agent_type` as present whenever a hook fires inside a subagent, so
575
+ // a named instance with no type and no transcript is not a spawn anyone asked for, and
576
+ // nothing is recorded. A NAMED spawn whose file is late or missing keeps its zero-token
577
+ // row (N17/N25); a payload with no agent instance at all keeps the parent-slice path.
578
+ // Identical in pan-cost-logger.js and pan-trace-logger.js (tests/trace-logger.test.cjs
579
+ // pins the copies).
580
+ function isHostInternalAgent(data, agentTranscript) {
581
+ if (!data || typeof data !== 'object' || agentTranscript) return false;
582
+ const typed = [data.agent_type, data.subagent_type].some((v) => typeof v === 'string' && v.trim() !== '');
583
+ if (typed) return false;
584
+ return (typeof data.agent_id === 'string' && AGENT_ID_SAFE.test(data.agent_id))
585
+ || (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path !== '');
586
+ }
587
+
523
588
  // A slice is a SUM over one subagent's conversation, so its ceiling sits above a
524
589
  // single call's (PLAUSIBLE_MAX): a long agent legitimately re-reads its cached
525
590
  // context on every turn. The cache_read and output ceilings are the absolute
@@ -537,6 +602,29 @@ function clampSlice(n, max) {
537
602
  return typeof n === 'number' && n >= 0 && n <= max ? n : 0;
538
603
  }
539
604
 
605
+ // Copilot CLI's camelCase hook payload (docs.github.com/en/copilot/reference/hooks-reference,
606
+ // read 2026-09-28; documented, not yet observed live) names every field differently from
607
+ // Claude's snake_case one, so this hook read no session, transcript or agent from it and
608
+ // booked every Copilot spawn as `unknown`. Its `agentType` is the KIND of agent (built-in
609
+ // or custom); `agentName` is the configured name PAN's agents carry, so the name wins.
610
+ // Claude payloads carry none of these keys and pass through unchanged. Identical in
611
+ // pan-cost-logger.js and pan-trace-logger.js (tests/copilot-payload.test.cjs pins both).
612
+ function normalizeHookPayload(data) {
613
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
614
+ const pick = (...keys) => {
615
+ for (const k of keys) if (typeof data[k] === 'string' && data[k]) return data[k];
616
+ return undefined;
617
+ };
618
+ const out = { ...data };
619
+ if (out.session_id === undefined && pick('sessionId')) out.session_id = pick('sessionId');
620
+ if (out.transcript_path === undefined && pick('transcriptPath')) out.transcript_path = pick('transcriptPath');
621
+ if (out.agent_id === undefined && pick('agentId')) out.agent_id = pick('agentId');
622
+ const name = pick('agent_name', 'agentName');
623
+ if (name) out.agent_type = name;
624
+ else if (out.agent_type === undefined && pick('agentType')) out.agent_type = pick('agentType');
625
+ return out;
626
+ }
627
+
540
628
  /**
541
629
  * Extract what we can from the SubagentStop event payload.
542
630
  * Pure function — safe to test without stdin.
@@ -547,6 +635,7 @@ function clampSlice(n, max) {
547
635
  */
548
636
  function buildCostRecord(data, cwd) {
549
637
  if (!data || typeof data !== 'object') return null;
638
+ data = normalizeHookPayload(data);
550
639
 
551
640
  // Only log actual subagent stops; ignore other Stop variants.
552
641
  if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return null;
@@ -564,6 +653,8 @@ function buildCostRecord(data, cwd) {
564
653
  let outputTokens = 0;
565
654
  let cacheRead = 0;
566
655
  let cacheWrite = 0;
656
+ // { h1, m5 } when the source carried the cache-write lifetime split (M13).
657
+ let ttlSplit = null;
567
658
  let durationMs = null;
568
659
  // token_source records WHICH path produced the counts: `agent-transcript` (the
569
660
  // subagent's own conversation file — see resolveAgentTranscript), `transcript`
@@ -572,6 +663,7 @@ function buildCostRecord(data, cwd) {
572
663
  // a value dropped to 0 by a plausibility guard so a guarded zero is
573
664
  // distinguishable from a genuine zero-token run.
574
665
  const agentTranscript = resolveAgentTranscript(data);
666
+ if (isHostInternalAgent(data, agentTranscript)) return null; // the host's own helper, not a spawn
575
667
  const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
576
668
  // The host NAMED an agent (a bare id, or an explicit agent transcript path) but
577
669
  // its file is not there — not flushed yet, or a host that sends ids without
@@ -621,6 +713,10 @@ function buildCostRecord(data, cwd) {
621
713
  cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
622
714
  clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
623
715
  || rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
716
+ // A clamped write total no longer matches its split, so the split is dropped with it.
717
+ if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
718
+ ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
719
+ }
624
720
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
625
721
  if (!model) model = fromTranscript.model;
626
722
  if (fromTranscript.lineCount > since) {
@@ -676,18 +772,24 @@ function buildCostRecord(data, cwd) {
676
772
  cacheRead = clampPlausible(rawCr);
677
773
  cacheWrite = clampPlausible(rawCw);
678
774
  clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
775
+ const split = {};
776
+ addCacheTtlSplit(split, data.usage);
777
+ if (typeof split.cache_write_1h_tokens === 'number' && cacheWrite === rawCw) {
778
+ ttlSplit = { h1: clampPlausible(split.cache_write_1h_tokens), m5: clampPlausible(split.cache_write_5m_tokens) };
779
+ }
679
780
  }
680
781
 
681
782
  // Backfill command/phase from the active trace session when the payload omits
682
783
  // them (real SubagentStop payloads carry neither); tier is derived from the model.
683
784
  const sessionMeta = readActiveSessionMeta(cwd);
684
- // The trace session only exists while the optimizer runs, so the parent transcript is
685
- // the fallback that makes command attribution work outside focus mode — see
785
+ // The trace logger's day-scoped auto-session names no command (only a session opened
786
+ // by `optimize trace init` or focus mode does), so the parent transcript is the
787
+ // fallback that makes command attribution work in ordinary use — see
686
788
  // readCommandFromTranscript.
687
789
  const command = data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null;
688
- // The trace session is only present while the optimizer is running (off by
689
- // default), so state.md is the fallback that makes phase attribution work in
690
- // ordinary use instead of only under tracing.
790
+ // The day-scoped auto-session names no phase either, so state.md is the fallback
791
+ // that makes phase attribution work in ordinary use, not only in a session that
792
+ // was opened with one.
691
793
  const phase = data.phase || sessionMeta.phase || readCurrentPhase(cwd) || null;
692
794
 
693
795
  const record = {
@@ -705,6 +807,8 @@ function buildCostRecord(data, cwd) {
705
807
  output_tokens: outputTokens,
706
808
  cache_read_tokens: cacheRead,
707
809
  cache_write_tokens: cacheWrite,
810
+ // The lifetime split of cache_write_tokens, present only when measured (M13).
811
+ ...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
708
812
  cost_usd: null,
709
813
  duration_ms: durationMs,
710
814
  phase,
@@ -833,11 +937,27 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
833
937
  totals.output_tokens += extractNumber(usage, 'output_tokens');
834
938
  totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
835
939
  totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
940
+ addCacheTtlSplit(totals, usage);
836
941
  }
837
942
  totals.lineCount = seen;
838
943
  return totals;
839
944
  }
840
945
 
946
+ /**
947
+ * Add a usage record's cache-write lifetime split to `totals` (M13). Claude Code
948
+ * transcripts carry `usage.cache_creation: { ephemeral_5m_input_tokens,
949
+ * ephemeral_1h_input_tokens }` beside the `cache_creation_input_tokens` total;
950
+ * one-hour writes bill at 2x base input against 1.25x for five-minute ones, so
951
+ * the ledger keeps the split. The totals gain the two fields only when some
952
+ * record carried the block — rows from hosts without it stay unchanged.
953
+ */
954
+ function addCacheTtlSplit(totals, usage) {
955
+ const cc = usage && usage.cache_creation;
956
+ if (!cc || typeof cc !== 'object') return;
957
+ totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + extractNumber(cc, 'ephemeral_1h_input_tokens');
958
+ totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
959
+ }
960
+
841
961
  /**
842
962
  * Append record to .planning/metrics/tokens.jsonl. Silently succeeds
843
963
  * even if the file or directory can't be written — hook must not block.
@@ -922,6 +1042,7 @@ if (require.main === module) {
922
1042
  // fall back to process.cwd() which is the project root when Claude Code
923
1043
  // invokes the hook.
924
1044
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
1045
+ if (deferToClaudeRegistration(cwd)) return;
925
1046
  // M62: a global-install hook fires in every repo; don't pollute non-PAN
926
1047
  // projects with .planning/ metrics artifacts. The tree must already exist —
927
1048
  // see hasPlanningTree (field sweep 2026-09-17).
@@ -934,4 +1055,4 @@ if (require.main === module) {
934
1055
  });
935
1056
  }
936
1057
 
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 };
1058
+ module.exports = { deferToClaudeRegistration, normalizeHookPayload, isHostInternalAgent, 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). Every PAN hook that
41
+ * reads the planning tree carries an 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 };