@compr/opscontext-mcp 2.8.3 → 2.9.0
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 +83 -0
- package/README.md +17 -17
- package/defaults/claude-code-hook.sh +11 -3
- package/defaults/simplicity-gate.py +135 -0
- package/dist/audit.d.ts +109 -1
- package/dist/audit.js +485 -41
- package/dist/cli-commands.js +2 -0
- package/dist/cli.js +80 -1
- package/dist/detector.js +7 -3
- package/dist/fleet-health.d.ts +15 -0
- package/dist/fleet-health.js +31 -2
- package/dist/http-server.d.ts +7 -0
- package/dist/http-server.js +38 -1
- package/dist/index.js +20 -1
- package/dist/install-claude-hook.d.ts +5 -0
- package/dist/install-claude-hook.js +111 -26
- package/dist/rubric.js +1 -1
- package/dist/secret-shapes.d.ts +28 -0
- package/dist/secret-shapes.js +132 -0
- package/dist/server-registry.d.ts +11 -1
- package/dist/server-registry.js +29 -4
- package/package.json +4 -3
package/dist/cli.js
CHANGED
|
@@ -651,7 +651,8 @@ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLea
|
|
|
651
651
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
652
652
|
import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
|
|
653
653
|
import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
|
|
654
|
-
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, } from "./audit.js";
|
|
654
|
+
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, } from "./audit.js";
|
|
655
|
+
import { redactPayload } from "./secret-shapes.js";
|
|
655
656
|
import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
|
|
656
657
|
import { buildCostReport } from "./cost-report.js";
|
|
657
658
|
import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
|
|
@@ -1996,6 +1997,68 @@ function cliAuditRotate(args) {
|
|
|
1996
1997
|
process.exit(2);
|
|
1997
1998
|
}
|
|
1998
1999
|
}
|
|
2000
|
+
/** Remove credentials from records already written. [LOCK] [SCRUB-IS-ACKNOWLEDGED-REDACTION] */
|
|
2001
|
+
function cliAuditScrub(args) {
|
|
2002
|
+
const reasonAt = args.indexOf("--reason");
|
|
2003
|
+
const reason = reasonAt >= 0 ? args[reasonAt + 1] ?? "" : "";
|
|
2004
|
+
const apply = args.includes("--apply");
|
|
2005
|
+
const r = scrubAuditLog({ apply, reason, redact: redactPayload, actor: "cli" });
|
|
2006
|
+
if (r.refusedReason) {
|
|
2007
|
+
console.error(`\nNot scrubbed: ${r.refusedReason}\n`);
|
|
2008
|
+
process.exit(1);
|
|
2009
|
+
}
|
|
2010
|
+
const hit = r.files.filter((f) => f.redacted > 0);
|
|
2011
|
+
console.log(`\nAudit scrub${apply ? "" : ", DRY RUN, nothing written"}\n`);
|
|
2012
|
+
console.log(` records to redact ${r.redactedRecords}${apply ? " (redacted)" : ""}, in ${hit.length} of ${r.files.length} file(s)`);
|
|
2013
|
+
for (const [id, n] of Object.entries(r.counts).sort((a, b) => b[1] - a[1])) {
|
|
2014
|
+
console.log(` ${id.padEnd(24)} ${n}`);
|
|
2015
|
+
}
|
|
2016
|
+
if (!apply) {
|
|
2017
|
+
if (r.redactedRecords > 0)
|
|
2018
|
+
console.log(`\n Run again with --apply --reason "<why>" to rewrite them and acknowledge it on the chain.\n`);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
console.log(` acknowledged by ${r.acknowledgements.length} audit.redact record(s)`);
|
|
2022
|
+
const after = verifyChain();
|
|
2023
|
+
console.log(`\n audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${after.total} record(s), ` +
|
|
2024
|
+
`${(after.redactedIndices ?? []).length} redacted and acknowledged, ${(after.tamperedIndices ?? []).length} altered.\n`);
|
|
2025
|
+
if (!after.ok)
|
|
2026
|
+
process.exit(2);
|
|
2027
|
+
}
|
|
2028
|
+
/** Put a lost block of audit records back from a backup. [LOCK] [RESTORE-ONLY-CLOSES-A-PROVEN-GAP] */
|
|
2029
|
+
function cliAuditRestore(args) {
|
|
2030
|
+
const reasonAt = args.indexOf("--reason");
|
|
2031
|
+
const reason = reasonAt >= 0 ? args[reasonAt + 1] ?? "" : "";
|
|
2032
|
+
const apply = args.includes("--apply");
|
|
2033
|
+
const file = args.find((a, i) => !a.startsWith("--") && !(reasonAt >= 0 && i === reasonAt + 1));
|
|
2034
|
+
if (!file) {
|
|
2035
|
+
console.error(`usage: contextengine audit-restore <file.jsonl> [--apply --reason "<where the records came from and why>"]`);
|
|
2036
|
+
console.error(` Without --apply it only checks the file and says what it would do.`);
|
|
2037
|
+
process.exit(1);
|
|
2038
|
+
}
|
|
2039
|
+
const r = restoreSegment(resolve(file), { apply, reason, actor: "cli" });
|
|
2040
|
+
if (r.refusedReason) {
|
|
2041
|
+
console.error(`\nNot restored: ${r.refusedReason}\n`);
|
|
2042
|
+
process.exit(1);
|
|
2043
|
+
}
|
|
2044
|
+
console.log(`\nAudit restore${apply ? "" : ", DRY RUN, nothing written"}\n`);
|
|
2045
|
+
console.log(` block ${r.records} record(s), ${r.firstTs} to ${r.lastTs}`);
|
|
2046
|
+
console.log(` every record matches its own hash`);
|
|
2047
|
+
console.log(` fits between ${r.after} and ${r.before}`);
|
|
2048
|
+
console.log(` closes the orphan at index ${r.orphanIndex}`);
|
|
2049
|
+
if (!apply) {
|
|
2050
|
+
console.log(` would write audit-archive/${r.segmentFile}`);
|
|
2051
|
+
console.log(`\n Run again with --apply --reason "<where the records came from and why>" to write it.\n`);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
console.log(` wrote audit-archive/${r.segmentFile}`);
|
|
2055
|
+
console.log(` recorded as audit.restore, hash ${r.record.hash.slice(0, 16)}...`);
|
|
2056
|
+
const after = verifyChain();
|
|
2057
|
+
console.log(`\n audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${after.total} record(s), ` +
|
|
2058
|
+
`${(after.orphanIndices ?? []).length} orphan(s), ${(after.tamperedIndices ?? []).length} altered.\n`);
|
|
2059
|
+
if (!after.ok)
|
|
2060
|
+
process.exit(2);
|
|
2061
|
+
}
|
|
1999
2062
|
/** Acknowledge deliberately redacted audit records on the chain. [LOCK] [REDACTION-IS-A-CHAINED-RECORD] */
|
|
2000
2063
|
function cliAuditRedactAck(args) {
|
|
2001
2064
|
const idxAt = args.indexOf("--index");
|
|
@@ -2058,6 +2121,8 @@ async function cliAuditVerify() {
|
|
|
2058
2121
|
console.error(`\n Orphaned records (parent hash absent from the log):`);
|
|
2059
2122
|
console.error(` ${o.slice(0, 10).join(", ")}${o.length > 10 ? `, … (+${o.length - 10} more)` : ""}`);
|
|
2060
2123
|
console.error(` This means earlier records were deleted or the log was truncated.`);
|
|
2124
|
+
console.error(` If a backup holds the missing records, 'contextengine audit-restore <file>' checks`);
|
|
2125
|
+
console.error(` that they fill this gap exactly, and puts them back with --apply.`);
|
|
2061
2126
|
}
|
|
2062
2127
|
if (forks.length > 0) {
|
|
2063
2128
|
console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
|
|
@@ -2552,6 +2617,14 @@ Usage:
|
|
|
2552
2617
|
the size ceiling (default 50000 live records),
|
|
2553
2618
|
whichever is more. The chain stays linear: segments
|
|
2554
2619
|
are part of the verified history, never deleted.
|
|
2620
|
+
contextengine audit-restore <file> [--apply --reason "..."]
|
|
2621
|
+
Put a lost block of records back from a backup. Only
|
|
2622
|
+
fills a gap audit-verify reports, at a segment boundary,
|
|
2623
|
+
after checking every record's hash. Dry run by default.
|
|
2624
|
+
contextengine audit-scrub [--apply --reason "..."]
|
|
2625
|
+
Remove credentials (keys, tokens, passwords in commands
|
|
2626
|
+
and URLs) from captured records already written, and
|
|
2627
|
+
acknowledge each rewrite on the chain. Dry run by default.
|
|
2555
2628
|
contextengine cost [--session ID] [--project NAME] [--run wf_ID] [--days N] [--top N] [--json]
|
|
2556
2629
|
Multi-agent spend from Claude Code transcripts. Always prints
|
|
2557
2630
|
VOLUME (tokens), VALUED COST (API list prices — notional on a
|
|
@@ -2757,6 +2830,12 @@ else if (command === "audit-redact-ack") {
|
|
|
2757
2830
|
else if (command === "audit-rotate") {
|
|
2758
2831
|
cliAuditRotate(process.argv.slice(3));
|
|
2759
2832
|
}
|
|
2833
|
+
else if (command === "audit-restore") {
|
|
2834
|
+
cliAuditRestore(process.argv.slice(3));
|
|
2835
|
+
}
|
|
2836
|
+
else if (command === "audit-scrub") {
|
|
2837
|
+
cliAuditScrub(process.argv.slice(3));
|
|
2838
|
+
}
|
|
2760
2839
|
else if (command === "servers") {
|
|
2761
2840
|
const fleet = listServers();
|
|
2762
2841
|
const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
|
package/dist/detector.js
CHANGED
|
@@ -79,7 +79,9 @@ function stableStringify(v) {
|
|
|
79
79
|
}
|
|
80
80
|
function getText(r) {
|
|
81
81
|
const p = r.payload || {};
|
|
82
|
-
|
|
82
|
+
// A prompt kept as a fingerprint only ([PROMPT-TEXT-IS-NOT-KEPT]) compares as one token: equal
|
|
83
|
+
// for an exact repeat, different otherwise.
|
|
84
|
+
return String(p.text ?? p.text_fingerprint ?? p.preview ?? p.args_preview ?? "");
|
|
83
85
|
}
|
|
84
86
|
function sessionKey(r) {
|
|
85
87
|
const p = r.payload || {};
|
|
@@ -120,7 +122,7 @@ function detectLoop(events) {
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
if (dupes >= 2) {
|
|
123
|
-
const snippet = getText(prompts[i]).slice(0, 80);
|
|
125
|
+
const snippet = typeof prompts[i].payload?.text === "string" ? getText(prompts[i]).slice(0, 80) : "(prompt text not kept)";
|
|
124
126
|
return mk("loop", "warn", `Same prompt sent ${dupes + 1}× in 5 min`, matches, { repetitions: dupes + 1, snippet });
|
|
125
127
|
}
|
|
126
128
|
}
|
|
@@ -188,7 +190,9 @@ function detectFabrication(events, cwd) {
|
|
|
188
190
|
function detectDrift(events) {
|
|
189
191
|
// Per-session: if the last 3 prompts are jointly far (low overlap) from
|
|
190
192
|
// the session's FIRST prompt, the conversation has drifted off-topic.
|
|
191
|
-
|
|
193
|
+
// Word overlap needs words: prompts kept as a fingerprint only are left out, or every session
|
|
194
|
+
// would read as drifted. [LOCK] [PROMPT-TEXT-IS-NOT-KEPT]
|
|
195
|
+
const bySession = groupBy(events.filter((e) => (e.event === "browser.prompt" || e.event === "vscode.prompt_submit") && typeof e.payload?.text === "string"), sessionKey);
|
|
192
196
|
for (const [sid, prompts] of bySession) {
|
|
193
197
|
if (prompts.length < 4)
|
|
194
198
|
continue;
|
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)
|
package/dist/http-server.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ declare function loadSecret(): string | null;
|
|
|
10
10
|
declare function constantTimeEqual(a: string, b: string): boolean;
|
|
11
11
|
/** Validate an event has the minimum shape we'll write to audit. */
|
|
12
12
|
declare function validateEvent(e: IncomingEvent, idx: number): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* The payload as it will be written: every string redacted, and the per-shape counts recorded
|
|
15
|
+
* under `redacted_at_ingest` when anything was removed. The only path from the network to the
|
|
16
|
+
* log. [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR] (src/secret-shapes.ts)
|
|
17
|
+
*/
|
|
18
|
+
export declare function prepareCapturedPayload(payload: Record<string, unknown>, event?: string): Record<string, unknown>;
|
|
13
19
|
/**
|
|
14
20
|
* Boot the local event-ingest HTTP server. Returns the listening port or null
|
|
15
21
|
* if the port was already in use (caller may decide whether to retry on
|
|
@@ -20,6 +26,7 @@ declare function validateEvent(e: IncomingEvent, idx: number): string | null;
|
|
|
20
26
|
export declare function startEventIngestServer(): Promise<number | null>;
|
|
21
27
|
export declare function stopEventIngestServer(): Promise<void>;
|
|
22
28
|
export declare const _internal: {
|
|
29
|
+
prepareCapturedPayload: typeof prepareCapturedPayload;
|
|
23
30
|
loadSecret: typeof loadSecret;
|
|
24
31
|
constantTimeEqual: typeof constantTimeEqual;
|
|
25
32
|
validateEvent: typeof validateEvent;
|
package/dist/http-server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { join } from "path";
|
|
|
23
23
|
import { homedir } from "os";
|
|
24
24
|
import { timingSafeEqual } from "crypto";
|
|
25
25
|
import { safeAppend } from "./audit.js";
|
|
26
|
+
import { redactPayload, textFingerprint } from "./secret-shapes.js";
|
|
26
27
|
const PORT = parseInt(process.env.OPSCONTEXT_EVENT_PORT || "7842", 10);
|
|
27
28
|
const HOST = "127.0.0.1";
|
|
28
29
|
const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
|
|
@@ -73,6 +74,40 @@ function validateEvent(e, idx) {
|
|
|
73
74
|
}
|
|
74
75
|
return null;
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The payload as it will be written: every string redacted, and the per-shape counts recorded
|
|
79
|
+
* under `redacted_at_ingest` when anything was removed. The only path from the network to the
|
|
80
|
+
* log. [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR] (src/secret-shapes.ts)
|
|
81
|
+
*/
|
|
82
|
+
export function prepareCapturedPayload(payload, event) {
|
|
83
|
+
const { value, counts, changed } = redactPayload(dropPromptText(payload, event));
|
|
84
|
+
return changed ? { ...value, redacted_at_ingest: counts } : value;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* [LOCKED] [PROMPT-TEXT-IS-NOT-KEPT] - 2026-09-25
|
|
88
|
+
* [NEVER] write the text of a prompt or an AI response to the audit log by default.
|
|
89
|
+
* WHY: the owner's decision, 2026-09-25. The text fed one feature, the loop and drift alarm, which
|
|
90
|
+
* fired 0 times in three months of real use; meanwhile it held the owner's typed passwords
|
|
91
|
+
* and logins. A record of "prompt sent at 10:02, 340 characters" keeps the evidence of
|
|
92
|
+
* activity without keeping what was said.
|
|
93
|
+
* FIX: prompts (vscode.prompt_submit, browser.prompt) and responses (browser.response) keep their
|
|
94
|
+
* length and a keyed fingerprint (textFingerprint), so an exact repeat is still detectable.
|
|
95
|
+
* Commands and tool input stay, redacted: they are the record of what an agent did.
|
|
96
|
+
* OPSCONTEXT_KEEP_PROMPT_TEXT=1 keeps the (redacted) text, for someone who wants it.
|
|
97
|
+
*/
|
|
98
|
+
const PROMPT_TEXT_EVENTS = new Set(["vscode.prompt_submit", "browser.prompt", "browser.response"]);
|
|
99
|
+
function dropPromptText(payload, event) {
|
|
100
|
+
if (!event || !PROMPT_TEXT_EVENTS.has(event) || process.env.OPSCONTEXT_KEEP_PROMPT_TEXT === "1")
|
|
101
|
+
return payload;
|
|
102
|
+
if (typeof payload.text !== "string")
|
|
103
|
+
return payload;
|
|
104
|
+
const { text, ...rest } = payload;
|
|
105
|
+
return {
|
|
106
|
+
...rest,
|
|
107
|
+
char_count: typeof rest.char_count === "number" ? rest.char_count : text.length,
|
|
108
|
+
text_fingerprint: textFingerprint(text),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
76
111
|
function sendJson(res, status, body) {
|
|
77
112
|
const json = JSON.stringify(body);
|
|
78
113
|
res.writeHead(status, {
|
|
@@ -143,7 +178,8 @@ function handleEvents(req, res) {
|
|
|
143
178
|
for (const ev of batch.events) {
|
|
144
179
|
const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
|
|
145
180
|
// event/payload were validated above — cast is safe.
|
|
146
|
-
|
|
181
|
+
// [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR]: redact before the append, never after.
|
|
182
|
+
safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
|
|
147
183
|
written++;
|
|
148
184
|
}
|
|
149
185
|
sendJson(res, 200, { ok: true, written });
|
|
@@ -233,6 +269,7 @@ export function stopEventIngestServer() {
|
|
|
233
269
|
// Test helpers (not exported in dist surface in production use — but the
|
|
234
270
|
// module is small enough that tests can import them directly).
|
|
235
271
|
export const _internal = {
|
|
272
|
+
prepareCapturedPayload,
|
|
236
273
|
loadSecret,
|
|
237
274
|
constantTimeEqual,
|
|
238
275
|
validateEvent,
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
|
|
6
6
|
import { ingestSources } from "./ingest.js";
|
|
7
|
+
import { redactSecrets } from "./secret-shapes.js";
|
|
7
8
|
import { summarizeSource } from "./source-summary.js";
|
|
8
9
|
import { searchChunks } from "./search.js";
|
|
9
10
|
import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
|
|
@@ -152,6 +153,8 @@ async function buildIndex(opts) {
|
|
|
152
153
|
console.error(`[ContextEngine] 🌐 Injected ${added} community rule chunks ` +
|
|
153
154
|
`(${skipped} dedup'd vs local; ${store.rules.length} total in cache)`);
|
|
154
155
|
}
|
|
156
|
+
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: before the adapters, whose block can return early.
|
|
157
|
+
chunks = chunks.map(redactChunk);
|
|
155
158
|
// Collect from plugin adapters
|
|
156
159
|
if (config.adapters && config.adapters.length > 0) {
|
|
157
160
|
if (opts.loadAdapters) {
|
|
@@ -161,11 +164,27 @@ async function buildIndex(opts) {
|
|
|
161
164
|
}
|
|
162
165
|
const adapterChunks = await collectFromAdapters(config.adapters);
|
|
163
166
|
if (adapterChunks.length > 0) {
|
|
164
|
-
chunks.push(...adapterChunks);
|
|
167
|
+
chunks.push(...adapterChunks.map(redactChunk));
|
|
165
168
|
console.error(`[ContextEngine] 🔌 Adapters contributed ${adapterChunks.length} chunks`);
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25
|
|
174
|
+
* [NEVER] let a chunk into the index, the shared index file or a search result without passing
|
|
175
|
+
* its text through redactSecrets().
|
|
176
|
+
* WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
|
|
177
|
+
* files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
|
|
178
|
+
* runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
|
|
179
|
+
* chunks to every AI agent that asks, and the index file rides the weekly backup.
|
|
180
|
+
* FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
|
|
181
|
+
* adapters), is redacted with the capture shapes (src/secret-shapes.ts) as the index is built.
|
|
182
|
+
* The source files are not touched: cleaning those is the owner's call, file by file.
|
|
183
|
+
*/
|
|
184
|
+
function redactChunk(c) {
|
|
185
|
+
const r = redactSecrets(c.content);
|
|
186
|
+
return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
|
|
187
|
+
}
|
|
169
188
|
/** Load the model once; every caller shares the same promise. */
|
|
170
189
|
function ensureModel() {
|
|
171
190
|
if (!modelInit)
|
|
@@ -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
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const wrong = Object.entries(counts).filter(([, n]) => n !==
|
|
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
|
-
|
|
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.
|
|
265
|
-
|
|
266
|
-
|
|
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) =>
|
|
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/dist/rubric.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* WHAT THIS IS NOT: hidden behaviour. This file contains numbers and nothing else — no network
|
|
10
10
|
* calls, no file access, no data collection. Every code path in this package ships readable,
|
|
11
11
|
* including the two (and only two) network calls the tool ever makes: licence activation and the
|
|
12
|
-
* daily heartbeat, both in activation.js. Full source: https://github.com/FASTPROD/
|
|
12
|
+
* daily heartbeat, both in activation.js. Full source: https://github.com/FASTPROD/opscontext-mcp
|
|
13
13
|
*
|
|
14
14
|
* Your project data never leaves your machine. See the Privacy section of the README, and verify
|
|
15
15
|
* it in the source rather than taking our word for it.
|