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.
- package/README.md +40 -40
- package/bin/install-lib.cjs +186 -83
- package/bin/install.js +213 -195
- package/commands/pan/army.md +4 -4
- package/commands/pan/audit-deployment.md +2 -2
- package/commands/pan/cost.md +5 -5
- package/commands/pan/exec-phase.md +2 -0
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +41 -0
- package/hooks/dist/pan-cost-logger.js +71 -2
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +127 -2
- package/hooks/dist/pan-trace-logger.js +42 -1
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands.cjs +20 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +3 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +249 -27
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +234 -88
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +86 -22
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- package/pan-wizard-core/references/model-profiles.md +4 -4
- package/pan-wizard-core/references/planning-config.md +19 -23
- package/pan-wizard-core/workflows/exec-phase.md +8 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/settings.md +2 -4
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +11 -9
package/bin/install-lib.cjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
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 = `\
|
|
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
|
|
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
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
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:
|
|
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 —
|
|
1686
|
-
* ${CLAUDE_PLUGIN_ROOT}-anchored commands
|
|
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
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
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;
|