@compr/opscontext-mcp 2.8.2 → 2.8.3

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,22 @@ 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.3] 2026-09-16: one hook per event, verified
8
+
9
+ ### Fixed
10
+
11
+ - **`install-claude-hook` no longer registers the emit hooks twice** (`src/install-claude-hook.ts`,
12
+ LOCKs `[HOOKS-COMPARED-BY-EXPANDED-PATH]`, `[INSTALL-VERIFIES-BY-COUNT]`). Its "already
13
+ installed?" check compared command text literally, so hooks written as
14
+ `$HOME/.claude/hooks/opscontext-emit.sh` looked absent and a second set was added: on the
15
+ author's machine every Claude Code prompt and tool call reached the audit log twice from
16
+ 2026-09-06 to 2026-09-15, doubling the inputs of the `stuck` and `silent_failure` heuristics.
17
+ The installer now compares script paths with `$HOME`, `${HOME}` and `~` expanded, removes
18
+ extra copies of its own commands (other hooks untouched), and after writing re-reads
19
+ `settings.json` and exits 1 unless each event runs its script exactly once. Running
20
+ `install-claude-hook` again repairs an affected machine; the audit log is left as it is (it is
21
+ hash-chained).
22
+
7
23
  ## [2.8.2] 2026-09-08: every source says what it is
8
24
 
9
25
  ### Added
@@ -1,3 +1,26 @@
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>;
1
24
  export declare function cliInstallClaudeHook(args: string[]): Promise<void>;
2
25
  export declare function cliUninstallClaudeHook(args: string[]): Promise<void>;
3
26
  //# sourceMappingURL=install-claude-hook.d.ts.map
@@ -50,10 +50,59 @@ function backupSettings() {
50
50
  copyFileSync(SETTINGS_FILE, backup);
51
51
  return backup;
52
52
  }
53
- function hookAlreadyWired(entries, hookScript) {
54
- if (!entries)
55
- return false;
56
- return entries.some((e) => e.hooks?.some((h) => h.command?.startsWith(hookScript)));
53
+ // [LOCKED] [HOOKS-COMPARED-BY-EXPANDED-PATH] - 2026-09-15
54
+ // [NEVER] compare a hook command with startsWith or any other literal text match again.
55
+ // WHY: settings.json held the emit hooks as "$HOME/.claude/hooks/opscontext-emit.sh <Kind>",
56
+ // hand-wired on 2026-06-23 before this installer existed. On 2026-09-06 the 2.7.0 rollout
57
+ // ran install-claude-hook; its startsWith(absolute path) check did not see them, printed
58
+ // "4 hook entries added, 0 already present" and wrote a second set. From 2026-09-06
59
+ // 08:20:21Z every Claude Code event reached the audit log twice (0 doubled events in the 8
60
+ // days before, 99.5 to 100 percent every day after), doubling the stuck and silent_failure
61
+ // inputs that [OPSCONTEXT-CC-HOOK] protects.
62
+ // FIX: compare the script path after expanding $HOME, ${HOME} and a leading ~; installing also
63
+ // removes extra copies of our own commands under the same matcher, and nothing else.
64
+ /** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
65
+ export function hookScriptPath(command, home = homedir()) {
66
+ const m = /^\s*(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(command ?? "");
67
+ const path = m ? (m[1] ?? m[2] ?? m[3]) : "";
68
+ return path.replace(/^(?:\$HOME|\$\{HOME\}|~)(?=\/)/, home);
69
+ }
70
+ /** The command with its script path expanded, so two spellings of one call compare equal. */
71
+ function normalizedCommand(command, home) {
72
+ const args = (command ?? "").trim().replace(/^(?:"[^"]*"|'[^']*'|\S+)/, "").trim();
73
+ return `${hookScriptPath(command, home)} ${args}`.trim();
74
+ }
75
+ function hookAlreadyWired(entries, hookScript, home = homedir()) {
76
+ return (entries ?? []).some((e) => e.hooks?.some((h) => hookScriptPath(h.command, home) === hookScript));
77
+ }
78
+ /** Removes repeated registrations of our scripts under the same matcher. Keeps the first copy
79
+ * and every hook that is not ours; drops an entry only when that leaves it empty. */
80
+ export function dropDuplicateHooks(entries, ourScripts, home = homedir()) {
81
+ const seen = new Set();
82
+ let removed = 0;
83
+ const kept = [];
84
+ for (const entry of entries) {
85
+ const before = entry.hooks ?? [];
86
+ const hooks = before.filter((h) => {
87
+ if (!ourScripts.includes(hookScriptPath(h.command, home)))
88
+ return true;
89
+ const key = `${entry.matcher ?? ""}\u0000${normalizedCommand(h.command, home)}`;
90
+ if (seen.has(key)) {
91
+ removed++;
92
+ return false;
93
+ }
94
+ seen.add(key);
95
+ return true;
96
+ });
97
+ if (hooks.length > 0 || before.length === 0)
98
+ kept.push({ ...entry, hooks });
99
+ }
100
+ return { entries: kept, removed };
101
+ }
102
+ /** How many times each event runs `script`. A correct install has exactly 1 everywhere. */
103
+ export function countOurHooks(settings, events, script, home = homedir()) {
104
+ const count = (ev) => (settings.hooks?.[ev] ?? []).flatMap((e) => e.hooks ?? []).filter((h) => hookScriptPath(h.command, home) === script).length;
105
+ return Object.fromEntries(events.map((ev) => [ev, count(ev)]));
57
106
  }
58
107
  /** Path to the reference hook script bundled with this package. */
59
108
  function bundledHookSource() {
@@ -129,7 +178,17 @@ Run: opscontext install-autostart
129
178
  if (backup)
130
179
  console.log(`✅ Backed up settings.json → ${backup}`);
131
180
  settings.hooks ??= {};
132
- const hookCmdPrefix = `${HOOK_SCRIPT}`; // command string starts with this
181
+ const hookCmdPrefix = `${HOOK_SCRIPT}`; // compared by expanded path, [HOOKS-COMPARED-BY-EXPANDED-PATH]
182
+ // [LOCK] [HOOKS-COMPARED-BY-EXPANDED-PATH]: remove extra copies before deciding what to add.
183
+ let deduped = 0;
184
+ for (const kind of [...EVENT_KINDS, "Stop"]) {
185
+ const entries = settings.hooks[kind];
186
+ if (!entries)
187
+ continue;
188
+ const r = dropDuplicateHooks(entries, [HOOK_SCRIPT, GATE_SCRIPT]);
189
+ settings.hooks[kind] = r.entries;
190
+ deduped += r.removed;
191
+ }
133
192
  let added = 0;
134
193
  let skipped = 0;
135
194
  for (const kind of EVENT_KINDS) {
@@ -169,7 +228,26 @@ Run: opscontext install-autostart
169
228
  }
170
229
  console.log(`✅ Installed session gate: ${GATE_SCRIPT}`);
171
230
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
172
- console.log(`✅ ${added} hook entries added, ${skipped} already present.`);
231
+ const removedNote = deduped ? `, ${deduped} duplicate registrations removed` : "";
232
+ console.log(`✅ ${added} hook entries added, ${skipped} already present${removedNote}.`);
233
+ // [LOCKED] [INSTALL-VERIFIES-BY-COUNT] - 2026-09-15
234
+ // [NEVER] treat the added/present counters above as proof of a correct install.
235
+ // WHY: on 2026-09-06 this command printed "0 already present" over three existing hooks, and
236
+ // the session that ran it recorded "they were not there"; the doubled audit events then
237
+ // went unseen for nine days.
238
+ // 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);
245
+ if (wrong.length > 0) {
246
+ const detail = wrong.map(([ev, n]) => `${ev}=${n}`).join(", ");
247
+ console.error(`❌ settings.json must hold exactly one OpsContext hook per event, found ${detail}. Backup: ${backup || "none"}`);
248
+ process.exit(1);
249
+ }
250
+ console.log(`✅ Verified in settings.json: exactly one registration for ${Object.keys(counts).join(", ")}.`);
173
251
  console.log(``);
174
252
  console.log(`Test live:`);
175
253
  console.log(` 1. Open a NEW VS Code terminal (settings.json is read at session start).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.8.2",
3
+ "version": "2.8.3",
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",