@compr/opscontext-mcp 2.8.2 → 2.8.4
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/CHANGELOG.md +37 -0
- package/defaults/simplicity-gate.py +135 -0
- package/dist/fleet-health.d.ts +15 -0
- package/dist/fleet-health.js +31 -2
- package/dist/install-claude-hook.d.ts +28 -0
- package/dist/install-claude-hook.js +187 -24
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
|
|
|
4
4
|
|
|
5
5
|
> Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
|
|
6
6
|
|
|
7
|
+
## [2.8.4] 2026-09-17: a gate on complexity, a watch on doubled hooks
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`install-claude-hook --simplicity`: a PostToolUse gate against complexity an edit just
|
|
12
|
+
introduced** (`defaults/simplicity-gate.py`, LOCK `[SIMPLICITY-GATE-SILENT-WHEN-BLIND]`).
|
|
13
|
+
After Claude edits or writes a Python file, ruff's complexity rules (C901, PLR0911, PLR0912,
|
|
14
|
+
PLR0915) run on the file and on its git HEAD version; only functions the edit made new
|
|
15
|
+
offenders or worse come back to Claude (exit 2) with the ask to simplify them without
|
|
16
|
+
changing behavior. Complexity that was already there, files outside git and a missing ruff
|
|
17
|
+
are silent. Registered once under `Edit|Write|MultiEdit` through the same count-verified
|
|
18
|
+
installer; a plain re-run keeps it, `uninstall-claude-hook --simplicity` removes only it.
|
|
19
|
+
Needs ruff (`brew install ruff`); the installer says where it found it, or that it did not.
|
|
20
|
+
- **Fleet health flags a doubled Claude Code hook within a minute, not nine days**
|
|
21
|
+
(`src/fleet-health.ts`). Two measured checks: the OpsContext registrations per event in
|
|
22
|
+
`~/.claude/settings.json`, counted the way the installer counts them (a registration above 1,
|
|
23
|
+
or a partial install, is a warning), and the day's `vscode.*` audit records that repeat the
|
|
24
|
+
previous one in event and payload within 2 s (a warning above 5 percent, once the day has 10
|
|
25
|
+
hook events). Both show in `contextengine servers` (exit 1), `end-session` and the VS Code
|
|
26
|
+
status bar. The 2026-09-06 doubling ran at 99.5 to 100 percent for nine days unseen.
|
|
27
|
+
|
|
28
|
+
## [2.8.3] 2026-09-16: one hook per event, verified
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **`install-claude-hook` no longer registers the emit hooks twice** (`src/install-claude-hook.ts`,
|
|
33
|
+
LOCKs `[HOOKS-COMPARED-BY-EXPANDED-PATH]`, `[INSTALL-VERIFIES-BY-COUNT]`). Its "already
|
|
34
|
+
installed?" check compared command text literally, so hooks written as
|
|
35
|
+
`$HOME/.claude/hooks/opscontext-emit.sh` looked absent and a second set was added: on the
|
|
36
|
+
author's machine every Claude Code prompt and tool call reached the audit log twice from
|
|
37
|
+
2026-09-06 to 2026-09-15, doubling the inputs of the `stuck` and `silent_failure` heuristics.
|
|
38
|
+
The installer now compares script paths with `$HOME`, `${HOME}` and `~` expanded, removes
|
|
39
|
+
extra copies of its own commands (other hooks untouched), and after writing re-reads
|
|
40
|
+
`settings.json` and exits 1 unless each event runs its script exactly once. Running
|
|
41
|
+
`install-claude-hook` again repairs an affected machine; the audit log is left as it is (it is
|
|
42
|
+
hash-chained).
|
|
43
|
+
|
|
7
44
|
## [2.8.2] 2026-09-08: every source says what it is
|
|
8
45
|
|
|
9
46
|
### Added
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OpsContext simplicity gate: a Claude Code PostToolUse hook (Edit, Write, MultiEdit).
|
|
3
|
+
|
|
4
|
+
Reads the hook JSON on stdin, runs ruff's complexity rules (C901, PLR0911, PLR0912, PLR0915)
|
|
5
|
+
on the edited Python file and on the same file at git HEAD, and exits 2 (stderr goes back to
|
|
6
|
+
Claude) only for functions that are new offenders or got worse. Installed by
|
|
7
|
+
`opscontext install-claude-hook --simplicity`; removed by `uninstall-claude-hook --simplicity`.
|
|
8
|
+
|
|
9
|
+
[LOCKED] [SIMPLICITY-GATE-SILENT-WHEN-BLIND] 2026-09-17
|
|
10
|
+
[NEVER] exit 2 for complexity that was already there at HEAD, or when ruff, git, the file or
|
|
11
|
+
the input cannot be read. A gate that nags about code the edit did not touch, or that
|
|
12
|
+
fails when a tool is missing, gets disabled and then protects nothing.
|
|
13
|
+
WHY: the 2026-09-15 bake-off on KONIVE: three simplification tools cut branches, none cut
|
|
14
|
+
lines, and a guidelines-only pass deleted a function a newer commit used. Only a diff
|
|
15
|
+
against HEAD, per (rule, function), tells "this edit made it worse" from "it was like that".
|
|
16
|
+
FIX: compare per (rule, function) against HEAD; every blind path returns 0 with no output.
|
|
17
|
+
ruff is looked up in SIMPLICITY_RUFF, then PATH, then the usual install dirs, because
|
|
18
|
+
Claude Code runs hooks without the user's shell PATH.
|
|
19
|
+
"""
|
|
20
|
+
import ast
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
RULES = "C901,PLR0911,PLR0912,PLR0915"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_ruff():
|
|
32
|
+
"""ruff to run: SIMPLICITY_RUFF, then PATH, then where brew, pipx and cargo put it."""
|
|
33
|
+
env = os.environ.get("SIMPLICITY_RUFF")
|
|
34
|
+
if env:
|
|
35
|
+
return env
|
|
36
|
+
on_path = shutil.which("ruff")
|
|
37
|
+
if on_path:
|
|
38
|
+
return on_path
|
|
39
|
+
home = os.path.expanduser("~")
|
|
40
|
+
for candidate in (
|
|
41
|
+
"/opt/homebrew/bin/ruff",
|
|
42
|
+
"/usr/local/bin/ruff",
|
|
43
|
+
os.path.join(home, ".local", "bin", "ruff"),
|
|
44
|
+
os.path.join(home, ".cargo", "bin", "ruff"),
|
|
45
|
+
):
|
|
46
|
+
if os.access(candidate, os.X_OK):
|
|
47
|
+
return candidate
|
|
48
|
+
return "ruff" # run() then fails with OSError and the gate stays silent
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
RUFF = find_ruff()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(cmd, **kwargs):
|
|
55
|
+
try:
|
|
56
|
+
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def def_names(source):
|
|
62
|
+
"""Map a def line number to its function name."""
|
|
63
|
+
try:
|
|
64
|
+
tree = ast.parse(source)
|
|
65
|
+
except SyntaxError:
|
|
66
|
+
return {}
|
|
67
|
+
return {n.lineno: n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def violations(path, source):
|
|
71
|
+
"""{(rule, function): (value, message)}; None when ruff cannot run."""
|
|
72
|
+
if not source:
|
|
73
|
+
return {}
|
|
74
|
+
done = run([RUFF, "check", "--no-cache", "--select", RULES, "--output-format", "json",
|
|
75
|
+
"--stdin-filename", path, "-"], input=source)
|
|
76
|
+
if done is None:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
items = json.loads(done.stdout or "[]")
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
names = def_names(source)
|
|
83
|
+
found = {}
|
|
84
|
+
for v in items:
|
|
85
|
+
if v.get("code") not in RULES.split(","):
|
|
86
|
+
continue # ruff always reports syntax errors; a half-written file is not "complex"
|
|
87
|
+
row = (v.get("location") or {}).get("row")
|
|
88
|
+
numbers = re.findall(r"\((\d+) > \d+\)", v.get("message", ""))
|
|
89
|
+
found[(v.get("code"), names.get(row, f"line {row}"))] = (int(numbers[0]) if numbers else 0, v.get("message", ""))
|
|
90
|
+
return found
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def head_source(path):
|
|
94
|
+
"""File content at HEAD; "" when the file is new; None when not inside a git repo."""
|
|
95
|
+
top = run(["git", "-C", os.path.dirname(path) or ".", "rev-parse", "--show-toplevel"])
|
|
96
|
+
if top is None or top.returncode != 0:
|
|
97
|
+
return None
|
|
98
|
+
root = top.stdout.strip()
|
|
99
|
+
rel = os.path.relpath(os.path.realpath(path), os.path.realpath(root))
|
|
100
|
+
shown = run(["git", "-C", root, "show", f"HEAD:{rel}"])
|
|
101
|
+
return shown.stdout if shown is not None and shown.returncode == 0 else ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
try:
|
|
106
|
+
event = json.load(sys.stdin)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return 0
|
|
109
|
+
path = (event.get("tool_input") or {}).get("file_path", "")
|
|
110
|
+
if not path.endswith(".py") or not os.path.isfile(path):
|
|
111
|
+
return 0
|
|
112
|
+
baseline = head_source(path)
|
|
113
|
+
if baseline is None:
|
|
114
|
+
return 0
|
|
115
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
116
|
+
now = violations(path, fh.read())
|
|
117
|
+
before = violations(path, baseline)
|
|
118
|
+
if now is None or before is None:
|
|
119
|
+
return 0
|
|
120
|
+
worse = [key for key, (value, _) in now.items() if key not in before or value > before[key][0]]
|
|
121
|
+
if not worse:
|
|
122
|
+
return 0
|
|
123
|
+
lines = "\n".join(f"- {name}: {now[(code, name)][1]}" for code, name in worse)
|
|
124
|
+
print(
|
|
125
|
+
f"Simplicity gate: your edit of {os.path.basename(path)} made these functions more complex than the limit:\n"
|
|
126
|
+
f"{lines}\n"
|
|
127
|
+
"Simplify them now without changing behavior (early returns, a lookup table instead of branches, "
|
|
128
|
+
"split one job per function). Keep every LOCK comment. Do not touch functions you did not edit.",
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
return 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
package/dist/fleet-health.d.ts
CHANGED
|
@@ -23,7 +23,16 @@ export interface FleetHealth {
|
|
|
23
23
|
/** Above this many writes per hour a warning is raised. */
|
|
24
24
|
threshold: number;
|
|
25
25
|
};
|
|
26
|
+
/** Claude Code hook registrations per event, read from ~/.claude/settings.json the way the
|
|
27
|
+
* installer counts them (paths expanded). null: no readable settings.json. A correct install
|
|
28
|
+
* has 1 for every event; the 2026-09-06 doubling would have shown 2 here within a minute. */
|
|
29
|
+
claudeHooks: Record<string, number> | null;
|
|
26
30
|
today: {
|
|
31
|
+
/** Claude Code hook events (vscode.*) since local midnight. */
|
|
32
|
+
hookEvents: number;
|
|
33
|
+
/** Of those, records identical in event and payload to the previous hook event within
|
|
34
|
+
* DOUBLED_WINDOW_MS: what a hook registered twice produces. */
|
|
35
|
+
doubledHookEvents: number;
|
|
27
36
|
/** Pre-commit blocks (hook.block) since local midnight. */
|
|
28
37
|
blocks: number;
|
|
29
38
|
/** Store refusals (unreadable, shrink refused, growth refused) since local midnight. */
|
|
@@ -42,6 +51,11 @@ export interface FleetHealth {
|
|
|
42
51
|
warnings: string[];
|
|
43
52
|
}
|
|
44
53
|
export declare const REINDEX_PER_HOUR_WARN = 30;
|
|
54
|
+
/** Doubled hook events above this share of the day's hook events raise a warning, once the day
|
|
55
|
+
* has at least DOUBLED_MIN_EVENTS of them (a single repeated call is not a doubled hook). */
|
|
56
|
+
export declare const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
|
|
57
|
+
export declare const DOUBLED_MIN_EVENTS = 10;
|
|
58
|
+
export declare const DOUBLED_WINDOW_MS = 2000;
|
|
45
59
|
export declare function fleetHealthPath(): string;
|
|
46
60
|
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
47
61
|
export declare function lastVerifiedRelease(dir?: string): string | null;
|
|
@@ -50,6 +64,7 @@ export declare function computeFleetHealth(opts?: {
|
|
|
50
64
|
version?: string;
|
|
51
65
|
auditPath?: string;
|
|
52
66
|
report?: ServerReport;
|
|
67
|
+
settingsPath?: string;
|
|
53
68
|
}): FleetHealth;
|
|
54
69
|
/** Temp file + rename: a reader never sees half a file. */
|
|
55
70
|
export declare function writeFleetHealth(h: FleetHealth): string;
|
package/dist/fleet-health.js
CHANGED
|
@@ -13,7 +13,13 @@ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdi
|
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { homedir } from "os";
|
|
15
15
|
import { listServers } from "./server-registry.js";
|
|
16
|
+
import { claudeHookRegistrations } from "./install-claude-hook.js";
|
|
16
17
|
export const REINDEX_PER_HOUR_WARN = 30;
|
|
18
|
+
/** Doubled hook events above this share of the day's hook events raise a warning, once the day
|
|
19
|
+
* has at least DOUBLED_MIN_EVENTS of them (a single repeated call is not a doubled hook). */
|
|
20
|
+
export const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
|
|
21
|
+
export const DOUBLED_MIN_EVENTS = 10;
|
|
22
|
+
export const DOUBLED_WINDOW_MS = 2000;
|
|
17
23
|
const TAIL_BYTES = 8 * 1024 * 1024;
|
|
18
24
|
function ceHome() {
|
|
19
25
|
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
@@ -100,12 +106,20 @@ export function computeFleetHealth(opts = {}) {
|
|
|
100
106
|
const midnight = localMidnight(now).getTime();
|
|
101
107
|
const hourAgo = now.getTime() - 3_600_000;
|
|
102
108
|
const perCorpus = {};
|
|
103
|
-
let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0;
|
|
109
|
+
let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0, hookEvents = 0, doubledHookEvents = 0;
|
|
104
110
|
const lastBlocks = [];
|
|
111
|
+
let prevHook = null;
|
|
105
112
|
for (const r of records) {
|
|
106
113
|
const t = Date.parse(r.ts);
|
|
107
114
|
if (Number.isNaN(t))
|
|
108
115
|
continue;
|
|
116
|
+
if (r.event.startsWith("vscode.") && t >= midnight) {
|
|
117
|
+
hookEvents++;
|
|
118
|
+
const payload = JSON.stringify(r.payload ?? {});
|
|
119
|
+
if (prevHook && prevHook.event === r.event && prevHook.payload === payload && t - prevHook.t <= DOUBLED_WINDOW_MS)
|
|
120
|
+
doubledHookEvents++;
|
|
121
|
+
prevHook = { t, event: r.event, payload };
|
|
122
|
+
}
|
|
109
123
|
if (r.event === "index.write" && t >= hourAgo) {
|
|
110
124
|
lastHourWrites++;
|
|
111
125
|
const c = String(r.payload?.corpus ?? "?");
|
|
@@ -127,9 +141,22 @@ export function computeFleetHealth(opts = {}) {
|
|
|
127
141
|
const stale = report.servers.filter((s) => s.staleBuild).map((s) => ({ pid: s.pid, version: s.version, build: s.build, cwd: s.cwd }));
|
|
128
142
|
const diskBuild = report.servers.find((s) => s.currentBuild)?.currentBuild ?? null;
|
|
129
143
|
const indexers = report.servers.filter((s) => s.role !== "reader").length;
|
|
144
|
+
const claudeHooks = claudeHookRegistrations(opts.settingsPath);
|
|
130
145
|
const warnings = [];
|
|
131
146
|
if (stale.length > 0)
|
|
132
147
|
warnings.push(`${stale.length} server(s) on an old build (pid ${stale.map((s) => s.pid).join(", ")}): reload their windows`);
|
|
148
|
+
if (claudeHooks) {
|
|
149
|
+
const doubled = Object.entries(claudeHooks).filter(([, n]) => n > 1);
|
|
150
|
+
if (doubled.length > 0)
|
|
151
|
+
warnings.push(`Claude Code runs an OpsContext hook more than once (${doubled.map(([ev, n]) => `${ev}=${n}`).join(", ")}): every event reaches the audit log that many times, run install-claude-hook`);
|
|
152
|
+
const core = ["UserPromptSubmit", "PostToolUse", "SessionStart", "Stop"];
|
|
153
|
+
const present = core.filter((ev) => (claudeHooks[ev] ?? 0) >= 1);
|
|
154
|
+
if (present.length > 0 && present.length < core.length)
|
|
155
|
+
warnings.push(`OpsContext hooks installed for ${present.join(", ")} but not ${core.filter((ev) => !present.includes(ev)).join(", ")}: run install-claude-hook`);
|
|
156
|
+
}
|
|
157
|
+
if (hookEvents >= DOUBLED_MIN_EVENTS && doubledHookEvents * 100 > hookEvents * DOUBLED_HOOK_EVENTS_WARN_PCT) {
|
|
158
|
+
warnings.push(`${doubledHookEvents} of ${hookEvents} Claude Code hook events today arrived twice within ${DOUBLED_WINDOW_MS / 1000} s: a hook is registered twice somewhere, run install-claude-hook`);
|
|
159
|
+
}
|
|
133
160
|
if (lastHourWrites > REINDEX_PER_HOUR_WARN)
|
|
134
161
|
warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
|
|
135
162
|
if (refusals > 0)
|
|
@@ -143,7 +170,8 @@ export function computeFleetHealth(opts = {}) {
|
|
|
143
170
|
writerPid: process.pid,
|
|
144
171
|
servers: { total: report.servers.length, indexers, readers: report.servers.length - indexers, stale, diskBuild },
|
|
145
172
|
reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
|
|
146
|
-
|
|
173
|
+
claudeHooks,
|
|
174
|
+
today: { hookEvents, doubledHookEvents, blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
|
|
147
175
|
lastVerifiedRelease: lastVerifiedRelease(),
|
|
148
176
|
warnings,
|
|
149
177
|
};
|
|
@@ -163,6 +191,7 @@ export function formatFleetHealth(h) {
|
|
|
163
191
|
lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
|
|
164
192
|
lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
|
|
165
193
|
lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
|
|
194
|
+
lines.push(` claude code: ${h.today.hookEvents} hook event(s) today, ${h.today.doubledHookEvents} doubled; registrations ${h.claudeHooks ? Object.entries(h.claudeHooks).map(([ev, n]) => `${ev}=${n}`).join(" ") : "no settings.json"}`);
|
|
166
195
|
for (const b of h.today.lastBlocks)
|
|
167
196
|
lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
|
|
168
197
|
for (const w of h.warnings)
|
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
export interface HookCommand {
|
|
2
|
+
type: string;
|
|
3
|
+
command: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface HookEntry {
|
|
7
|
+
matcher?: string;
|
|
8
|
+
hooks: HookCommand[];
|
|
9
|
+
}
|
|
10
|
+
export interface Settings {
|
|
11
|
+
hooks?: Record<string, HookEntry[]>;
|
|
12
|
+
[k: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
|
|
15
|
+
export declare function hookScriptPath(command: string, home?: string): string;
|
|
16
|
+
/** Removes repeated registrations of our scripts under the same matcher. Keeps the first copy
|
|
17
|
+
* and every hook that is not ours; drops an entry only when that leaves it empty. */
|
|
18
|
+
export declare function dropDuplicateHooks(entries: HookEntry[], ourScripts: string[], home?: string): {
|
|
19
|
+
entries: HookEntry[];
|
|
20
|
+
removed: number;
|
|
21
|
+
};
|
|
22
|
+
/** How many times each event runs `script`. A correct install has exactly 1 everywhere. */
|
|
23
|
+
export declare function countOurHooks(settings: Settings, events: readonly string[], script: string, home?: string): Record<string, number>;
|
|
24
|
+
/** How many times Claude Code runs each OpsContext hook, read from settings.json: the three
|
|
25
|
+
* emit events, the Stop gate, and the optional simplicity gate as "PostToolUse(simplicity)".
|
|
26
|
+
* null when there is no readable settings.json. Shared by the install verification
|
|
27
|
+
* ([INSTALL-VERIFIES-BY-COUNT]) and fleet health, so both count the same way. */
|
|
28
|
+
export declare function claudeHookRegistrations(settingsPath?: string, home?: string): Record<string, number> | null;
|
|
1
29
|
export declare function cliInstallClaudeHook(args: string[]): Promise<void>;
|
|
2
30
|
export declare function cliUninstallClaudeHook(args: string[]): Promise<void>;
|
|
3
31
|
//# sourceMappingURL=install-claude-hook.d.ts.map
|
|
@@ -31,6 +31,12 @@ const HOOK_SCRIPT = join(HOOKS_DIR, "opscontext-emit.sh");
|
|
|
31
31
|
/** The Stop gate: a wrapper that runs `session-gate` with the node and CLI that installed it.
|
|
32
32
|
* [LOCK] [SESSION-SAVE-IS-A-GATE] (src/session-gate.ts) */
|
|
33
33
|
const GATE_SCRIPT = join(HOOKS_DIR, "opscontext-session-gate.sh");
|
|
34
|
+
/** The simplicity gate: a PostToolUse hook on Edit/Write that reports complexity the edit just
|
|
35
|
+
* introduced in a Python file, compared with git HEAD. Optional: `--simplicity`.
|
|
36
|
+
* [LOCK] [SIMPLICITY-GATE-SILENT-WHEN-BLIND] (defaults/simplicity-gate.py) */
|
|
37
|
+
const SIMPLICITY_SCRIPT = join(HOOKS_DIR, "opscontext-simplicity-gate.py");
|
|
38
|
+
const SIMPLICITY_MATCHER = "Edit|Write|MultiEdit";
|
|
39
|
+
const OUR_SCRIPTS = [HOOK_SCRIPT, GATE_SCRIPT, SIMPLICITY_SCRIPT];
|
|
34
40
|
const EVENT_KINDS = ["UserPromptSubmit", "PostToolUse", "SessionStart"];
|
|
35
41
|
function readSettings() {
|
|
36
42
|
if (!existsSync(SETTINGS_FILE))
|
|
@@ -50,27 +56,113 @@ function backupSettings() {
|
|
|
50
56
|
copyFileSync(SETTINGS_FILE, backup);
|
|
51
57
|
return backup;
|
|
52
58
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
// [LOCKED] [HOOKS-COMPARED-BY-EXPANDED-PATH] - 2026-09-15
|
|
60
|
+
// [NEVER] compare a hook command with startsWith or any other literal text match again.
|
|
61
|
+
// WHY: settings.json held the emit hooks as "$HOME/.claude/hooks/opscontext-emit.sh <Kind>",
|
|
62
|
+
// hand-wired on 2026-06-23 before this installer existed. On 2026-09-06 the 2.7.0 rollout
|
|
63
|
+
// ran install-claude-hook; its startsWith(absolute path) check did not see them, printed
|
|
64
|
+
// "4 hook entries added, 0 already present" and wrote a second set. From 2026-09-06
|
|
65
|
+
// 08:20:21Z every Claude Code event reached the audit log twice (0 doubled events in the 8
|
|
66
|
+
// days before, 99.5 to 100 percent every day after), doubling the stuck and silent_failure
|
|
67
|
+
// inputs that [OPSCONTEXT-CC-HOOK] protects.
|
|
68
|
+
// FIX: compare the script path after expanding $HOME, ${HOME} and a leading ~; installing also
|
|
69
|
+
// removes extra copies of our own commands under the same matcher, and nothing else.
|
|
70
|
+
/** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
|
|
71
|
+
export function hookScriptPath(command, home = homedir()) {
|
|
72
|
+
const m = /^\s*(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(command ?? "");
|
|
73
|
+
const path = m ? (m[1] ?? m[2] ?? m[3]) : "";
|
|
74
|
+
return path.replace(/^(?:\$HOME|\$\{HOME\}|~)(?=\/)/, home);
|
|
57
75
|
}
|
|
58
|
-
/**
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
/** The command with its script path expanded, so two spellings of one call compare equal. */
|
|
77
|
+
function normalizedCommand(command, home) {
|
|
78
|
+
const args = (command ?? "").trim().replace(/^(?:"[^"]*"|'[^']*'|\S+)/, "").trim();
|
|
79
|
+
return `${hookScriptPath(command, home)} ${args}`.trim();
|
|
80
|
+
}
|
|
81
|
+
function hookAlreadyWired(entries, hookScript, home = homedir()) {
|
|
82
|
+
return (entries ?? []).some((e) => e.hooks?.some((h) => hookScriptPath(h.command, home) === hookScript));
|
|
83
|
+
}
|
|
84
|
+
/** Removes repeated registrations of our scripts under the same matcher. Keeps the first copy
|
|
85
|
+
* and every hook that is not ours; drops an entry only when that leaves it empty. */
|
|
86
|
+
export function dropDuplicateHooks(entries, ourScripts, home = homedir()) {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
let removed = 0;
|
|
89
|
+
const kept = [];
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const before = entry.hooks ?? [];
|
|
92
|
+
const hooks = before.filter((h) => {
|
|
93
|
+
if (!ourScripts.includes(hookScriptPath(h.command, home)))
|
|
94
|
+
return true;
|
|
95
|
+
const key = `${entry.matcher ?? ""}\u0000${normalizedCommand(h.command, home)}`;
|
|
96
|
+
if (seen.has(key)) {
|
|
97
|
+
removed++;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
seen.add(key);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
if (hooks.length > 0 || before.length === 0)
|
|
104
|
+
kept.push({ ...entry, hooks });
|
|
105
|
+
}
|
|
106
|
+
return { entries: kept, removed };
|
|
107
|
+
}
|
|
108
|
+
/** How many times each event runs `script`. A correct install has exactly 1 everywhere. */
|
|
109
|
+
export function countOurHooks(settings, events, script, home = homedir()) {
|
|
110
|
+
const count = (ev) => (settings.hooks?.[ev] ?? []).flatMap((e) => e.hooks ?? []).filter((h) => hookScriptPath(h.command, home) === script).length;
|
|
111
|
+
return Object.fromEntries(events.map((ev) => [ev, count(ev)]));
|
|
112
|
+
}
|
|
113
|
+
/** How many times Claude Code runs each OpsContext hook, read from settings.json: the three
|
|
114
|
+
* emit events, the Stop gate, and the optional simplicity gate as "PostToolUse(simplicity)".
|
|
115
|
+
* null when there is no readable settings.json. Shared by the install verification
|
|
116
|
+
* ([INSTALL-VERIFIES-BY-COUNT]) and fleet health, so both count the same way. */
|
|
117
|
+
export function claudeHookRegistrations(settingsPath = SETTINGS_FILE, home = homedir()) {
|
|
118
|
+
let settings;
|
|
119
|
+
try {
|
|
120
|
+
if (!existsSync(settingsPath))
|
|
121
|
+
return null;
|
|
122
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const hooksDir = join(home, ".claude", "hooks");
|
|
128
|
+
const emit = join(hooksDir, "opscontext-emit.sh");
|
|
129
|
+
return {
|
|
130
|
+
...countOurHooks(settings, EVENT_KINDS, emit, home),
|
|
131
|
+
...countOurHooks(settings, ["Stop"], join(hooksDir, "opscontext-session-gate.sh"), home),
|
|
132
|
+
"PostToolUse(simplicity)": countOurHooks(settings, ["PostToolUse"], join(hooksDir, "opscontext-simplicity-gate.py"), home).PostToolUse,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Path to a file bundled under defaults/ with this package. */
|
|
136
|
+
function bundledFile(name) {
|
|
137
|
+
// dist/install-claude-hook.js → ../defaults/<name> in dev tree,
|
|
138
|
+
// or .../node_modules/@compr/opscontext-mcp/defaults/<name>
|
|
62
139
|
// when globally / locally installed via npm. Both follow the same relative
|
|
63
140
|
// shape because npm copies defaults/ via the `files` whitelist.
|
|
64
|
-
const candidates = [
|
|
65
|
-
join(__dirname_esm, "..", "defaults", "claude-code-hook.sh"),
|
|
66
|
-
join(__dirname_esm, "defaults", "claude-code-hook.sh"),
|
|
67
|
-
];
|
|
141
|
+
const candidates = [join(__dirname_esm, "..", "defaults", name), join(__dirname_esm, "defaults", name)];
|
|
68
142
|
for (const c of candidates) {
|
|
69
143
|
if (existsSync(c))
|
|
70
144
|
return c;
|
|
71
145
|
}
|
|
72
146
|
return null;
|
|
73
147
|
}
|
|
148
|
+
/** Where the simplicity gate will find ruff: PATH, then the usual install dirs (the same
|
|
149
|
+
* order as the script itself). null when it is nowhere, so the install can say so. */
|
|
150
|
+
function findRuff() {
|
|
151
|
+
try {
|
|
152
|
+
const onPath = execSync("command -v ruff 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
153
|
+
if (onPath)
|
|
154
|
+
return onPath;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* not on PATH */
|
|
158
|
+
}
|
|
159
|
+
const home = homedir();
|
|
160
|
+
for (const c of ["/opt/homebrew/bin/ruff", "/usr/local/bin/ruff", join(home, ".local", "bin", "ruff"), join(home, ".cargo", "bin", "ruff")]) {
|
|
161
|
+
if (existsSync(c))
|
|
162
|
+
return c;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
74
166
|
/** dist/cli.js of a global install, when there is one (same preference as install-autostart). */
|
|
75
167
|
function globalCliPath() {
|
|
76
168
|
try {
|
|
@@ -85,7 +177,7 @@ function globalCliPath() {
|
|
|
85
177
|
export async function cliInstallClaudeHook(args) {
|
|
86
178
|
const help = args.includes("-h") || args.includes("--help");
|
|
87
179
|
if (help) {
|
|
88
|
-
console.log(`Usage: opscontext install-claude-hook
|
|
180
|
+
console.log(`Usage: opscontext install-claude-hook [--simplicity]
|
|
89
181
|
|
|
90
182
|
Wires OpsContext into Claude Code's hook system so every terminal Claude
|
|
91
183
|
Code session sends prompts + tool calls to the OpsContext audit log.
|
|
@@ -96,12 +188,19 @@ Events emitted (all go through the local HTTP endpoint, never the network):
|
|
|
96
188
|
• SessionStart → vscode.session_start
|
|
97
189
|
• Stop → the session gate: a turn cannot end while the repo's CE session
|
|
98
190
|
is older than the last commit (contextengine session-gate --help)
|
|
191
|
+
• PostToolUse on Edit|Write|MultiEdit, with --simplicity → the simplicity gate: after an
|
|
192
|
+
edit to a Python file, ruff's complexity rules run on the file and on
|
|
193
|
+
its git HEAD version; functions the edit made new offenders or worse
|
|
194
|
+
are reported back to Claude (exit 2). Pre-existing complexity, files
|
|
195
|
+
outside git and a missing ruff are silent. Needs ruff (brew install ruff).
|
|
99
196
|
|
|
100
197
|
The installer:
|
|
101
198
|
1. Copies the bundled hook script to ~/.claude/hooks/opscontext-emit.sh
|
|
102
|
-
and writes ~/.claude/hooks/opscontext-session-gate.sh (node + this CLI, absolute paths)
|
|
103
|
-
|
|
104
|
-
|
|
199
|
+
and writes ~/.claude/hooks/opscontext-session-gate.sh (node + this CLI, absolute paths);
|
|
200
|
+
with --simplicity also ~/.claude/hooks/opscontext-simplicity-gate.py
|
|
201
|
+
2. Splices four entries (five with --simplicity) into ~/.claude/settings.json under "hooks"
|
|
202
|
+
3. Preserves every existing hook entry (idempotent, safe to re-run; a re-run without
|
|
203
|
+
--simplicity keeps an installed simplicity gate and refreshes its script)
|
|
105
204
|
|
|
106
205
|
A timestamped backup is written next to settings.json before any change.
|
|
107
206
|
|
|
@@ -111,9 +210,10 @@ Run: opscontext install-autostart
|
|
|
111
210
|
`);
|
|
112
211
|
return;
|
|
113
212
|
}
|
|
213
|
+
const simplicityAsked = args.includes("--simplicity");
|
|
114
214
|
// Step 1: Install / verify the hook script
|
|
115
215
|
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
116
|
-
const src =
|
|
216
|
+
const src = bundledFile("claude-code-hook.sh");
|
|
117
217
|
if (!src) {
|
|
118
218
|
console.error(`❌ Could not find bundled hook script defaults/claude-code-hook.sh.`);
|
|
119
219
|
console.error(` This means the install is incomplete. Reinstall opscontext:`);
|
|
@@ -129,7 +229,17 @@ Run: opscontext install-autostart
|
|
|
129
229
|
if (backup)
|
|
130
230
|
console.log(`✅ Backed up settings.json → ${backup}`);
|
|
131
231
|
settings.hooks ??= {};
|
|
132
|
-
const hookCmdPrefix = `${HOOK_SCRIPT}`; //
|
|
232
|
+
const hookCmdPrefix = `${HOOK_SCRIPT}`; // compared by expanded path, [HOOKS-COMPARED-BY-EXPANDED-PATH]
|
|
233
|
+
// [LOCK] [HOOKS-COMPARED-BY-EXPANDED-PATH]: remove extra copies before deciding what to add.
|
|
234
|
+
let deduped = 0;
|
|
235
|
+
for (const kind of [...EVENT_KINDS, "Stop"]) {
|
|
236
|
+
const entries = settings.hooks[kind];
|
|
237
|
+
if (!entries)
|
|
238
|
+
continue;
|
|
239
|
+
const r = dropDuplicateHooks(entries, OUR_SCRIPTS);
|
|
240
|
+
settings.hooks[kind] = r.entries;
|
|
241
|
+
deduped += r.removed;
|
|
242
|
+
}
|
|
133
243
|
let added = 0;
|
|
134
244
|
let skipped = 0;
|
|
135
245
|
for (const kind of EVENT_KINDS) {
|
|
@@ -168,8 +278,57 @@ Run: opscontext install-autostart
|
|
|
168
278
|
added++;
|
|
169
279
|
}
|
|
170
280
|
console.log(`✅ Installed session gate: ${GATE_SCRIPT}`);
|
|
281
|
+
// Step 4: the simplicity gate, when asked for or already there (a plain re-run keeps it and
|
|
282
|
+
// refreshes its script, so an upgrade reaches it too).
|
|
283
|
+
const simplicityWired = hookAlreadyWired(settings.hooks.PostToolUse, SIMPLICITY_SCRIPT);
|
|
284
|
+
const wantSimplicity = simplicityAsked || simplicityWired;
|
|
285
|
+
if (wantSimplicity) {
|
|
286
|
+
const gateSrc = bundledFile("simplicity-gate.py");
|
|
287
|
+
if (!gateSrc) {
|
|
288
|
+
console.error(`❌ Could not find bundled defaults/simplicity-gate.py. Reinstall opscontext.`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
copyFileSync(gateSrc, SIMPLICITY_SCRIPT);
|
|
292
|
+
chmodSync(SIMPLICITY_SCRIPT, 0o755);
|
|
293
|
+
if (simplicityWired) {
|
|
294
|
+
skipped++;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
settings.hooks.PostToolUse.push({
|
|
298
|
+
matcher: SIMPLICITY_MATCHER,
|
|
299
|
+
hooks: [{ type: "command", command: SIMPLICITY_SCRIPT, timeout: 30 }],
|
|
300
|
+
});
|
|
301
|
+
added++;
|
|
302
|
+
}
|
|
303
|
+
console.log(`✅ Installed simplicity gate: ${SIMPLICITY_SCRIPT} (PostToolUse ${SIMPLICITY_MATCHER})`);
|
|
304
|
+
const ruff = findRuff();
|
|
305
|
+
if (ruff)
|
|
306
|
+
console.log(` ruff: ${ruff}`);
|
|
307
|
+
else
|
|
308
|
+
console.log(`⚠️ ruff not found (PATH, /opt/homebrew/bin, /usr/local/bin, ~/.local/bin, ~/.cargo/bin): the gate stays silent until it is installed (brew install ruff, or pipx install ruff).`);
|
|
309
|
+
}
|
|
171
310
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
172
|
-
|
|
311
|
+
const removedNote = deduped ? `, ${deduped} duplicate registrations removed` : "";
|
|
312
|
+
console.log(`✅ ${added} hook entries added, ${skipped} already present${removedNote}.`);
|
|
313
|
+
// [LOCKED] [INSTALL-VERIFIES-BY-COUNT] - 2026-09-15
|
|
314
|
+
// [NEVER] treat the added/present counters above as proof of a correct install.
|
|
315
|
+
// WHY: on 2026-09-06 this command printed "0 already present" over three existing hooks, and
|
|
316
|
+
// the session that ran it recorded "they were not there"; the doubled audit events then
|
|
317
|
+
// went unseen for nine days.
|
|
318
|
+
// FIX: re-read settings.json from disk and require exactly one registration per event.
|
|
319
|
+
const counts = claudeHookRegistrations() ?? {};
|
|
320
|
+
// Every event exactly once; the optional gate exactly once when wanted, never otherwise (a
|
|
321
|
+
// dedup pass alone never adds it).
|
|
322
|
+
const expected = Object.fromEntries(Object.keys(counts).map((ev) => [ev, 1]));
|
|
323
|
+
expected["PostToolUse(simplicity)"] = wantSimplicity ? 1 : 0;
|
|
324
|
+
const wrong = Object.entries(counts).filter(([ev, n]) => n !== expected[ev]);
|
|
325
|
+
if (wrong.length > 0) {
|
|
326
|
+
const detail = wrong.map(([ev, n]) => `${ev}=${n}`).join(", ");
|
|
327
|
+
console.error(`❌ settings.json must hold exactly one OpsContext hook per event, found ${detail}. Backup: ${backup || "none"}`);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
const once = Object.keys(expected).filter((ev) => expected[ev] === 1);
|
|
331
|
+
console.log(`✅ Verified in settings.json: exactly one registration for ${once.join(", ")}.`);
|
|
173
332
|
console.log(``);
|
|
174
333
|
console.log(`Test live:`);
|
|
175
334
|
console.log(` 1. Open a NEW VS Code terminal (settings.json is read at session start).`);
|
|
@@ -181,13 +340,17 @@ Run: opscontext install-autostart
|
|
|
181
340
|
}
|
|
182
341
|
export async function cliUninstallClaudeHook(args) {
|
|
183
342
|
if (args.includes("-h") || args.includes("--help")) {
|
|
184
|
-
console.log(`Usage: opscontext uninstall-claude-hook
|
|
343
|
+
console.log(`Usage: opscontext uninstall-claude-hook [--simplicity]
|
|
185
344
|
|
|
186
|
-
Removes OpsContext hook entries from ~/.claude/settings.json.
|
|
187
|
-
|
|
188
|
-
|
|
345
|
+
Removes OpsContext hook entries from ~/.claude/settings.json. With --simplicity
|
|
346
|
+
only the simplicity gate entry is removed; the emit hooks and the Stop gate stay.
|
|
347
|
+
The hook script files under ~/.claude/hooks/ are left in place — delete
|
|
348
|
+
manually if you want them gone. The audit log is NOT touched.`);
|
|
189
349
|
return;
|
|
190
350
|
}
|
|
351
|
+
const ourNames = args.includes("--simplicity")
|
|
352
|
+
? ["opscontext-simplicity-gate.py"]
|
|
353
|
+
: ["opscontext-emit.sh", "opscontext-session-gate.sh", "opscontext-simplicity-gate.py"];
|
|
191
354
|
const settings = readSettings();
|
|
192
355
|
if (!settings.hooks) {
|
|
193
356
|
console.log(` (no hooks block in settings.json — nothing to remove)`);
|
|
@@ -201,7 +364,7 @@ manually if you want it gone. The audit log is NOT touched.`);
|
|
|
201
364
|
const entries = settings.hooks[kind];
|
|
202
365
|
if (!entries)
|
|
203
366
|
continue;
|
|
204
|
-
const filtered = entries.filter((e) => !e.hooks?.some((h) =>
|
|
367
|
+
const filtered = entries.filter((e) => !e.hooks?.some((h) => ourNames.some((n) => h.command?.includes(n))));
|
|
205
368
|
removed += entries.length - filtered.length;
|
|
206
369
|
if (filtered.length === 0) {
|
|
207
370
|
delete settings.hooks[kind];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.4",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"!dist/test*",
|
|
67
67
|
"!dist/**/*.map",
|
|
68
68
|
"defaults/",
|
|
69
|
+
"!defaults/__pycache__",
|
|
69
70
|
"skills/",
|
|
70
71
|
"examples/",
|
|
71
72
|
"LICENSE",
|