@compr/opscontext-mcp 2.0.2 → 2.1.1
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 +77 -0
- package/defaults/claude-code-hook.sh +100 -0
- package/dist/audit.d.ts +1 -1
- package/dist/cli.js +246 -0
- package/dist/detector.d.ts +64 -0
- package/dist/detector.js +336 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +242 -0
- package/dist/index.js +44 -0
- package/dist/install-autostart.d.ts +4 -0
- package/dist/install-autostart.js +300 -0
- package/dist/install-claude-hook.d.ts +3 -0
- package/dist/install-claude-hook.js +180 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,83 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to OpsContext for AI Agents (previously ContextEngine — MCP server + CLI) are documented here.
|
|
4
4
|
|
|
5
|
+
## [2.1.1] — 2026-06-23 — Phase 1c: one-command install + Claude Code terminal capture
|
|
6
|
+
|
|
7
|
+
Closes the last surface gap from 2.1.0. Before this patch, "use OpsContext" meant running `nohup npx ...` every time the Mac restarted and hand-editing `~/.claude/settings.json` to wire Claude Code hooks. Now both are single commands. This is the release that lets non-technical users actually adopt OpsContext.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`src/install-autostart.ts`** (LOCK `[AUTOSTART-INSTALL]`) — installs a macOS LaunchAgent at `~/Library/LaunchAgents/com.opscontext.mcp.plist`. Set-and-forget — server starts at every login, KeepAlive restarts on crash, logs to `~/.contextengine/logs/mcp-{stdout,stderr}.log`. Companion `uninstall-autostart` + `autostart-status` commands. Auto-detects either a global npm install or a dev tree; pins node path absolutely (launchd has no PATH).
|
|
11
|
+
- **`src/install-claude-hook.ts`** (LOCK `[CLAUDE-HOOK-INSTALL]`) — copies `defaults/claude-code-hook.sh` to `~/.claude/hooks/opscontext-emit.sh` and splices three entries into `~/.claude/settings.json` under `hooks`: `UserPromptSubmit` → `vscode.prompt_submit`, `PostToolUse` (`.*` matcher) → `vscode.tool_call`, `SessionStart` → `vscode.session_start`. **Idempotent + preserves every existing hook entry** (backs up settings.json before any change). Closes the terminal-side capture gap — every Claude Code session in any project now feeds the audit log.
|
|
12
|
+
- **`defaults/claude-code-hook.sh`** — the actual shell hook: ~3 KB, pure bash + jq + curl, ~28 ms latency per invocation, **silent on every failure** (never blocks Claude Code), 1-second hard timeout on the HTTP call. Posts to `127.0.0.1:7842/events` with shared secret in `X-OpsContext-Secret` header. LOCK `[OPSCONTEXT-CC-HOOK]`.
|
|
13
|
+
- **New CLI subcommands:**
|
|
14
|
+
- `opscontext install-autostart [--force]`
|
|
15
|
+
- `opscontext uninstall-autostart`
|
|
16
|
+
- `opscontext autostart-status`
|
|
17
|
+
- `opscontext install-claude-hook`
|
|
18
|
+
- `opscontext uninstall-claude-hook`
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **`chrome-extension/src/options/options.html`** — Secret field placeholder said `32 hex chars` but the actual secret is 64 hex (256-bit). Updated to `64 hex chars` to match what `init-extension-secret` writes. No backwards-compat issue — placeholder text only, not validation.
|
|
22
|
+
- **`chrome-extension` content scripts now bundled as IIFE** (companion `@compr/opscontext-chrome@0.1.1`). The MV3 manifest can't put `"type": "module"` on `content_scripts` entries — only on `background`. The previous build shipped `dist/content/claude.js` with top-level `import` statements which Chrome silently rejected, dark-launching the entire capture surface (Options page still saved the secret fine because popup/options DO support modules via inline `<script type="module">`). New `scripts/bundle-content.mjs` runs esbuild after `tsc` to inline all `./shared/*` and `../lib/*` imports into a single IIFE per content entry. LOCK `[CONTENT-SCRIPT-BUNDLE]`. Reload the unpacked extension after rebuild for the fix to take effect.
|
|
23
|
+
|
|
24
|
+
### Why this matters
|
|
25
|
+
- Before: `nohup npx -y --package=@compr/opscontext-mcp@2.1.0 -- opscontext > /tmp/opscontext-mcp.log 2>&1 < /dev/null &` every reboot, plus hand-editing JSON for Claude Code hooks. Friction kills adoption.
|
|
26
|
+
- After: `opscontext install-autostart && opscontext install-claude-hook`. Two commands, ever. Server auto-starts at login forever; terminal Claude Code sessions feed audit log automatically.
|
|
27
|
+
|
|
28
|
+
### Architecture notes
|
|
29
|
+
- Hook namespace stays `vscode.*` (not `claude_code.*`) so the published 2.1.0 detector heuristics fire today without a parallel namespace migration. `payload.surface = "claude-code"` disambiguates source for any caller that cares.
|
|
30
|
+
- LaunchAgent uses `gui/$UID` domain (per-user, no root) — same pattern as the user's existing `com.invocme.backup-*` plists.
|
|
31
|
+
- Hook deliberately emits on PostToolUse ONLY (not PreToolUse) — emitting both would double-count for the `stuck` heuristic and skew `silent_failure` counts.
|
|
32
|
+
- All transport via HTTP `POST /events` not direct file writes — keeps event writes serialized through the running MCP server's single in-process chain cache, sidestepping the historic concurrent-write race (8 chain breaks on 2026-06-10/11, all `system` actor, all pre-flag-day; zero breaks since).
|
|
33
|
+
|
|
34
|
+
### Known surface gaps still open
|
|
35
|
+
- `Stop` hook event not emitted (would enable "assistant gave up mid-task" detection — Phase 3.1).
|
|
36
|
+
- No Linux support yet for `install-autostart` (systemd --user unit equivalent is ~30 min of work).
|
|
37
|
+
- No tool-result exit codes from VS Code extension yet (candidate for vscode-ext 0.10).
|
|
38
|
+
|
|
39
|
+
## [2.1.0] — 2026-06-23 — Phase 1: cross-surface capture + drift detector + local event ingest
|
|
40
|
+
|
|
41
|
+
The first feature release after the OpsContext rebrand. Closes the wedge the audit identified: **no other tool captures AI interactions across browser + IDE + terminal and feeds them into a tamper-evident audit log with policy enforcement**. Now we do.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- **`src/http-server.ts`** (LOCK `[HTTP-EVENT-INGEST]`) — local event-ingest HTTP endpoint at `http://127.0.0.1:7842`. The browser extension and the VS Code extension POST batched events here; the MCP server validates and appends them to the existing hash-chained audit log via `safeAppend()`.
|
|
45
|
+
- `POST /events` — schema-validated batched events (max 50 events, 64 KB body). Event-kind allowlist: `^(browser|vscode|cli)\.` — system kinds like `learning.save` can only come from the local writer, never the network. Auth via shared 32-byte hex secret at `~/.contextengine/extension-secret` (mode 0600), compared in constant time.
|
|
46
|
+
- `GET /health` — unauthenticated liveness probe.
|
|
47
|
+
- Bound to `127.0.0.1` ONLY — never `0.0.0.0`. LAN devices cannot inject audit events.
|
|
48
|
+
- Hot-reload of the secret on every request — `init-extension-secret --force` rotates without restarting MCP.
|
|
49
|
+
- Started automatically at MCP boot. Gracefully degrades on port conflict (`OPSCONTEXT_EVENT_PORT=<n>` to override).
|
|
50
|
+
- **`src/detector.ts`** (LOCK `[DRIFT-HEURISTICS]`) — 8-heuristic drift / loop / fabrication detector.
|
|
51
|
+
- **loop** (warn): same prompt sent 3+ times in 5 min (Jaccard > 0.6 token overlap)
|
|
52
|
+
- **stuck** (warn): identical tool call 3+ times in 5 min
|
|
53
|
+
- **context_bloat** (warn): session > 80K tokens with no `session.save` event
|
|
54
|
+
- **fabrication_suspect** (critical): assistant response cites `file.ext:NN` that doesn't exist on disk
|
|
55
|
+
- **drift** (info): per-session, last 3 prompts have joint Jaccard < 0.10 against the session's first prompt
|
|
56
|
+
- **no_insight** (info): 30+ tool calls since the last `learning.save`
|
|
57
|
+
- **silent_failure** (critical): same tool returns error 3+ times in 5 min
|
|
58
|
+
- **stale_doc_signal**: stubbed (Phase 3.1; reads `policy.json` `doc_coverage`)
|
|
59
|
+
- `watchAuditLog()` uses `fs.watch` + 250 ms debounce + in-memory LRU dedupe (100 entries) so the same signal doesn't fire every poll cycle.
|
|
60
|
+
- Auto-emits `drift.detected` audit records for each fired signal — alerting itself is auditable.
|
|
61
|
+
- **`contextengine watch`** CLI — streams alerts as they fire. Supports `--json` (NDJSON for log aggregators), `--severity info|warn|critical` (floor filter), `--once` (single-scan exit, code 2 if any critical signal — usable in CI), `--window SECONDS`.
|
|
62
|
+
- **`contextengine init-extension-secret`** CLI — generates a 32-byte hex secret at `~/.contextengine/extension-secret` (mode 0600). Refuses by default if one already exists (`--force` to rotate).
|
|
63
|
+
- **`contextengine emit-event <kind> <payload-json> [--actor NAME]`** CLI — appends a single event to the audit log. Used by the VS Code extension `0.9.0` for `vscode.prompt_submit` and `vscode.tool_call` events. Also useful for custom integrations and scripted tests.
|
|
64
|
+
- **`drift_status` MCP tool** — agents can call this between major task phases to self-check active signals. Returns "pause and surface to the human" guidance if any critical signal is active.
|
|
65
|
+
|
|
66
|
+
### Audit-log event types added (additive — no breaking changes)
|
|
67
|
+
- `browser.prompt`, `browser.response`, `browser.tool_call`, `browser.session_start`, `browser.session_end`, `browser.capture_miss`
|
|
68
|
+
- `vscode.prompt_submit`, `vscode.tool_call`, `vscode.session_start`
|
|
69
|
+
- `drift.detected`, `notification.fired`
|
|
70
|
+
|
|
71
|
+
### Tests
|
|
72
|
+
- **14 new tests** in `tests/detector.test.ts` with 11 hand-written NDJSON fixtures in `tests/__fixtures__/audit-logs/`. One catalog test per heuristic + its negative ("similar prompts" fires loop; "different prompts" doesn't). Plus 2 integration tests on `detect()` and evidence cap.
|
|
73
|
+
- **196 / 196 tests passing total** (was 182).
|
|
74
|
+
|
|
75
|
+
### Companion release
|
|
76
|
+
- **`@compr/opscontext-chrome@0.1.0`** — new Chrome extension scaffold under `chrome-extension/`. Captures Claude.ai + ChatGPT prompts/responses/tool-calls, streams them via the new `POST /events` endpoint. Not yet on the Chrome Web Store; loadable unpacked via `chrome://extensions` → Developer mode → "Load unpacked" → pick `chrome-extension/dist/`. BSL-1.1 license; selector seeds attributed to MIT prior art in `chrome-extension/LICENSE_THIRD_PARTY.md`.
|
|
77
|
+
- **`css-llc.contextengine@0.9.0`** — VS Code extension companion release that emits `vscode.prompt_submit` and `vscode.tool_call` events into the audit log via the new `emit-event` CLI.
|
|
78
|
+
|
|
79
|
+
### Day-1 test plan
|
|
80
|
+
[`docs/test-plans/PHASE1_DAY1.md`](docs/test-plans/PHASE1_DAY1.md) — 10-step, ~15-minute end-to-end verification. Starts with `init-extension-secret`, ends with deliberate `fabrication_suspect` + `silent_failure` triggers verifying `watch` exits code 2.
|
|
81
|
+
|
|
5
82
|
## [2.0.2] — 2026-06-11 — HTML score report browser tab title → OpsContext
|
|
6
83
|
|
|
7
84
|
Tiny patch release. One change:
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# OpsContext — Claude Code hook emitter
|
|
3
|
+
#
|
|
4
|
+
# 🔒 LOCKED [OPSCONTEXT-CC-HOOK] — 2026-06-23
|
|
5
|
+
# ⛔ NEVER block on success or fail loudly. Claude Code waits for hooks to
|
|
6
|
+
# complete before continuing — any error must exit 0 + silent.
|
|
7
|
+
# ⛔ NEVER emit on PreToolUse. PostToolUse alone — PreToolUse would double-
|
|
8
|
+
# count vs PostToolUse for the `stuck` heuristic and skew `silent_failure`.
|
|
9
|
+
# ⛔ NEVER print to stdout (would be interpreted as a hook decision message).
|
|
10
|
+
# WHY: This hook is the ONLY way Claude Code terminal sessions get into the
|
|
11
|
+
# OpsContext audit log. If it's slow or breaks, the user disables it and
|
|
12
|
+
# loses cross-surface drift visibility — the entire wedge collapses.
|
|
13
|
+
# FIX: To support a new Claude Code hook event, add a case branch. Keep the
|
|
14
|
+
# exit-0-on-any-error discipline. Events go via HTTP (NOT direct file
|
|
15
|
+
# write) so the running MCP server's in-process chain cache prevents the
|
|
16
|
+
# concurrent-write race.
|
|
17
|
+
|
|
18
|
+
set +e
|
|
19
|
+
|
|
20
|
+
EVENT_KIND="${1:-}"
|
|
21
|
+
SECRET_FILE="$HOME/.contextengine/extension-secret"
|
|
22
|
+
ENDPOINT="${OPSCONTEXT_EVENT_URL:-http://127.0.0.1:7842/events}"
|
|
23
|
+
|
|
24
|
+
# Bail fast if not initialized — never block Claude Code
|
|
25
|
+
[ -r "$SECRET_FILE" ] || exit 0
|
|
26
|
+
SECRET=$(cat "$SECRET_FILE" 2>/dev/null)
|
|
27
|
+
[ -n "$SECRET" ] || exit 0
|
|
28
|
+
|
|
29
|
+
INPUT=$(cat)
|
|
30
|
+
[ -n "$INPUT" ] || exit 0
|
|
31
|
+
|
|
32
|
+
NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
|
|
33
|
+
|
|
34
|
+
case "$EVENT_KIND" in
|
|
35
|
+
UserPromptSubmit)
|
|
36
|
+
PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
|
|
37
|
+
v: 1, ts: $ts, event: "vscode.prompt_submit", actor: "claude-code",
|
|
38
|
+
payload: {
|
|
39
|
+
surface: "claude-code",
|
|
40
|
+
text: ((.prompt // "")[:4000]),
|
|
41
|
+
session: (.session_id // ""),
|
|
42
|
+
cwd: (.cwd // ""),
|
|
43
|
+
char_count: ((.prompt // "") | length)
|
|
44
|
+
}
|
|
45
|
+
}' 2>/dev/null)
|
|
46
|
+
;;
|
|
47
|
+
PostToolUse)
|
|
48
|
+
PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
|
|
49
|
+
v: 1, ts: $ts, event: "vscode.tool_call", actor: "claude-code",
|
|
50
|
+
payload: ({
|
|
51
|
+
surface: "claude-code",
|
|
52
|
+
tool: (.tool_name // ""),
|
|
53
|
+
args_preview: (
|
|
54
|
+
(.tool_input.command
|
|
55
|
+
// .tool_input.file_path
|
|
56
|
+
// .tool_input.pattern
|
|
57
|
+
// (.tool_input | tostring)
|
|
58
|
+
// ""
|
|
59
|
+
)[:200]
|
|
60
|
+
),
|
|
61
|
+
session: (.session_id // ""),
|
|
62
|
+
cwd: (.cwd // "")
|
|
63
|
+
} + (
|
|
64
|
+
if (.tool_response.is_error == true)
|
|
65
|
+
or ((.tool_response.error // "") != "")
|
|
66
|
+
or ((.tool_response.interrupt // false) == true)
|
|
67
|
+
then { error: ((.tool_response.error
|
|
68
|
+
// (.tool_response.content | tostring)
|
|
69
|
+
// "tool reported error")[:500]) }
|
|
70
|
+
else {}
|
|
71
|
+
end
|
|
72
|
+
))
|
|
73
|
+
}' 2>/dev/null)
|
|
74
|
+
;;
|
|
75
|
+
SessionStart)
|
|
76
|
+
PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
|
|
77
|
+
v: 1, ts: $ts, event: "vscode.session_start", actor: "claude-code",
|
|
78
|
+
payload: {
|
|
79
|
+
surface: "claude-code",
|
|
80
|
+
session: (.session_id // ""),
|
|
81
|
+
cwd: (.cwd // ""),
|
|
82
|
+
source: (.source // "")
|
|
83
|
+
}
|
|
84
|
+
}' 2>/dev/null)
|
|
85
|
+
;;
|
|
86
|
+
*)
|
|
87
|
+
exit 0
|
|
88
|
+
;;
|
|
89
|
+
esac
|
|
90
|
+
|
|
91
|
+
[ -n "$PAYLOAD" ] || exit 0
|
|
92
|
+
|
|
93
|
+
# POST with 1s hard timeout. Any error → silent (curl >/dev/null 2>&1, exit 0).
|
|
94
|
+
curl -sS --max-time 1.0 \
|
|
95
|
+
-H "Content-Type: application/json" \
|
|
96
|
+
-H "X-OpsContext-Secret: $SECRET" \
|
|
97
|
+
--data "{\"events\":[$PAYLOAD]}" \
|
|
98
|
+
"$ENDPOINT" >/dev/null 2>&1
|
|
99
|
+
|
|
100
|
+
exit 0
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
package/dist/cli.js
CHANGED
|
@@ -1212,6 +1212,193 @@ tamper-evident audit log at ~/.contextengine/audit.log.`);
|
|
|
1212
1212
|
console.error(`Unknown hook subcommand: ${sub}. Try 'contextengine hook --help'.`);
|
|
1213
1213
|
process.exit(1);
|
|
1214
1214
|
}
|
|
1215
|
+
async function cliEmitEvent(args) {
|
|
1216
|
+
const { safeAppend } = await import("./audit.js");
|
|
1217
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
1218
|
+
if (help || args.length < 2) {
|
|
1219
|
+
console.log(`Usage: contextengine emit-event <event-kind> <payload-json> [--actor NAME]
|
|
1220
|
+
|
|
1221
|
+
Appends a single event to the hash-chained audit log. Useful for VS Code
|
|
1222
|
+
extensions, custom integrations, or scripted test scenarios.
|
|
1223
|
+
|
|
1224
|
+
event-kind One of: browser.* / vscode.* / cli.* / learning.* / etc.
|
|
1225
|
+
payload-json A JSON object describing the event. Will be validated as
|
|
1226
|
+
a Record<string, unknown>.
|
|
1227
|
+
--actor NAME Override the actor field. Defaults to 'cli'.
|
|
1228
|
+
|
|
1229
|
+
Examples:
|
|
1230
|
+
contextengine emit-event vscode.tool_call '{"tool":"Edit","args_preview":"file=src/x.ts"}'
|
|
1231
|
+
contextengine emit-event browser.prompt '{"surface":"claude.ai","text":"hello","char_count":5}' --actor browser-ext
|
|
1232
|
+
|
|
1233
|
+
The event becomes a regular audit-chain record (prev_hash + hash added by
|
|
1234
|
+
safeAppend), visible via 'contextengine audit-verify' and consumed by the
|
|
1235
|
+
'contextengine watch' detector + 'drift_status' MCP tool.`);
|
|
1236
|
+
process.exit(help ? 0 : 1);
|
|
1237
|
+
}
|
|
1238
|
+
const eventKind = args[0];
|
|
1239
|
+
const payloadJson = args[1];
|
|
1240
|
+
let actor = "cli";
|
|
1241
|
+
for (let i = 2; i < args.length; i++) {
|
|
1242
|
+
if (args[i] === "--actor" && args[i + 1])
|
|
1243
|
+
actor = args[++i];
|
|
1244
|
+
}
|
|
1245
|
+
let payload;
|
|
1246
|
+
try {
|
|
1247
|
+
const parsed = JSON.parse(payloadJson);
|
|
1248
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1249
|
+
throw new Error("payload must be a JSON object (not array, not primitive)");
|
|
1250
|
+
}
|
|
1251
|
+
payload = parsed;
|
|
1252
|
+
}
|
|
1253
|
+
catch (e) {
|
|
1254
|
+
console.error(`Bad payload JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
1255
|
+
process.exit(1);
|
|
1256
|
+
}
|
|
1257
|
+
// Cast — the audit module accepts any string for the event field; the
|
|
1258
|
+
// AuditEvent union is documentation, not enforcement. Validation of
|
|
1259
|
+
// "what's a valid event kind" is the caller's responsibility (the HTTP
|
|
1260
|
+
// server enforces a prefix allow-list; this CLI is trusted).
|
|
1261
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1262
|
+
safeAppend(eventKind, payload, actor);
|
|
1263
|
+
console.log(`✅ Appended ${eventKind} to audit log.`);
|
|
1264
|
+
}
|
|
1265
|
+
async function cliWatch(args) {
|
|
1266
|
+
const { watchAuditLog, detect } = await import("./detector.js");
|
|
1267
|
+
let jsonMode = false;
|
|
1268
|
+
let once = false;
|
|
1269
|
+
let minSeverity = "info";
|
|
1270
|
+
let windowSeconds = 300;
|
|
1271
|
+
for (let i = 0; i < args.length; i++) {
|
|
1272
|
+
const a = args[i];
|
|
1273
|
+
if (a === "--json")
|
|
1274
|
+
jsonMode = true;
|
|
1275
|
+
else if (a === "--once")
|
|
1276
|
+
once = true;
|
|
1277
|
+
else if (a === "--severity" && args[i + 1]) {
|
|
1278
|
+
const sev = args[++i];
|
|
1279
|
+
if (sev !== "info" && sev !== "warn" && sev !== "critical") {
|
|
1280
|
+
console.error(`Unknown severity: ${sev}. Try info|warn|critical.`);
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
minSeverity = sev;
|
|
1284
|
+
}
|
|
1285
|
+
else if (a === "--window" && args[i + 1]) {
|
|
1286
|
+
windowSeconds = parseInt(args[++i], 10) || 300;
|
|
1287
|
+
}
|
|
1288
|
+
else if (a === "-h" || a === "--help") {
|
|
1289
|
+
console.log(`Usage: contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
|
|
1290
|
+
|
|
1291
|
+
Streams drift / hallucination / loop / stuck-tool / context-bloat alerts as
|
|
1292
|
+
they're detected in ~/.contextengine/audit.log.
|
|
1293
|
+
|
|
1294
|
+
--json One line of NDJSON per alert (for log aggregators / jq).
|
|
1295
|
+
--severity X Floor filter. Default: info (everything).
|
|
1296
|
+
--once Run a single scan over the recent window and exit.
|
|
1297
|
+
Good for cron / health checks.
|
|
1298
|
+
--window SECONDS How far back to scan in --once mode. Default: 300.
|
|
1299
|
+
|
|
1300
|
+
Exit codes:
|
|
1301
|
+
0 clean (or --once found no critical signals)
|
|
1302
|
+
2 --once found at least one critical signal — useful in CI pipelines
|
|
1303
|
+
|
|
1304
|
+
Signals also append a 'drift.detected' record to the audit log (hash-chained)
|
|
1305
|
+
so the alerting itself is auditable.
|
|
1306
|
+
|
|
1307
|
+
Status-bar / OS-notification integration is via the VS Code extension —
|
|
1308
|
+
this CLI is for terminal users, CI, and cron.`);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const sevOrder = { info: 0, warn: 1, critical: 2 };
|
|
1313
|
+
const passesFilter = (s) => sevOrder[s.severity] >= sevOrder[minSeverity];
|
|
1314
|
+
const fmt = (s) => {
|
|
1315
|
+
if (jsonMode) {
|
|
1316
|
+
return JSON.stringify({
|
|
1317
|
+
ts: new Date(s.detectedAt).toISOString(),
|
|
1318
|
+
kind: s.kind,
|
|
1319
|
+
severity: s.severity,
|
|
1320
|
+
reason: s.reason,
|
|
1321
|
+
payload: s.payload,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
const sev = s.severity === "critical" ? "CRIT " : s.severity === "warn" ? "WARN " : "INFO ";
|
|
1325
|
+
const t = new Date(s.detectedAt).toISOString().slice(11, 19);
|
|
1326
|
+
return `[${t}] ${sev} ${s.kind.padEnd(20)} ${s.reason}`;
|
|
1327
|
+
};
|
|
1328
|
+
if (once) {
|
|
1329
|
+
const signals = detect({ windowSeconds }).filter(passesFilter);
|
|
1330
|
+
for (const s of signals) {
|
|
1331
|
+
console.log(fmt(s));
|
|
1332
|
+
}
|
|
1333
|
+
const hasCritical = signals.some((s) => s.severity === "critical");
|
|
1334
|
+
process.exit(hasCritical ? 2 : 0);
|
|
1335
|
+
}
|
|
1336
|
+
if (!jsonMode) {
|
|
1337
|
+
console.error("[opscontext watch] streaming drift signals (Ctrl-C to exit)…");
|
|
1338
|
+
}
|
|
1339
|
+
const dispose = watchAuditLog((s) => {
|
|
1340
|
+
if (passesFilter(s))
|
|
1341
|
+
console.log(fmt(s));
|
|
1342
|
+
}, { windowSeconds });
|
|
1343
|
+
process.on("SIGINT", () => {
|
|
1344
|
+
dispose();
|
|
1345
|
+
if (!jsonMode)
|
|
1346
|
+
console.error("\n[opscontext watch] stopped.");
|
|
1347
|
+
process.exit(0);
|
|
1348
|
+
});
|
|
1349
|
+
// Keep alive — the watcher uses internal timers, but a stdin listener also
|
|
1350
|
+
// helps catch terminal closes.
|
|
1351
|
+
process.stdin.resume();
|
|
1352
|
+
}
|
|
1353
|
+
async function cliInitExtensionSecret(args) {
|
|
1354
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
1355
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
1356
|
+
if (help) {
|
|
1357
|
+
console.log(`Usage: contextengine init-extension-secret [--force]
|
|
1358
|
+
|
|
1359
|
+
Generates a 32-byte hex token at ~/.contextengine/extension-secret (mode 0600)
|
|
1360
|
+
and prints it to stdout. The OpsContext browser extension reads the same
|
|
1361
|
+
token from its options page; both sides must match for events to flow.
|
|
1362
|
+
|
|
1363
|
+
--force, -f Overwrite an existing secret. Default is to refuse if one
|
|
1364
|
+
already exists (prevents accidental rotation that would
|
|
1365
|
+
disconnect the extension until it's re-pasted).
|
|
1366
|
+
|
|
1367
|
+
After running, paste the printed value into the extension's Options page
|
|
1368
|
+
(Cmd+Shift+P → "Open extension options" in Chrome, or click the extension
|
|
1369
|
+
icon → Options).`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const { randomBytes } = await import("crypto");
|
|
1373
|
+
const { writeFileSync, existsSync, chmodSync, mkdirSync } = await import("fs");
|
|
1374
|
+
const { join } = await import("path");
|
|
1375
|
+
const { homedir } = await import("os");
|
|
1376
|
+
const dir = join(homedir(), ".contextengine");
|
|
1377
|
+
const path = join(dir, "extension-secret");
|
|
1378
|
+
if (existsSync(path) && !force) {
|
|
1379
|
+
console.error(`❌ ${path} already exists. Re-running would invalidate any extension that already has the old value pasted in.\n` +
|
|
1380
|
+
`\n` +
|
|
1381
|
+
` Pass --force to rotate (you'll need to re-paste the new value in the extension's Options page).\n` +
|
|
1382
|
+
` Or read the current secret with: cat ${path}`);
|
|
1383
|
+
process.exit(1);
|
|
1384
|
+
}
|
|
1385
|
+
mkdirSync(dir, { recursive: true });
|
|
1386
|
+
const secret = randomBytes(32).toString("hex");
|
|
1387
|
+
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
|
1388
|
+
try {
|
|
1389
|
+
chmodSync(path, 0o600);
|
|
1390
|
+
}
|
|
1391
|
+
catch { /* best-effort */ }
|
|
1392
|
+
console.log(`✅ Wrote ${path} (mode 600)\n`);
|
|
1393
|
+
console.log(`Secret (paste this into the browser extension's Options page):\n`);
|
|
1394
|
+
console.log(` ${secret}\n`);
|
|
1395
|
+
console.log(`Next steps:`);
|
|
1396
|
+
console.log(` 1. Open Chrome → chrome://extensions → Find "OpsContext Browser Capture"`);
|
|
1397
|
+
console.log(` 2. Click "Options" → paste the secret → Save`);
|
|
1398
|
+
console.log(` 3. Visit https://claude.ai or https://chatgpt.com — events will flow.`);
|
|
1399
|
+
console.log(`\nThe MCP server's event-ingest endpoint is at http://127.0.0.1:7842/events`);
|
|
1400
|
+
console.log(`(GET /health to verify it's running).`);
|
|
1401
|
+
}
|
|
1215
1402
|
async function cliPolicy(args) {
|
|
1216
1403
|
const sub = args[0];
|
|
1217
1404
|
if (!sub || sub === "-h" || sub === "--help") {
|
|
@@ -1595,6 +1782,17 @@ Usage:
|
|
|
1595
1782
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
1596
1783
|
contextengine policy <validate|show> [args]
|
|
1597
1784
|
Author + validate the declarative .contextengine/policy.json
|
|
1785
|
+
contextengine init-extension-secret [--force]
|
|
1786
|
+
Generate ~/.contextengine/extension-secret for the browser ext
|
|
1787
|
+
contextengine install-autostart [--force]
|
|
1788
|
+
Install macOS LaunchAgent so MCP server auto-starts at login
|
|
1789
|
+
(uninstall-autostart / autostart-status — companion commands)
|
|
1790
|
+
contextengine install-claude-hook Wire Claude Code terminal sessions into the OpsContext audit log
|
|
1791
|
+
(UserPromptSubmit + PostToolUse + SessionStart hook entries)
|
|
1792
|
+
contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
|
|
1793
|
+
Stream drift / loop / stuck-tool / fabrication alerts from the audit log
|
|
1794
|
+
contextengine emit-event <kind> <payload-json> [--actor NAME]
|
|
1795
|
+
Append a single event to the audit log (for integrations / scripted tests)
|
|
1598
1796
|
contextengine hook <secret-scan|doc-coverage>
|
|
1599
1797
|
Run policy-driven pre-commit checks against staged diff
|
|
1600
1798
|
(exit 1 on blocking violation; CE_JSON=1 for CI output)
|
|
@@ -1793,6 +1991,54 @@ else if (command === "activate") {
|
|
|
1793
1991
|
process.exit(1);
|
|
1794
1992
|
});
|
|
1795
1993
|
}
|
|
1994
|
+
else if (command === "emit-event") {
|
|
1995
|
+
cliEmitEvent(process.argv.slice(3)).catch((err) => {
|
|
1996
|
+
console.error("Error:", err);
|
|
1997
|
+
process.exit(1);
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
else if (command === "watch") {
|
|
2001
|
+
cliWatch(process.argv.slice(3)).catch((err) => {
|
|
2002
|
+
console.error("Error:", err);
|
|
2003
|
+
process.exit(1);
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
else if (command === "init-extension-secret") {
|
|
2007
|
+
cliInitExtensionSecret(process.argv.slice(3)).catch((err) => {
|
|
2008
|
+
console.error("Error:", err);
|
|
2009
|
+
process.exit(1);
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
else if (command === "install-autostart") {
|
|
2013
|
+
import("./install-autostart.js").then((m) => m.cliInstallAutostart(process.argv.slice(3))).catch((err) => {
|
|
2014
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
2015
|
+
process.exit(1);
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
else if (command === "uninstall-autostart") {
|
|
2019
|
+
import("./install-autostart.js").then((m) => m.cliUninstallAutostart(process.argv.slice(3))).catch((err) => {
|
|
2020
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
2021
|
+
process.exit(1);
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
else if (command === "autostart-status") {
|
|
2025
|
+
import("./install-autostart.js").then((m) => m.cliAutostartStatus(process.argv.slice(3))).catch((err) => {
|
|
2026
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
2027
|
+
process.exit(1);
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
else if (command === "install-claude-hook") {
|
|
2031
|
+
import("./install-claude-hook.js").then((m) => m.cliInstallClaudeHook(process.argv.slice(3))).catch((err) => {
|
|
2032
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
2033
|
+
process.exit(1);
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
else if (command === "uninstall-claude-hook") {
|
|
2037
|
+
import("./install-claude-hook.js").then((m) => m.cliUninstallClaudeHook(process.argv.slice(3))).catch((err) => {
|
|
2038
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
2039
|
+
process.exit(1);
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
1796
2042
|
else if (command === "stats") {
|
|
1797
2043
|
cliStats();
|
|
1798
2044
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type AuditRecord, type AuditEvent } from "./audit.js";
|
|
2
|
+
export type DriftKind = "loop" | "stuck" | "context_bloat" | "fabrication_suspect" | "drift" | "no_insight" | "stale_doc_signal" | "silent_failure";
|
|
3
|
+
export type Severity = "info" | "warn" | "critical";
|
|
4
|
+
export interface DriftSignal {
|
|
5
|
+
kind: DriftKind;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
reason: string;
|
|
8
|
+
evidence: AuditRecord[];
|
|
9
|
+
payload: Record<string, unknown>;
|
|
10
|
+
detectedAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DetectorOptions {
|
|
13
|
+
/** Window in seconds for event scan. Default 300 (5 min). */
|
|
14
|
+
windowSeconds?: number;
|
|
15
|
+
/** Inject a "now" for deterministic tests. */
|
|
16
|
+
now?: number;
|
|
17
|
+
/** Inject the events instead of reading from disk (for tests). */
|
|
18
|
+
events?: AuditRecord[];
|
|
19
|
+
/** Project root for fabrication_suspect file-existence checks. */
|
|
20
|
+
cwd?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function scanRecentEvents(windowSeconds?: number, now?: number): AuditRecord[];
|
|
23
|
+
/** Tokenize text for cheap similarity comparisons (Jaccard / BM25-lite). */
|
|
24
|
+
export declare function tokens(s: string): Set<string>;
|
|
25
|
+
export declare function jaccard(a: Set<string>, b: Set<string>): number;
|
|
26
|
+
declare function detectLoop(events: AuditRecord[]): DriftSignal | null;
|
|
27
|
+
declare function detectStuck(events: AuditRecord[], now: number): DriftSignal | null;
|
|
28
|
+
declare function detectContextBloat(events: AuditRecord[]): DriftSignal | null;
|
|
29
|
+
declare function detectFabrication(events: AuditRecord[], cwd: string): DriftSignal | null;
|
|
30
|
+
declare function detectDrift(events: AuditRecord[]): DriftSignal | null;
|
|
31
|
+
declare function detectNoInsight(events: AuditRecord[]): DriftSignal | null;
|
|
32
|
+
declare function detectSilentFailure(events: AuditRecord[], now: number): DriftSignal | null;
|
|
33
|
+
declare function detectStaleDocSignal(_events: AuditRecord[]): DriftSignal | null;
|
|
34
|
+
export declare function runHeuristics(events: AuditRecord[], opts?: {
|
|
35
|
+
now?: number;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}): DriftSignal[];
|
|
38
|
+
/** Convenience for callers: scan recent events and run heuristics in one call. */
|
|
39
|
+
export declare function detect(opts?: DetectorOptions): DriftSignal[];
|
|
40
|
+
/**
|
|
41
|
+
* Watch the audit log and fire `onAlert` for each new signal. Dedupe key is
|
|
42
|
+
* `kind:reason` kept in an in-memory LRU bounded at 100 entries — prevents
|
|
43
|
+
* the same drift from firing every poll cycle.
|
|
44
|
+
*
|
|
45
|
+
* Returns a dispose function. Caller is responsible for handling SIGINT
|
|
46
|
+
* cleanly.
|
|
47
|
+
*/
|
|
48
|
+
export declare function watchAuditLog(onAlert: (s: DriftSignal) => void, opts?: {
|
|
49
|
+
windowSeconds?: number;
|
|
50
|
+
debounceMs?: number;
|
|
51
|
+
emitAuditEvent?: boolean;
|
|
52
|
+
}): () => void;
|
|
53
|
+
export declare const _internal: {
|
|
54
|
+
detectLoop: typeof detectLoop;
|
|
55
|
+
detectStuck: typeof detectStuck;
|
|
56
|
+
detectContextBloat: typeof detectContextBloat;
|
|
57
|
+
detectFabrication: typeof detectFabrication;
|
|
58
|
+
detectDrift: typeof detectDrift;
|
|
59
|
+
detectNoInsight: typeof detectNoInsight;
|
|
60
|
+
detectSilentFailure: typeof detectSilentFailure;
|
|
61
|
+
detectStaleDocSignal: typeof detectStaleDocSignal;
|
|
62
|
+
};
|
|
63
|
+
export type { AuditRecord, AuditEvent };
|
|
64
|
+
//# sourceMappingURL=detector.d.ts.map
|