@compr/opscontext-mcp 2.1.0 → 2.1.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.
@@ -0,0 +1,356 @@
1
+ // 🔒 LOCKED [AUTOSTART-INSTALL] — 2026-06-23
2
+ // ⛔ NEVER bootstrap into a `system/` domain (would need root + run as root).
3
+ // Use `gui/$UID` — per-user agent, started at user login, runs as the user.
4
+ // ⛔ NEVER write the plist before checking if a server is already listening on
5
+ // the port. A pre-existing process means we'd race with the launchd-managed
6
+ // one for port 7842.
7
+ // ⛔ NEVER ship a plist that calls `npx -y @latest` — every restart would
8
+ // fetch the registry, eating ~3s and breaking offline. Pin a specific
9
+ // node path + a specific dist path.
10
+ // WHY: This is the "set it and forget it" entrypoint for non-technical users.
11
+ // If it fails silently or starts duplicating processes, the entire
12
+ // auto-capture story collapses and the user has to type `nohup npx ...`
13
+ // forever — defeating the whole point.
14
+ // FIX: To add platform support beyond macOS, branch on process.platform and
15
+ // add equivalent systemd / NSSM logic. Keep `gui/$UID` and KeepAlive
16
+ // discipline in any new platform.
17
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
18
+ import { join, dirname } from "path";
19
+ import { homedir, platform } from "os";
20
+ import { execSync } from "child_process";
21
+ import { createRequire } from "module";
22
+ import { fileURLToPath } from "url";
23
+ // 🔒 LOCKED [M2-ESM-FILENAME-FIX] — 2026-06-24
24
+ // ⛔ NEVER reference bare `__filename` in this file — the package is
25
+ // `"type": "module"` so __filename is `undefined` at runtime and
26
+ // `dirname(__filename || "")` was returning dirname("") = "." which
27
+ // silently broke the dev-tree fallback. Audit FRESH_USER_AUDIT_
28
+ // 2026-06-23.md finding M2.
29
+ // FIX: Resolve module path via fileURLToPath(import.meta.url). For
30
+ // cross-package resolution (e.g. when running via npx and the
31
+ // @compr/opscontext-mcp tarball is in npx's transient cache), also
32
+ // try createRequire(import.meta.url).resolve("@compr/opscontext-mcp/
33
+ // dist/index.js") which works inside npx.
34
+ const __filename_esm = fileURLToPath(import.meta.url);
35
+ const __dirname_esm = dirname(__filename_esm);
36
+ const requireFromHere = createRequire(import.meta.url);
37
+ const LABEL = "com.opscontext.mcp";
38
+ const PLIST_FILE = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
39
+ const LOG_DIR = join(homedir(), ".contextengine", "logs");
40
+ const PORT = 7842;
41
+ /** Resolve an absolute node binary path that launchd can find without PATH. */
42
+ function detectNodePath() {
43
+ // process.execPath is the node that's running THIS script — absolute path.
44
+ // launchd runs without the user's interactive shell, so we MUST pass an
45
+ // absolute path (no PATH lookup of "node" works under launchd).
46
+ return process.execPath;
47
+ }
48
+ /** Find a stable path to the opscontext entrypoint that survives version
49
+ * upgrades. Order: (1) globally installed bin → resolve symlink to real path;
50
+ * (2) ./dist/index.js next to this module (dev tree). Falls through with
51
+ * null if neither is found. */
52
+ function detectOpscontextEntry() {
53
+ // (1) Try global install via `npm root -g`
54
+ try {
55
+ const globalRoot = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
56
+ const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
57
+ if (existsSync(candidate))
58
+ return { kind: "global", path: candidate };
59
+ }
60
+ catch {
61
+ /* no npm root available; fall through */
62
+ }
63
+ // (2) Try dev tree relative to this module's location (dist/install-autostart.js)
64
+ // → __dirname_esm IS dist/, so dist/index.js is a sibling. ESM-safe (uses
65
+ // fileURLToPath(import.meta.url), not the broken `__filename` reference
66
+ // that the M2 audit caught).
67
+ try {
68
+ const candidate = join(__dirname_esm, "index.js");
69
+ if (existsSync(candidate))
70
+ return { kind: "devtree", path: candidate };
71
+ }
72
+ catch {
73
+ /* ignore */
74
+ }
75
+ // (3) NEW: Try resolve via createRequire (works inside npx's transient
76
+ // install — when the user runs `npx -y @compr/opscontext-mcp
77
+ // install-autostart` the package is in npx's cache, not npm's global root,
78
+ // so step (1) misses. createRequire walks Node's resolution algorithm and
79
+ // finds the cache copy. Audit M2 fix.
80
+ try {
81
+ const resolved = requireFromHere.resolve("@compr/opscontext-mcp/dist/index.js");
82
+ if (existsSync(resolved))
83
+ return { kind: "npx", path: resolved };
84
+ }
85
+ catch {
86
+ /* not resolvable — caller will print the install-globally hint */
87
+ }
88
+ return null;
89
+ }
90
+ function buildPlist(nodePath, entryPath, nodeBinDir) {
91
+ return `<?xml version="1.0" encoding="UTF-8"?>
92
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93
+ <plist version="1.0">
94
+ <dict>
95
+ <key>Label</key>
96
+ <string>${LABEL}</string>
97
+
98
+ <key>ProgramArguments</key>
99
+ <array>
100
+ <string>${nodePath}</string>
101
+ <string>${entryPath}</string>
102
+ </array>
103
+
104
+ <key>EnvironmentVariables</key>
105
+ <dict>
106
+ <key>PATH</key>
107
+ <string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
108
+ <key>HOME</key>
109
+ <string>${homedir()}</string>
110
+ <key>OPSCONTEXT_SKIP_CLAUDE_MEMORY</key>
111
+ <string>1</string>
112
+ </dict>
113
+
114
+ <key>WorkingDirectory</key>
115
+ <string>${homedir()}</string>
116
+
117
+ <key>RunAtLoad</key>
118
+ <true/>
119
+
120
+ <key>KeepAlive</key>
121
+ <true/>
122
+
123
+ <key>ThrottleInterval</key>
124
+ <integer>10</integer>
125
+
126
+ <key>StandardOutPath</key>
127
+ <string>${join(LOG_DIR, "mcp-stdout.log")}</string>
128
+
129
+ <key>StandardErrorPath</key>
130
+ <string>${join(LOG_DIR, "mcp-stderr.log")}</string>
131
+
132
+ <key>ProcessType</key>
133
+ <string>Background</string>
134
+ </dict>
135
+ </plist>
136
+ `;
137
+ }
138
+ function isMacOS() {
139
+ return platform() === "darwin";
140
+ }
141
+ function userId() {
142
+ return process.getuid?.() ?? 501;
143
+ }
144
+ function portIsOurs() {
145
+ try {
146
+ execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1`, { stdio: "ignore" });
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ function waitForPort(timeoutSec = 30) {
154
+ const start = Date.now();
155
+ while (Date.now() - start < timeoutSec * 1000) {
156
+ if (portIsOurs())
157
+ return true;
158
+ execSync("sleep 1");
159
+ }
160
+ return false;
161
+ }
162
+ export async function cliInstallAutostart(args) {
163
+ const help = args.includes("-h") || args.includes("--help");
164
+ if (help) {
165
+ console.log(`Usage: opscontext install-autostart [--force]
166
+
167
+ Installs OpsContext as a macOS LaunchAgent so the MCP server starts
168
+ automatically at every login and restarts if it crashes.
169
+
170
+ After running this once, you never need to start the server manually again.
171
+ Browser extension events + Claude Code hook events + VS Code emitter events
172
+ all flow through the auto-started server.
173
+
174
+ --force Re-create the plist even if one already exists (use after a
175
+ node version upgrade or after moving the install location).
176
+
177
+ To stop / uninstall: opscontext uninstall-autostart
178
+ To check status: opscontext autostart-status
179
+ To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
180
+ `);
181
+ return;
182
+ }
183
+ if (!isMacOS()) {
184
+ console.error(`❌ install-autostart currently supports macOS only (this is ${platform()}).`);
185
+ console.error(` For Linux: write a systemd --user unit. For Windows: NSSM or Task Scheduler.`);
186
+ process.exit(1);
187
+ }
188
+ const force = args.includes("--force") || args.includes("-f");
189
+ if (existsSync(PLIST_FILE) && !force) {
190
+ console.error(`❌ ${PLIST_FILE} already exists.`);
191
+ console.error(` Pass --force to overwrite, or run: opscontext autostart-status`);
192
+ process.exit(1);
193
+ }
194
+ // Allow operator to pin the entry path explicitly — escape hatch when
195
+ // detection fails (e.g. monorepo / private registry / unconventional layout).
196
+ // Audit M2 follow-up: was originally a CLI flag suggestion.
197
+ const entryFlagIdx = args.findIndex((a) => a === "--entry" || a.startsWith("--entry="));
198
+ let entry = null;
199
+ if (entryFlagIdx >= 0) {
200
+ const raw = args[entryFlagIdx].includes("=")
201
+ ? args[entryFlagIdx].split("=")[1]
202
+ : args[entryFlagIdx + 1];
203
+ if (!raw) {
204
+ console.error(`❌ --entry requires a path. Usage: --entry=/path/to/dist/index.js`);
205
+ process.exit(1);
206
+ }
207
+ if (!existsSync(raw)) {
208
+ console.error(`❌ --entry path does not exist: ${raw}`);
209
+ process.exit(1);
210
+ }
211
+ entry = { kind: "manual", path: raw };
212
+ }
213
+ const nodePath = detectNodePath();
214
+ if (!entry)
215
+ entry = detectOpscontextEntry();
216
+ if (!entry) {
217
+ console.error(`❌ Could not locate opscontext entrypoint. Tried 3 paths:`);
218
+ console.error(` (1) npm global root → @compr/opscontext-mcp/dist/index.js`);
219
+ console.error(` (2) dev tree sibling (this script's dist/ dir)`);
220
+ console.error(` (3) Node resolution of "@compr/opscontext-mcp/dist/index.js" via createRequire (npx cache)`);
221
+ console.error(``);
222
+ console.error(` If you installed with npx, install globally first:`);
223
+ console.error(` npm install -g @compr/opscontext-mcp`);
224
+ console.error(``);
225
+ console.error(` Or run from a clone: cd .../ContextEngine && npm run build`);
226
+ console.error(``);
227
+ console.error(` Or pin a path explicitly: opscontext install-autostart --entry=/full/path/to/dist/index.js`);
228
+ process.exit(1);
229
+ }
230
+ // Ensure log dir
231
+ mkdirSync(LOG_DIR, { recursive: true });
232
+ mkdirSync(dirname(PLIST_FILE), { recursive: true });
233
+ const nodeBinDir = dirname(nodePath);
234
+ const plist = buildPlist(nodePath, entry.path, nodeBinDir);
235
+ writeFileSync(PLIST_FILE, plist);
236
+ console.log(`✅ Wrote ${PLIST_FILE}`);
237
+ console.log(` node: ${nodePath}`);
238
+ console.log(` entry: ${entry.path} (${entry.kind})`);
239
+ // Stop any currently-running unmanaged opscontext server on the port —
240
+ // it would race with launchd for port 7842.
241
+ if (portIsOurs()) {
242
+ console.log(` detected existing process on :${PORT} — relying on launchctl bootout to clean it.`);
243
+ }
244
+ // Idempotent bootstrap: bootout (ignore failure) → bootstrap
245
+ const uid = userId();
246
+ try {
247
+ execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
248
+ }
249
+ catch {
250
+ /* not loaded — fine */
251
+ }
252
+ try {
253
+ execSync(`launchctl bootstrap gui/${uid} ${PLIST_FILE}`, { stdio: "inherit" });
254
+ }
255
+ catch (err) {
256
+ console.error(`❌ launchctl bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
257
+ process.exit(1);
258
+ }
259
+ console.log(` waiting for the server to bind port ${PORT}...`);
260
+ if (waitForPort(30)) {
261
+ console.log(`✅ OpsContext is now running as a LaunchAgent (started at every login).`);
262
+ console.log(``);
263
+ console.log(`Verify: curl -s http://127.0.0.1:${PORT}/health | jq .`);
264
+ console.log(`Logs: tail -f ~/.contextengine/logs/mcp-stderr.log`);
265
+ console.log(`Stop: opscontext uninstall-autostart`);
266
+ }
267
+ else {
268
+ console.error(`⚠️ Server didn't bind port ${PORT} within 30s.`);
269
+ console.error(` Check the logs: tail -50 ~/.contextengine/logs/mcp-stderr.log`);
270
+ process.exit(1);
271
+ }
272
+ }
273
+ export async function cliUninstallAutostart(args) {
274
+ const help = args.includes("-h") || args.includes("--help");
275
+ if (help) {
276
+ console.log(`Usage: opscontext uninstall-autostart
277
+
278
+ Removes the LaunchAgent and stops the OpsContext MCP server. The audit log
279
+ and extension secret are NOT touched — only the auto-start wiring goes away.
280
+ You can re-install with: opscontext install-autostart`);
281
+ return;
282
+ }
283
+ if (!isMacOS()) {
284
+ console.error(`❌ Only macOS LaunchAgents supported here.`);
285
+ process.exit(1);
286
+ }
287
+ const uid = userId();
288
+ let removed = false;
289
+ try {
290
+ execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
291
+ removed = true;
292
+ }
293
+ catch {
294
+ /* not loaded */
295
+ }
296
+ if (existsSync(PLIST_FILE)) {
297
+ const { unlinkSync } = await import("fs");
298
+ unlinkSync(PLIST_FILE);
299
+ console.log(`✅ Removed ${PLIST_FILE}`);
300
+ }
301
+ else {
302
+ console.log(` (no plist at ${PLIST_FILE})`);
303
+ }
304
+ if (removed) {
305
+ console.log(`✅ Stopped the running ${LABEL} agent.`);
306
+ }
307
+ else {
308
+ console.log(` (no running ${LABEL} agent found)`);
309
+ }
310
+ console.log(``);
311
+ console.log(`Audit log and extension secret kept at ~/.contextengine/ — re-install any time.`);
312
+ }
313
+ export async function cliAutostartStatus(args) {
314
+ if (args.includes("-h") || args.includes("--help")) {
315
+ console.log(`Usage: opscontext autostart-status
316
+
317
+ Shows whether OpsContext is configured to auto-start (LaunchAgent present),
318
+ whether it's currently running (port 7842 listening), and the path to the
319
+ running entrypoint.`);
320
+ return;
321
+ }
322
+ if (!isMacOS()) {
323
+ console.log(`platform: ${platform()} (LaunchAgent applies to macOS only)`);
324
+ return;
325
+ }
326
+ const plistExists = existsSync(PLIST_FILE);
327
+ const portUp = portIsOurs();
328
+ const uid = userId();
329
+ let launchctlState = "not loaded";
330
+ try {
331
+ const out = execSync(`launchctl print gui/${uid}/${LABEL} 2>/dev/null || true`, { encoding: "utf-8" });
332
+ const match = out.match(/state\s*=\s*(\S+)/);
333
+ if (match)
334
+ launchctlState = match[1];
335
+ }
336
+ catch {
337
+ /* ignore */
338
+ }
339
+ console.log(`OpsContext auto-start status`);
340
+ console.log(`─────────────────────────────`);
341
+ console.log(` plist: ${plistExists ? "✅ " + PLIST_FILE : "❌ not installed (run: opscontext install-autostart)"}`);
342
+ console.log(` launchctl: ${launchctlState}`);
343
+ console.log(` port ${PORT}: ${portUp ? "✅ listening" : "❌ not listening"}`);
344
+ if (portUp) {
345
+ try {
346
+ const health = execSync(`curl -sf http://127.0.0.1:${PORT}/health`, { encoding: "utf-8", timeout: 2000 });
347
+ console.log(` health: ${health.trim()}`);
348
+ }
349
+ catch {
350
+ console.log(` health: ⚠ port open but /health didn't respond`);
351
+ }
352
+ }
353
+ console.log(``);
354
+ console.log(`Logs: ~/.contextengine/logs/mcp-stderr.log`);
355
+ }
356
+ //# sourceMappingURL=install-autostart.js.map
@@ -0,0 +1,3 @@
1
+ export declare function cliInstallClaudeHook(args: string[]): Promise<void>;
2
+ export declare function cliUninstallClaudeHook(args: string[]): Promise<void>;
3
+ //# sourceMappingURL=install-claude-hook.d.ts.map
@@ -0,0 +1,180 @@
1
+ // 🔒 LOCKED [CLAUDE-HOOK-INSTALL] — 2026-06-23
2
+ // ⛔ NEVER overwrite existing entries in hooks.PostToolUse — must APPEND.
3
+ // Users (and CE itself via the dogfood settings) commonly have
4
+ // matcher-specific PostToolUse entries (e.g. "Read|Edit|Write" gating)
5
+ // that would be silently destroyed by a replace.
6
+ // ⛔ NEVER write to ~/.claude/settings.json without parsing first. A typo
7
+ // or non-JSON state means Claude Code refuses to start.
8
+ // ⛔ NEVER emit on PreToolUse — would double-count vs PostToolUse for the
9
+ // `stuck` heuristic and skew `silent_failure` counts.
10
+ // WHY: Claude Code hook wiring is the ONLY way the user's terminal Claude
11
+ // Code sessions get into the OpsContext audit log. The installer has to
12
+ // be safe (idempotent, preserve existing) AND legible (clear error
13
+ // messages) AND fast (one command). If users have to hand-edit JSON,
14
+ // they won't.
15
+ // FIX: To add a new hook event, extend EVENT_KINDS + the splice block.
16
+ // Keep the "preserve existing" discipline in every code path.
17
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync } from "fs";
18
+ import { join } from "path";
19
+ import { homedir } from "os";
20
+ const CLAUDE_DIR = join(homedir(), ".claude");
21
+ const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
22
+ const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
23
+ const HOOK_SCRIPT = join(HOOKS_DIR, "opscontext-emit.sh");
24
+ const EVENT_KINDS = ["UserPromptSubmit", "PostToolUse", "SessionStart"];
25
+ function readSettings() {
26
+ if (!existsSync(SETTINGS_FILE))
27
+ return {};
28
+ try {
29
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
30
+ }
31
+ catch (err) {
32
+ throw new Error(`${SETTINGS_FILE} is not valid JSON — refusing to touch. (${err instanceof Error ? err.message : err})`);
33
+ }
34
+ }
35
+ function backupSettings() {
36
+ if (!existsSync(SETTINGS_FILE))
37
+ return "";
38
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
39
+ const backup = `${SETTINGS_FILE}.bak-pre-opscontext-${ts}`;
40
+ copyFileSync(SETTINGS_FILE, backup);
41
+ return backup;
42
+ }
43
+ function hookAlreadyWired(entries, hookScript) {
44
+ if (!entries)
45
+ return false;
46
+ return entries.some((e) => e.hooks?.some((h) => h.command?.startsWith(hookScript)));
47
+ }
48
+ /** Path to the reference hook script bundled with this package. */
49
+ function bundledHookSource() {
50
+ // dist/install-claude-hook.js → ../defaults/claude-code-hook.sh in dev tree,
51
+ // or .../node_modules/@compr/opscontext-mcp/defaults/claude-code-hook.sh
52
+ // when globally / locally installed via npm. Both follow the same relative
53
+ // shape because npm copies defaults/ via the `files` whitelist.
54
+ const candidates = [
55
+ join(__dirname || "", "..", "defaults", "claude-code-hook.sh"),
56
+ join(__dirname || "", "defaults", "claude-code-hook.sh"),
57
+ ];
58
+ for (const c of candidates) {
59
+ if (existsSync(c))
60
+ return c;
61
+ }
62
+ return null;
63
+ }
64
+ export async function cliInstallClaudeHook(args) {
65
+ const help = args.includes("-h") || args.includes("--help");
66
+ if (help) {
67
+ console.log(`Usage: opscontext install-claude-hook
68
+
69
+ Wires OpsContext into Claude Code's hook system so every terminal Claude
70
+ Code session sends prompts + tool calls to the OpsContext audit log.
71
+
72
+ Events emitted (all go through the local HTTP endpoint, never the network):
73
+ • UserPromptSubmit → vscode.prompt_submit (feeds the loop heuristic)
74
+ • PostToolUse → vscode.tool_call (feeds stuck + silent_failure)
75
+ • SessionStart → vscode.session_start
76
+
77
+ The installer:
78
+ 1. Copies the bundled hook script to ~/.claude/hooks/opscontext-emit.sh
79
+ 2. Splices three entries into ~/.claude/settings.json under "hooks"
80
+ 3. Preserves every existing hook entry (idempotent, safe to re-run)
81
+
82
+ A timestamped backup is written next to settings.json before any change.
83
+
84
+ Pre-req: the MCP server must be auto-started or running (otherwise the hook
85
+ silently no-ops, which is the safe default — you won't lose events later).
86
+ Run: opscontext install-autostart
87
+ `);
88
+ return;
89
+ }
90
+ // Step 1: Install / verify the hook script
91
+ mkdirSync(HOOKS_DIR, { recursive: true });
92
+ const src = bundledHookSource();
93
+ if (!src) {
94
+ console.error(`❌ Could not find bundled hook script defaults/claude-code-hook.sh.`);
95
+ console.error(` This means the install is incomplete. Reinstall opscontext:`);
96
+ console.error(` npm install -g @compr/opscontext-mcp`);
97
+ process.exit(1);
98
+ }
99
+ copyFileSync(src, HOOK_SCRIPT);
100
+ chmodSync(HOOK_SCRIPT, 0o755);
101
+ console.log(`✅ Installed hook script: ${HOOK_SCRIPT}`);
102
+ // Step 2: Splice into settings.json
103
+ const settings = readSettings();
104
+ const backup = backupSettings();
105
+ if (backup)
106
+ console.log(`✅ Backed up settings.json → ${backup}`);
107
+ settings.hooks ??= {};
108
+ const hookCmdPrefix = `${HOOK_SCRIPT}`; // command string starts with this
109
+ let added = 0;
110
+ let skipped = 0;
111
+ for (const kind of EVENT_KINDS) {
112
+ settings.hooks[kind] ??= [];
113
+ if (hookAlreadyWired(settings.hooks[kind], hookCmdPrefix)) {
114
+ skipped++;
115
+ continue;
116
+ }
117
+ const entry = {
118
+ hooks: [
119
+ {
120
+ type: "command",
121
+ command: `${HOOK_SCRIPT} ${kind}`,
122
+ timeout: 5,
123
+ },
124
+ ],
125
+ };
126
+ // PostToolUse needs a matcher (PreToolUse/PostToolUse are tool-matched);
127
+ // ".*" matches every tool. Other events are not tool-scoped.
128
+ if (kind === "PostToolUse")
129
+ entry.matcher = ".*";
130
+ settings.hooks[kind].push(entry);
131
+ added++;
132
+ }
133
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
134
+ console.log(`✅ ${added} hook entries added, ${skipped} already present.`);
135
+ console.log(``);
136
+ console.log(`Test live:`);
137
+ console.log(` 1. Open a NEW VS Code terminal (settings.json is read at session start).`);
138
+ console.log(` 2. Run \`claude\` and ask anything — Claude will use tools.`);
139
+ console.log(` 3. In any other terminal:`);
140
+ console.log(` tail -f ~/.contextengine/audit.log | grep --line-buffered '"actor":"claude-code"'`);
141
+ console.log(``);
142
+ console.log(`To remove: opscontext uninstall-claude-hook (or hand-edit ~/.claude/settings.json)`);
143
+ }
144
+ export async function cliUninstallClaudeHook(args) {
145
+ if (args.includes("-h") || args.includes("--help")) {
146
+ console.log(`Usage: opscontext uninstall-claude-hook
147
+
148
+ Removes OpsContext hook entries from ~/.claude/settings.json. The hook
149
+ script file (~/.claude/hooks/opscontext-emit.sh) is left in place — delete
150
+ manually if you want it gone. The audit log is NOT touched.`);
151
+ return;
152
+ }
153
+ const settings = readSettings();
154
+ if (!settings.hooks) {
155
+ console.log(` (no hooks block in settings.json — nothing to remove)`);
156
+ return;
157
+ }
158
+ const backup = backupSettings();
159
+ if (backup)
160
+ console.log(`✅ Backed up settings.json → ${backup}`);
161
+ let removed = 0;
162
+ for (const kind of EVENT_KINDS) {
163
+ const entries = settings.hooks[kind];
164
+ if (!entries)
165
+ continue;
166
+ const filtered = entries.filter((e) => !e.hooks?.some((h) => h.command?.includes("opscontext-emit.sh")));
167
+ removed += entries.length - filtered.length;
168
+ if (filtered.length === 0) {
169
+ delete settings.hooks[kind];
170
+ }
171
+ else {
172
+ settings.hooks[kind] = filtered;
173
+ }
174
+ }
175
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
176
+ console.log(`✅ Removed ${removed} hook entries.`);
177
+ console.log(` Hook script kept at: ${HOOK_SCRIPT}`);
178
+ console.log(` Audit log untouched.`);
179
+ }
180
+ //# sourceMappingURL=install-claude-hook.js.map
package/dist/policy.d.ts CHANGED
@@ -42,6 +42,38 @@ export declare const DeployVerifyHostSchema: z.ZodObject<{
42
42
  description: z.ZodOptional<z.ZodString>;
43
43
  }, z.core.$strip>;
44
44
  export type DeployVerifyHost = z.infer<typeof DeployVerifyHostSchema>;
45
+ /**
46
+ * A staged-path → required-commit-message-pattern rule. Fires when a
47
+ * commit touches any path matching `paths` AND the commit message does
48
+ * NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
49
+ * encodes the Session 15 / Sprint 16 lesson: shared production
50
+ * infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
51
+ * must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
52
+ * OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
53
+ * that gets recorded in the audit log.
54
+ *
55
+ * Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
56
+ * caught 5 design errors + 2 structural blockers in an Option B blue/green
57
+ * rollout that would otherwise have shipped and crashed sibling apps on
58
+ * the multi-tenant VPS. The rule turns that one-time discipline into a
59
+ * machine-enforced gate.
60
+ *
61
+ * NOTE: processor implementation (parsing staged paths + scanning the
62
+ * commit message buffer in the prepare-commit-msg or commit-msg hook)
63
+ * is intentionally left to a follow-up — this file only declares the
64
+ * shape so policy.json validation accepts the new rule today.
65
+ */
66
+ export declare const CommitMessageRequiredSchema: z.ZodObject<{
67
+ id: z.ZodString;
68
+ paths: z.ZodArray<z.ZodString>;
69
+ pattern: z.ZodString;
70
+ severity: z.ZodDefault<z.ZodEnum<{
71
+ warn: "warn";
72
+ block: "block";
73
+ }>>;
74
+ description: z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strip>;
76
+ export type CommitMessageRequired = z.infer<typeof CommitMessageRequiredSchema>;
45
77
  /**
46
78
  * A documented escape hatch for the hook. Beats undocumented `touch` /
47
79
  * `--no-verify` workarounds. Bypass token requires a reason and lives in
@@ -85,6 +117,16 @@ export declare const PolicySchema: z.ZodObject<{
85
117
  within_seconds: z.ZodDefault<z.ZodNumber>;
86
118
  description: z.ZodOptional<z.ZodString>;
87
119
  }, z.core.$strip>>>;
120
+ commit_message_required: z.ZodDefault<z.ZodArray<z.ZodObject<{
121
+ id: z.ZodString;
122
+ paths: z.ZodArray<z.ZodString>;
123
+ pattern: z.ZodString;
124
+ severity: z.ZodDefault<z.ZodEnum<{
125
+ warn: "warn";
126
+ block: "block";
127
+ }>>;
128
+ description: z.ZodOptional<z.ZodString>;
129
+ }, z.core.$strip>>>;
88
130
  bypass_tokens: z.ZodDefault<z.ZodArray<z.ZodObject<{
89
131
  id: z.ZodString;
90
132
  ttl_seconds: z.ZodDefault<z.ZodNumber>;
package/dist/policy.js CHANGED
@@ -58,6 +58,40 @@ export const DeployVerifyHostSchema = z.object({
58
58
  within_seconds: z.number().int().positive().default(60),
59
59
  description: z.string().optional(),
60
60
  });
61
+ /**
62
+ * A staged-path → required-commit-message-pattern rule. Fires when a
63
+ * commit touches any path matching `paths` AND the commit message does
64
+ * NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
65
+ * encodes the Session 15 / Sprint 16 lesson: shared production
66
+ * infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
67
+ * must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
68
+ * OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
69
+ * that gets recorded in the audit log.
70
+ *
71
+ * Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
72
+ * caught 5 design errors + 2 structural blockers in an Option B blue/green
73
+ * rollout that would otherwise have shipped and crashed sibling apps on
74
+ * the multi-tenant VPS. The rule turns that one-time discipline into a
75
+ * machine-enforced gate.
76
+ *
77
+ * NOTE: processor implementation (parsing staged paths + scanning the
78
+ * commit message buffer in the prepare-commit-msg or commit-msg hook)
79
+ * is intentionally left to a follow-up — this file only declares the
80
+ * shape so policy.json validation accepts the new rule today.
81
+ */
82
+ export const CommitMessageRequiredSchema = z.object({
83
+ id: z.string().min(1).describe("Stable identifier for audit-log attribution"),
84
+ paths: z
85
+ .array(z.string())
86
+ .min(1)
87
+ .describe("Glob patterns of staged files that trigger this rule (e.g. server/deploy.sh)"),
88
+ pattern: z
89
+ .string()
90
+ .min(1)
91
+ .describe("ERE regex the commit message MUST match for the commit to proceed"),
92
+ severity: z.enum(["block", "warn"]).default("block"),
93
+ description: z.string().optional(),
94
+ });
61
95
  /**
62
96
  * A documented escape hatch for the hook. Beats undocumented `touch` /
63
97
  * `--no-verify` workarounds. Bypass token requires a reason and lives in
@@ -82,6 +116,7 @@ export const PolicySchema = z.object({
82
116
  secret_patterns: z.array(SecretPatternSchema).default([]),
83
117
  doc_coverage: z.array(DocCoverageSchema).default([]),
84
118
  deploy_verify_hosts: z.array(DeployVerifyHostSchema).default([]),
119
+ commit_message_required: z.array(CommitMessageRequiredSchema).default([]),
85
120
  bypass_tokens: z.array(BypassTokenSchema).default([]),
86
121
  });
87
122
  export function validatePolicy(raw) {
@@ -165,6 +200,11 @@ export function formatPolicySummary(policy) {
165
200
  lines.push(` - ${h.host} → probe within ${h.within_seconds}s: ${h.require_probe}`);
166
201
  }
167
202
  lines.push("");
203
+ lines.push(`Commit-message-required rules: ${policy.commit_message_required.length}`);
204
+ for (const r of policy.commit_message_required) {
205
+ lines.push(` - [${r.severity}] ${r.id} → paths ${r.paths.join(", ")} must match /${r.pattern}/`);
206
+ }
207
+ lines.push("");
168
208
  lines.push(`Bypass tokens: ${policy.bypass_tokens.length}`);
169
209
  for (const b of policy.bypass_tokens) {
170
210
  lines.push(` - ${b.id} → TTL ${b.ttl_seconds}s, reason ≥ ${b.requires_reason_min_length} chars`);