pan-wizard 3.30.0 → 3.32.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 +30 -26
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.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 +42 -1
- package/hooks/dist/pan-cost-logger.js +132 -11
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +50 -4
- package/hooks/dist/pan-trace-logger.js +324 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +22 -5
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +31 -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 +135 -46
- package/pan-wizard-core/bin/lib/findings.cjs +381 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +4 -1
- 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/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +93 -24
- 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/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +69 -6
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +73 -47
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- 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/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/git-hooks/pre-commit +2 -1
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +1 -0
|
@@ -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,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// context limits (the statusline only shows the user).
|
|
6
6
|
//
|
|
7
7
|
// How it works:
|
|
8
|
-
// 1. The statusline hook writes metrics to
|
|
8
|
+
// 1. The statusline hook writes metrics to <os tmpdir>/pan-hooks-<uid>/claude-ctx-{session_id}.json
|
|
9
9
|
// 2. This hook reads those metrics after each tool use
|
|
10
10
|
// 3. When remaining context drops below thresholds, it injects a warning
|
|
11
11
|
// as additionalContext, which the agent sees in its conversation
|
|
@@ -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).
|
|
@@ -107,9 +146,11 @@ const CURSOR_FILE = '.cost-cursor.json';
|
|
|
107
146
|
// the per-invocation `event_sig` discriminator; v4 added `agent_id` and the
|
|
108
147
|
// `agent-transcript` token source. Kept as a literal in each hook —
|
|
109
148
|
// they are standalone zero-dep scripts that can't import from pan-wizard-core, so
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
149
|
+
// each hook's copy is kept by hand (in lockstep through v4; the trace logger's events
|
|
150
|
+
// have since moved to 5 on their own). cost-rebuild.cjs in
|
|
151
|
+
// pan-wizard-core mirrors it for the rows it rebuilds; the readers there take a row
|
|
152
|
+
// field by field rather than switching on its version, so an added field is additive
|
|
153
|
+
// for them.
|
|
113
154
|
const SCHEMA_V = 4;
|
|
114
155
|
|
|
115
156
|
// Reverse-map a resolved model id to its cost tier so the "By tier" dashboard
|
|
@@ -417,10 +458,13 @@ const MAX_CURSOR_KEYS = 512;
|
|
|
417
458
|
// marker phantom (an over-count) and from N29 (concurrent same-type siblings).
|
|
418
459
|
//
|
|
419
460
|
// Returns null when the payload cannot be serialized — callers then fail OPEN
|
|
420
|
-
// (record, never mark).
|
|
461
|
+
// (record, never mark). SHA-256 cut to the 40 hex characters the SHA-1 it replaced
|
|
462
|
+
// produced, so persisted `event_sig` values keep their shape: a dedup key, not a
|
|
463
|
+
// security boundary, but CodeQL rightly flags SHA-1 over a payload that carries a
|
|
464
|
+
// session id (2026-09-29).
|
|
421
465
|
function eventSignature(data) {
|
|
422
466
|
try {
|
|
423
|
-
return crypto.createHash('
|
|
467
|
+
return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 40);
|
|
424
468
|
} catch { return null; }
|
|
425
469
|
}
|
|
426
470
|
|
|
@@ -520,6 +564,27 @@ function resolveAgentTranscript(data) {
|
|
|
520
564
|
}
|
|
521
565
|
}
|
|
522
566
|
|
|
567
|
+
// Host-internal agents (measured 2026-09-28). Claude Code runs helper agents of its own:
|
|
568
|
+
// the compaction summariser, and one at almost every turn end of an interactive session.
|
|
569
|
+
// It fires SubagentStop for them with an `agent_id`, an EMPTY `agent_type`, and an
|
|
570
|
+
// `agent_transcript_path` it never writes. That shape was captured from a real `/compact`
|
|
571
|
+
// on Claude Code 2.1.280 (tests/fixtures/hooks/subagent-stop-internal-claude.json). Both
|
|
572
|
+
// loggers booked each as an `unknown` zero-token spawn: 181 rows in three field projects
|
|
573
|
+
// in eleven days, skewing optimize learn's agent stats and the ledger's spawn counts.
|
|
574
|
+
// The host documents `agent_type` as present whenever a hook fires inside a subagent, so
|
|
575
|
+
// a named instance with no type and no transcript is not a spawn anyone asked for, and
|
|
576
|
+
// nothing is recorded. A NAMED spawn whose file is late or missing keeps its zero-token
|
|
577
|
+
// row (N17/N25); a payload with no agent instance at all keeps the parent-slice path.
|
|
578
|
+
// Identical in pan-cost-logger.js and pan-trace-logger.js (tests/trace-logger.test.cjs
|
|
579
|
+
// pins the copies).
|
|
580
|
+
function isHostInternalAgent(data, agentTranscript) {
|
|
581
|
+
if (!data || typeof data !== 'object' || agentTranscript) return false;
|
|
582
|
+
const typed = [data.agent_type, data.subagent_type].some((v) => typeof v === 'string' && v.trim() !== '');
|
|
583
|
+
if (typed) return false;
|
|
584
|
+
return (typeof data.agent_id === 'string' && AGENT_ID_SAFE.test(data.agent_id))
|
|
585
|
+
|| (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path !== '');
|
|
586
|
+
}
|
|
587
|
+
|
|
523
588
|
// A slice is a SUM over one subagent's conversation, so its ceiling sits above a
|
|
524
589
|
// single call's (PLAUSIBLE_MAX): a long agent legitimately re-reads its cached
|
|
525
590
|
// context on every turn. The cache_read and output ceilings are the absolute
|
|
@@ -537,6 +602,29 @@ function clampSlice(n, max) {
|
|
|
537
602
|
return typeof n === 'number' && n >= 0 && n <= max ? n : 0;
|
|
538
603
|
}
|
|
539
604
|
|
|
605
|
+
// Copilot CLI's camelCase hook payload (docs.github.com/en/copilot/reference/hooks-reference,
|
|
606
|
+
// read 2026-09-28; documented, not yet observed live) names every field differently from
|
|
607
|
+
// Claude's snake_case one, so this hook read no session, transcript or agent from it and
|
|
608
|
+
// booked every Copilot spawn as `unknown`. Its `agentType` is the KIND of agent (built-in
|
|
609
|
+
// or custom); `agentName` is the configured name PAN's agents carry, so the name wins.
|
|
610
|
+
// Claude payloads carry none of these keys and pass through unchanged. Identical in
|
|
611
|
+
// pan-cost-logger.js and pan-trace-logger.js (tests/copilot-payload.test.cjs pins both).
|
|
612
|
+
function normalizeHookPayload(data) {
|
|
613
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
|
|
614
|
+
const pick = (...keys) => {
|
|
615
|
+
for (const k of keys) if (typeof data[k] === 'string' && data[k]) return data[k];
|
|
616
|
+
return undefined;
|
|
617
|
+
};
|
|
618
|
+
const out = { ...data };
|
|
619
|
+
if (out.session_id === undefined && pick('sessionId')) out.session_id = pick('sessionId');
|
|
620
|
+
if (out.transcript_path === undefined && pick('transcriptPath')) out.transcript_path = pick('transcriptPath');
|
|
621
|
+
if (out.agent_id === undefined && pick('agentId')) out.agent_id = pick('agentId');
|
|
622
|
+
const name = pick('agent_name', 'agentName');
|
|
623
|
+
if (name) out.agent_type = name;
|
|
624
|
+
else if (out.agent_type === undefined && pick('agentType')) out.agent_type = pick('agentType');
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
|
|
540
628
|
/**
|
|
541
629
|
* Extract what we can from the SubagentStop event payload.
|
|
542
630
|
* Pure function — safe to test without stdin.
|
|
@@ -547,6 +635,7 @@ function clampSlice(n, max) {
|
|
|
547
635
|
*/
|
|
548
636
|
function buildCostRecord(data, cwd) {
|
|
549
637
|
if (!data || typeof data !== 'object') return null;
|
|
638
|
+
data = normalizeHookPayload(data);
|
|
550
639
|
|
|
551
640
|
// Only log actual subagent stops; ignore other Stop variants.
|
|
552
641
|
if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return null;
|
|
@@ -564,6 +653,8 @@ function buildCostRecord(data, cwd) {
|
|
|
564
653
|
let outputTokens = 0;
|
|
565
654
|
let cacheRead = 0;
|
|
566
655
|
let cacheWrite = 0;
|
|
656
|
+
// { h1, m5 } when the source carried the cache-write lifetime split (M13).
|
|
657
|
+
let ttlSplit = null;
|
|
567
658
|
let durationMs = null;
|
|
568
659
|
// token_source records WHICH path produced the counts: `agent-transcript` (the
|
|
569
660
|
// subagent's own conversation file — see resolveAgentTranscript), `transcript`
|
|
@@ -572,6 +663,7 @@ function buildCostRecord(data, cwd) {
|
|
|
572
663
|
// a value dropped to 0 by a plausibility guard so a guarded zero is
|
|
573
664
|
// distinguishable from a genuine zero-token run.
|
|
574
665
|
const agentTranscript = resolveAgentTranscript(data);
|
|
666
|
+
if (isHostInternalAgent(data, agentTranscript)) return null; // the host's own helper, not a spawn
|
|
575
667
|
const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
|
|
576
668
|
// The host NAMED an agent (a bare id, or an explicit agent transcript path) but
|
|
577
669
|
// its file is not there — not flushed yet, or a host that sends ids without
|
|
@@ -621,6 +713,10 @@ function buildCostRecord(data, cwd) {
|
|
|
621
713
|
cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
|
|
622
714
|
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
|
|
623
715
|
|| rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
|
|
716
|
+
// A clamped write total no longer matches its split, so the split is dropped with it.
|
|
717
|
+
if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
|
|
718
|
+
ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
|
|
719
|
+
}
|
|
624
720
|
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
|
|
625
721
|
if (!model) model = fromTranscript.model;
|
|
626
722
|
if (fromTranscript.lineCount > since) {
|
|
@@ -676,18 +772,24 @@ function buildCostRecord(data, cwd) {
|
|
|
676
772
|
cacheRead = clampPlausible(rawCr);
|
|
677
773
|
cacheWrite = clampPlausible(rawCw);
|
|
678
774
|
clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
|
|
775
|
+
const split = {};
|
|
776
|
+
addCacheTtlSplit(split, data.usage);
|
|
777
|
+
if (typeof split.cache_write_1h_tokens === 'number' && cacheWrite === rawCw) {
|
|
778
|
+
ttlSplit = { h1: clampPlausible(split.cache_write_1h_tokens), m5: clampPlausible(split.cache_write_5m_tokens) };
|
|
779
|
+
}
|
|
679
780
|
}
|
|
680
781
|
|
|
681
782
|
// Backfill command/phase from the active trace session when the payload omits
|
|
682
783
|
// them (real SubagentStop payloads carry neither); tier is derived from the model.
|
|
683
784
|
const sessionMeta = readActiveSessionMeta(cwd);
|
|
684
|
-
// The trace
|
|
685
|
-
//
|
|
785
|
+
// The trace logger's day-scoped auto-session names no command (only a session opened
|
|
786
|
+
// by `optimize trace init` or focus mode does), so the parent transcript is the
|
|
787
|
+
// fallback that makes command attribution work in ordinary use — see
|
|
686
788
|
// readCommandFromTranscript.
|
|
687
789
|
const command = data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null;
|
|
688
|
-
// The
|
|
689
|
-
//
|
|
690
|
-
//
|
|
790
|
+
// The day-scoped auto-session names no phase either, so state.md is the fallback
|
|
791
|
+
// that makes phase attribution work in ordinary use, not only in a session that
|
|
792
|
+
// was opened with one.
|
|
691
793
|
const phase = data.phase || sessionMeta.phase || readCurrentPhase(cwd) || null;
|
|
692
794
|
|
|
693
795
|
const record = {
|
|
@@ -705,6 +807,8 @@ function buildCostRecord(data, cwd) {
|
|
|
705
807
|
output_tokens: outputTokens,
|
|
706
808
|
cache_read_tokens: cacheRead,
|
|
707
809
|
cache_write_tokens: cacheWrite,
|
|
810
|
+
// The lifetime split of cache_write_tokens, present only when measured (M13).
|
|
811
|
+
...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
|
|
708
812
|
cost_usd: null,
|
|
709
813
|
duration_ms: durationMs,
|
|
710
814
|
phase,
|
|
@@ -833,11 +937,27 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
833
937
|
totals.output_tokens += extractNumber(usage, 'output_tokens');
|
|
834
938
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
835
939
|
totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
|
|
940
|
+
addCacheTtlSplit(totals, usage);
|
|
836
941
|
}
|
|
837
942
|
totals.lineCount = seen;
|
|
838
943
|
return totals;
|
|
839
944
|
}
|
|
840
945
|
|
|
946
|
+
/**
|
|
947
|
+
* Add a usage record's cache-write lifetime split to `totals` (M13). Claude Code
|
|
948
|
+
* transcripts carry `usage.cache_creation: { ephemeral_5m_input_tokens,
|
|
949
|
+
* ephemeral_1h_input_tokens }` beside the `cache_creation_input_tokens` total;
|
|
950
|
+
* one-hour writes bill at 2x base input against 1.25x for five-minute ones, so
|
|
951
|
+
* the ledger keeps the split. The totals gain the two fields only when some
|
|
952
|
+
* record carried the block — rows from hosts without it stay unchanged.
|
|
953
|
+
*/
|
|
954
|
+
function addCacheTtlSplit(totals, usage) {
|
|
955
|
+
const cc = usage && usage.cache_creation;
|
|
956
|
+
if (!cc || typeof cc !== 'object') return;
|
|
957
|
+
totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + extractNumber(cc, 'ephemeral_1h_input_tokens');
|
|
958
|
+
totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
|
|
959
|
+
}
|
|
960
|
+
|
|
841
961
|
/**
|
|
842
962
|
* Append record to .planning/metrics/tokens.jsonl. Silently succeeds
|
|
843
963
|
* even if the file or directory can't be written — hook must not block.
|
|
@@ -922,6 +1042,7 @@ if (require.main === module) {
|
|
|
922
1042
|
// fall back to process.cwd() which is the project root when Claude Code
|
|
923
1043
|
// invokes the hook.
|
|
924
1044
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
1045
|
+
if (deferToClaudeRegistration(cwd)) return;
|
|
925
1046
|
// M62: a global-install hook fires in every repo; don't pollute non-PAN
|
|
926
1047
|
// projects with .planning/ metrics artifacts. The tree must already exist —
|
|
927
1048
|
// see hasPlanningTree (field sweep 2026-09-17).
|
|
@@ -934,4 +1055,4 @@ if (require.main === module) {
|
|
|
934
1055
|
});
|
|
935
1056
|
}
|
|
936
1057
|
|
|
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 };
|
|
1058
|
+
module.exports = { deferToClaudeRegistration, normalizeHookPayload, isHostInternalAgent, 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). Every PAN hook that
|
|
41
|
+
* reads the planning tree carries an 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 };
|