pan-wizard 3.29.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 (50) hide show
  1. package/README.md +40 -40
  2. package/bin/install-lib.cjs +186 -83
  3. package/bin/install.js +213 -195
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +5 -5
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/commands/pan/verify-phase.md +8 -9
  10. package/hooks/dist/pan-check-update.js +53 -2
  11. package/hooks/dist/pan-context-monitor.js +41 -0
  12. package/hooks/dist/pan-cost-logger.js +71 -2
  13. package/hooks/dist/pan-state-reinject.js +158 -0
  14. package/hooks/dist/pan-stop-guard.js +127 -2
  15. package/hooks/dist/pan-trace-logger.js +42 -1
  16. package/package.json +1 -1
  17. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  18. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  19. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  20. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  21. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  22. package/pan-wizard-core/bin/lib/core.cjs +249 -27
  23. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  24. package/pan-wizard-core/bin/lib/cost.cjs +234 -88
  25. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  26. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  27. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
  28. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  29. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  30. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  31. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  32. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  33. package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
  34. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  35. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  36. package/pan-wizard-core/references/model-profiles.md +4 -4
  37. package/pan-wizard-core/references/planning-config.md +19 -23
  38. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  39. package/pan-wizard-core/workflows/help.md +2 -2
  40. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  41. package/pan-wizard-core/workflows/progress.md +1 -1
  42. package/pan-wizard-core/workflows/settings.md +2 -4
  43. package/pan-wizard-core/workflows/todo-add.md +1 -1
  44. package/pan-wizard-core/workflows/todo-check.md +7 -6
  45. package/scripts/build-agent-plugin.js +4 -0
  46. package/scripts/build-hooks.js +2 -1
  47. package/scripts/build-plugin.js +10 -0
  48. package/scripts/npm-dist-tag.js +47 -0
  49. package/scripts/plugin-path.js +6 -1
  50. package/scripts/test-surface.cjs +11 -9
@@ -446,7 +446,7 @@ function convertClaudeCommandToCodexSkill(content, skillName) {
446
446
  *
447
447
  * Unlike the Codex/Copilot adapters, this header makes no assumptions about
448
448
  * the consuming runtime — the same SKILL.md in the shared `.agents/skills/`
449
- * tree is read by every runtime, so invocation, delegation, and interaction
449
+ * tree is read by every runtime (Claude Code via its byte-identical .claude/skills/ copy), so invocation, delegation, and interaction
450
450
  * guidance are phrased in terms of "your runtime's native mechanism".
451
451
  */
452
452
  function getUnifiedSkillAdapterHeader(skillName, note) {
@@ -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 ─────────────────────────────────────────────────
@@ -884,51 +908,6 @@ function buildHookCommand(configDir, hookName) {
884
908
 
885
909
  // ─── Opus 4.7 Skills & Thinking ────────────────────────────────────────────
886
910
 
887
- /**
888
- * Build a Claude Code native skill shim for a PAN command.
889
- *
890
- * Claude Code 1.x discovers skills in `.claude/skills/` by frontmatter.
891
- * PAN's commands live in `.claude/commands/pan/`, so we write a small shim
892
- * that registers the command as a skill pointing back at the command file.
893
- *
894
- * @param {Object} opts
895
- * @param {string} opts.commandName - e.g. "focus-scan"
896
- * @param {string} opts.description - Human-readable one-liner (≤120 chars preferred)
897
- * @param {string} [opts.trigger] - Optional trigger guidance for auto-invocation
898
- * @returns {string} Skill markdown content
899
- */
900
- function buildClaudeSkillShim(opts) {
901
- if (!opts || typeof opts.commandName !== 'string' || !opts.commandName.trim()) {
902
- throw new Error('buildClaudeSkillShim: commandName is required');
903
- }
904
- const name = opts.commandName.trim();
905
- const description = (opts.description || '').replace(/\s+/g, ' ').trim();
906
- const trigger = (opts.trigger || '').replace(/\s+/g, ' ').trim();
907
-
908
- const frontmatter = [
909
- '---',
910
- `name: pan-${name}`,
911
- `description: ${yamlQuote(description)}`,
912
- trigger ? `trigger: ${yamlQuote(trigger)}` : null,
913
- 'source: pan-wizard',
914
- '---',
915
- ].filter(Boolean).join('\n');
916
-
917
- const body = [
918
- '',
919
- `# /pan:${name}`,
920
- '',
921
- description || `PAN command: ${name}`,
922
- '',
923
- `Invokes the command defined at \`.claude/commands/pan/${name}.md\`.`,
924
- '',
925
- `To use, run: \`/pan:${name}\``,
926
- '',
927
- ].join('\n');
928
-
929
- return frontmatter + body;
930
- }
931
-
932
911
  /**
933
912
  * Translate a reasoning-depth directive from the generic PAN frontmatter
934
913
  * shape into runtime-specific syntax (or prose fallback).
@@ -1066,7 +1045,7 @@ function stripThinkingFrontmatter(content, runtime) {
1066
1045
  * @returns {Object} A `.github/hooks/pan.json` config object
1067
1046
  */
1068
1047
  function buildCopilotHooksConfig(commands) {
1069
- const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
1048
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand } = commands || {};
1070
1049
  const config = { version: 1, hooks: {} };
1071
1050
  if (updateCheckCommand) {
1072
1051
  config.hooks.sessionStart = [{ type: 'command', command: updateCheckCommand }];
@@ -1075,34 +1054,109 @@ function buildCopilotHooksConfig(commands) {
1075
1054
  config.hooks.postToolUse = [{ type: 'command', command: contextMonitorCommand }];
1076
1055
  }
1077
1056
  // subagentStop is Copilot's SubagentStop equivalent (verified docs.github.com
1078
- // 2026-06) — carries the cost + trace loggers, same as Claude/Gemini.
1057
+ // 2026-06) — carries the cost + trace loggers, same as Claude and Codex (Gemini registers neither).
1079
1058
  const subagentStop = [];
1080
1059
  if (costLoggerCommand) subagentStop.push({ type: 'command', command: costLoggerCommand });
1081
1060
  if (traceLoggerCommand) subagentStop.push({ type: 'command', command: traceLoggerCommand });
1082
1061
  if (subagentStop.length > 0) {
1083
1062
  config.hooks.subagentStop = subagentStop;
1084
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
+ }
1085
1070
  return config;
1086
1071
  }
1087
1072
 
1088
1073
  // ─── Codex hooks config (2026-06) ───────────────────────────────────────────
1089
1074
 
1090
1075
  /**
1091
- * Cross-runtime hook event map (canonical PAN event → per-runtime name).
1092
- * Claude/Gemini register in settings.json; Codex in `.codex/hooks.json`
1093
- * (Claude-compatible PascalCase events — verified developers.openai.com
1094
- * 2026-06, project-scoped hooks load once the project is trusted); Copilot
1095
- * in `.github/hooks/pan.json` (camelCase — verified docs.github.com 2026-06).
1096
- * OpenCode has no hook support.
1076
+ * Cross-runtime hook event map: canonical PAN slot → the event name PAN registers
1077
+ * under on each runtime, or null where PAN deliberately registers nothing for that
1078
+ * slot. The installers read this table; tests/fixtures/hook-vocabulary.json holds
1079
+ * each runtime's documented event names, and tests/hook-vocabulary.test.cjs fails
1080
+ * when an emitted key is not one of them.
1081
+ *
1082
+ * - claude: settings.json, PascalCase (code.claude.com/docs/en/hooks).
1083
+ * - gemini: settings.json, in Gemini's OWN vocabulary (gemini-cli
1084
+ * packages/core/src/hooks/types.ts `HookEventName`, read 2026-09-23). Any
1085
+ * other key is skipped at load with an "Invalid hook event name" warning —
1086
+ * which is what PAN's Claude-named PostToolUse, SubagentStop and Stop entries
1087
+ * were from v3.4 until 2026-09-23: registered, reported, never run (R29).
1088
+ * postToolUse is null because no Gemini hook payload or setting exposes the
1089
+ * context-window usage the context monitor reads (Gemini has no statusline
1090
+ * command either). subagentStop is null because Gemini has no
1091
+ * subagent-completion event and hands every hook the main session's
1092
+ * transcript, not the subagent's, so neither logger has anything to measure.
1093
+ * - codex: `.codex/hooks.json`, Claude-compatible PascalCase (developers.openai.com
1094
+ * 2026-06; codex-rs config/src/hook_config.rs read 2026-09-23). Project-scoped
1095
+ * hooks load once the project is trusted.
1096
+ * - copilot: `.github/hooks/pan.json`, camelCase (docs.github.com hooks reference).
1097
+ * - opencode: no hook system.
1098
+ *
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.
1097
1113
  */
1098
1114
  const HOOK_EVENT_MAP = Object.freeze({
1099
- claude: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop' },
1100
- gemini: { surface: 'settings.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop' },
1101
- codex: { surface: 'hooks.json', sessionStart: 'SessionStart', postToolUse: 'PostToolUse', subagentStop: 'SubagentStop' },
1102
- copilot: { surface: 'hooks/pan.json', sessionStart: 'sessionStart', postToolUse: 'postToolUse', subagentStop: 'subagentStop' },
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 },
1103
1119
  opencode: null,
1104
1120
  });
1105
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
+
1129
+ /** The hook scripts PAN registers in a Claude-shaped settings.json `hooks` block. */
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']);
1131
+
1132
+ /**
1133
+ * Remove, from every event array of a Claude-shaped `hooks` object except
1134
+ * `keepEvent`, the entries that run one of `hookNames`; emptied arrays are
1135
+ * dropped. Returns the event names entries were removed from. Mutates `hooks`.
1136
+ *
1137
+ * This is how a hook that moved event, or lost its event on a runtime, is cleaned
1138
+ * up on upgrade and on uninstall — keyed on the script, never on a remembered list
1139
+ * of event names, which is how dead keys survived before (R29).
1140
+ *
1141
+ * @param {object} hooks - settings.hooks
1142
+ * @param {string[]} hookNames - script basenames without `.js` (e.g. 'pan-stop-guard')
1143
+ * @param {string|null} [keepEvent] - the event the hook now belongs to, left alone
1144
+ * @returns {string[]}
1145
+ */
1146
+ function stripPanHookEntries(hooks, hookNames, keepEvent = null) {
1147
+ const touched = [];
1148
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return touched;
1149
+ for (const event of Object.keys(hooks)) {
1150
+ if (event === keepEvent || !Array.isArray(hooks[event])) continue;
1151
+ const before = hooks[event].length;
1152
+ hooks[event] = hooks[event].filter(entry => !(entry && Array.isArray(entry.hooks)
1153
+ && entry.hooks.some(h => h && typeof h.command === 'string' && hookNames.some(n => h.command.includes(n)))));
1154
+ if (hooks[event].length < before) touched.push(event);
1155
+ if (hooks[event].length === 0) delete hooks[event];
1156
+ }
1157
+ return touched;
1158
+ }
1159
+
1106
1160
  // ─── MCP server registration (2026-08) ──────────────────────────────────────
1107
1161
 
1108
1162
  /**
@@ -1257,7 +1311,7 @@ function buildCodexMcpSnippet(serverPath, panToolsPath, projectRoot, serverName
1257
1311
  * @returns {object} Merged config object to serialize back to hooks.json
1258
1312
  */
1259
1313
  function mergeCodexHooksConfig(existing, commands) {
1260
- const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
1314
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand } = commands || {};
1261
1315
  const config = (existing && typeof existing === 'object') ? existing : {};
1262
1316
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
1263
1317
 
@@ -1270,6 +1324,10 @@ function mergeCodexHooksConfig(existing, commands) {
1270
1324
  // - check-update spawns a detached child and prints nothing → async.
1271
1325
  // - context-monitor returns `additionalContext` the model must see THIS
1272
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).
1273
1331
  // Codex-only: Claude Code and Copilot hook schemas were not checked for an
1274
1332
  // equivalent flag (plan item 2 gate) — do not copy this column into their
1275
1333
  // builders without reading their docs first.
@@ -1278,9 +1336,11 @@ function mergeCodexHooksConfig(existing, commands) {
1278
1336
  ['PostToolUse', contextMonitorCommand, 'pan-context-monitor', false],
1279
1337
  ['SubagentStop', costLoggerCommand, 'pan-cost-logger', true],
1280
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],
1281
1341
  ];
1282
1342
 
1283
- for (const [event, command, marker, async] of wanted) {
1343
+ for (const [event, command, marker, async, matcher] of wanted) {
1284
1344
  if (!command) continue;
1285
1345
  if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
1286
1346
  let existingHandler = null;
@@ -1300,7 +1360,7 @@ function mergeCodexHooksConfig(existing, commands) {
1300
1360
  }
1301
1361
  const handler = { type: 'command', command };
1302
1362
  if (async) handler.async = true;
1303
- config.hooks[event].push({ hooks: [handler] });
1363
+ config.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [handler] });
1304
1364
  }
1305
1365
  return config;
1306
1366
  }
@@ -1316,7 +1376,7 @@ function removeCodexPanHooks(existing) {
1316
1376
  for (const event of Object.keys(existing.hooks)) {
1317
1377
  if (!Array.isArray(existing.hooks[event])) continue;
1318
1378
  existing.hooks[event] = existing.hooks[event].filter(group =>
1319
- !(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))));
1320
1380
  if (existing.hooks[event].length === 0) delete existing.hooks[event];
1321
1381
  }
1322
1382
  if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
@@ -1389,6 +1449,8 @@ function codexTrustNotice() {
1389
1449
  'installed pan-* agents) only loads once the project is trusted in Codex.',
1390
1450
  'If commands or agents seem missing, approve the project when Codex prompts,',
1391
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.',
1392
1454
  ].join('\n');
1393
1455
  }
1394
1456
 
@@ -1682,20 +1744,26 @@ function buildPluginManifest(pkg) {
1682
1744
  }
1683
1745
 
1684
1746
  /**
1685
- * Build the plugin hooks/hooks.json — PAN's four hooks registered with
1686
- * ${CLAUDE_PLUGIN_ROOT}-anchored commands (the documented plugin-relative
1687
- * 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.
1688
1752
  * @returns {object} hooks.json object
1689
1753
  */
1690
1754
  function buildPluginHooksConfig() {
1691
- const hook = (script) => ({
1755
+ const hook = (script, matcher) => ({
1756
+ ...(matcher ? { matcher } : {}),
1692
1757
  hooks: [{ type: 'command', command: `node "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"` }],
1693
1758
  });
1759
+ const events = HOOK_EVENT_MAP.claude;
1694
1760
  return {
1695
1761
  hooks: {
1696
- SessionStart: [hook('pan-check-update.js')],
1697
- PostToolUse: [hook('pan-context-monitor.js')],
1698
- 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')],
1699
1767
  },
1700
1768
  };
1701
1769
  }
@@ -2289,7 +2357,6 @@ module.exports = {
2289
2357
  parseJsonc,
2290
2358
  // Opus 4.7 capabilities
2291
2359
  detectModelCapabilities,
2292
- buildClaudeSkillShim,
2293
2360
  translateThinkingDirective,
2294
2361
  stripThinkingFrontmatter,
2295
2362
  // Gemini CLI → Antigravity transition (2026-06)
@@ -2300,6 +2367,9 @@ module.exports = {
2300
2367
  // Copilot CLI hooks config (2026-06)
2301
2368
  buildCopilotHooksConfig,
2302
2369
  HOOK_EVENT_MAP,
2370
+ HOOK_SLOT_MATCHERS,
2371
+ PAN_SETTINGS_HOOKS,
2372
+ stripPanHookEntries,
2303
2373
  mergeCodexHooksConfig,
2304
2374
  MCP_REGISTRATION,
2305
2375
  buildMcpServerEntry,
@@ -2357,7 +2427,9 @@ function dirDigest(dir) {
2357
2427
  const crypto = require('crypto');
2358
2428
  const lines = [];
2359
2429
  const walk = (abs, rel) => {
2360
- 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));
2361
2433
  for (const e of entries) {
2362
2434
  const childAbs = path.join(abs, e.name);
2363
2435
  const childRel = rel ? `${rel}/${e.name}` : e.name;
@@ -2369,3 +2441,34 @@ function dirDigest(dir) {
2369
2441
  return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
2370
2442
  }
2371
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;