pan-wizard 3.22.0 → 3.25.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 +38 -24
- package/agents/pan-conductor.md +4 -4
- package/agents/pan-design-checker.md +83 -0
- package/agents/pan-designer.md +67 -0
- package/agents/pan-document_code.md +3 -2
- package/agents/pan-executor.md +2 -2
- package/agents/pan-plan-checker.md +19 -6
- package/agents/pan-planner.md +4 -1
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-roadmapper.md +3 -1
- package/agents/pan-verifier.md +10 -10
- package/bin/install-lib.cjs +128 -17
- package/bin/install.js +480 -74
- package/commands/pan/army.md +34 -14
- package/commands/pan/audit-deployment.md +17 -14
- package/commands/pan/cost.md +2 -2
- package/commands/pan/debug.md +1 -1
- package/commands/pan/design-phase.md +77 -0
- package/commands/pan/exec-phase.md +2 -2
- package/commands/pan/experiment.md +18 -14
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/focus-design.md +13 -1
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/learn.md +1 -1
- package/commands/pan/links.md +3 -1
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-new.md +3 -1
- package/commands/pan/optimize.md +2 -2
- package/commands/pan/patches.md +1 -1
- package/commands/pan/plan-phase.md +3 -1
- package/commands/pan/preview.md +3 -3
- package/commands/pan/profile.md +3 -3
- package/commands/pan/research-phase.md +1 -1
- package/commands/pan/retro.md +4 -1
- package/commands/pan/review-deep.md +1 -1
- package/commands/pan/settings.md +2 -2
- package/commands/pan/what-if.md +1 -1
- package/hooks/dist/pan-check-update.js +149 -38
- package/hooks/dist/pan-context-monitor.js +155 -81
- package/hooks/dist/pan-cost-logger.js +288 -13
- package/hooks/dist/pan-statusline.js +24 -8
- package/hooks/dist/pan-stop-guard.js +160 -0
- package/hooks/dist/pan-trace-logger.js +261 -19
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
- package/pan-wizard-core/bin/lib/bus.cjs +19 -4
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
- package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
- package/pan-wizard-core/bin/lib/commands.cjs +88 -6
- package/pan-wizard-core/bin/lib/config.cjs +27 -2
- package/pan-wizard-core/bin/lib/constants.cjs +1 -0
- package/pan-wizard-core/bin/lib/core.cjs +113 -8
- package/pan-wizard-core/bin/lib/cost.cjs +61 -24
- package/pan-wizard-core/bin/lib/distill.cjs +22 -4
- package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
- package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
- package/pan-wizard-core/bin/lib/focus.cjs +42 -7
- package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
- package/pan-wizard-core/bin/lib/git.cjs +51 -16
- package/pan-wizard-core/bin/lib/hud.cjs +17 -2
- package/pan-wizard-core/bin/lib/init.cjs +2 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
- package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
- package/pan-wizard-core/bin/lib/links.cjs +9 -6
- package/pan-wizard-core/bin/lib/lock.cjs +23 -4
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
- package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
- package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
- package/pan-wizard-core/bin/lib/phase.cjs +82 -11
- package/pan-wizard-core/bin/lib/preview.cjs +23 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
- package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
- package/pan-wizard-core/bin/lib/runner.cjs +17 -7
- package/pan-wizard-core/bin/lib/squads.cjs +43 -11
- package/pan-wizard-core/bin/lib/state.cjs +32 -11
- package/pan-wizard-core/bin/lib/template.cjs +8 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
- package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
- package/pan-wizard-core/bin/lib/verify.cjs +84 -39
- package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
- package/pan-wizard-core/bin/lib/worktree.cjs +98 -0
- package/pan-wizard-core/bin/pan-tools.cjs +45 -20
- package/pan-wizard-core/learnings/index.json +7 -7
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
- package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
- package/pan-wizard-core/references/design-methodology.md +94 -0
- package/pan-wizard-core/references/git-integration.md +9 -9
- package/pan-wizard-core/references/guardrails.md +4 -1
- package/pan-wizard-core/references/model-profile-resolution.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +44 -34
- package/pan-wizard-core/references/planning-config.md +5 -5
- package/pan-wizard-core/references/verification-patterns.md +67 -0
- package/pan-wizard-core/templates/codebase/architecture.md +1 -1
- package/pan-wizard-core/templates/codebase/concerns.md +1 -1
- package/pan-wizard-core/templates/codebase/conventions.md +1 -1
- package/pan-wizard-core/templates/codebase/integrations.md +1 -1
- package/pan-wizard-core/templates/codebase/stack.md +1 -1
- package/pan-wizard-core/templates/codebase/structure.md +1 -1
- package/pan-wizard-core/templates/codebase/testing.md +1 -1
- package/pan-wizard-core/templates/design.md +146 -0
- package/pan-wizard-core/templates/discovery.md +3 -3
- package/pan-wizard-core/templates/milestone-archive.md +2 -2
- package/pan-wizard-core/templates/playbook.md +1 -1
- package/pan-wizard-core/templates/uat.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
- package/pan-wizard-core/workflows/exec-phase.md +28 -8
- package/pan-wizard-core/workflows/execute-plan.md +2 -3
- package/pan-wizard-core/workflows/health.md +24 -1
- package/pan-wizard-core/workflows/help.md +5 -4
- package/pan-wizard-core/workflows/learn.md +5 -5
- package/pan-wizard-core/workflows/map-codebase.md +1 -1
- package/pan-wizard-core/workflows/milestone-audit.md +1 -1
- package/pan-wizard-core/workflows/milestone-new.md +4 -4
- package/pan-wizard-core/workflows/new-project.md +47 -32
- package/pan-wizard-core/workflows/optimize.md +8 -8
- package/pan-wizard-core/workflows/pause.md +1 -1
- package/pan-wizard-core/workflows/phase-tests.md +2 -2
- package/pan-wizard-core/workflows/plan-phase.md +4 -0
- package/pan-wizard-core/workflows/profile.md +13 -10
- package/pan-wizard-core/workflows/research-phase.md +1 -1
- package/pan-wizard-core/workflows/resume-project.md +1 -1
- package/pan-wizard-core/workflows/settings.md +9 -9
- package/pan-wizard-core/workflows/transition.md +24 -8
- package/pan-wizard-core/workflows/update.md +23 -20
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/pan-zcode/README.md +12 -2
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/pan-zcode/mcp/native-tools.cjs +1 -1
- package/pan-zcode/mcp/server.cjs +76 -14
- package/scripts/build-hooks.js +2 -1
- package/scripts/generate-skills-docs.py +0 -4
- package/scripts/run-tests.cjs +11 -4
package/commands/pan/profile.md
CHANGED
|
@@ -37,7 +37,7 @@ The workflow handles all logic including:
|
|
|
37
37
|
</process>
|
|
38
38
|
|
|
39
39
|
<tier_decision_tree>
|
|
40
|
-
**
|
|
40
|
+
**Capability-aware routing** (shipped v2.10.0 — E-7). Even within a single profile, PAN picks a tier per-call based on three hints: context estimate, whether the task needs extended thinking, and whether prompt cache is warm.
|
|
41
41
|
|
|
42
42
|
The decision order `resolveModel` applies after the baseline profile pick:
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ Baseline tier (from MODEL_PROFILES[agent][profile])
|
|
|
46
46
|
│
|
|
47
47
|
▼
|
|
48
48
|
┌─────────────────────────────────────────────┐
|
|
49
|
-
│ context_estimate > 700K tokens? │── yes ──▶ force reasoning (
|
|
49
|
+
│ context_estimate > 700K tokens? │── yes ──▶ force reasoning (widest ctx tier)
|
|
50
50
|
└─────────────────────────────────────────────┘
|
|
51
51
|
│ no
|
|
52
52
|
▼
|
|
@@ -66,7 +66,7 @@ Final tier → provider-native model name
|
|
|
66
66
|
|
|
67
67
|
**Quick guide:**
|
|
68
68
|
- Heavy verification (plan-checker, verifier, integration-checker, reviewer, debugger): `needs_thinking: true` — baseline upgrades fast→mid.
|
|
69
|
-
-
|
|
69
|
+
- Whole-repo context estimates above the large-context threshold (`context_estimate > 700K` tokens — `LARGE_CONTEXT_TOKEN_THRESHOLD`): forced to reasoning, which inherits the model you launched with. Note the direction: `/pan:map-codebase` picks `single-shot` **at or below** 700K and `sharded` above it, so the repos that trip this rule are the ones the mapper is already sharding.
|
|
70
70
|
- Routine exec tasks with project.md cached: `cache_warm + small ctx` — mid gets downgraded to fast for a cost win.
|
|
71
71
|
- All rules are additive to the `quality` / `balanced` / `budget` profile you pick here — profile sets the floor, capability hints adjust upward or downward within that floor's band.
|
|
72
72
|
|
|
@@ -21,7 +21,7 @@ Research how to implement a phase. Spawns pan-phase-researcher agent with phase
|
|
|
21
21
|
|
|
22
22
|
**Orchestrator role:** Parse phase, validate against roadmap, check existing research, gather context, spawn researcher agent, present results.
|
|
23
23
|
|
|
24
|
-
**Why subagent:** Research burns context fast (WebSearch, Context7 queries, source verification).
|
|
24
|
+
**Why subagent:** Research burns context fast (WebSearch, Context7 queries, source verification). A fresh context window for investigation. Main context stays lean for user interaction.
|
|
25
25
|
</objective>
|
|
26
26
|
|
|
27
27
|
<context>
|
package/commands/pan/retro.md
CHANGED
|
@@ -14,7 +14,7 @@ Analyze completed milestone work to identify process improvement opportunities.
|
|
|
14
14
|
|
|
15
15
|
Examines roadmap phases (planned vs completed, gap closures), verification results (pass rates, common gaps), and estimation accuracy. Output guides future planning improvements.
|
|
16
16
|
|
|
17
|
-
This is a reflection command — it does not modify any files.
|
|
17
|
+
This is a reflection command — **read-only by default**: with no flags it does not modify any files. Passing `--write-memory` (as `/pan:army` does) is the one exception — it appends recurring-pattern entries to agent memory so they persist into the next mission.
|
|
18
18
|
</objective>
|
|
19
19
|
|
|
20
20
|
<execution_context>
|
|
@@ -24,6 +24,9 @@ This is a reflection command — it does not modify any files.
|
|
|
24
24
|
<context>
|
|
25
25
|
No arguments required. Operates on the current `.planning/` directory.
|
|
26
26
|
|
|
27
|
+
**Flags:**
|
|
28
|
+
- `--write-memory` — after analysis, append recurring-pattern entries to agent memory (used by `/pan:army`). Without this flag the command is strictly read-only.
|
|
29
|
+
|
|
27
30
|
The retro command is typically run after `/pan:milestone-done` to reflect on the milestone before starting the next one.
|
|
28
31
|
</context>
|
|
29
32
|
|
|
@@ -117,7 +117,7 @@ Verdict is driven by the highest-severity finding across all three sources. Meta
|
|
|
117
117
|
| Codex | Same | Same | Full |
|
|
118
118
|
| Copilot | Same | Same | Full |
|
|
119
119
|
|
|
120
|
-
The merger CLI (`pan-tools review-deep merge`) is pure Node.js and works identically across runtimes. Only the *quality* of the hardener and meta-reviewer outputs varies with model capability — Opus
|
|
120
|
+
The merger CLI (`pan-tools review-deep merge`) is pure Node.js and works identically across runtimes. Only the *quality* of the hardener and meta-reviewer outputs varies with model capability — an Opus-class model with extended thinking produces the richest findings.
|
|
121
121
|
|
|
122
122
|
</runtime_compatibility>
|
|
123
123
|
|
package/commands/pan/settings.md
CHANGED
|
@@ -21,11 +21,11 @@ Routes to the settings workflow which handles:
|
|
|
21
21
|
</objective>
|
|
22
22
|
|
|
23
23
|
<execution_context>
|
|
24
|
-
|
|
24
|
+
@~/.claude/pan-wizard-core/workflows/settings.md
|
|
25
25
|
</execution_context>
|
|
26
26
|
|
|
27
27
|
<process>
|
|
28
|
-
**Follow the settings workflow** from
|
|
28
|
+
**Follow the settings workflow** from `@~/.claude/pan-wizard-core/workflows/settings.md`.
|
|
29
29
|
|
|
30
30
|
The workflow handles all logic including:
|
|
31
31
|
1. Config file creation with defaults if missing
|
package/commands/pan/what-if.md
CHANGED
|
@@ -113,7 +113,7 @@ git branch -D <branch_name>
|
|
|
113
113
|
- `<parent>/pan-whatif-<phase>-<slug>-<ts>/` — the worktree (temporary, deleted after report)
|
|
114
114
|
- branch `pan-whatif/<phase>-<slug>-<ts>` — the worktree's branch (deleted after report)
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
The worktree dir and branch include a timestamp, so concurrent/repeat runs never collide there. The **report** is keyed on phase+slug only (no timestamp), so re-running what-if on the same phase+scenario **overwrites** the previous report at `.planning/counterfactuals/<phase>-<slug>.md`. Rename or copy a report you want to keep before re-running the same scenario.
|
|
117
117
|
|
|
118
118
|
</output_paths>
|
|
119
119
|
|
|
@@ -1,62 +1,173 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Check for PAN updates in background, write result to cache
|
|
3
3
|
// Called by SessionStart hook - runs once per session
|
|
4
|
+
//
|
|
5
|
+
// Structure (M58, ADR audit 2026-08): the update-check logic used to live in
|
|
6
|
+
// an inline `node -e` string that was never parsed, linted, or executed by any
|
|
7
|
+
// test — a syntax error or field rename crashed the detached child silently
|
|
8
|
+
// forever. The logic now lives in exported pure functions (below) that are
|
|
9
|
+
// syntax-checked at load and unit-tested (tests/check-update-hook.test.cjs).
|
|
10
|
+
// The parent process still spawns a detached child so the ~10s `npm view`
|
|
11
|
+
// never blocks SessionStart; the child re-runs THIS file with `--run-check`.
|
|
4
12
|
|
|
5
13
|
const fs = require('fs');
|
|
6
14
|
const path = require('path');
|
|
7
15
|
const os = require('os');
|
|
8
16
|
const { spawn } = require('child_process');
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
const cwd = process.cwd();
|
|
12
|
-
const cacheDir = path.join(homeDir, '.claude', 'cache');
|
|
13
|
-
const cacheFile = path.join(cacheDir, 'pan-update-check.json');
|
|
18
|
+
// ── Pure logic (exported for tests) ─────────────────────────────────────────
|
|
14
19
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// Parse a version string into numeric [major, minor, patch, ...] components,
|
|
21
|
+
// dropping a leading 'v' and any pre-release/build metadata after '-' or '+'.
|
|
22
|
+
// Returns null when the string has no parseable numeric core.
|
|
23
|
+
function parseVersion(v) {
|
|
24
|
+
if (typeof v !== 'string') return null;
|
|
25
|
+
const core = v.trim().replace(/^v/, '').split(/[-+]/)[0];
|
|
26
|
+
if (!core) return null;
|
|
27
|
+
const parts = core.split('.').map((n) => parseInt(n, 10));
|
|
28
|
+
if (parts.length === 0 || parts.some((n) => Number.isNaN(n))) return null;
|
|
29
|
+
return parts;
|
|
30
|
+
}
|
|
18
31
|
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
// -1 if a < b, 0 if equal, 1 if a > b, null if either is unparseable.
|
|
33
|
+
function compareVersions(a, b) {
|
|
34
|
+
const pa = parseVersion(a);
|
|
35
|
+
const pb = parseVersion(b);
|
|
36
|
+
if (!pa || !pb) return null;
|
|
37
|
+
const len = Math.max(pa.length, pb.length);
|
|
38
|
+
for (let i = 0; i < len; i++) {
|
|
39
|
+
const x = pa[i] || 0;
|
|
40
|
+
const y = pb[i] || 0;
|
|
41
|
+
if (x < y) return -1;
|
|
42
|
+
if (x > y) return 1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
// L38: only flag an update when the installed version is strictly OLDER than
|
|
48
|
+
// npm latest. The old `installed !== latest` check flagged an update for ANY
|
|
49
|
+
// difference, so a source checkout ahead of the npm 'latest' dist-tag showed a
|
|
50
|
+
// permanent "update" badge that nudged the user to DOWNGRADE. Also skip the
|
|
51
|
+
// '0.0.0' sentinel (no VERSION file found → we don't know what's installed).
|
|
52
|
+
function isUpdateAvailable(installed, latest) {
|
|
53
|
+
if (!latest) return false;
|
|
54
|
+
if (!installed || installed === '0.0.0') return false;
|
|
55
|
+
const cmp = compareVersions(installed, latest);
|
|
56
|
+
// Unparseable tag: fall back to strict inequality (matches historical
|
|
57
|
+
// behavior for odd tags; the '0.0.0' sentinel is already excluded above).
|
|
58
|
+
if (cmp === null) return installed !== latest;
|
|
59
|
+
return cmp < 0;
|
|
60
|
+
}
|
|
28
61
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
// Build the cache record consumed by the statusline.
|
|
63
|
+
function computeUpdateResult(installed, latest, nowSeconds) {
|
|
64
|
+
return {
|
|
65
|
+
update_available: isUpdateAvailable(installed, latest),
|
|
66
|
+
installed,
|
|
67
|
+
latest: latest || 'unknown',
|
|
68
|
+
checked: nowSeconds != null ? nowSeconds : Math.floor(Date.now() / 1000),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
32
71
|
|
|
33
|
-
|
|
72
|
+
// Resolve the installed version: project VERSION wins over global VERSION.
|
|
73
|
+
// Falls back to the '0.0.0' sentinel when neither is readable.
|
|
74
|
+
function resolveInstalledVersion(fsMod, projectVersionFile, globalVersionFile) {
|
|
34
75
|
let installed = '0.0.0';
|
|
35
76
|
try {
|
|
36
|
-
if (
|
|
37
|
-
installed =
|
|
38
|
-
} else if (
|
|
39
|
-
installed =
|
|
77
|
+
if (fsMod.existsSync(projectVersionFile)) {
|
|
78
|
+
installed = fsMod.readFileSync(projectVersionFile, 'utf8').trim();
|
|
79
|
+
} else if (fsMod.existsSync(globalVersionFile)) {
|
|
80
|
+
installed = fsMod.readFileSync(globalVersionFile, 'utf8').trim();
|
|
40
81
|
}
|
|
41
|
-
} catch (e) {}
|
|
82
|
+
} catch (e) { /* fall through to sentinel */ }
|
|
83
|
+
return installed || '0.0.0';
|
|
84
|
+
}
|
|
42
85
|
|
|
86
|
+
// Perform the full check and write the cache file. `fetchLatest` is injectable
|
|
87
|
+
// so tests never touch the network; it returns the latest version or null.
|
|
88
|
+
function runCheck(opts) {
|
|
89
|
+
const {
|
|
90
|
+
fsMod = fs,
|
|
91
|
+
cacheFile,
|
|
92
|
+
projectVersionFile,
|
|
93
|
+
globalVersionFile,
|
|
94
|
+
fetchLatest,
|
|
95
|
+
nowSeconds,
|
|
96
|
+
} = opts;
|
|
97
|
+
const installed = resolveInstalledVersion(fsMod, projectVersionFile, globalVersionFile);
|
|
43
98
|
let latest = null;
|
|
44
99
|
try {
|
|
45
|
-
latest =
|
|
46
|
-
} catch (e) {
|
|
100
|
+
latest = fetchLatest ? fetchLatest() : null;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
latest = null;
|
|
103
|
+
}
|
|
104
|
+
const result = computeUpdateResult(installed, latest, nowSeconds);
|
|
105
|
+
fsMod.writeFileSync(cacheFile, JSON.stringify(result));
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
47
108
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
109
|
+
// Default network fetch — queried only in the detached child, never in tests.
|
|
110
|
+
function defaultFetchLatest() {
|
|
111
|
+
const { execSync } = require('child_process');
|
|
112
|
+
return execSync('npm view pan-wizard version', {
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
timeout: 10000,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
}).trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Runtime entry point ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function main() {
|
|
122
|
+
const homeDir = os.homedir();
|
|
123
|
+
const cwd = process.cwd();
|
|
124
|
+
const cacheDir = path.join(homeDir, '.claude', 'cache');
|
|
125
|
+
const cacheFile = path.join(cacheDir, 'pan-update-check.json');
|
|
126
|
+
|
|
127
|
+
// VERSION file locations (check project first, then global)
|
|
128
|
+
const projectVersionFile = path.join(cwd, '.claude', 'pan-wizard-core', 'VERSION');
|
|
129
|
+
const globalVersionFile = path.join(homeDir, '.claude', 'pan-wizard-core', 'VERSION');
|
|
130
|
+
|
|
131
|
+
// Ensure cache directory exists
|
|
132
|
+
try {
|
|
133
|
+
if (!fs.existsSync(cacheDir)) {
|
|
134
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
} catch (e) { /* best-effort — a write failure below is swallowed too */ }
|
|
137
|
+
|
|
138
|
+
if (process.argv.includes('--run-check')) {
|
|
139
|
+
// Child mode: perform the (possibly slow) npm check synchronously, then
|
|
140
|
+
// exit. Never surface errors — a hook must not crash the session.
|
|
141
|
+
try {
|
|
142
|
+
runCheck({
|
|
143
|
+
cacheFile,
|
|
144
|
+
projectVersionFile,
|
|
145
|
+
globalVersionFile,
|
|
146
|
+
fetchLatest: defaultFetchLatest,
|
|
147
|
+
});
|
|
148
|
+
} catch (e) { /* silent */ }
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
54
151
|
|
|
55
|
-
|
|
56
|
-
`
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
152
|
+
// Parent mode: run the check in a detached background child so the 10s
|
|
153
|
+
// `npm view` never blocks SessionStart. windowsHide prevents a console flash.
|
|
154
|
+
const child = spawn(process.execPath, [__filename, '--run-check'], {
|
|
155
|
+
stdio: 'ignore',
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
detached: true, // Required on Windows for proper process detachment
|
|
158
|
+
});
|
|
159
|
+
child.unref();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (require.main === module) {
|
|
163
|
+
main();
|
|
164
|
+
}
|
|
61
165
|
|
|
62
|
-
|
|
166
|
+
module.exports = {
|
|
167
|
+
parseVersion,
|
|
168
|
+
compareVersions,
|
|
169
|
+
isUpdateAvailable,
|
|
170
|
+
computeUpdateResult,
|
|
171
|
+
resolveInstalledVersion,
|
|
172
|
+
runCheck,
|
|
173
|
+
};
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Debounce: 5 tool uses between warnings to avoid spam
|
|
18
18
|
// Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
|
|
19
|
+
//
|
|
20
|
+
// The decision logic lives in the pure, exported buildContextWarning() so it is
|
|
21
|
+
// unit-tested (tests/context-monitor-hook.test.cjs) rather than only reachable
|
|
22
|
+
// via stdin (M59, ADR audit 2026-08).
|
|
19
23
|
|
|
20
24
|
const fs = require('fs');
|
|
21
25
|
const os = require('os');
|
|
@@ -28,8 +32,25 @@ const path = require('path');
|
|
|
28
32
|
function bridgeDir() {
|
|
29
33
|
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
30
34
|
const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
|
|
31
|
-
try {
|
|
32
|
-
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
// mkdirSync{recursive} is a silent no-op on an EXISTING dir, so on a shared
|
|
38
|
+
// host an attacker can pre-plant pan-hooks-<uid> and read the session id /
|
|
39
|
+
// swap in a symlink. Verify WE own it, it isn't a symlink, and it isn't
|
|
40
|
+
// group/other-accessible; fail CLOSED (null) otherwise — the bridge is
|
|
41
|
+
// best-effort observability, so skipping beats a leak (M60, ADR audit 2026-08).
|
|
42
|
+
const st = fs.lstatSync(dir);
|
|
43
|
+
if (st.isSymbolicLink()) return null; // cross-platform
|
|
44
|
+
// POSIX ownership/mode checks ONLY where they're meaningful. Windows fakes
|
|
45
|
+
// mode bits — a dir just created with mode 0o700 lstats as 0o666 — so
|
|
46
|
+
// applying the mode/uid gate there returned null on every call and disabled
|
|
47
|
+
// the bridge entirely (N15). The finding's threat model is shared Unix hosts.
|
|
48
|
+
if (typeof process.getuid === 'function') {
|
|
49
|
+
if (st.uid !== process.getuid()) return null;
|
|
50
|
+
if ((st.mode & 0o077) !== 0) return null;
|
|
51
|
+
}
|
|
52
|
+
return dir;
|
|
53
|
+
} catch { return null; }
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
|
|
@@ -37,98 +58,151 @@ const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
|
|
|
37
58
|
const STALE_SECONDS = 60; // ignore metrics older than 60s
|
|
38
59
|
const DEBOUNCE_CALLS = 5; // min tool uses between warnings
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
// Pure decision function (exported for tests).
|
|
62
|
+
//
|
|
63
|
+
// metrics — { remaining_percentage, used_pct?, timestamp? } from the bridge
|
|
64
|
+
// warnState — { callsSinceWarn, lastLevel } from the prior warn file, or null
|
|
65
|
+
// on the first warning (no file yet / corrupt file)
|
|
66
|
+
// nowSeconds — current unix time in seconds
|
|
67
|
+
//
|
|
68
|
+
// Returns one of:
|
|
69
|
+
// { action: 'exit' } — nothing to do
|
|
70
|
+
// { action: 'debounce', warnState } — persist counter, no warn
|
|
71
|
+
// { action: 'emit', level, warnState, message } — persist + emit warning
|
|
72
|
+
function buildContextWarning(metrics, warnState, nowSeconds) {
|
|
73
|
+
if (!metrics || typeof metrics !== 'object') return { action: 'exit' };
|
|
74
|
+
|
|
75
|
+
// Ignore stale metrics (statusline stopped updating — e.g. session ended).
|
|
76
|
+
if (metrics.timestamp != null && (nowSeconds - metrics.timestamp) > STALE_SECONDS) {
|
|
77
|
+
return { action: 'exit' };
|
|
78
|
+
}
|
|
47
79
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
80
|
+
const remaining = Number(metrics.remaining_percentage);
|
|
81
|
+
if (!Number.isFinite(remaining)) return { action: 'exit' };
|
|
51
82
|
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
// No warning needed
|
|
84
|
+
if (remaining > WARNING_THRESHOLD) return { action: 'exit' };
|
|
54
85
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let metrics;
|
|
59
|
-
try {
|
|
60
|
-
metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
61
|
-
} catch {
|
|
62
|
-
process.exit(0);
|
|
63
|
-
}
|
|
64
|
-
const now = Math.floor(Date.now() / 1000);
|
|
86
|
+
const firstWarn = warnState == null || typeof warnState !== 'object';
|
|
87
|
+
const prev = firstWarn ? { callsSinceWarn: 0, lastLevel: null } : warnState;
|
|
88
|
+
const callsSinceWarn = (prev.callsSinceWarn || 0) + 1;
|
|
65
89
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
90
|
+
const isCritical = remaining <= CRITICAL_THRESHOLD;
|
|
91
|
+
const currentLevel = isCritical ? 'critical' : 'warning';
|
|
70
92
|
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
// Emit immediately on first warning, then debounce subsequent ones.
|
|
94
|
+
// Severity escalation (WARNING -> CRITICAL) bypasses debounce.
|
|
95
|
+
const severityEscalated = currentLevel === 'critical' && prev.lastLevel === 'warning';
|
|
96
|
+
if (!firstWarn && callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
|
97
|
+
// Bump the counter but leave lastLevel untouched, then exit without warning.
|
|
98
|
+
return {
|
|
99
|
+
action: 'debounce',
|
|
100
|
+
warnState: { callsSinceWarn, lastLevel: prev.lastLevel || null },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
73
103
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
// L39: report Usage and Remaining on the SAME scale so they are internally
|
|
105
|
+
// consistent (sum to 100). The bridge's used_pct is the statusline's
|
|
106
|
+
// 80%-rescaled figure (80% real usage displays as 100%); pairing it with the
|
|
107
|
+
// raw remaining produced self-contradictory numbers like
|
|
108
|
+
// "Usage at 94%. Remaining: 25%". Derive used from remaining instead.
|
|
109
|
+
const rem = Math.round(remaining);
|
|
110
|
+
const used = 100 - rem;
|
|
111
|
+
|
|
112
|
+
let message;
|
|
113
|
+
if (isCritical) {
|
|
114
|
+
message = `CONTEXT MONITOR CRITICAL: Usage at ${used}%. Remaining: ${rem}%. ` +
|
|
115
|
+
'STOP new work immediately. Save state NOW and inform the user that context is nearly exhausted. ' +
|
|
116
|
+
'If using PAN, run /pan:pause to save execution state.';
|
|
117
|
+
} else {
|
|
118
|
+
message = `CONTEXT MONITOR WARNING: Usage at ${used}%. Remaining: ${rem}%. ` +
|
|
119
|
+
'Begin wrapping up current task. Do not start new complex work. ' +
|
|
120
|
+
'If using PAN, consider /pan:pause to save state.';
|
|
121
|
+
}
|
|
78
122
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
return {
|
|
124
|
+
action: 'emit',
|
|
125
|
+
level: currentLevel,
|
|
126
|
+
warnState: { callsSinceWarn: 0, lastLevel: currentLevel },
|
|
127
|
+
message,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
83
130
|
|
|
131
|
+
function main() {
|
|
132
|
+
let input = '';
|
|
133
|
+
process.stdin.setEncoding('utf8');
|
|
134
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
135
|
+
process.stdin.on('end', () => {
|
|
84
136
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
// No prior warning file (or corrupted) — treat as first warning.
|
|
89
|
-
}
|
|
137
|
+
const data = JSON.parse(input);
|
|
138
|
+
const sessionId = data.session_id;
|
|
90
139
|
|
|
91
|
-
|
|
140
|
+
if (!sessionId) {
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
92
143
|
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
const tmpDir = bridgeDir();
|
|
145
|
+
if (!tmpDir) process.exit(0); // insecure/unavailable bridge dir — fail closed (M60)
|
|
146
|
+
const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
|
|
147
|
+
|
|
148
|
+
// Read metrics directly; absence (subagent/fresh session) or a corrupt
|
|
149
|
+
// file just means "nothing to warn about" — exit silently. No
|
|
150
|
+
// existsSync-then-read gap.
|
|
151
|
+
let metrics;
|
|
152
|
+
try {
|
|
153
|
+
metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
154
|
+
} catch {
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
const now = Math.floor(Date.now() / 1000);
|
|
158
|
+
|
|
159
|
+
// Load prior warn state (null when no file / corrupt → treated as first warn).
|
|
160
|
+
const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
|
|
161
|
+
let warnData = null;
|
|
162
|
+
try {
|
|
163
|
+
warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
warnData = null;
|
|
166
|
+
}
|
|
95
167
|
|
|
96
|
-
|
|
97
|
-
// Severity escalation (WARNING -> CRITICAL) bypasses debounce
|
|
98
|
-
const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
|
|
99
|
-
if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
|
100
|
-
// Update counter and exit without warning
|
|
101
|
-
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
102
|
-
process.exit(0);
|
|
103
|
-
}
|
|
168
|
+
const decision = buildContextWarning(metrics, warnData, now);
|
|
104
169
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
109
|
-
|
|
110
|
-
// Build warning message
|
|
111
|
-
let message;
|
|
112
|
-
if (isCritical) {
|
|
113
|
-
message = `CONTEXT MONITOR CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
114
|
-
'STOP new work immediately. Save state NOW and inform the user that context is nearly exhausted. ' +
|
|
115
|
-
'If using PAN, run /pan:pause to save execution state.';
|
|
116
|
-
} else {
|
|
117
|
-
message = `CONTEXT MONITOR WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
118
|
-
'Begin wrapping up current task. Do not start new complex work. ' +
|
|
119
|
-
'If using PAN, consider /pan:pause to save state.';
|
|
120
|
-
}
|
|
170
|
+
if (decision.action === 'exit') {
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
121
173
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
additionalContext: message
|
|
174
|
+
if (decision.action === 'debounce') {
|
|
175
|
+
fs.writeFileSync(warnPath, JSON.stringify(decision.warnState));
|
|
176
|
+
process.exit(0);
|
|
126
177
|
}
|
|
127
|
-
};
|
|
128
178
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
179
|
+
// action === 'emit'
|
|
180
|
+
fs.writeFileSync(warnPath, JSON.stringify(decision.warnState));
|
|
181
|
+
|
|
182
|
+
const output = {
|
|
183
|
+
hookSpecificOutput: {
|
|
184
|
+
hookEventName: "PostToolUse",
|
|
185
|
+
additionalContext: decision.message
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
process.stdout.write(JSON.stringify(output));
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// Silent fail -- never block tool execution
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
main();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
buildContextWarning,
|
|
203
|
+
bridgeDir,
|
|
204
|
+
WARNING_THRESHOLD,
|
|
205
|
+
CRITICAL_THRESHOLD,
|
|
206
|
+
STALE_SECONDS,
|
|
207
|
+
DEBOUNCE_CALLS,
|
|
208
|
+
};
|