@compr/opscontext-mcp 2.8.3 → 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 CHANGED
@@ -4,6 +4,27 @@ 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
+
7
28
  ## [2.8.3] 2026-09-16: one hook per event, verified
8
29
 
9
30
  ### Fixed
@@ -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())
@@ -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;
@@ -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
- today: { blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
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)
@@ -21,6 +21,11 @@ export declare function dropDuplicateHooks(entries: HookEntry[], ourScripts: str
21
21
  };
22
22
  /** How many times each event runs `script`. A correct install has exactly 1 everywhere. */
23
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;
24
29
  export declare function cliInstallClaudeHook(args: string[]): Promise<void>;
25
30
  export declare function cliUninstallClaudeHook(args: string[]): Promise<void>;
26
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))
@@ -104,22 +110,59 @@ export function countOurHooks(settings, events, script, home = homedir()) {
104
110
  const count = (ev) => (settings.hooks?.[ev] ?? []).flatMap((e) => e.hooks ?? []).filter((h) => hookScriptPath(h.command, home) === script).length;
105
111
  return Object.fromEntries(events.map((ev) => [ev, count(ev)]));
106
112
  }
107
- /** Path to the reference hook script bundled with this package. */
108
- function bundledHookSource() {
109
- // dist/install-claude-hook.js → ../defaults/claude-code-hook.sh in dev tree,
110
- // or .../node_modules/@compr/opscontext-mcp/defaults/claude-code-hook.sh
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>
111
139
  // when globally / locally installed via npm. Both follow the same relative
112
140
  // shape because npm copies defaults/ via the `files` whitelist.
113
- const candidates = [
114
- join(__dirname_esm, "..", "defaults", "claude-code-hook.sh"),
115
- join(__dirname_esm, "defaults", "claude-code-hook.sh"),
116
- ];
141
+ const candidates = [join(__dirname_esm, "..", "defaults", name), join(__dirname_esm, "defaults", name)];
117
142
  for (const c of candidates) {
118
143
  if (existsSync(c))
119
144
  return c;
120
145
  }
121
146
  return null;
122
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
+ }
123
166
  /** dist/cli.js of a global install, when there is one (same preference as install-autostart). */
124
167
  function globalCliPath() {
125
168
  try {
@@ -134,7 +177,7 @@ function globalCliPath() {
134
177
  export async function cliInstallClaudeHook(args) {
135
178
  const help = args.includes("-h") || args.includes("--help");
136
179
  if (help) {
137
- console.log(`Usage: opscontext install-claude-hook
180
+ console.log(`Usage: opscontext install-claude-hook [--simplicity]
138
181
 
139
182
  Wires OpsContext into Claude Code's hook system so every terminal Claude
140
183
  Code session sends prompts + tool calls to the OpsContext audit log.
@@ -145,12 +188,19 @@ Events emitted (all go through the local HTTP endpoint, never the network):
145
188
  • SessionStart → vscode.session_start
146
189
  • Stop → the session gate: a turn cannot end while the repo's CE session
147
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).
148
196
 
149
197
  The installer:
150
198
  1. Copies the bundled hook script to ~/.claude/hooks/opscontext-emit.sh
151
- and writes ~/.claude/hooks/opscontext-session-gate.sh (node + this CLI, absolute paths)
152
- 2. Splices four entries into ~/.claude/settings.json under "hooks"
153
- 3. Preserves every existing hook entry (idempotent, safe to re-run)
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)
154
204
 
155
205
  A timestamped backup is written next to settings.json before any change.
156
206
 
@@ -160,9 +210,10 @@ Run: opscontext install-autostart
160
210
  `);
161
211
  return;
162
212
  }
213
+ const simplicityAsked = args.includes("--simplicity");
163
214
  // Step 1: Install / verify the hook script
164
215
  mkdirSync(HOOKS_DIR, { recursive: true });
165
- const src = bundledHookSource();
216
+ const src = bundledFile("claude-code-hook.sh");
166
217
  if (!src) {
167
218
  console.error(`❌ Could not find bundled hook script defaults/claude-code-hook.sh.`);
168
219
  console.error(` This means the install is incomplete. Reinstall opscontext:`);
@@ -185,7 +236,7 @@ Run: opscontext install-autostart
185
236
  const entries = settings.hooks[kind];
186
237
  if (!entries)
187
238
  continue;
188
- const r = dropDuplicateHooks(entries, [HOOK_SCRIPT, GATE_SCRIPT]);
239
+ const r = dropDuplicateHooks(entries, OUR_SCRIPTS);
189
240
  settings.hooks[kind] = r.entries;
190
241
  deduped += r.removed;
191
242
  }
@@ -227,6 +278,35 @@ Run: opscontext install-autostart
227
278
  added++;
228
279
  }
229
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
+ }
230
310
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
231
311
  const removedNote = deduped ? `, ${deduped} duplicate registrations removed` : "";
232
312
  console.log(`✅ ${added} hook entries added, ${skipped} already present${removedNote}.`);
@@ -236,18 +316,19 @@ Run: opscontext install-autostart
236
316
  // the session that ran it recorded "they were not there"; the doubled audit events then
237
317
  // went unseen for nine days.
238
318
  // FIX: re-read settings.json from disk and require exactly one registration per event.
239
- const written = readSettings();
240
- const counts = {
241
- ...countOurHooks(written, EVENT_KINDS, HOOK_SCRIPT),
242
- ...countOurHooks(written, ["Stop"], GATE_SCRIPT),
243
- };
244
- const wrong = Object.entries(counts).filter(([, n]) => n !== 1);
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]);
245
325
  if (wrong.length > 0) {
246
326
  const detail = wrong.map(([ev, n]) => `${ev}=${n}`).join(", ");
247
327
  console.error(`❌ settings.json must hold exactly one OpsContext hook per event, found ${detail}. Backup: ${backup || "none"}`);
248
328
  process.exit(1);
249
329
  }
250
- console.log(`✅ Verified in settings.json: exactly one registration for ${Object.keys(counts).join(", ")}.`);
330
+ const once = Object.keys(expected).filter((ev) => expected[ev] === 1);
331
+ console.log(`✅ Verified in settings.json: exactly one registration for ${once.join(", ")}.`);
251
332
  console.log(``);
252
333
  console.log(`Test live:`);
253
334
  console.log(` 1. Open a NEW VS Code terminal (settings.json is read at session start).`);
@@ -259,13 +340,17 @@ Run: opscontext install-autostart
259
340
  }
260
341
  export async function cliUninstallClaudeHook(args) {
261
342
  if (args.includes("-h") || args.includes("--help")) {
262
- console.log(`Usage: opscontext uninstall-claude-hook
343
+ console.log(`Usage: opscontext uninstall-claude-hook [--simplicity]
263
344
 
264
- Removes OpsContext hook entries from ~/.claude/settings.json. The hook
265
- script file (~/.claude/hooks/opscontext-emit.sh) is left in place — delete
266
- manually if you want it gone. The audit log is NOT touched.`);
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.`);
267
349
  return;
268
350
  }
351
+ const ourNames = args.includes("--simplicity")
352
+ ? ["opscontext-simplicity-gate.py"]
353
+ : ["opscontext-emit.sh", "opscontext-session-gate.sh", "opscontext-simplicity-gate.py"];
269
354
  const settings = readSettings();
270
355
  if (!settings.hooks) {
271
356
  console.log(` (no hooks block in settings.json — nothing to remove)`);
@@ -279,7 +364,7 @@ manually if you want it gone. The audit log is NOT touched.`);
279
364
  const entries = settings.hooks[kind];
280
365
  if (!entries)
281
366
  continue;
282
- const filtered = entries.filter((e) => !e.hooks?.some((h) => h.command?.includes("opscontext-emit.sh") || h.command?.includes("opscontext-session-gate.sh")));
367
+ const filtered = entries.filter((e) => !e.hooks?.some((h) => ourNames.some((n) => h.command?.includes(n))));
283
368
  removed += entries.length - filtered.length;
284
369
  if (filtered.length === 0) {
285
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",
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",