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
package/README.md CHANGED
@@ -103,7 +103,7 @@ PAN is the context engineering layer that makes Claude Code reliable. It breaks
103
103
  - **Isolated builders.** Each Build agent forks its own `army/<task>` branch + git worktree, so parallel agents never share a file.
104
104
  - **Caps.** Delegation-depth cap, per-cycle spawn + budget ceilings, and a `.planning/orchestration/abort` kill-switch, re-checked before every spawn by Mission Control's protocol (prompt-enforced; the MCP `pan_next_action` path enforces the cycle cap, the budget cap when `caps.enforceBudget` is set, an `aborted` flag the caller sets (it does not read the abort file) and the human gate in code — delegation depth is bounded by which agents hold the `Task` tool) — the same harness as hierarchical exec, at campaign scale.
105
105
 
106
- **Run it over days.** `--schedule` arms a resumable campaign with a per-day budget that burns the backlog down across sessions (the per-day budget is advisory — `campaign status` shows the day's spend — and an external scheduler triggers each `--continue`) — and *still* waits for you at every merge. **Autonomy runs up to the irreversible step; a human is at the step.**
106
+ **Run it over days.** `--schedule` arms a resumable campaign with a per-day budget that burns the backlog down across sessions (the per-day budget is advisory unless the schedule is armed with `--enforce-budget`, which pauses the day's run once spent — `campaign status` shows the day's spend — and an external scheduler triggers each `--continue`) — and *still* waits for you at every merge. **Autonomy runs up to the irreversible step; a human is at the step.**
107
107
 
108
108
  **Watch it live.** `/pan:hud` renders a single self-contained HTML dashboard — Mission Control over the squads, in-flight worktrees, campaign budget, telemetry, and the safety harness — in one page.
109
109
 
@@ -737,7 +737,7 @@ Control which Claude model each agent uses. Balance quality vs token spend.
737
737
  | `balanced` (default) | reasoning | reasoning | reasoning |
738
738
  | `budget` | Sonnet | Sonnet | Haiku |
739
739
 
740
- > `reasoning` = the session model (inherit) — every agent runs on the model you launched the session with, except the reviewer-class agents (reviewer, hardener, meta-reviewer), which pin a reasoning-tier model on Claude Code. The other runtimes' agent files drop the pin, so there those agents inherit the session model too. Both `quality` and `balanced` resolve this way; only `budget` steps down — to a Sonnet/Haiku mix on Claude Code, and to the provider's own mid and fast models where PAN detects OpenAI (Codex, OpenCode) or Google (Gemini CLI); `pan-tools resolve-model <agent>` prints the id. Actual assignment varies by agent role — see [User Guide](docs/USER-GUIDE.md#model-profiles-per-agent-breakdown) for the full per-agent breakdown.
740
+ > `reasoning` = the session model (inherit) — every agent runs on the model you launched the session with, except the reviewer-class agents (reviewer, hardener, meta-reviewer), which pin a reasoning-tier model on Claude Code. The other runtimes' agent files drop the pin, so there those agents inherit the session model too. Both `quality` and `balanced` resolve this way; only `budget` steps down — to a Sonnet/Haiku mix on Claude Code, and to the provider's own mid and fast models where PAN detects OpenAI (Codex) or Google (Gemini CLI) — under OpenCode, as `provider/model` ids for the provider of OpenCode's configured `model`, or, when that names none PAN routes for, the provider the runtime directories suggest (`.opencode/` alone counts as OpenAI); `pan-tools resolve-model <agent>` prints the id. Actual assignment varies by agent role — see [User Guide](docs/USER-GUIDE.md#model-profiles-per-agent-breakdown) for the full per-agent breakdown.
741
741
 
742
742
  Switch profiles:
743
743
  ```text
@@ -677,7 +677,7 @@ function buildAgentPluginMcpConfig() {
677
677
  * @param {{updateCheckCommand?:string, contextMonitorCommand?:string, costLoggerCommand?:string, traceLoggerCommand?:string}} commands
678
678
  */
679
679
  function buildCopilotPluginHooksConfig(commands) {
680
- const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
680
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand } = commands || {};
681
681
  const hooks = {};
682
682
  if (updateCheckCommand) hooks.SessionStart = [{ type: 'command', command: updateCheckCommand }];
683
683
  if (contextMonitorCommand) hooks.PostToolUse = [{ type: 'command', command: contextMonitorCommand }];
@@ -685,6 +685,8 @@ function buildCopilotPluginHooksConfig(commands) {
685
685
  if (costLoggerCommand) subagentStop.push({ type: 'command', command: costLoggerCommand });
686
686
  if (traceLoggerCommand) subagentStop.push({ type: 'command', command: traceLoggerCommand });
687
687
  if (subagentStop.length > 0) hooks.SubagentStop = subagentStop;
688
+ // Copilot's documented PascalCase alias for agentStop (M14).
689
+ if (stopGuardCommand) hooks.Stop = [{ type: 'command', command: stopGuardCommand }];
688
690
  return { hooks };
689
691
  }
690
692
 
@@ -692,9 +694,15 @@ function buildCopilotPluginHooksConfig(commands) {
692
694
  const COPILOT_PLUGIN_NAMESPACE = 'com.github.copilot';
693
695
 
694
696
  /** The adapter paragraph appended to every bundled skill (ADR-0045 D3). */
695
- function agentPluginSkillAdapterNote() {
697
+ function agentPluginSkillAdapterNote(kind = 'skill') {
698
+ // An agent file (<root>/com.github.copilot/agents/<name>.agent.md) sits as deep as a
699
+ // skill (<root>/skills/<name>/SKILL.md), so the same derivation holds; until
700
+ // 2026-09-26 only the skills carried the note, and the bundled Copilot agents held
701
+ // the token with nothing to say what it meant (3.28.0 review, LOW).
702
+ const what = kind === 'agent' ? 'agent' : 'skill';
703
+ const file = kind === 'agent' ? 'this agent file' : 'this SKILL.md';
696
704
  return `Plugin bundle (Agent Plugins format):
697
- - \`${AGENT_PLUGIN_ROOT_TOKEN}\` in this skill is the directory that holds this plugin's \`plugin.json\` — two levels above this SKILL.md. Your runtime reports this skill's file location when it loads it; derive the root from that path and substitute it wherever \`${AGENT_PLUGIN_ROOT_TOKEN}\` appears before running a command.
705
+ - \`${AGENT_PLUGIN_ROOT_TOKEN}\` in this ${what} is the directory that holds this plugin's \`plugin.json\` — two levels above ${file}. Your runtime reports this ${what}'s file location when it loads it; derive the root from that path and substitute it wherever \`${AGENT_PLUGIN_ROOT_TOKEN}\` appears before running a command.
698
706
  - Prefer the \`pan\` MCP server's tools when your runtime has connected this plugin's \`mcp.json\`, and pass the project's absolute path as each tool's \`cwd\` argument — the server is started in the plugin's directory, which is never the project. Otherwise run \`node ${AGENT_PLUGIN_ROOT_TOKEN}/pan-wizard-core/bin/pan-tools.cjs <verb>\` from the project root.
699
707
  - \`${AGENT_PLUGIN_RUNTIME_HOME_TOKEN}\` is your runtime's user-level configuration directory (for example \`~/.claude\`, \`~/.codex\`, \`~/.gemini\`, \`~/.config/opencode\`, \`~/.copilot\`) and \`${AGENT_PLUGIN_RUNTIME_DIR_TOKEN}\` its project-level directory (\`.claude\`, \`.codex\`, \`.gemini\`, \`.opencode\`, \`.github\`). Substitute the one that applies to the runtime you are.`;
700
708
  }
@@ -742,13 +750,29 @@ function convertClaudeCommandToCopilotSkill(content, skillName) {
742
750
  /**
743
751
  * @param {string} content - Claude agent markdown
744
752
  * @param {object} [opts]
745
- * @param {Record<string,string[]>} [opts.modelLists] - Copilot CLI (>= 1.0.83) accepts a
746
- * `model:` LIST tried in order plus `model-policy`. When a PAN agent pins `model:
747
- * <alias>` and this map has an entry for the alias, the Copilot agent gets that list
748
- * and `model-policy: prefer` (degrade gracefully; `required` would refuse to run).
749
- * NOT wired into the installer yet: the Copilot model ids must be verified on a live
750
- * CLI first (ADR-0028's rule; harness/scenarios/live-gate-copilot.json carries the
751
- * probe). Reality check RC15 / plan item R13, 2026-09-10.
753
+ * Every agent carries `include-custom-instructions: true` (market-ideas M15). Copilot
754
+ * does not give a custom agent spawned as a subagent the repository's instruction
755
+ * files — `copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md` — unless its frontmatter
756
+ * opts in (Copilot CLI reference, "Repository custom instructions for subagents";
757
+ * default false). PAN's rules section lives in AGENTS.md, and PAN's agents run as
758
+ * subagents. Measured 2026-09-26 on Copilot CLI 1.0.88: with an AGENTS.md canary, a
759
+ * subagent with the field answered it and the same agent without the field answered
760
+ * that it had none.
761
+ *
762
+ * @param {Record<string,string[]>} [opts.modelLists] - Copilot CLI accepts a priority
763
+ * list under `models:` ("the runtime uses the first model the user's plan can
764
+ * access; if none resolve, dispatch falls back to the session's model") plus
765
+ * `model-policy`. When a PAN agent pins `model: <alias>` and this map has an entry
766
+ * for the alias, the Copilot agent gets that list and `model-policy: preferred`
767
+ * (degrade gracefully; `required` would refuse to run). Until 2026-09-26 the list
768
+ * was written under the singular `model:` and the policy as `prefer`; the first live
769
+ * probe (Copilot CLI 1.0.88) refused to load that agent — `model-policy: Expected
770
+ * "preferred" or "required"` — and the reference names the list field `models`.
771
+ * Copilot's model ids are dotted (`claude-opus-5.5`; its /models list, same day). A
772
+ * session on `auto` — every Copilot Free session — ignores the list, so the list's
773
+ * fall-through is still unmeasured; NOT wired into the installer until it is, on a
774
+ * paid plan (harness/scenarios/copilot-agent-frontmatter.json). Reality check RC15 /
775
+ * plan item R13, 2026-09-10.
752
776
  */
753
777
  function convertClaudeToCopilotAgent(content, opts = {}) {
754
778
  const converted = convertClaudeToCopilotMarkdown(content);
@@ -806,10 +830,10 @@ function convertClaudeToCopilotAgent(content, opts = {}) {
806
830
  const pinned = extractFrontmatterField(frontmatter, 'model');
807
831
  const list = pinned && Array.isArray(lists[pinned]) ? lists[pinned].filter(Boolean) : null;
808
832
  if (list && list.length) {
809
- modelYaml = `\nmodel:\n${list.map(m => ` - ${yamlQuote(m)}`).join('\n')}\nmodel-policy: prefer`;
833
+ modelYaml = `\nmodels:\n${list.map(m => ` - ${yamlQuote(m)}`).join('\n')}\nmodel-policy: preferred`;
810
834
  }
811
835
  }
812
- return `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(description)}${toolsYaml}${modelYaml}\n---\n${body}`;
836
+ return `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(description)}${toolsYaml}\ninclude-custom-instructions: true${modelYaml}\n---\n${body}`;
813
837
  }
814
838
 
815
839
  // ─── Attribution Processing ─────────────────────────────────────────────────
@@ -1021,7 +1045,7 @@ function stripThinkingFrontmatter(content, runtime) {
1021
1045
  * @returns {Object} A `.github/hooks/pan.json` config object
1022
1046
  */
1023
1047
  function buildCopilotHooksConfig(commands) {
1024
- const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
1048
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand } = commands || {};
1025
1049
  const config = { version: 1, hooks: {} };
1026
1050
  if (updateCheckCommand) {
1027
1051
  config.hooks.sessionStart = [{ type: 'command', command: updateCheckCommand }];
@@ -1037,6 +1061,12 @@ function buildCopilotHooksConfig(commands) {
1037
1061
  if (subagentStop.length > 0) {
1038
1062
  config.hooks.subagentStop = subagentStop;
1039
1063
  }
1064
+ // agentStop: the auto-advance stop guard (M14). Copilot blocks a stop only on
1065
+ // the JSON `decision: "block"` output, and ends the turn anyway after eight
1066
+ // consecutive blocks; the guard blocks once per stop chain.
1067
+ if (stopGuardCommand) {
1068
+ config.hooks.agentStop = [{ type: 'command', command: stopGuardCommand }];
1069
+ }
1040
1070
  return config;
1041
1071
  }
1042
1072
 
@@ -1066,20 +1096,38 @@ function buildCopilotHooksConfig(commands) {
1066
1096
  * - copilot: `.github/hooks/pan.json`, camelCase (docs.github.com hooks reference).
1067
1097
  * - opencode: no hook system.
1068
1098
  *
1069
- * `stop` is where the auto-advance stop guard registers (P-1809): Claude's Stop
1070
- * and Gemini's AfterAgent, both of which re-prompt the agent when a hook blocks.
1071
- * Codex and Copilot have a stop event too; PAN does not register the guard there yet.
1099
+ * `stop` is where the auto-advance stop guard registers (P-1809): Claude's Stop,
1100
+ * Gemini's AfterAgent, Codex's Stop and Copilot's agentStop — each re-prompts the
1101
+ * agent when a hook prints `{"decision":"block","reason":…}`, and each hands the
1102
+ * hook `stop_hook_active` (M14, 2026-09-26: codex-rs hooks/schema
1103
+ * stop.command.input.schema.json; Copilot hooks-reference, where the field stays
1104
+ * snake_case in the camelCase agentStop payload). Copilot does not block on exit
1105
+ * code 2 for agentStop — only the JSON form, which is what the guard prints.
1106
+ *
1107
+ * `compact` is where the state re-injection hook registers (M10): SessionStart with
1108
+ * the `compact` matcher (HOOK_SLOT_MATCHERS), the one event after a compaction whose
1109
+ * `hookSpecificOutput.additionalContext` reaches the model on Claude Code and Codex —
1110
+ * both hosts' PostCompact discard it (hooks references, read 2026-09-26). Gemini's
1111
+ * PreCompress fires before the summary and Copilot's preCompact is notification-only,
1112
+ * so neither gets it.
1072
1113
  */
1073
1114
  const HOOK_EVENT_MAP = Object.freeze({
1074
- claude: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop', stop: 'Stop' },
1075
- gemini: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: null, subagentStop: null, stop: 'AfterAgent' },
1076
- codex: { surface: 'hooks.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop', stop: null },
1077
- copilot: { surface: 'hooks/pan.json', sessionStart: 'sessionStart', postToolUse: 'postToolUse', subagentStop: 'subagentStop', stop: null },
1115
+ claude: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop', stop: 'Stop', compact: 'SessionStart' },
1116
+ gemini: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: null, subagentStop: null, stop: 'AfterAgent', compact: null },
1117
+ codex: { surface: 'hooks.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop', stop: 'Stop', compact: 'SessionStart' },
1118
+ copilot: { surface: 'hooks/pan.json', sessionStart: 'sessionStart', postToolUse: 'postToolUse', subagentStop: 'subagentStop', stop: 'agentStop', compact: null },
1078
1119
  opencode: null,
1079
1120
  });
1080
1121
 
1122
+ /**
1123
+ * The matcher a slot's registration carries, where the event alone is too broad.
1124
+ * `compact` is SessionStart narrowed to the start that follows a compaction — the
1125
+ * `source: "compact"` matcher value on Claude Code and Codex alike.
1126
+ */
1127
+ const HOOK_SLOT_MATCHERS = Object.freeze({ compact: 'compact' });
1128
+
1081
1129
  /** The hook scripts PAN registers in a Claude-shaped settings.json `hooks` block. */
1082
- const PAN_SETTINGS_HOOKS = Object.freeze(['pan-check-update', 'pan-context-monitor', 'pan-cost-logger', 'pan-trace-logger', 'pan-stop-guard']);
1130
+ const PAN_SETTINGS_HOOKS = Object.freeze(['pan-check-update', 'pan-context-monitor', 'pan-cost-logger', 'pan-trace-logger', 'pan-stop-guard', 'pan-state-reinject']);
1083
1131
 
1084
1132
  /**
1085
1133
  * Remove, from every event array of a Claude-shaped `hooks` object except
@@ -1263,7 +1311,7 @@ function buildCodexMcpSnippet(serverPath, panToolsPath, projectRoot, serverName
1263
1311
  * @returns {object} Merged config object to serialize back to hooks.json
1264
1312
  */
1265
1313
  function mergeCodexHooksConfig(existing, commands) {
1266
- const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
1314
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand } = commands || {};
1267
1315
  const config = (existing && typeof existing === 'object') ? existing : {};
1268
1316
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
1269
1317
 
@@ -1276,6 +1324,10 @@ function mergeCodexHooksConfig(existing, commands) {
1276
1324
  // - check-update spawns a detached child and prints nothing → async.
1277
1325
  // - context-monitor returns `additionalContext` the model must see THIS
1278
1326
  // turn → stays synchronous.
1327
+ // - stop-guard decides whether the turn may end (`decision: "block"`), which
1328
+ // an async handler cannot do → synchronous (M14).
1329
+ // - state-reinject returns `additionalContext` for the continuation after a
1330
+ // compaction → synchronous, and matched to `source: "compact"` (M10).
1279
1331
  // Codex-only: Claude Code and Copilot hook schemas were not checked for an
1280
1332
  // equivalent flag (plan item 2 gate) — do not copy this column into their
1281
1333
  // builders without reading their docs first.
@@ -1284,9 +1336,11 @@ function mergeCodexHooksConfig(existing, commands) {
1284
1336
  ['PostToolUse', contextMonitorCommand, 'pan-context-monitor', false],
1285
1337
  ['SubagentStop', costLoggerCommand, 'pan-cost-logger', true],
1286
1338
  ['SubagentStop', traceLoggerCommand, 'pan-trace-logger', true],
1339
+ ['Stop', stopGuardCommand, 'pan-stop-guard', false],
1340
+ ['SessionStart', stateReinjectCommand, 'pan-state-reinject', false, HOOK_SLOT_MATCHERS.compact],
1287
1341
  ];
1288
1342
 
1289
- for (const [event, command, marker, async] of wanted) {
1343
+ for (const [event, command, marker, async, matcher] of wanted) {
1290
1344
  if (!command) continue;
1291
1345
  if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
1292
1346
  let existingHandler = null;
@@ -1306,7 +1360,7 @@ function mergeCodexHooksConfig(existing, commands) {
1306
1360
  }
1307
1361
  const handler = { type: 'command', command };
1308
1362
  if (async) handler.async = true;
1309
- config.hooks[event].push({ hooks: [handler] });
1363
+ config.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [handler] });
1310
1364
  }
1311
1365
  return config;
1312
1366
  }
@@ -1322,7 +1376,7 @@ function removeCodexPanHooks(existing) {
1322
1376
  for (const event of Object.keys(existing.hooks)) {
1323
1377
  if (!Array.isArray(existing.hooks[event])) continue;
1324
1378
  existing.hooks[event] = existing.hooks[event].filter(group =>
1325
- !(Array.isArray(group.hooks) && group.hooks.some(h => h.command && /pan-(check-update|context-monitor|cost-logger|trace-logger)/.test(h.command))));
1379
+ !(Array.isArray(group.hooks) && group.hooks.some(h => h.command && /pan-(check-update|context-monitor|cost-logger|trace-logger|stop-guard|state-reinject)/.test(h.command))));
1326
1380
  if (existing.hooks[event].length === 0) delete existing.hooks[event];
1327
1381
  }
1328
1382
  if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
@@ -1395,6 +1449,8 @@ function codexTrustNotice() {
1395
1449
  'installed pan-* agents) only loads once the project is trusted in Codex.',
1396
1450
  'If commands or agents seem missing, approve the project when Codex prompts,',
1397
1451
  'or set trust_level for this path in ~/.codex/config.toml.',
1452
+ 'Codex also skips new or changed hooks until you trust them: after installing',
1453
+ 'or upgrading PAN, run /hooks in Codex and trust the pan-* hooks.',
1398
1454
  ].join('\n');
1399
1455
  }
1400
1456
 
@@ -1688,20 +1744,26 @@ function buildPluginManifest(pkg) {
1688
1744
  }
1689
1745
 
1690
1746
  /**
1691
- * Build the plugin hooks/hooks.json — PAN's four hooks registered with
1692
- * ${CLAUDE_PLUGIN_ROOT}-anchored commands (the documented plugin-relative
1693
- * path convention for hook configs).
1747
+ * Build the plugin hooks/hooks.json — every hook a Claude settings.json install
1748
+ * registers (PAN_SETTINGS_HOOKS), with ${CLAUDE_PLUGIN_ROOT}-anchored commands
1749
+ * (the documented plugin-relative path convention for hook configs). The stop
1750
+ * guard was missing until R43 (2026-09-26), so an autonomous chain run from the
1751
+ * plugin could drop at a phase boundary with nothing to catch it.
1694
1752
  * @returns {object} hooks.json object
1695
1753
  */
1696
1754
  function buildPluginHooksConfig() {
1697
- const hook = (script) => ({
1755
+ const hook = (script, matcher) => ({
1756
+ ...(matcher ? { matcher } : {}),
1698
1757
  hooks: [{ type: 'command', command: `node "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"` }],
1699
1758
  });
1759
+ const events = HOOK_EVENT_MAP.claude;
1700
1760
  return {
1701
1761
  hooks: {
1702
- SessionStart: [hook('pan-check-update.js')],
1703
- PostToolUse: [hook('pan-context-monitor.js')],
1704
- SubagentStop: [hook('pan-cost-logger.js'), hook('pan-trace-logger.js')],
1762
+ // compact shares SessionStart with the update check, narrowed by its matcher (M10).
1763
+ [events.sessionStart]: [hook('pan-check-update.js'), hook('pan-state-reinject.js', HOOK_SLOT_MATCHERS.compact)],
1764
+ [events.postToolUse]: [hook('pan-context-monitor.js')],
1765
+ [events.subagentStop]: [hook('pan-cost-logger.js'), hook('pan-trace-logger.js')],
1766
+ [events.stop]: [hook('pan-stop-guard.js')],
1705
1767
  },
1706
1768
  };
1707
1769
  }
@@ -2305,6 +2367,7 @@ module.exports = {
2305
2367
  // Copilot CLI hooks config (2026-06)
2306
2368
  buildCopilotHooksConfig,
2307
2369
  HOOK_EVENT_MAP,
2370
+ HOOK_SLOT_MATCHERS,
2308
2371
  PAN_SETTINGS_HOOKS,
2309
2372
  stripPanHookEntries,
2310
2373
  mergeCodexHooksConfig,
@@ -2364,7 +2427,9 @@ function dirDigest(dir) {
2364
2427
  const crypto = require('crypto');
2365
2428
  const lines = [];
2366
2429
  const walk = (abs, rel) => {
2367
- const entries = fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
2430
+ // Code-unit order, not localeCompare: the digest compares builds across machines,
2431
+ // and a locale-dependent sort could order the same tree two ways (3.28.0 review, LOW).
2432
+ const entries = fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
2368
2433
  for (const e of entries) {
2369
2434
  const childAbs = path.join(abs, e.name);
2370
2435
  const childRel = rel ? `${rel}/${e.name}` : e.name;
@@ -2376,3 +2441,34 @@ function dirDigest(dir) {
2376
2441
  return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
2377
2442
  }
2378
2443
  module.exports.dirDigest = dirDigest;
2444
+
2445
+ /**
2446
+ * Whether `buf` is the file a manifest hash was taken from, allowing for line
2447
+ * endings. The manifest records sha256 over raw bytes, so a file git re-checked
2448
+ * out with `core.autocrlf=true` — or one an editor saved with the other line
2449
+ * ending — hashed as a user edit, and every upgrade backed it up to
2450
+ * pan-local-patches/ as a "locally modified PAN file" with nothing to merge (the
2451
+ * 3.30.0 upgrade sweep: 8 such files in three projects, 357 in one that commits
2452
+ * `.claude/` to git). A file matches when its raw bytes, its all-LF form or its
2453
+ * all-CRLF form hash to `expectedHash`, so existing manifests keep working
2454
+ * whichever line ending they were written with. A buffer holding a NUL byte is
2455
+ * treated as binary and compared raw only. Pure. `validateRuntimeInstall` in
2456
+ * pan-wizard-core/bin/lib/verify-deploy.cjs carries the same rule (the installed
2457
+ * core cannot require this file); tests/install-eol-hash.test.cjs pins both.
2458
+ * @param {Buffer} buf
2459
+ * @param {string|null|undefined} expectedHash - hex sha256 from the manifest
2460
+ * @returns {boolean}
2461
+ */
2462
+ function bytesMatchHashIgnoringEol(buf, expectedHash) {
2463
+ if (!expectedHash) return false;
2464
+ const crypto = require('crypto');
2465
+ const sha = b => crypto.createHash('sha256').update(b).digest('hex');
2466
+ if (sha(buf) === expectedHash) return true;
2467
+ if (buf.includes(0)) return false;
2468
+ // latin1 maps every byte to one char and back, so the round trip is lossless;
2469
+ // no UTF-8 multi-byte sequence contains 0x0D or 0x0A.
2470
+ const lf = buf.toString('latin1').replace(/\r\n/g, '\n');
2471
+ if (sha(Buffer.from(lf, 'latin1')) === expectedHash) return true;
2472
+ return sha(Buffer.from(lf.replace(/\n/g, '\r\n'), 'latin1')) === expectedHash;
2473
+ }
2474
+ module.exports.bytesMatchHashIgnoringEol = bytesMatchHashIgnoringEol;
package/bin/install.js CHANGED
@@ -1218,7 +1218,7 @@ function uninstall(isGlobal, runtime = 'claude') {
1218
1218
  // 4. Remove PAN hooks (scripts + Copilot CLI hooks config file)
1219
1219
  const hooksDir = path.join(targetDir, 'hooks');
1220
1220
  if (fs.existsSync(hooksDir)) {
1221
- const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan.json'];
1221
+ const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan-state-reinject.js', 'pan.json'];
1222
1222
  let hookCount = 0;
1223
1223
  for (const hook of panHooks) {
1224
1224
  const hookPath = path.join(hooksDir, hook);
@@ -2014,8 +2014,10 @@ function saveLocalPatches(configDir) {
2014
2014
 
2015
2015
  const fullPath = path.join(configDir, relPath);
2016
2016
  if (!fs.existsSync(fullPath)) continue;
2017
- const currentHash = fileHash(fullPath);
2018
- if (currentHash !== originalHash) {
2017
+ // A line-ending-only difference is not a user edit (git autocrlf, editors).
2018
+ let current;
2019
+ try { current = fs.readFileSync(fullPath); } catch { continue; }
2020
+ if (!lib.bytesMatchHashIgnoringEol(current, originalHash)) {
2019
2021
  const backupPath = path.join(patchesDir, safeRel);
2020
2022
  // Defence in depth: never write outside patchesDir, whatever the manifest says.
2021
2023
  const resolvedPatches = path.resolve(patchesDir);
@@ -2739,6 +2741,9 @@ function install(isGlobal, runtime = 'claude') {
2739
2741
  const stopGuardCommand = isGlobal
2740
2742
  ? buildHookCommand(targetDir, 'pan-stop-guard.js')
2741
2743
  : 'node ' + dirName + '/hooks/pan-stop-guard.js';
2744
+ const stateReinjectCommand = isGlobal
2745
+ ? buildHookCommand(targetDir, 'pan-state-reinject.js')
2746
+ : 'node ' + dirName + '/hooks/pan-state-reinject.js';
2742
2747
 
2743
2748
  if (isCodex) {
2744
2749
  // Codex hooks (2026-06): Claude-compatible PascalCase events in the shared
@@ -2750,13 +2755,13 @@ function install(isGlobal, runtime = 'claude') {
2750
2755
  let existing = null;
2751
2756
  try { existing = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); } catch { /* absent or invalid — start fresh */ }
2752
2757
  const merged = lib.mergeCodexHooksConfig(existing, {
2753
- updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand,
2758
+ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand,
2754
2759
  });
2755
2760
  fs.writeFileSync(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n');
2756
2761
  // Print the path we actually wrote. The hardcoded '.codex/hooks.json' was
2757
2762
  // wrong for --global and for --config-dir, telling users to inspect a file
2758
2763
  // that does not exist while the real one sat elsewhere.
2759
- console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksJsonPath)}: update check, context monitor, cost + trace loggers)`);
2764
+ console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksJsonPath)}: update check, context monitor, cost + trace loggers, stop guard, state re-injection)`);
2760
2765
  } catch (e) {
2761
2766
  pushInstallWarning('codexHooks', 'hooks.json', e);
2762
2767
  }
@@ -2768,12 +2773,12 @@ function install(isGlobal, runtime = 'claude') {
2768
2773
  // from any legacy config.json registration.
2769
2774
  if (isCopilot) {
2770
2775
  const hooksConfigPath = path.join(targetDir, 'hooks', 'pan.json');
2771
- const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand });
2776
+ const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand });
2772
2777
  try {
2773
2778
  fs.mkdirSync(path.dirname(hooksConfigPath), { recursive: true });
2774
2779
  fs.writeFileSync(hooksConfigPath, JSON.stringify(hooksConfig, null, 2) + '\n');
2775
2780
  // Same as the Codex case: a global Copilot install writes to ~/.copilot/, not .github/.
2776
- console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksConfigPath)}: update check, context monitor, cost + trace loggers)`);
2781
+ console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksConfigPath)}: update check, context monitor, cost + trace loggers, stop guard)`);
2777
2782
  } catch (e) {
2778
2783
  console.error(` ${yellow}✗${reset} Failed to write Copilot hooks config: ${e.message}`);
2779
2784
  }
@@ -2866,6 +2871,7 @@ function install(isGlobal, runtime = 'claude') {
2866
2871
  { slot: 'subagentStop', hook: 'pan-cost-logger', command: costLoggerCommand, label: 'cost logger hook' },
2867
2872
  { slot: 'subagentStop', hook: 'pan-trace-logger', command: traceLoggerCommand, label: 'trace logger hook' },
2868
2873
  { slot: 'stop', hook: 'pan-stop-guard', command: stopGuardCommand, label: 'auto-advance stop guard hook' },
2874
+ { slot: 'compact', hook: 'pan-state-reinject', command: stateReinjectCommand, label: 'state re-injection hook (after compaction)' },
2869
2875
  ];
2870
2876
  const unsupported = [];
2871
2877
  for (const r of registrations) {
@@ -2888,7 +2894,8 @@ function install(isGlobal, runtime = 'claude') {
2888
2894
  entry.hooks && entry.hooks.some(h => h.command && h.command.includes(r.hook))
2889
2895
  );
2890
2896
  if (!registered) {
2891
- settings.hooks[event].push({ hooks: [{ type: 'command', command: r.command }] });
2897
+ const matcher = lib.HOOK_SLOT_MATCHERS[r.slot];
2898
+ settings.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command: r.command }] });
2892
2899
  console.log(` ${green}✓${reset} Configured ${r.label}`);
2893
2900
  }
2894
2901
  }
@@ -2896,7 +2903,7 @@ function install(isGlobal, runtime = 'claude') {
2896
2903
  delete settings.hooks;
2897
2904
  }
2898
2905
  if (isGemini && unsupported.length > 0) {
2899
- console.log(` ${dim}ℹ Gemini CLI has no context-window metric for hooks and no subagent-completion event, so ${unsupported.join(', ')} ${unsupported.length === 1 ? 'is' : 'are'} not registered there${reset}`);
2906
+ console.log(` ${dim}ℹ Gemini CLI has no context-window metric for hooks, no subagent-completion event and no post-compaction event that adds context, so ${unsupported.join(', ')} ${unsupported.length === 1 ? 'is' : 'are'} not registered there${reset}`);
2900
2907
  }
2901
2908
  }
2902
2909
 
@@ -85,14 +85,14 @@ Every cap the conductor enforces applies to the campaign, scaled up:
85
85
  | `--source` | `backlog` | Work selection (delegates to focus-auto): `backlog` = ranked roadmap/requirements items; `scan` = category code-scan. |
86
86
  | `--max-cycles` | 5 | Mission items landed before stopping. |
87
87
  | `--total-budget` | 300 | Cumulative point budget. **Advisory by default** — tracked/surfaced but not a hard stop unless `--enforce-budget` / config `budget.enforce: true`. |
88
- | `--enforce-budget` | off | Make `--total-budget` a hard stop again (also settable via config `budget.enforce: true`); `--daily-budget` is enforced only by `enforce_budget: true` in `schedule.json`. |
88
+ | `--enforce-budget` | off | Make `--total-budget` a hard stop again (also settable via config `budget.enforce: true`). With `--schedule`, also arm the campaign with `pan-tools campaign schedule --enforce-budget`, so `--daily-budget` pauses the day's run once spent. |
89
89
  | `--verify-reserve` | 0.15 | Fraction of `--total-budget` (0–0.5) held back for the final Quality re-review so it can't be starved (config `budget.verify_reserve`). Advisory by default (surfaced as `into_verify_reserve`); under `--enforce-budget` it stops taking on new missions early (`budget_reserve_reached`) and the reserved points fund the closing `--clean-seal` re-verification before the last INTEGRATE. |
90
90
  | `--squads` | all | Restrict to a subset, e.g. `--squads architecture,build,quality`. |
91
91
  | `--no-build-worktrees` | off | Build in the main tree instead of branch-per-agent worktrees (small/serial projects). |
92
92
  | `--push` | off | Push approved merges to origin (still human-gated). |
93
93
  | `--clean-seal` | off | One clean build + full verification after the last item (commands from config). |
94
94
  | `--schedule` | off | Arm a self-resuming campaign at this cadence (`hourly`/`daily`/`weekly`/`Nh`/`Nd`) instead of running once — writes the schedule descriptor (ADR-0034). Pair with `--daily-budget`. |
95
- | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `enforce_budget: true` is set by hand in the schedule descriptor (`schedule.json`); no flag or config key sets it. |
95
+ | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it pauses the day's run only when the schedule is armed with `--enforce-budget` (`enforce_budget: true` in `schedule.json`). |
96
96
  | `--dry-run` | off | Plan + squad delegation preview only; STOP. |
97
97
  | `--continue` / `--stop` / `--status` | — | Resume / halt / report from `.planning/orchestration/` + focus-auto state. |
98
98
 
@@ -163,10 +163,10 @@ PAN is not a daemon — it cannot wake itself while the session is closed. `--sc
163
163
  - **Arm:** `/pan:army "<goal>" --schedule daily --daily-budget 200` writes `.planning/orchestration/schedule.json` (cadence, daily budget, next-due) instead of running once.
164
164
  - **The trigger (you wire one):** a host scheduler (Claude Code routines / cron / scheduled-tasks) or a `/loop` runs `pan-tools campaign due` and, when it reports due, invokes `/pan:army --continue`. On next session open, a due campaign is surfaced as a nudge.
165
165
  - **Resume (`--continue`):** read the schedule + `.planning/orchestration/` + focus-auto state. If `campaign due` is true and the day's `--daily-budget` isn't spent, run the next mission(s), then `campaign record-run` (advances next-due, accrues the day's spend). If not due or budget-spent, report next-due and STOP.
166
- - **Bounded spend:** point budgets (`--total-budget`, `--daily-budget`) are **advisory indicators by default** — they're tracked and surfaced, not hard stops. `--enforce-budget` (or config `budget.enforce`) makes `--total-budget` a hard stop; a scheduled campaign's `--daily-budget` pauses the day's run only when `enforce_budget: true` is set by hand in `schedule.json`. The real bounds are `--max-cycles`, the conductor caps, the abort file, and the human merge gate at every integrate. A scheduled campaign runs the backlog down to staged, reviewed, green PRs over days.
166
+ - **Bounded spend:** point budgets (`--total-budget`, `--daily-budget`) are **advisory indicators by default** — they're tracked and surfaced, not hard stops. `--enforce-budget` (or config `budget.enforce`) makes `--total-budget` a hard stop; a scheduled campaign's `--daily-budget` pauses the day's run only when the schedule was armed with `--enforce-budget` (`campaign schedule --enforce-budget`; `--advisory-budget` turns it back off). The real bounds are `--max-cycles`, the conductor caps, the abort file, and the human merge gate at every integrate. A scheduled campaign runs the backlog down to staged, reviewed, green PRs over days.
167
167
  - **Verify reserve:** a fraction of `--total-budget` (`--verify-reserve`, default 0.15) is held back so the closing Quality re-review isn't starved. Surfaced always via `campaign status` / `focus auto --status` (`into_verify_reserve`, `new_work_budget_remaining`); under `--enforce-budget` the run stops taking on new missions early (`budget_reserve_reached`) and spends the reserve on the final `--clean-seal` verification before the last INTEGRATE.
168
168
 
169
- Manage it: `pan-tools campaign status` (active/paused, spent today, next-due), `campaign schedule --pause` / `--resume` / `--disable`.
169
+ Manage it: `pan-tools campaign status` (active/paused, spent today, next-due), `campaign schedule --pause` / `--resume` / `--disable`, and `--enforce-budget` / `--advisory-budget` for the daily budget.
170
170
 
171
171
  ---
172
172
 
@@ -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,