@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 +16 -0
- package/dist/install-claude-hook.d.ts +23 -0
- package/dist/install-claude-hook.js +84 -6
- package/package.json +1 -1
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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}`; //
|
|
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
|
-
|
|
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.
|
|
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",
|