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.
- package/README.md +2 -2
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- 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 +70 -1
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +48 -2
- package/hooks/dist/pan-trace-logger.js +41 -0
- package/package.json +1 -1
- 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 +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +132 -43
- 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 +9 -2
- 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 +19 -2
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- 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/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 +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
|
|
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
|
package/bin/install-lib.cjs
CHANGED
|
@@ -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 ─────────────────────────────────────────────────
|
|
@@ -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
|
-
*
|
|
1071
|
-
*
|
|
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:
|
|
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 —
|
|
1692
|
-
* ${CLAUDE_PLUGIN_ROOT}-anchored commands
|
|
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
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2018
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/commands/pan/army.md
CHANGED
|
@@ -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`)
|
|
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
|
|
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 `
|
|
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:
|
|
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
|
-
|
|
16
|
+
Verify that the phase delivered what it promised, not just that its tasks completed.
|
|
17
17
|
|
|
18
|
-
Purpose:
|
|
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}-
|
|
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:
|
|
30
|
+
- If not provided: prompt for the phase
|
|
32
31
|
|
|
33
|
-
Context files are resolved inside the workflow (`init
|
|
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-
|
|
91
|
-
Preserve all workflow gates (
|
|
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
|
|
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
|
|
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,
|