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
|
@@ -77,7 +77,7 @@ For the detected runtime config directory (CONFIG_DIR), audit ALL of the followi
|
|
|
77
77
|
- [ ] `CONFIG_DIR/pan-wizard-core/references/` — non-empty, every manifest-listed reference present
|
|
78
78
|
|
|
79
79
|
**1.4 Commands**
|
|
80
|
-
- For Claude/Gemini: `CONFIG_DIR/commands/pan/` — every manifest-listed command file present
|
|
80
|
+
- For Claude/Gemini: `CONFIG_DIR/commands/pan/` — every manifest-listed command file present (a `--unified-skills` install has none: check `./.agents/skills/pan-*/SKILL.md`, and for Claude also `CONFIG_DIR/skills/pan-*/SKILL.md`)
|
|
81
81
|
- For OpenCode: `CONFIG_DIR/commands/` — every manifest-listed `pan-*.md` present (flat, one file per command)
|
|
82
82
|
- For Copilot: `CONFIG_DIR/skills/pan-*/SKILL.md` — every manifest-listed skill directory present
|
|
83
83
|
- For Codex: the SHARED skills tree, not `CONFIG_DIR` — `./.agents/skills/pan-*/SKILL.md` for a local
|
|
@@ -95,7 +95,7 @@ For the detected runtime config directory (CONFIG_DIR), audit ALL of the followi
|
|
|
95
95
|
|
|
96
96
|
**1.7 Settings/Config**
|
|
97
97
|
- [ ] Settings file exists (settings.json / opencode.json / config.json)
|
|
98
|
-
- [ ] Hooks are registered
|
|
98
|
+
- [ ] Hooks are registered — Claude `settings.json`: SessionStart, PostToolUse, SubagentStop, Stop, statusLine; Gemini `settings.json`: SessionStart and AfterAgent only (no statusLine); Codex: `.codex/hooks.json`; Copilot: `.github/hooks/pan.json`
|
|
99
99
|
- [ ] Hook commands point to existing files
|
|
100
100
|
|
|
101
101
|
**1.8 Manifest Integrity**
|
package/commands/pan/cost.md
CHANGED
|
@@ -123,7 +123,7 @@ When a record has neither a known model nor a known tier, its cost is `null` and
|
|
|
123
123
|
|
|
124
124
|
<instrumentation_note>
|
|
125
125
|
|
|
126
|
-
Token records are written by any caller that knows its usage — typically the host runtime or a wrapper. PAN ships the log format
|
|
126
|
+
Token records are written by any caller that knows its usage — typically the host runtime or a wrapper. PAN ships the log format, this aggregator, and the `pan-cost-logger` capture hook, which the installer registers on Claude Code, Codex and Copilot CLI. Elsewhere, records can be appended manually via `pan-tools cost append` or by external scripts reading the provider API.
|
|
127
127
|
|
|
128
128
|
If `.planning/metrics/tokens.jsonl` is empty, `/pan:cost` returns zero totals — the feature is inert, not broken.
|
|
129
129
|
|
|
@@ -134,10 +134,10 @@ If `.planning/metrics/tokens.jsonl` is empty, `/pan:cost` returns zero totals
|
|
|
134
134
|
| Runtime | Support |
|
|
135
135
|
|---------|---------|
|
|
136
136
|
| Claude Code | Full — data format + aggregation + all output formats |
|
|
137
|
-
| OpenCode | Full aggregator;
|
|
138
|
-
| Gemini | Full aggregator;
|
|
139
|
-
| Codex | Full
|
|
140
|
-
| Copilot CLI | Full aggregator;
|
|
137
|
+
| OpenCode | Full aggregator; PAN registers no hooks on OpenCode, so records come from `pan-tools cost append` or an external script |
|
|
138
|
+
| Gemini | Full aggregator; PAN registers no cost hook on Gemini CLI (no subagent-completion event), so records come from `pan-tools cost append` or an external script |
|
|
139
|
+
| Codex | Full — `pan-cost-logger` registered on `SubagentStop` in `.codex/hooks.json` |
|
|
140
|
+
| Copilot CLI | Full aggregator; `pan-cost-logger` registered on `subagentStop` in `.github/hooks/pan.json` (token counts depend on what the payload carries) |
|
|
141
141
|
|
|
142
142
|
The aggregator is runtime-agnostic. What varies across runtimes is how records *get into* `tokens.jsonl` in the first place.
|
|
143
143
|
|
|
@@ -117,6 +117,8 @@ Next: Wave {N+1} — {task count} tasks [{task IDs}]
|
|
|
117
117
|
```
|
|
118
118
|
This prevents drift in multi-wave phases where the agent loses track of which waves remain and what the test baseline was.
|
|
119
119
|
|
|
120
|
+
**Task tracking:** where your runtime offers a todo or task-tracking tool, you may keep the wave list in it. Do not depend on one: some runtimes offer none on their newest models (Claude Code gates its task tools behind `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` there). The attention anchor above, the wave summaries and state.md are the record either way.
|
|
121
|
+
|
|
120
122
|
**State Intent Before Implementing (M+ tasks):**
|
|
121
123
|
For each STANDARD or FULL task, state before coding: "I will modify [files], adding [what], to achieve [goal]. Risk: [what could break]."
|
|
122
124
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: focus-auto
|
|
3
3
|
group: Focus
|
|
4
|
-
description: Continuous scan-plan-exec loop with purpose-driven categories and
|
|
4
|
+
description: Continuous scan-plan-exec loop with purpose-driven categories and a layered safety harness
|
|
5
5
|
allowed-tools:
|
|
6
6
|
- Read
|
|
7
7
|
- Write
|
|
@@ -107,7 +107,7 @@ Wait for the user's reply before proceeding. Do not guess or pick a default cate
|
|
|
107
107
|
| `--stop` | — | Gracefully stop active run |
|
|
108
108
|
| `--status` | — | Show current campaign progress |
|
|
109
109
|
| `--dry-run` | — | Show plan without executing |
|
|
110
|
-
| `--deep-review` | off | After every exec cycle, run inline OWASP security check on changed files. Verdict `block` or `review_required` stops the campaign (
|
|
110
|
+
| `--deep-review` | off | After every exec cycle, run inline OWASP security check on changed files. Verdict `block` or `review_required` stops the campaign (the security-gate layer of the safety harness). Works with all categories. |
|
|
111
111
|
| `--parallel-research` | off | Fan out the per-item *research* stage via the Workflow tool (read-only agents). No-op fallback to sequential where the host has no Workflow tool. (ADR-0031) |
|
|
112
112
|
| `--parallel-verify` | off | Fan out the per-item *verify* stage via the Workflow tool (read-only). The implement/exec stage always stays a single agent. (ADR-0031) |
|
|
113
113
|
| `--clean-seal` | off | After the loop's last item, run one clean build + full verification (commands from `config.json → build`/`verification`) to catch cross-item orphans. (ADR-0031) |
|
|
@@ -185,7 +185,7 @@ Phase 2 (each cycle): Scan → Plan → Exec → Commit is strictly sequential w
|
|
|
185
185
|
HARD STOP conditions:
|
|
186
186
|
- Phase 1 fails (tests broken): Do not enter main loop — report and exit
|
|
187
187
|
- Any cycle: test count drops below baseline after revert → stop campaign, preserve state
|
|
188
|
-
- Context drops below 25%: stop campaign cleanly (safety harness
|
|
188
|
+
- Context drops below 25%: stop campaign cleanly (safety harness)
|
|
189
189
|
</phase_dependencies>
|
|
190
190
|
|
|
191
191
|
### Phase 2: Main Loop
|
|
@@ -413,7 +413,7 @@ Then continue immediately to the next cycle (back to Step 2.1).
|
|
|
413
413
|
|
|
414
414
|
4. Remove safety tag: `git tag -d focus-auto-baseline 2>/dev/null`
|
|
415
415
|
|
|
416
|
-
##
|
|
416
|
+
## Layered Safety Harness
|
|
417
417
|
|
|
418
418
|
| Layer | Mechanism | Action |
|
|
419
419
|
|-------|-----------|--------|
|
|
@@ -425,7 +425,7 @@ Then continue immediately to the next cycle (back to Step 2.1).
|
|
|
425
425
|
| Zero-completed guard | 0 items done in a cycle | Stop — further cycles won't help |
|
|
426
426
|
| Security gate (`--deep-review`) | Critical/high OWASP pattern in changed files | Revert last commit (critical) or flag for manual review (high), stop campaign |
|
|
427
427
|
|
|
428
|
-
##
|
|
428
|
+
## Behavioral Rules
|
|
429
429
|
|
|
430
430
|
1. **Read Before Write** — Read every file before editing. Understand context, callers, invariants.
|
|
431
431
|
2. **Root Cause** — Fix the actual defect, not symptoms. Trace the code path.
|
|
@@ -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,
|
|
@@ -25,6 +25,45 @@ const fs = require('fs');
|
|
|
25
25
|
const os = require('os');
|
|
26
26
|
const path = require('path');
|
|
27
27
|
|
|
28
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
29
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
30
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
31
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
32
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
33
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
34
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
35
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
36
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
37
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
38
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
39
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
40
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
41
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
42
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
43
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
44
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
45
|
+
try {
|
|
46
|
+
const hooksDir = path.dirname(hookFile);
|
|
47
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
48
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
49
|
+
const script = path.basename(hookFile);
|
|
50
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
51
|
+
let settings;
|
|
52
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
53
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
54
|
+
if (!events) continue;
|
|
55
|
+
for (const groups of Object.values(events)) {
|
|
56
|
+
if (!Array.isArray(groups)) continue;
|
|
57
|
+
for (const group of groups) {
|
|
58
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
59
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* fail open: run this copy */ }
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
// Per-user bridge directory inside tmpdir, created 0700 so another user on a
|
|
29
68
|
// shared host can't pre-plant a symlink at a predictable session path or read
|
|
30
69
|
// the bridge files. Both hooks derive the same dir from the same uid, so the
|
|
@@ -135,6 +174,7 @@ function main() {
|
|
|
135
174
|
process.stdin.on('end', () => {
|
|
136
175
|
try {
|
|
137
176
|
const data = JSON.parse(input);
|
|
177
|
+
if (deferToClaudeRegistration(data.cwd || process.cwd())) process.exit(0);
|
|
138
178
|
const sessionId = data.session_id;
|
|
139
179
|
|
|
140
180
|
if (!sessionId) {
|
|
@@ -199,6 +239,7 @@ if (require.main === module) {
|
|
|
199
239
|
}
|
|
200
240
|
|
|
201
241
|
module.exports = {
|
|
242
|
+
deferToClaudeRegistration,
|
|
202
243
|
buildContextWarning,
|
|
203
244
|
bridgeDir,
|
|
204
245
|
WARNING_THRESHOLD,
|
|
@@ -17,6 +17,45 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
22
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
23
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
24
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
25
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
26
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
27
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
28
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
29
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
30
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
31
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
32
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
33
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
34
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
35
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
36
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
37
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
38
|
+
try {
|
|
39
|
+
const hooksDir = path.dirname(hookFile);
|
|
40
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
41
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
42
|
+
const script = path.basename(hookFile);
|
|
43
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
44
|
+
let settings;
|
|
45
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
46
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
47
|
+
if (!events) continue;
|
|
48
|
+
for (const groups of Object.values(events)) {
|
|
49
|
+
if (!Array.isArray(groups)) continue;
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
52
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { /* fail open: run this copy */ }
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
20
59
|
const crypto = require('crypto');
|
|
21
60
|
|
|
22
61
|
// Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
|
|
@@ -182,7 +221,7 @@ function readActiveSessionMeta(cwd) {
|
|
|
182
221
|
const COMMAND_TAIL_BYTES = 262144;
|
|
183
222
|
|
|
184
223
|
// PAN's own command namespace, as a runtime writes it: `/pan:exec-phase` (Claude Code,
|
|
185
|
-
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot). Only these spawn PAN agents,
|
|
224
|
+
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot, and Claude Code after --unified-skills). Only these spawn PAN agents,
|
|
186
225
|
// so only these are attributed — a host UI command (`/model`, `/compact`) and a plain
|
|
187
226
|
// typed prompt leave the row honestly unattributed instead of borrowing a name.
|
|
188
227
|
const PAN_COMMAND_RE = /<command-name>\s*\/?pan[:-]([a-z0-9][a-z0-9-]*)\s*<\/command-name>/i;
|
|
@@ -564,6 +603,8 @@ function buildCostRecord(data, cwd) {
|
|
|
564
603
|
let outputTokens = 0;
|
|
565
604
|
let cacheRead = 0;
|
|
566
605
|
let cacheWrite = 0;
|
|
606
|
+
// { h1, m5 } when the source carried the cache-write lifetime split (M13).
|
|
607
|
+
let ttlSplit = null;
|
|
567
608
|
let durationMs = null;
|
|
568
609
|
// token_source records WHICH path produced the counts: `agent-transcript` (the
|
|
569
610
|
// subagent's own conversation file — see resolveAgentTranscript), `transcript`
|
|
@@ -621,6 +662,10 @@ function buildCostRecord(data, cwd) {
|
|
|
621
662
|
cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
|
|
622
663
|
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
|
|
623
664
|
|| rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
|
|
665
|
+
// A clamped write total no longer matches its split, so the split is dropped with it.
|
|
666
|
+
if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
|
|
667
|
+
ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
|
|
668
|
+
}
|
|
624
669
|
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
|
|
625
670
|
if (!model) model = fromTranscript.model;
|
|
626
671
|
if (fromTranscript.lineCount > since) {
|
|
@@ -676,6 +721,11 @@ function buildCostRecord(data, cwd) {
|
|
|
676
721
|
cacheRead = clampPlausible(rawCr);
|
|
677
722
|
cacheWrite = clampPlausible(rawCw);
|
|
678
723
|
clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
|
|
724
|
+
const split = {};
|
|
725
|
+
addCacheTtlSplit(split, data.usage);
|
|
726
|
+
if (typeof split.cache_write_1h_tokens === 'number' && cacheWrite === rawCw) {
|
|
727
|
+
ttlSplit = { h1: clampPlausible(split.cache_write_1h_tokens), m5: clampPlausible(split.cache_write_5m_tokens) };
|
|
728
|
+
}
|
|
679
729
|
}
|
|
680
730
|
|
|
681
731
|
// Backfill command/phase from the active trace session when the payload omits
|
|
@@ -705,6 +755,8 @@ function buildCostRecord(data, cwd) {
|
|
|
705
755
|
output_tokens: outputTokens,
|
|
706
756
|
cache_read_tokens: cacheRead,
|
|
707
757
|
cache_write_tokens: cacheWrite,
|
|
758
|
+
// The lifetime split of cache_write_tokens, present only when measured (M13).
|
|
759
|
+
...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
|
|
708
760
|
cost_usd: null,
|
|
709
761
|
duration_ms: durationMs,
|
|
710
762
|
phase,
|
|
@@ -833,11 +885,27 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
833
885
|
totals.output_tokens += extractNumber(usage, 'output_tokens');
|
|
834
886
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
835
887
|
totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
|
|
888
|
+
addCacheTtlSplit(totals, usage);
|
|
836
889
|
}
|
|
837
890
|
totals.lineCount = seen;
|
|
838
891
|
return totals;
|
|
839
892
|
}
|
|
840
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Add a usage record's cache-write lifetime split to `totals` (M13). Claude Code
|
|
896
|
+
* transcripts carry `usage.cache_creation: { ephemeral_5m_input_tokens,
|
|
897
|
+
* ephemeral_1h_input_tokens }` beside the `cache_creation_input_tokens` total;
|
|
898
|
+
* one-hour writes bill at 2x base input against 1.25x for five-minute ones, so
|
|
899
|
+
* the ledger keeps the split. The totals gain the two fields only when some
|
|
900
|
+
* record carried the block — rows from hosts without it stay unchanged.
|
|
901
|
+
*/
|
|
902
|
+
function addCacheTtlSplit(totals, usage) {
|
|
903
|
+
const cc = usage && usage.cache_creation;
|
|
904
|
+
if (!cc || typeof cc !== 'object') return;
|
|
905
|
+
totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + extractNumber(cc, 'ephemeral_1h_input_tokens');
|
|
906
|
+
totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
|
|
907
|
+
}
|
|
908
|
+
|
|
841
909
|
/**
|
|
842
910
|
* Append record to .planning/metrics/tokens.jsonl. Silently succeeds
|
|
843
911
|
* even if the file or directory can't be written — hook must not block.
|
|
@@ -922,6 +990,7 @@ if (require.main === module) {
|
|
|
922
990
|
// fall back to process.cwd() which is the project root when Claude Code
|
|
923
991
|
// invokes the hook.
|
|
924
992
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
993
|
+
if (deferToClaudeRegistration(cwd)) return;
|
|
925
994
|
// M62: a global-install hook fires in every repo; don't pollute non-PAN
|
|
926
995
|
// projects with .planning/ metrics artifacts. The tree must already exist —
|
|
927
996
|
// see hasPlanningTree (field sweep 2026-09-17).
|
|
@@ -934,4 +1003,4 @@ if (require.main === module) {
|
|
|
934
1003
|
});
|
|
935
1004
|
}
|
|
936
1005
|
|
|
937
|
-
module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
|
|
1006
|
+
module.exports = { deferToClaudeRegistration, buildCostRecord, appendRecord, readUsageFromTranscript, addCacheTtlSplit, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// State Re-injection - SessionStart hook, `compact` matcher (market-ideas queue M10)
|
|
3
|
+
//
|
|
4
|
+
// Compaction replaces the conversation with a summary, and a summary can drop the
|
|
5
|
+
// one line that mattered: which phase is in flight, which plan, where it stopped.
|
|
6
|
+
// The PanLoop boundary-drop class (finding 0) is that failure — an agent that no
|
|
7
|
+
// longer knows it is mid-chain ends the turn or re-plans finished work. This hook
|
|
8
|
+
// runs when the session restarts after a compaction and hands the model PAN's
|
|
9
|
+
// current position, read from the planning tree on disk, as `additionalContext`.
|
|
10
|
+
//
|
|
11
|
+
// Why SessionStart and not PostCompact: Claude Code's PostCompact has no decision
|
|
12
|
+
// control and discards its output ("Used for side effects like logging"), while
|
|
13
|
+
// SessionStart fires again after a compaction with `source: "compact"` and honours
|
|
14
|
+
// `hookSpecificOutput.additionalContext` (code.claude.com/docs/en/hooks, read
|
|
15
|
+
// 2026-09-26). Codex is the same: its PostCompact output schema has no
|
|
16
|
+
// additionalContext, and SessionStart hooks matching `source: "compact"` deliver it
|
|
17
|
+
// to the continuation (codex hooks docs, read 2026-09-26). Registered with the
|
|
18
|
+
// `compact` matcher on both; the source is re-checked here so a registration
|
|
19
|
+
// without the matcher stays inert on every other start.
|
|
20
|
+
//
|
|
21
|
+
// Inert unless the project is a PAN project with work in flight: `.planning/`
|
|
22
|
+
// holds a state.md that names a current phase AND a roadmap.md with an unticked
|
|
23
|
+
// phase. Everywhere else — no planning tree, a focus-only project, a finished
|
|
24
|
+
// milestone — it prints nothing. It never writes a file (field finding: hooks
|
|
25
|
+
// that scaffolded `.planning/` in projects that never ran PAN).
|
|
26
|
+
//
|
|
27
|
+
// Fail-open everywhere: bad stdin, unreadable files — exit 0 silently. A missing
|
|
28
|
+
// reminder costs a re-read; a crashed hook is a failing hook at every start.
|
|
29
|
+
//
|
|
30
|
+
// The block is capped well under the host's 10,000-character limit for
|
|
31
|
+
// additionalContext; each field is truncated on its own.
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which planning tree this hook acts on.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
|
|
40
|
+
* (they are standalone and run inside the host runtime). All PAN hooks carry an
|
|
41
|
+
* identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
42
|
+
* `.planning/`, that tree's telemetry lands in the wrong place.
|
|
43
|
+
*
|
|
44
|
+
* Env only — a hook gets no argv. A value that escapes the project root is
|
|
45
|
+
* ignored rather than honoured: a bad value degrades to the default, never
|
|
46
|
+
* writes outside the project.
|
|
47
|
+
*/
|
|
48
|
+
function planningDirName() {
|
|
49
|
+
const raw = process.env.PAN_PLANNING_DIR || '';
|
|
50
|
+
if (raw.trim()) {
|
|
51
|
+
const rel = raw.trim().replace(/\\/g, '/');
|
|
52
|
+
const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
|
|
53
|
+
|| rel.split('/').includes('..');
|
|
54
|
+
if (!bad) return rel;
|
|
55
|
+
}
|
|
56
|
+
const track = (process.env.PAN_TRACK || '').trim();
|
|
57
|
+
if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
|
|
58
|
+
return `.planning/tracks/${track}`;
|
|
59
|
+
}
|
|
60
|
+
return '.planning';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Absolute path inside the active planning tree. */
|
|
64
|
+
function planningPath(cwd, ...segments) {
|
|
65
|
+
return path.join(cwd, ...planningDirName().split('/'), ...segments);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Same unticked-phase shape the stop guard and the PanLoop harness use: the
|
|
69
|
+
// checklist line templates/roadmap.md and pan-roadmapper.md emit.
|
|
70
|
+
const UNTICKED_PHASE_RE = /^- \[ \] \*\*Phase (\d+(?:\.\d+)?):/m;
|
|
71
|
+
|
|
72
|
+
const FIELD_MAX = 240;
|
|
73
|
+
const BLOCK_MAX = 2000;
|
|
74
|
+
|
|
75
|
+
/** A `**Name:** value` field from state.md — the shape state.cjs reads and writes. */
|
|
76
|
+
function field(content, name) {
|
|
77
|
+
const m = content.match(new RegExp(`\\*\\*${name}:\\*\\*[ \\t]*(.+)`, 'i'));
|
|
78
|
+
if (!m) return null;
|
|
79
|
+
const v = m[1].trim();
|
|
80
|
+
// Template placeholders ("[X]", "[Phase name]") are not a position.
|
|
81
|
+
if (!v || /^\[.*\]$/.test(v) || /^none$/i.test(v)) return null;
|
|
82
|
+
return v.length > FIELD_MAX ? v.slice(0, FIELD_MAX - 1) + '…' : v;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure: the additionalContext block for this planning tree, or null when there is
|
|
87
|
+
* nothing in flight to restore.
|
|
88
|
+
* @param {object} args
|
|
89
|
+
* @param {string|null} args.stateContent .planning/state.md, or null
|
|
90
|
+
* @param {string|null} args.roadmapContent .planning/roadmap.md, or null
|
|
91
|
+
* @param {string} [args.planningRel] the planning directory, for the pointer line
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
function buildReinjectContext({ stateContent, roadmapContent, planningRel = '.planning' }) {
|
|
95
|
+
if (typeof stateContent !== 'string' || typeof roadmapContent !== 'string') return null;
|
|
96
|
+
const phase = field(stateContent, 'Current Phase');
|
|
97
|
+
if (!phase) return null;
|
|
98
|
+
const unticked = roadmapContent.match(UNTICKED_PHASE_RE);
|
|
99
|
+
if (!unticked) return null; // every roadmap phase is built — nothing in flight
|
|
100
|
+
|
|
101
|
+
const name = field(stateContent, 'Current Phase Name');
|
|
102
|
+
const plan = field(stateContent, 'Current Plan');
|
|
103
|
+
const plans = field(stateContent, 'Total Plans in Phase');
|
|
104
|
+
const status = field(stateContent, 'Status');
|
|
105
|
+
const stoppedAt = field(stateContent, 'Stopped At');
|
|
106
|
+
const lastDesc = field(stateContent, 'Last Activity Description');
|
|
107
|
+
const resume = field(stateContent, 'Resume File');
|
|
108
|
+
|
|
109
|
+
const lines = ['PAN project state, re-read from disk after context compaction:'];
|
|
110
|
+
let position = `- Current phase: ${phase}${name ? ` (${name})` : ''}`;
|
|
111
|
+
if (plan) position += `, plan ${plan}${plans ? ` of ${plans}` : ''}`;
|
|
112
|
+
lines.push(position);
|
|
113
|
+
if (status) lines.push(`- Status: ${status}`);
|
|
114
|
+
if (stoppedAt) lines.push(`- Stopped at: ${stoppedAt}`);
|
|
115
|
+
else if (lastDesc) lines.push(`- Last activity: ${lastDesc}`);
|
|
116
|
+
if (resume) lines.push(`- Resume file: ${resume}`);
|
|
117
|
+
lines.push(`- First unbuilt roadmap phase: Phase ${unticked[1]}`);
|
|
118
|
+
lines.push(`Re-read ${planningRel}/state.md and the current phase's plan before the next step: continue the work in flight rather than re-planning finished work.`);
|
|
119
|
+
const block = lines.join('\n');
|
|
120
|
+
return block.length > BLOCK_MAX ? block.slice(0, BLOCK_MAX - 1) + '…' : block;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readIfExists(p) {
|
|
124
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function main() {
|
|
128
|
+
let input = '';
|
|
129
|
+
process.stdin.setEncoding('utf8');
|
|
130
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
131
|
+
process.stdin.on('end', () => {
|
|
132
|
+
try {
|
|
133
|
+
let payload = {};
|
|
134
|
+
try { payload = JSON.parse(input); } catch { /* fail open on bad stdin */ }
|
|
135
|
+
if (!payload || typeof payload !== 'object') payload = {};
|
|
136
|
+
if (payload.source !== 'compact') { process.exit(0); return; }
|
|
137
|
+
|
|
138
|
+
const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
139
|
+
const context = buildReinjectContext({
|
|
140
|
+
stateContent: readIfExists(planningPath(projectDir, 'state.md')),
|
|
141
|
+
roadmapContent: readIfExists(planningPath(projectDir, 'roadmap.md')),
|
|
142
|
+
planningRel: planningDirName(),
|
|
143
|
+
});
|
|
144
|
+
if (context) {
|
|
145
|
+
process.stdout.write(JSON.stringify({
|
|
146
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context },
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
} catch { /* fail open — never break a session start */ }
|
|
150
|
+
process.exit(0);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (require.main === module) {
|
|
155
|
+
main();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { buildReinjectContext, UNTICKED_PHASE_RE };
|