context-guardian 0.1.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/.claude-plugin/marketplace.json +28 -0
- package/.claude-plugin/plugin.json +14 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/install.js +299 -0
- package/hooks/cg-context-monitor.cjs +140 -0
- package/hooks/cg-statusline.cjs +105 -0
- package/package.json +36 -0
- package/skills/context-status/SKILL.md +50 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "context-guardian",
|
|
3
|
+
"description": "Graceful context wind-down with session handoff for Claude Code",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "zeroToOneProjects"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "context-guardian",
|
|
10
|
+
"source": "./",
|
|
11
|
+
"hooks": {
|
|
12
|
+
"StatusLine": [
|
|
13
|
+
{
|
|
14
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/cg-statusline.cjs\"",
|
|
15
|
+
"timeout": 2000
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"PostToolUse": [
|
|
19
|
+
{
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/cg-context-monitor.cjs\"",
|
|
21
|
+
"matcher": "*",
|
|
22
|
+
"timeout": 1000
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "context-guardian",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Graceful context wind-down with session handoff for Claude Code",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "zeroToOneProjects"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/zeroToOneProjects/context-guardian"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": ["context", "guardian", "statusline", "session", "handoff"]
|
|
14
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Hector Ros
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Context Guardian
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/context-guardian)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Graceful context wind-down with session handoff for Claude Code.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx context-guardian
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's it. One command. The installer copies two hook scripts to `~/.claude/hooks/` and configures your `settings.json` with a statusline and a PostToolUse hook. It merges with your existing settings -- nothing is overwritten.
|
|
16
|
+
|
|
17
|
+
To install for the current project only (instead of globally):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx context-guardian --local
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### What You Get
|
|
24
|
+
|
|
25
|
+
After installation, your Claude Code statusline shows a live context usage bar:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Claude Opus 4.6 | my-project ████████░░ 78% <- normal (green)
|
|
29
|
+
Claude Opus 4.6 | my-project █████████░ 85% <- wind down (orange)
|
|
30
|
+
Claude Opus 4.6 | my-project 💀 ██████████ 96% <- emergency (red+blink)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
As context fills up, Claude receives escalating warnings that shift its behavior:
|
|
34
|
+
|
|
35
|
+
- **At 80%** -- Wrap up current work, avoid starting new tasks.
|
|
36
|
+
- **At 85%** -- Finish in-progress work only, no new work.
|
|
37
|
+
- **At 95%** -- Stop immediately. Write a handoff file with what was done, what remains, and the exact prompt to continue. Suggest `/clear`.
|
|
38
|
+
|
|
39
|
+
The result: every session ends with a handoff file. No work lost, no context wasted.
|
|
40
|
+
|
|
41
|
+
### Uninstall
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx context-guardian --uninstall
|
|
45
|
+
npx context-guardian --uninstall --local
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## The Problem
|
|
49
|
+
|
|
50
|
+
Claude Code conversations have a finite context window. When it fills up, Claude Code auto-compacts -- silently summarizing and discarding older messages. This means:
|
|
51
|
+
|
|
52
|
+
- **Work gets lost mid-task.** Claude forgets what it was doing, why, and what remains.
|
|
53
|
+
- **No warning before it happens.** A productive session becomes amnesia with no transition.
|
|
54
|
+
- **No graceful handoff.** There is no mechanism for Claude to document its state or prepare a continuation plan before context is wiped.
|
|
55
|
+
|
|
56
|
+
The problem is not that context is finite -- it is that there is no protocol for winding down gracefully.
|
|
57
|
+
|
|
58
|
+
## How It Works
|
|
59
|
+
|
|
60
|
+
Two scripts, two hooks -- a sensor and an actuator:
|
|
61
|
+
|
|
62
|
+
- **`cg-statusline.cjs`** (StatusLine hook) -- The sensor. Runs on every statusline render. Reads `context_window.remaining_percentage` from Claude Code's stdin, calculates the scaled usage percentage, persists session state to disk, and renders a colored progress bar.
|
|
63
|
+
|
|
64
|
+
- **`cg-context-monitor.cjs`** (PostToolUse hook) -- The actuator. Runs after every tool call. Reads the session state and, when a threshold is crossed, injects a system-level warning into Claude's context via `additionalContext`. Fires once per level transition to avoid noise.
|
|
65
|
+
|
|
66
|
+
### Data Flow
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Claude Code statusline refresh
|
|
70
|
+
-> stdin JSON with context_window.remaining_percentage
|
|
71
|
+
-> cg-statusline.cjs calculates scaled used%
|
|
72
|
+
-> Writes to ~/.claude/context-guardian/sessions/session-{id}.json
|
|
73
|
+
-> Returns statusline text with visual progress bar
|
|
74
|
+
|
|
75
|
+
Claude uses any tool
|
|
76
|
+
-> PostToolUse fires cg-context-monitor.cjs
|
|
77
|
+
-> Reads session-{id}.json
|
|
78
|
+
-> If level CHANGED (not repeated) -> injects additionalContext
|
|
79
|
+
-> Claude receives the warning and acts accordingly
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Key Design Decisions
|
|
83
|
+
|
|
84
|
+
- **Statusline writes, PostToolUse reads.** The StatusLine hook is the only one that receives `context_window` data from Claude Code. PostToolUse hooks do not get this data, so state is persisted to a file that the PostToolUse hook reads. This two-file architecture is the core of the plugin.
|
|
85
|
+
|
|
86
|
+
- **Scaled percentage.** Context usage is scaled so that 100% on our bar means Claude has only 5% of its raw context remaining. This gives the user a percentage that matches their intuition ("100% means full") while leaving a small safety margin.
|
|
87
|
+
|
|
88
|
+
- **One-shot warnings.** Each level fires exactly once. Crossing a threshold triggers one notification; re-notification only occurs when the next threshold is crossed. This prevents spam while ensuring every transition is communicated.
|
|
89
|
+
|
|
90
|
+
- **Advisory, not blocking.** The plugin never executes `/clear` or `/compact` automatically. It injects messages that Claude reads and acts on. The user makes the final call.
|
|
91
|
+
|
|
92
|
+
- **claude-mem detection.** At Level 3, the plugin checks if claude-mem is installed. If yes, the emergency message notes that persistent memory is active. If not, it emphasizes that everything must be written to the handoff file.
|
|
93
|
+
|
|
94
|
+
## Alert Levels
|
|
95
|
+
|
|
96
|
+
| Level | Threshold | Action |
|
|
97
|
+
|-------|-----------|--------|
|
|
98
|
+
| 0 | < 80% | Normal operation |
|
|
99
|
+
| 1 | >= 80% | Wrap up current work, avoid starting new tasks |
|
|
100
|
+
| 2 | >= 85% | Finish in-progress work only, no new work |
|
|
101
|
+
| 3 | >= 95% | Write handoff summary and suggest `/clear` |
|
|
102
|
+
|
|
103
|
+
Levels are one-way during a session -- once escalated, the level never drops back down.
|
|
104
|
+
|
|
105
|
+
## Session Data
|
|
106
|
+
|
|
107
|
+
All state is stored in `~/.claude/context-guardian/`:
|
|
108
|
+
|
|
109
|
+
- `sessions/` -- Per-session state files (current alert level, context percentage, timestamps). Auto-cleaned after 24 hours.
|
|
110
|
+
- `handoffs/` -- Handoff summaries written by Claude at Level 3.
|
|
111
|
+
|
|
112
|
+
This directory lives outside any repository. Context Guardian never reads or writes files in your project.
|
|
113
|
+
|
|
114
|
+
## Dependencies
|
|
115
|
+
|
|
116
|
+
None. Pure Node.js with no external packages. Uses only `fs`, `path`, and `os` from the standard library.
|
|
117
|
+
|
|
118
|
+
## Compatibility
|
|
119
|
+
|
|
120
|
+
Context Guardian operates independently through its own hooks and state directory. It works alongside other Claude Code plugins -- claude-mem, KanClaw, or any combination -- without conflicts.
|
|
121
|
+
|
|
122
|
+
**Note on GSD StatusLine**: If you also use GSD (Get Shit Done), the installer will detect its existing statusline and warn you rather than overwriting it. Use `--force` to replace the GSD statusline with Context Guardian's. The PostToolUse warning hook works regardless of which statusline is active, as long as one writes the session state file.
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
Contributions are welcome. Please open an issue to discuss proposed changes before submitting a pull request. Bug reports, feature requests, and documentation improvements are all appreciated.
|
|
127
|
+
|
|
128
|
+
1. Fork the repository
|
|
129
|
+
2. Create a feature branch
|
|
130
|
+
3. Make your changes
|
|
131
|
+
4. Run the tests: `node --test test/*.test.js`
|
|
132
|
+
5. Open a pull request against `main`
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Colors
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const cyan = '\x1b[36m';
|
|
11
|
+
const green = '\x1b[32m';
|
|
12
|
+
const yellow = '\x1b[33m';
|
|
13
|
+
const dim = '\x1b[2m';
|
|
14
|
+
const reset = '\x1b[0m';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Version & banner
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const pkg = require('../package.json');
|
|
20
|
+
|
|
21
|
+
const banner = '\n' +
|
|
22
|
+
cyan + ' ██████╗ ██████╗ \n' +
|
|
23
|
+
' ██╔════╝██╔════╝ \n' +
|
|
24
|
+
' ██║ ██║ ███╗\n' +
|
|
25
|
+
' ██║ ██║ ██║\n' +
|
|
26
|
+
' ╚██████╗╚██████╔╝\n' +
|
|
27
|
+
' ╚═════╝ ╚═════╝' + reset + '\n' +
|
|
28
|
+
'\n' +
|
|
29
|
+
' Context Guardian ' + dim + 'v' + pkg.version + reset + '\n' +
|
|
30
|
+
' Graceful context wind-down with session handoff for Claude Code.\n';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Parse args
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const hasGlobal = args.includes('--global') || args.includes('-g');
|
|
37
|
+
const hasLocal = args.includes('--local') || args.includes('-l');
|
|
38
|
+
const hasUninstall = args.includes('--uninstall') || args.includes('-u');
|
|
39
|
+
const hasForce = args.includes('--force');
|
|
40
|
+
const hasHelp = args.includes('--help') || args.includes('-h');
|
|
41
|
+
|
|
42
|
+
console.log(banner);
|
|
43
|
+
|
|
44
|
+
if (hasHelp) {
|
|
45
|
+
console.log(` ${yellow}Usage:${reset} npx context-guardian [options]\n`);
|
|
46
|
+
console.log(` ${yellow}Options:${reset}`);
|
|
47
|
+
console.log(` ${cyan}-g, --global${reset} Install globally to ~/.claude/ (default)`);
|
|
48
|
+
console.log(` ${cyan}-l, --local${reset} Install locally to ./.claude/`);
|
|
49
|
+
console.log(` ${cyan}-u, --uninstall${reset} Remove Context Guardian hooks and scripts`);
|
|
50
|
+
console.log(` ${cyan}--force${reset} Replace existing statusline (e.g., GSD's)`);
|
|
51
|
+
console.log(` ${cyan}-h, --help${reset} Show this help message`);
|
|
52
|
+
console.log('');
|
|
53
|
+
console.log(` ${yellow}Examples:${reset}`);
|
|
54
|
+
console.log(` ${dim}# Install globally (default)${reset}`);
|
|
55
|
+
console.log(` npx context-guardian`);
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(` ${dim}# Install for current project only${reset}`);
|
|
58
|
+
console.log(` npx context-guardian --local`);
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log(` ${dim}# Replace existing statusline${reset}`);
|
|
61
|
+
console.log(` npx context-guardian --force`);
|
|
62
|
+
console.log('');
|
|
63
|
+
console.log(` ${dim}# Uninstall${reset}`);
|
|
64
|
+
console.log(` npx context-guardian --uninstall`);
|
|
65
|
+
console.log('');
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (hasGlobal && hasLocal) {
|
|
70
|
+
console.error(` ${yellow}Cannot specify both --global and --local${reset}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Helpers
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function getConfigDir(isGlobal) {
|
|
79
|
+
if (!isGlobal) return path.join(process.cwd(), '.claude');
|
|
80
|
+
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
81
|
+
const p = process.env.CLAUDE_CONFIG_DIR;
|
|
82
|
+
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
|
|
83
|
+
}
|
|
84
|
+
return path.join(os.homedir(), '.claude');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readSettings(settingsPath) {
|
|
88
|
+
if (!fs.existsSync(settingsPath)) return {};
|
|
89
|
+
const raw = fs.readFileSync(settingsPath, 'utf8');
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw);
|
|
92
|
+
} catch {
|
|
93
|
+
console.error(` ${yellow}Error:${reset} ${settingsPath} contains invalid JSON.`);
|
|
94
|
+
console.error(` Fix it manually and re-run the installer.\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeSettings(settingsPath, settings) {
|
|
100
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildHookCommand(configDir, hookName) {
|
|
105
|
+
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
|
106
|
+
return `node "${hooksPath}"`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function configLabel(configDir, isGlobal) {
|
|
110
|
+
return isGlobal
|
|
111
|
+
? configDir.replace(os.homedir(), '~')
|
|
112
|
+
: configDir.replace(process.cwd(), '.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hasCgCommand(entry, hookName) {
|
|
116
|
+
return entry.hooks && entry.hooks.some(h => h.command && h.command.includes(hookName));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Uninstall
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function uninstall(isGlobal) {
|
|
124
|
+
const configDir = getConfigDir(isGlobal);
|
|
125
|
+
const label = configLabel(configDir, isGlobal);
|
|
126
|
+
|
|
127
|
+
console.log(` Uninstalling from ${cyan}${label}${reset}\n`);
|
|
128
|
+
|
|
129
|
+
if (!fs.existsSync(configDir)) {
|
|
130
|
+
console.log(` ${yellow}!${reset} Directory does not exist: ${label}`);
|
|
131
|
+
console.log(' Nothing to uninstall.\n');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let removedCount = 0;
|
|
136
|
+
|
|
137
|
+
// 1. Remove hook scripts
|
|
138
|
+
const hooksDir = path.join(configDir, 'hooks');
|
|
139
|
+
if (fs.existsSync(hooksDir)) {
|
|
140
|
+
const hookFiles = ['cg-statusline.cjs', 'cg-context-monitor.cjs'];
|
|
141
|
+
let hookCount = 0;
|
|
142
|
+
for (const file of hookFiles) {
|
|
143
|
+
const hookPath = path.join(hooksDir, file);
|
|
144
|
+
if (fs.existsSync(hookPath)) {
|
|
145
|
+
fs.unlinkSync(hookPath);
|
|
146
|
+
hookCount++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (hookCount > 0) {
|
|
150
|
+
removedCount++;
|
|
151
|
+
console.log(` ${green}+${reset} Removed ${hookCount} hook script(s)`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. Clean up settings.json
|
|
156
|
+
const settingsPath = path.join(configDir, 'settings.json');
|
|
157
|
+
if (fs.existsSync(settingsPath)) {
|
|
158
|
+
const settings = readSettings(settingsPath);
|
|
159
|
+
let modified = false;
|
|
160
|
+
|
|
161
|
+
if (settings.statusLine && settings.statusLine.command &&
|
|
162
|
+
settings.statusLine.command.includes('cg-statusline')) {
|
|
163
|
+
delete settings.statusLine;
|
|
164
|
+
modified = true;
|
|
165
|
+
console.log(` ${green}+${reset} Removed statusline from settings`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (settings.hooks && settings.hooks.PostToolUse) {
|
|
169
|
+
const before = settings.hooks.PostToolUse.length;
|
|
170
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
|
|
171
|
+
entry => !hasCgCommand(entry, 'cg-context-monitor')
|
|
172
|
+
);
|
|
173
|
+
if (settings.hooks.PostToolUse.length < before) {
|
|
174
|
+
modified = true;
|
|
175
|
+
console.log(` ${green}+${reset} Removed PostToolUse hook from settings`);
|
|
176
|
+
}
|
|
177
|
+
if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
181
|
+
delete settings.hooks;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (modified) {
|
|
185
|
+
writeSettings(settingsPath, settings);
|
|
186
|
+
removedCount++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (removedCount === 0) {
|
|
191
|
+
console.log(` ${yellow}!${reset} No Context Guardian files found to remove.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(`\n ${green}Done!${reset} Context Guardian has been uninstalled.\n`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Install
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function install(isGlobal) {
|
|
202
|
+
const configDir = getConfigDir(isGlobal);
|
|
203
|
+
const label = configLabel(configDir, isGlobal);
|
|
204
|
+
|
|
205
|
+
console.log(` Installing to ${cyan}${label}${reset}\n`);
|
|
206
|
+
|
|
207
|
+
// 1. Copy hook scripts to target hooks/ directory
|
|
208
|
+
const hooksDestDir = path.join(configDir, 'hooks');
|
|
209
|
+
fs.mkdirSync(hooksDestDir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
const srcRoot = path.join(__dirname, '..');
|
|
212
|
+
const hookFiles = ['cg-statusline.cjs', 'cg-context-monitor.cjs'];
|
|
213
|
+
|
|
214
|
+
for (const file of hookFiles) {
|
|
215
|
+
fs.copyFileSync(path.join(srcRoot, 'hooks', file), path.join(hooksDestDir, file));
|
|
216
|
+
console.log(` ${green}+${reset} Installed ${file}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Configure settings.json (merge, not overwrite)
|
|
220
|
+
const settingsPath = path.join(configDir, 'settings.json');
|
|
221
|
+
const settings = readSettings(settingsPath);
|
|
222
|
+
|
|
223
|
+
// 2a. StatusLine (top-level key, NOT inside hooks)
|
|
224
|
+
const statuslineConfig = {
|
|
225
|
+
type: 'command',
|
|
226
|
+
command: buildHookCommand(configDir, 'cg-statusline.cjs'),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let statuslineInstalled = false;
|
|
230
|
+
const existingCmd = settings.statusLine && settings.statusLine.command;
|
|
231
|
+
const isOurStatusline = existingCmd && existingCmd.includes('cg-statusline');
|
|
232
|
+
|
|
233
|
+
if (!settings.statusLine || isOurStatusline || hasForce) {
|
|
234
|
+
const verb = isOurStatusline ? 'Updated' : settings.statusLine ? 'Replaced existing' : 'Configured';
|
|
235
|
+
settings.statusLine = statuslineConfig;
|
|
236
|
+
statuslineInstalled = true;
|
|
237
|
+
console.log(` ${green}+${reset} ${verb} statusline`);
|
|
238
|
+
} else {
|
|
239
|
+
console.log(` ${yellow}!${reset} Existing statusline detected (${dim}${existingCmd || '(custom)'}${reset})`);
|
|
240
|
+
console.log(` Skipping statusline. Use ${cyan}--force${reset} to replace it.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2b. PostToolUse hook (inside hooks section)
|
|
244
|
+
if (!settings.hooks) settings.hooks = {};
|
|
245
|
+
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
246
|
+
|
|
247
|
+
const contextMonitorCommand = buildHookCommand(configDir, 'cg-context-monitor.cjs');
|
|
248
|
+
const hasCgMonitor = settings.hooks.PostToolUse.some(
|
|
249
|
+
entry => hasCgCommand(entry, 'cg-context-monitor')
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (hasCgMonitor) {
|
|
253
|
+
for (const entry of settings.hooks.PostToolUse) {
|
|
254
|
+
if (!entry.hooks) continue;
|
|
255
|
+
for (const h of entry.hooks) {
|
|
256
|
+
if (h.command && h.command.includes('cg-context-monitor')) {
|
|
257
|
+
h.command = contextMonitorCommand;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
console.log(` ${green}+${reset} Updated PostToolUse hook`);
|
|
262
|
+
} else {
|
|
263
|
+
settings.hooks.PostToolUse.push({
|
|
264
|
+
matcher: '*',
|
|
265
|
+
hooks: [{ type: 'command', command: contextMonitorCommand }],
|
|
266
|
+
});
|
|
267
|
+
console.log(` ${green}+${reset} Configured PostToolUse hook`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 3. Write settings.json
|
|
271
|
+
writeSettings(settingsPath, settings);
|
|
272
|
+
|
|
273
|
+
// 4. Print summary
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(` ${green}Done!${reset} Context Guardian is installed.\n`);
|
|
276
|
+
console.log(` ${dim}What was installed:${reset}`);
|
|
277
|
+
console.log(` ${dim}Scripts:${reset} ${label}/hooks/cg-statusline.cjs`);
|
|
278
|
+
console.log(` ${label}/hooks/cg-context-monitor.cjs`);
|
|
279
|
+
if (statuslineInstalled) {
|
|
280
|
+
console.log(` ${dim}StatusLine:${reset} Colored progress bar in the bottom status bar`);
|
|
281
|
+
}
|
|
282
|
+
console.log(` ${dim}PostToolUse:${reset} Escalating warnings at 80%, 85%, 95% context usage`);
|
|
283
|
+
console.log('');
|
|
284
|
+
console.log(` ${dim}Session data:${reset} ~/.claude/context-guardian/`);
|
|
285
|
+
console.log(` ${dim}Uninstall:${reset} npx context-guardian --uninstall${isGlobal ? '' : ' --local'}`);
|
|
286
|
+
console.log('');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Main
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
const isGlobal = !hasLocal;
|
|
294
|
+
|
|
295
|
+
if (hasUninstall) {
|
|
296
|
+
uninstall(isGlobal);
|
|
297
|
+
} else {
|
|
298
|
+
install(isGlobal);
|
|
299
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Context Guardian -- PostToolUse Hook
|
|
4
|
+
// Injects context-window warnings when usage crosses threshold levels.
|
|
5
|
+
// Fires once per level transition to avoid spamming.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const GUARDIAN_DIR = path.join(os.homedir(), '.claude', 'context-guardian');
|
|
12
|
+
const SESSIONS_DIR = path.join(GUARDIAN_DIR, 'sessions');
|
|
13
|
+
const HANDOFFS_DIR = path.join(GUARDIAN_DIR, 'handoffs');
|
|
14
|
+
|
|
15
|
+
const SUPPRESS = JSON.stringify({ continue: true, suppressOutput: true });
|
|
16
|
+
|
|
17
|
+
// IMPORTANT: thresholds must match getLevel() in cg-statusline.cjs
|
|
18
|
+
function getLevel(used) {
|
|
19
|
+
if (used >= 95) return 3;
|
|
20
|
+
if (used >= 85) return 2;
|
|
21
|
+
if (used >= 80) return 1;
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readJson(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeJson(filePath, data) {
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isClaudeMemActive() {
|
|
44
|
+
try {
|
|
45
|
+
return fs.existsSync(
|
|
46
|
+
path.join(os.homedir(), '.claude', 'plugins', 'cache', 'thedotmack', 'claude-mem')
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildWarning(level, used, sessionId) {
|
|
54
|
+
if (level === 1) {
|
|
55
|
+
return [
|
|
56
|
+
'\u26a0\ufe0f CONTEXT GUARDIAN \u2014 Heads Up (80%)',
|
|
57
|
+
`Context window is at ${used}%. Wrap up current work, avoid starting new tasks.`,
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (level === 2) {
|
|
62
|
+
return [
|
|
63
|
+
'\ud83d\udfe0 CONTEXT GUARDIAN \u2014 Wind Down (85%)',
|
|
64
|
+
`Context window is at ${used}%. Finish in-progress work only, no new work.`,
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (level === 3) {
|
|
69
|
+
const claudeMemNote = isClaudeMemActive()
|
|
70
|
+
? 'Note: claude-mem is active. Your important observations from this session are already persisted in memory.'
|
|
71
|
+
: 'Important: There is no persistent memory system. Make sure ALL important context is written to the handoff file.';
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
'\ud83d\udd34 CONTEXT GUARDIAN \u2014 Emergency Save (95%)',
|
|
75
|
+
`Context window is at ${used}%. STOP all work immediately.`,
|
|
76
|
+
'',
|
|
77
|
+
'You MUST do the following RIGHT NOW:',
|
|
78
|
+
`1. Write a handoff file to ~/.claude/context-guardian/handoffs/handoff-${sessionId}.md with:`,
|
|
79
|
+
' - What you were working on',
|
|
80
|
+
' - Current state of each task',
|
|
81
|
+
' - What remains to be done',
|
|
82
|
+
' - The exact prompt the user should paste to continue',
|
|
83
|
+
'2. Tell the user to run /clear and paste the continuation prompt',
|
|
84
|
+
'',
|
|
85
|
+
claudeMemNote,
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function suppress() {
|
|
93
|
+
process.stdout.write(SUPPRESS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Read JSON from stdin
|
|
97
|
+
let input = '';
|
|
98
|
+
process.stdin.setEncoding('utf8');
|
|
99
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
100
|
+
|
|
101
|
+
process.stdin.on('end', () => {
|
|
102
|
+
try {
|
|
103
|
+
const data = JSON.parse(input);
|
|
104
|
+
const sessionId = data.session_id;
|
|
105
|
+
|
|
106
|
+
if (!sessionId) return suppress();
|
|
107
|
+
|
|
108
|
+
// Read session state written by the statusline script
|
|
109
|
+
const state = readJson(path.join(SESSIONS_DIR, `session-${sessionId}.json`));
|
|
110
|
+
|
|
111
|
+
if (!state || typeof state.used !== 'number') return suppress();
|
|
112
|
+
|
|
113
|
+
const currentLevel = getLevel(state.used);
|
|
114
|
+
|
|
115
|
+
if (currentLevel === 0) return suppress();
|
|
116
|
+
|
|
117
|
+
// Only inject a warning when crossing into a NEW, higher level
|
|
118
|
+
const notifiedFile = path.join(SESSIONS_DIR, `notified-${sessionId}.json`);
|
|
119
|
+
const lastNotifiedLevel = (readJson(notifiedFile) || {}).last_notified_level || 0;
|
|
120
|
+
|
|
121
|
+
if (currentLevel <= lastNotifiedLevel) return suppress();
|
|
122
|
+
|
|
123
|
+
// Update tracking BEFORE output; suppress on failure to avoid spam
|
|
124
|
+
if (!writeJson(notifiedFile, { last_notified_level: currentLevel })) return suppress();
|
|
125
|
+
|
|
126
|
+
// Ensure handoffs directory exists (Level 3 tells Claude to write there)
|
|
127
|
+
if (currentLevel === 3) {
|
|
128
|
+
try { fs.mkdirSync(HANDOFFS_DIR, { recursive: true }); } catch { /* best-effort */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
process.stdout.write(JSON.stringify({
|
|
132
|
+
hookSpecificOutput: {
|
|
133
|
+
hookEventName: 'PostToolUse',
|
|
134
|
+
additionalContext: buildWarning(currentLevel, state.used, sessionId),
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
} catch {
|
|
138
|
+
suppress();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Context Guardian -- StatusLine Hook
|
|
4
|
+
// Renders a colored progress bar and persists session state for the monitor.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'context-guardian', 'sessions');
|
|
11
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
// IMPORTANT: thresholds must match getLevel() in cg-context-monitor.cjs
|
|
14
|
+
function getLevel(used) {
|
|
15
|
+
if (used >= 95) return 3;
|
|
16
|
+
if (used >= 85) return 2;
|
|
17
|
+
if (used >= 80) return 1;
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getBarColor(used) {
|
|
22
|
+
if (used >= 95) return '\x1b[5;31m'; // red + blink
|
|
23
|
+
if (used >= 80) return '\x1b[38;5;208m'; // orange
|
|
24
|
+
if (used >= 63) return '\x1b[33m'; // yellow
|
|
25
|
+
return '\x1b[32m'; // green
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Scale raw usage so our 100% = 5% remaining in Claude (rawUsed 95)
|
|
29
|
+
function scaleUsage(remainingPct) {
|
|
30
|
+
if (typeof remainingPct !== 'number') return 0;
|
|
31
|
+
const rawUsed = Math.max(0, Math.min(100, 100 - remainingPct));
|
|
32
|
+
return Math.min(100, Math.round((rawUsed / 95) * 100));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Remove stale session files (~5% of runs to avoid unnecessary I/O)
|
|
36
|
+
function cleanupStaleSessions(sessionId) {
|
|
37
|
+
if (Math.random() >= 0.05) return;
|
|
38
|
+
try {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
for (const file of fs.readdirSync(SESSIONS_DIR)) {
|
|
41
|
+
if (file === `session-${sessionId}.json` || file === `notified-${sessionId}.json`) continue;
|
|
42
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
43
|
+
if (now - fs.statSync(filePath).mtimeMs > MAX_AGE_MS) {
|
|
44
|
+
fs.unlinkSync(filePath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (_) {
|
|
48
|
+
// Best-effort cleanup
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function persistState(sessionId, used) {
|
|
53
|
+
if (!sessionId) return;
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
56
|
+
const stateFile = path.join(SESSIONS_DIR, `session-${sessionId}.json`);
|
|
57
|
+
fs.writeFileSync(stateFile, JSON.stringify({
|
|
58
|
+
used,
|
|
59
|
+
level: getLevel(used),
|
|
60
|
+
session_id: sessionId,
|
|
61
|
+
ts: Date.now(),
|
|
62
|
+
}));
|
|
63
|
+
cleanupStaleSessions(sessionId);
|
|
64
|
+
} catch (_) {
|
|
65
|
+
// Best-effort -- never break statusline for a file write failure
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderBar(used) {
|
|
70
|
+
const filled = Math.round(used / 10);
|
|
71
|
+
return '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Read JSON from stdin
|
|
75
|
+
let input = '';
|
|
76
|
+
process.stdin.setEncoding('utf8');
|
|
77
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
78
|
+
|
|
79
|
+
process.stdin.on('end', () => {
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(input);
|
|
82
|
+
const model = data.model || {};
|
|
83
|
+
const workspace = data.workspace || {};
|
|
84
|
+
const used = scaleUsage((data.context_window || {}).remaining_percentage);
|
|
85
|
+
|
|
86
|
+
persistState(data.session_id, used);
|
|
87
|
+
|
|
88
|
+
const reset = '\x1b[0m';
|
|
89
|
+
const dim = '\x1b[2m';
|
|
90
|
+
const color = getBarColor(used);
|
|
91
|
+
const prefix = used >= 95 ? '\uD83D\uDC80 ' : '';
|
|
92
|
+
const modelName = model.display_name || model.id || 'Claude';
|
|
93
|
+
const dirname = workspace.current_dir ? path.basename(workspace.current_dir) : '';
|
|
94
|
+
|
|
95
|
+
const parts = [
|
|
96
|
+
`${dim}${modelName}${reset}`,
|
|
97
|
+
dirname ? `${dim}${dirname}${reset}` : null,
|
|
98
|
+
`${color}${prefix}${renderBar(used)} ${used}%${reset}`,
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
|
|
101
|
+
process.stdout.write(parts.join(' \u2502 '));
|
|
102
|
+
} catch (_) {
|
|
103
|
+
// Silent failure -- never break Claude Code statusline
|
|
104
|
+
}
|
|
105
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "context-guardian",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Graceful context wind-down with session handoff for Claude Code",
|
|
5
|
+
"bin": {
|
|
6
|
+
"context-guardian": "bin/install.js"
|
|
7
|
+
},
|
|
8
|
+
"author": "Hector Ros",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"claude-code",
|
|
12
|
+
"context",
|
|
13
|
+
"statusline",
|
|
14
|
+
"plugin",
|
|
15
|
+
"session-handoff",
|
|
16
|
+
"context-window"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/zeroToOneProjects/context-guardian"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/*.test.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
".claude-plugin/",
|
|
30
|
+
"bin/",
|
|
31
|
+
"hooks/",
|
|
32
|
+
"skills/",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"README.md"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: context-status
|
|
3
|
+
description: "Check current context window usage. Use when the user asks about context usage, remaining context, how much context is left, or similar questions about the current session's context window."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Context Status
|
|
7
|
+
|
|
8
|
+
Read the current context window usage from the Context Guardian session state file.
|
|
9
|
+
|
|
10
|
+
## How to check
|
|
11
|
+
|
|
12
|
+
Read the file at `~/.claude/context-guardian/sessions/session-{SESSION_ID}.json` where `{SESSION_ID}` is the current session ID.
|
|
13
|
+
|
|
14
|
+
The file contains:
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"used": 78,
|
|
18
|
+
"level": 1,
|
|
19
|
+
"session_id": "abc123",
|
|
20
|
+
"ts": 1709000000000
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Fields
|
|
25
|
+
|
|
26
|
+
- `used` — Scaled context usage percentage (0-100). Scaled so that 100% displayed means only 5% of real context remains.
|
|
27
|
+
- `level` — Alert level: 0 (normal, < 80%), 1 (heads up, >= 80%), 2 (wind down, >= 85%), 3 (emergency, >= 95%)
|
|
28
|
+
- `ts` — Timestamp of last update (updated on every statusline refresh)
|
|
29
|
+
|
|
30
|
+
## How to respond
|
|
31
|
+
|
|
32
|
+
Report the usage to the user in a clear format. Examples:
|
|
33
|
+
|
|
34
|
+
**Normal (level 0):**
|
|
35
|
+
> Context usage: 45%. Plenty of room.
|
|
36
|
+
|
|
37
|
+
**Heads up (level 1):**
|
|
38
|
+
> Context usage: 82%. Getting there — I'm wrapping up current work and avoiding new tasks.
|
|
39
|
+
|
|
40
|
+
**Wind down (level 2):**
|
|
41
|
+
> Context usage: 87%. I'm finishing what's in progress and preparing handoff notes.
|
|
42
|
+
|
|
43
|
+
**Emergency (level 3):**
|
|
44
|
+
> Context usage: 96%. Critical — I need to write the handoff file and you should /clear soon.
|
|
45
|
+
|
|
46
|
+
## If the file doesn't exist
|
|
47
|
+
|
|
48
|
+
The session state file is written by the Context Guardian statusline hook. If it doesn't exist, respond:
|
|
49
|
+
|
|
50
|
+
> I can't check context usage right now — the Context Guardian statusline hook hasn't written any state for this session yet. The statusline needs to be configured and running for context tracking to work.
|