pan-wizard 3.22.0 → 3.24.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.
Files changed (134) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
  68. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  69. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  70. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  71. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  72. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  73. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  74. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  75. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  76. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  77. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  78. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  79. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  80. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  81. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  82. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  83. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  84. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  85. package/pan-wizard-core/learnings/index.json +7 -7
  86. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  87. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  88. package/pan-wizard-core/references/design-methodology.md +94 -0
  89. package/pan-wizard-core/references/git-integration.md +9 -9
  90. package/pan-wizard-core/references/guardrails.md +4 -1
  91. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  92. package/pan-wizard-core/references/model-profiles.md +44 -34
  93. package/pan-wizard-core/references/planning-config.md +5 -5
  94. package/pan-wizard-core/references/verification-patterns.md +67 -0
  95. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  96. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  97. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  98. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  99. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  100. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  101. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  102. package/pan-wizard-core/templates/design.md +146 -0
  103. package/pan-wizard-core/templates/discovery.md +3 -3
  104. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  105. package/pan-wizard-core/templates/playbook.md +1 -1
  106. package/pan-wizard-core/templates/uat.md +1 -1
  107. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  108. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  109. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  110. package/pan-wizard-core/workflows/health.md +24 -1
  111. package/pan-wizard-core/workflows/help.md +5 -4
  112. package/pan-wizard-core/workflows/learn.md +5 -5
  113. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  114. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  116. package/pan-wizard-core/workflows/new-project.md +47 -32
  117. package/pan-wizard-core/workflows/optimize.md +8 -8
  118. package/pan-wizard-core/workflows/pause.md +1 -1
  119. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  120. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  121. package/pan-wizard-core/workflows/profile.md +13 -10
  122. package/pan-wizard-core/workflows/research-phase.md +1 -1
  123. package/pan-wizard-core/workflows/resume-project.md +1 -1
  124. package/pan-wizard-core/workflows/settings.md +9 -9
  125. package/pan-wizard-core/workflows/transition.md +24 -8
  126. package/pan-wizard-core/workflows/update.md +23 -20
  127. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  128. package/pan-zcode/README.md +12 -2
  129. package/pan-zcode/bin/install-zcode.js +1 -1
  130. package/pan-zcode/mcp/native-tools.cjs +1 -1
  131. package/pan-zcode/mcp/server.cjs +76 -14
  132. package/scripts/build-hooks.js +2 -1
  133. package/scripts/generate-skills-docs.py +0 -4
  134. package/scripts/run-tests.cjs +11 -4
@@ -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). Fresh 200k context for investigation. Main context stays lean for user interaction.
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>
@@ -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 4.7 with extended thinking produces the richest findings.
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
 
@@ -21,11 +21,11 @@ Routes to the settings workflow which handles:
21
21
  </objective>
22
22
 
23
23
  <execution_context>
24
- @./.claude/pan-wizard-core/workflows/settings.md
24
+ @~/.claude/pan-wizard-core/workflows/settings.md
25
25
  </execution_context>
26
26
 
27
27
  <process>
28
- **Follow the settings workflow** from `@./.claude/pan-wizard-core/workflows/settings.md`.
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
@@ -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
- Filename + branch include a timestamp so running what-if multiple times on the same phase+scenario produces distinct reports without overwriting.
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
- const homeDir = os.homedir();
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
- // VERSION file locations (check project first, then global)
16
- const projectVersionFile = path.join(cwd, '.claude', 'pan-wizard-core', 'VERSION');
17
- const globalVersionFile = path.join(homeDir, '.claude', 'pan-wizard-core', 'VERSION');
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
- // Ensure cache directory exists
20
- if (!fs.existsSync(cacheDir)) {
21
- fs.mkdirSync(cacheDir, { recursive: true });
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
- // Run check in background (spawn background process, windowsHide prevents console flash)
25
- const child = spawn(process.execPath, ['-e', `
26
- const fs = require('fs');
27
- const { execSync } = require('child_process');
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
- const cacheFile = ${JSON.stringify(cacheFile)};
30
- const projectVersionFile = ${JSON.stringify(projectVersionFile)};
31
- const globalVersionFile = ${JSON.stringify(globalVersionFile)};
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
- // Check project directory first (local install), then global
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 (fs.existsSync(projectVersionFile)) {
37
- installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
38
- } else if (fs.existsSync(globalVersionFile)) {
39
- installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
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 = execSync('npm view pan-wizard version', { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim();
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
- const result = {
49
- update_available: latest && installed !== latest,
50
- installed,
51
- latest: latest || 'unknown',
52
- checked: Math.floor(Date.now() / 1000)
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
- fs.writeFileSync(cacheFile, JSON.stringify(result));
56
- `], {
57
- stdio: 'ignore',
58
- windowsHide: true,
59
- detached: true // Required on Windows for proper process detachment
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
- child.unref();
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 { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
32
- return dir;
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
- let input = '';
41
- process.stdin.setEncoding('utf8');
42
- process.stdin.on('data', chunk => input += chunk);
43
- process.stdin.on('end', () => {
44
- try {
45
- const data = JSON.parse(input);
46
- const sessionId = data.session_id;
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
- if (!sessionId) {
49
- process.exit(0);
50
- }
80
+ const remaining = Number(metrics.remaining_percentage);
81
+ if (!Number.isFinite(remaining)) return { action: 'exit' };
51
82
 
52
- const tmpDir = bridgeDir();
53
- const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
83
+ // No warning needed
84
+ if (remaining > WARNING_THRESHOLD) return { action: 'exit' };
54
85
 
55
- // Read metrics directly; absence (subagent/fresh session) or a corrupt
56
- // file just means "nothing to warn about" — exit silently. No
57
- // existsSync-then-read gap.
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
- // Ignore stale metrics
67
- if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) {
68
- process.exit(0);
69
- }
90
+ const isCritical = remaining <= CRITICAL_THRESHOLD;
91
+ const currentLevel = isCritical ? 'critical' : 'warning';
70
92
 
71
- const remaining = metrics.remaining_percentage;
72
- const usedPct = metrics.used_pct;
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
- // No warning needed
75
- if (remaining > WARNING_THRESHOLD) {
76
- process.exit(0);
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
- // Debounce: check if we warned recently
80
- const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
81
- let warnData = { callsSinceWarn: 0, lastLevel: null };
82
- let firstWarn = true;
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
- warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
86
- firstWarn = false;
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
- warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
140
+ if (!sessionId) {
141
+ process.exit(0);
142
+ }
92
143
 
93
- const isCritical = remaining <= CRITICAL_THRESHOLD;
94
- const currentLevel = isCritical ? 'critical' : 'warning';
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
- // Emit immediately on first warning, then debounce subsequent ones
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
- // Reset debounce counter
106
- warnData.callsSinceWarn = 0;
107
- warnData.lastLevel = currentLevel;
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
- const output = {
123
- hookSpecificOutput: {
124
- hookEventName: "PostToolUse",
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
- process.stdout.write(JSON.stringify(output));
130
- } catch (e) {
131
- // Silent fail -- never block tool execution
132
- process.exit(0);
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
+ };