@compr/opscontext-mcp 2.6.1 → 2.7.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 CHANGED
@@ -4,6 +4,43 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.7.1] 2026-09-06: the autostart agent is the standing indexer; servers --cost
8
+
9
+ ### Changed
10
+
11
+ - **`install-autostart` writes a plist that makes the agent the standing indexer**
12
+ (`[AUTOSTART-IS-THE-STANDING-INDEXER]`): `ProcessType Standard` instead of `Background` (as
13
+ Background it got 2.5 s of CPU in 15 minutes under load and served nobody),
14
+ `CONTEXTENGINE_SHARED_INDEX=1`, and `CONTEXTENGINE_CONFIG` / `CONTEXTENGINE_WORKSPACES` passed
15
+ through from the installing shell so its corpus id equals the chats'. The Claude-memory skip is
16
+ no longer a default; it is passed through only when the installer itself ran with it. Re-run
17
+ `install-autostart --force` to pick this up.
18
+ - **`contextengine servers --cost`**: CPU time and resident memory per server and in total, the
19
+ number that was invisible on 2026-09-05 until someone ran `ps` by hand.
20
+ - **The session-gate wrapper prefers a global install** of the CLI (`npm root -g`) over the
21
+ directory the installer ran from, so an npx cache prune cannot break the Stop hook.
22
+
23
+ ## [2.7.0] 2026-09-06: the session gate ships in the package
24
+
25
+ ### Added
26
+
27
+ - **`contextengine session-gate`** (`[SESSION-SAVE-IS-A-GATE]`, `src/session-gate.ts`): the body of
28
+ a Claude Code Stop hook. A turn cannot end (exit 2, reason on stderr) while the repo's CE session
29
+ (newest `~/.contextengine/sessions/*.json` whose name starts with the repo's name) is older than
30
+ the last commit. The message names the session to save, the newest `docs/sessions/SESSION_*.md`,
31
+ and how many `src/` commits `.github/copilot-instructions.md` is behind. Passes outside git,
32
+ before the first commit, and when Claude Code reports `stop_hook_active`. Why: the owner had to
33
+ type "check and update session and ce docs" at the end of every session; a reminder is not a rule.
34
+ - **`install-claude-hook` wires the gate** as a fourth entry (`Stop`) at user scope, through
35
+ `~/.claude/hooks/opscontext-session-gate.sh` with absolute node and CLI paths. Idempotent;
36
+ `uninstall-claude-hook` removes it with the others.
37
+
38
+ ### Fixed
39
+
40
+ - **`install-claude-hook` crashed with "__dirname is not defined"** from the published package:
41
+ the module is ESM and looked up `defaults/` through a CommonJS global. It had never worked
42
+ outside the dev tree. Resolved through `import.meta.url` (`[M2-ESM-FILENAME-FIX]`).
43
+
7
44
  ## [2.6.1] 2026-09-06: readers never embed; a store record without tags no longer breaks search
8
45
 
9
46
  ### Fixed
package/README.md CHANGED
@@ -162,10 +162,12 @@ Companion commands: `uninstall-autostart`, `autostart-status`.
162
162
  **3c. Wire Claude Code terminal sessions**
163
163
 
164
164
  ```bash
165
- npx @compr/opscontext-mcp install-claude-hook
165
+ npm i -g @compr/opscontext-mcp && opscontext install-claude-hook
166
166
  ```
167
167
 
168
- Adds `UserPromptSubmit`, `PostToolUse`, and `SessionStart` hook entries to `~/.claude/settings.json` so every Claude Code prompt + tool call lands in the same audit log as the browser events.
168
+ (Prefer the global install here: the hook scripts keep absolute paths to the CLI, and an `npx` cache copy can be pruned.)
169
+
170
+ Adds `UserPromptSubmit`, `PostToolUse`, and `SessionStart` hook entries to `~/.claude/settings.json` so every Claude Code prompt + tool call lands in the same audit log as the browser events, plus a `Stop` entry: the **session gate** (2.7.0). A Claude Code turn cannot end while the repo's OpsContext session is older than the last commit; the agent is told which session to save, which session doc to update, and how far the agent docs are behind. No more "did you save the session?" at the end of a day. Details: `npx @compr/opscontext-mcp session-gate --help`.
169
171
 
170
172
  Verify:
171
173
  ```bash
@@ -525,7 +527,7 @@ Everything happens locally — search, scoring, learnings, sessions, embeddings.
525
527
  | Learnings (operational rules) | `~/.contextengine/learnings.json` | ❌ Never |
526
528
  | Sessions (decisions, progress) | `~/.contextengine/sessions/` | ❌ Never |
527
529
  | Session stats (value meter) | `~/.contextengine/session-stats.json` | ❌ Never |
528
- | Search index & embeddings | In-memory + `~/.contextengine/embedding-cache.json` | ❌ Never |
530
+ | Search index & embeddings | In-memory + `~/.contextengine/embeddings.bin` (vectors) and `~/.contextengine/index/` (shared index) | ❌ Never |
529
531
  | Git history & branches | Local `git` commands | ❌ Never |
530
532
  | Dependencies & package.json | Read locally | ❌ Never |
531
533
  | .env variable names | Read locally (values are never read) | ❌ Never |
@@ -28,6 +28,7 @@ export const KNOWN_COMMANDS = [
28
28
  "audit-redact-ack",
29
29
  "audit-verify",
30
30
  "servers",
31
+ "session-gate",
31
32
  "autostart-status",
32
33
  "cost",
33
34
  "deactivate",
package/dist/cli.js CHANGED
@@ -2527,7 +2527,7 @@ Usage:
2527
2527
  Export hash-chained audit log (evidence aligned with
2528
2528
  SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
2529
2529
  contextengine audit-verify Verify audit log chain integrity (tamper detection)
2530
- contextengine servers List running MCP servers, their build vs the file on disk
2530
+ contextengine servers [--cost] List running MCP servers, their build vs the file on disk, role; --cost adds CPU time and memory
2531
2531
  contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
2532
2532
  contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
2533
2533
  Move old history into an archive segment. Archives
@@ -2548,7 +2548,8 @@ Usage:
2548
2548
  contextengine install-autostart [--force]
2549
2549
  Install macOS LaunchAgent so MCP server auto-starts at login
2550
2550
  (uninstall-autostart / autostart-status — companion commands)
2551
- contextengine install-claude-hook Wire Claude Code terminal sessions into the OpsContext audit log
2551
+ contextengine install-claude-hook Wire Claude Code terminal sessions into the OpsContext audit log, plus the Stop session gate
2552
+ contextengine session-gate Claude Code Stop hook body: exit 2 while the repo's CE session is older than HEAD
2552
2553
  (UserPromptSubmit + PostToolUse + SessionStart hook entries)
2553
2554
  contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
2554
2555
  Stream drift / loop / stuck-tool / fabrication alerts from the audit log
@@ -2741,7 +2742,7 @@ else if (command === "audit-rotate") {
2741
2742
  }
2742
2743
  else if (command === "servers") {
2743
2744
  const fleet = listServers();
2744
- console.log(formatServers(fleet));
2745
+ console.log(formatServers(fleet, undefined, { cost: process.argv.includes("--cost") }));
2745
2746
  process.exit(fleet.warnings.length > 0 ? 1 : 0);
2746
2747
  }
2747
2748
  else if (command === "audit-verify") {
@@ -2814,6 +2815,12 @@ else if (command === "autostart-status") {
2814
2815
  process.exit(1);
2815
2816
  });
2816
2817
  }
2818
+ else if (command === "session-gate") {
2819
+ import("./session-gate.js").then((m) => m.cliSessionGate(process.argv.slice(3))).catch((err) => {
2820
+ console.error("Error:", err);
2821
+ process.exit(0); // a broken gate must never trap the user in a turn
2822
+ });
2823
+ }
2817
2824
  else if (command === "install-claude-hook") {
2818
2825
  import("./install-claude-hook.js").then((m) => m.cliInstallClaudeHook(process.argv.slice(3))).catch((err) => {
2819
2826
  console.error("Error:", err instanceof Error ? err.message : err);
package/dist/index.js CHANGED
@@ -1402,6 +1402,14 @@ async function main() {
1402
1402
  else
1403
1403
  startIndexPolling();
1404
1404
  startRolePolling();
1405
+ // 5b. A daemon (launchd, OPSCONTEXT_DAEMON=1) has stdin on /dev/null, so the stdio transport
1406
+ // closes at once; as a reader it holds no watchers and every poller is unref'd, and the loop
1407
+ // would drain and exit 0, which KeepAlive turns into a restart every 10 s. Hold the loop open.
1408
+ // [LOCK] [AUTOSTART-IS-THE-STANDING-INDEXER]
1409
+ if (process.env.OPSCONTEXT_DAEMON === "1") {
1410
+ setInterval(() => { }, 60_000);
1411
+ console.error("[ContextEngine] 🛡 Daemon mode: staying alive without an MCP client");
1412
+ }
1405
1413
  // 6. Boot the local HTTP event-ingest endpoint for the browser extension.
1406
1414
  // Local 127.0.0.1:7842 only; auth via shared secret at
1407
1415
  // ~/.contextengine/extension-secret (see init-extension-secret CLI).
@@ -1,3 +1,4 @@
1
+ export declare function buildPlist(nodePath: string, entryPath: string, nodeBinDir: string, env?: NodeJS.ProcessEnv): string;
1
2
  export declare function cliInstallAutostart(args: string[]): Promise<void>;
2
3
  export declare function cliUninstallAutostart(args: string[]): Promise<void>;
3
4
  export declare function cliAutostartStatus(args: string[]): Promise<void>;
@@ -87,7 +87,30 @@ function detectOpscontextEntry() {
87
87
  }
88
88
  return null;
89
89
  }
90
- function buildPlist(nodePath, entryPath, nodeBinDir) {
90
+ // [LOCKED] [AUTOSTART-IS-THE-STANDING-INDEXER] 2026-09-06
91
+ // [NEVER] run the agent as ProcessType Background, drop CONTEXTENGINE_SHARED_INDEX from its
92
+ // environment, or give it a corpus the chats do not have.
93
+ // WHY: measured 2026-09-05 (SESSION_26): as Background the agent got 2.5 s of CPU in 15 minutes
94
+ // under load, took 4.5 min from exec to main(), served no MCP client (stdin is /dev/null),
95
+ // lost the browser-event port to whichever chat started first, and, with the memory skip and
96
+ // no config, indexed a corpus no chat used. It burned CPU keeping a cache warm that never
97
+ // hit. As the standing indexer it is the oldest server at every login, so it wins the
98
+ // election, every chat opens onto a ready index, and it owns the port from boot.
99
+ // FIX: Standard priority; the shared-index flag; CONTEXTENGINE_CONFIG passed through from the
100
+ // installing shell so its corpus id equals the chats'; the memory skip only if the installer
101
+ // was itself run with it (an explicit choice, not a default).
102
+ export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
103
+ // OPSCONTEXT_DAEMON tells the server it has no MCP client on stdin and must stay alive on its
104
+ // own: as a reader it holds no file watchers, and every poller is unref'd, so without this the
105
+ // event loop drained and launchd restarted it every 10 s (found 2026-09-06, first real run).
106
+ const passthrough = [["CONTEXTENGINE_SHARED_INDEX", "1"], ["OPSCONTEXT_DAEMON", "1"]];
107
+ if (env.CONTEXTENGINE_CONFIG)
108
+ passthrough.push(["CONTEXTENGINE_CONFIG", env.CONTEXTENGINE_CONFIG]);
109
+ if (env.CONTEXTENGINE_WORKSPACES)
110
+ passthrough.push(["CONTEXTENGINE_WORKSPACES", env.CONTEXTENGINE_WORKSPACES]);
111
+ if (env.OPSCONTEXT_SKIP_CLAUDE_MEMORY === "1")
112
+ passthrough.push(["OPSCONTEXT_SKIP_CLAUDE_MEMORY", "1"]);
113
+ const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${v.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string>`).join("\n");
91
114
  return `<?xml version="1.0" encoding="UTF-8"?>
92
115
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93
116
  <plist version="1.0">
@@ -107,8 +130,7 @@ function buildPlist(nodePath, entryPath, nodeBinDir) {
107
130
  <string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
108
131
  <key>HOME</key>
109
132
  <string>${homedir()}</string>
110
- <key>OPSCONTEXT_SKIP_CLAUDE_MEMORY</key>
111
- <string>1</string>
133
+ ${extraEnv}
112
134
  </dict>
113
135
 
114
136
  <key>WorkingDirectory</key>
@@ -130,7 +152,7 @@ function buildPlist(nodePath, entryPath, nodeBinDir) {
130
152
  <string>${join(LOG_DIR, "mcp-stderr.log")}</string>
131
153
 
132
154
  <key>ProcessType</key>
133
- <string>Background</string>
155
+ <string>Standard</string>
134
156
  </dict>
135
157
  </plist>
136
158
  `;
@@ -15,12 +15,22 @@
15
15
  // FIX: To add a new hook event, extend EVENT_KINDS + the splice block.
16
16
  // Keep the "preserve existing" discipline in every code path.
17
17
  import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync } from "fs";
18
- import { join } from "path";
18
+ import { join, dirname } from "path";
19
19
  import { homedir } from "os";
20
+ import { fileURLToPath } from "url";
21
+ import { execSync } from "child_process";
22
+ // [LOCK] [M2-ESM-FILENAME-FIX]: the package is "type": "module", so a bare __dirname is a
23
+ // ReferenceError at runtime. Found 2026-09-06 while adding the Stop gate: the defaults/ lookup
24
+ // below used `__dirname_esm`, and a real run against a throwaway HOME died with
25
+ // "__dirname is not defined": the installer had never worked from the published package.
26
+ const __dirname_esm = dirname(fileURLToPath(import.meta.url));
20
27
  const CLAUDE_DIR = join(homedir(), ".claude");
21
28
  const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
22
29
  const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
23
30
  const HOOK_SCRIPT = join(HOOKS_DIR, "opscontext-emit.sh");
31
+ /** The Stop gate: a wrapper that runs `session-gate` with the node and CLI that installed it.
32
+ * [LOCK] [SESSION-SAVE-IS-A-GATE] (src/session-gate.ts) */
33
+ const GATE_SCRIPT = join(HOOKS_DIR, "opscontext-session-gate.sh");
24
34
  const EVENT_KINDS = ["UserPromptSubmit", "PostToolUse", "SessionStart"];
25
35
  function readSettings() {
26
36
  if (!existsSync(SETTINGS_FILE))
@@ -52,8 +62,8 @@ function bundledHookSource() {
52
62
  // when globally / locally installed via npm. Both follow the same relative
53
63
  // shape because npm copies defaults/ via the `files` whitelist.
54
64
  const candidates = [
55
- join(__dirname || "", "..", "defaults", "claude-code-hook.sh"),
56
- join(__dirname || "", "defaults", "claude-code-hook.sh"),
65
+ join(__dirname_esm, "..", "defaults", "claude-code-hook.sh"),
66
+ join(__dirname_esm, "defaults", "claude-code-hook.sh"),
57
67
  ];
58
68
  for (const c of candidates) {
59
69
  if (existsSync(c))
@@ -61,6 +71,17 @@ function bundledHookSource() {
61
71
  }
62
72
  return null;
63
73
  }
74
+ /** dist/cli.js of a global install, when there is one (same preference as install-autostart). */
75
+ function globalCliPath() {
76
+ try {
77
+ const root = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
78
+ const c = join(root, "@compr", "opscontext-mcp", "dist", "cli.js");
79
+ return existsSync(c) ? c : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
64
85
  export async function cliInstallClaudeHook(args) {
65
86
  const help = args.includes("-h") || args.includes("--help");
66
87
  if (help) {
@@ -73,10 +94,13 @@ Events emitted (all go through the local HTTP endpoint, never the network):
73
94
  • UserPromptSubmit → vscode.prompt_submit (feeds the loop heuristic)
74
95
  • PostToolUse → vscode.tool_call (feeds stuck + silent_failure)
75
96
  • SessionStart → vscode.session_start
97
+ • Stop → the session gate: a turn cannot end while the repo's CE session
98
+ is older than the last commit (contextengine session-gate --help)
76
99
 
77
100
  The installer:
78
101
  1. Copies the bundled hook script to ~/.claude/hooks/opscontext-emit.sh
79
- 2. Splices three entries into ~/.claude/settings.json under "hooks"
102
+ and writes ~/.claude/hooks/opscontext-session-gate.sh (node + this CLI, absolute paths)
103
+ 2. Splices four entries into ~/.claude/settings.json under "hooks"
80
104
  3. Preserves every existing hook entry (idempotent, safe to re-run)
81
105
 
82
106
  A timestamped backup is written next to settings.json before any change.
@@ -130,6 +154,20 @@ Run: opscontext install-autostart
130
154
  settings.hooks[kind].push(entry);
131
155
  added++;
132
156
  }
157
+ // Step 3: the Stop gate. Absolute node + CLI paths: hooks run without the user's shell PATH.
158
+ // Prefer the global install: an npx cache copy can be pruned and the hook would then exit 127.
159
+ const cliPath = globalCliPath() ?? join(__dirname_esm, "cli.js");
160
+ writeFileSync(GATE_SCRIPT, `#!/bin/sh\n# Generated by \`opscontext install-claude-hook\`: the CE session gate on Claude Code Stop.\n# Exit 2 = the turn may not end yet (reason on stderr). See: contextengine session-gate --help\nexec "${process.execPath}" "${cliPath}" session-gate\n`);
161
+ chmodSync(GATE_SCRIPT, 0o755);
162
+ settings.hooks.Stop ??= [];
163
+ if (hookAlreadyWired(settings.hooks.Stop, GATE_SCRIPT)) {
164
+ skipped++;
165
+ }
166
+ else {
167
+ settings.hooks.Stop.push({ hooks: [{ type: "command", command: GATE_SCRIPT, timeout: 15 }] });
168
+ added++;
169
+ }
170
+ console.log(`✅ Installed session gate: ${GATE_SCRIPT}`);
133
171
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
134
172
  console.log(`✅ ${added} hook entries added, ${skipped} already present.`);
135
173
  console.log(``);
@@ -159,11 +197,11 @@ manually if you want it gone. The audit log is NOT touched.`);
159
197
  if (backup)
160
198
  console.log(`✅ Backed up settings.json → ${backup}`);
161
199
  let removed = 0;
162
- for (const kind of EVENT_KINDS) {
200
+ for (const kind of [...EVENT_KINDS, "Stop"]) {
163
201
  const entries = settings.hooks[kind];
164
202
  if (!entries)
165
203
  continue;
166
- const filtered = entries.filter((e) => !e.hooks?.some((h) => h.command?.includes("opscontext-emit.sh")));
204
+ const filtered = entries.filter((e) => !e.hooks?.some((h) => h.command?.includes("opscontext-emit.sh") || h.command?.includes("opscontext-session-gate.sh")));
167
205
  removed += entries.length - filtered.length;
168
206
  if (filtered.length === 0) {
169
207
  delete settings.hooks[kind];
@@ -43,5 +43,17 @@ export declare function registerServer(opts: {
43
43
  };
44
44
  /** Read every record, drop the dead ones, compare builds with the files on disk now. */
45
45
  export declare function listServers(): ServerReport;
46
- export declare function formatServers(report: ServerReport, home?: string): string;
46
+ /** CPU seconds consumed and resident memory of a live pid, from ps (hardcoded argv, no shell). */
47
+ export declare function processCost(pid: number): {
48
+ cpuSeconds: number;
49
+ rssMb: number;
50
+ } | null;
51
+ /**
52
+ * The listing. With `cost`, each line also carries CPU time and memory, and the total: this is
53
+ * the number that was invisible on 2026-09-05 (9.3 CPU-hours across eleven servers) until
54
+ * someone ran ps by hand. [LOCK] [ONE-INDEXER-MANY-READERS]
55
+ */
56
+ export declare function formatServers(report: ServerReport, home?: string, opts?: {
57
+ cost?: boolean;
58
+ }): string;
47
59
  //# sourceMappingURL=server-registry.d.ts.map
@@ -145,16 +145,57 @@ export function listServers() {
145
145
  }
146
146
  return report;
147
147
  }
148
- export function formatServers(report, home = homedir()) {
148
+ /** CPU seconds consumed and resident memory of a live pid, from ps (hardcoded argv, no shell). */
149
+ export function processCost(pid) {
150
+ try {
151
+ const out = execFileSync("ps", ["-o", "time=,rss=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
152
+ const [time, rss] = out.split(/\s+/);
153
+ if (!time)
154
+ return null;
155
+ const parts = time.split(":").map(Number);
156
+ const cpuSeconds = parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + (parts[1] || 0);
157
+ return { cpuSeconds, rssMb: Math.round(Number(rss || 0) / 1024) };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ function fmtCpu(s) {
164
+ if (s >= 3600)
165
+ return `${(s / 3600).toFixed(1)} CPU-h`;
166
+ if (s >= 60)
167
+ return `${Math.round(s / 60)} CPU-min`;
168
+ return `${Math.round(s)} CPU-s`;
169
+ }
170
+ /**
171
+ * The listing. With `cost`, each line also carries CPU time and memory, and the total: this is
172
+ * the number that was invisible on 2026-09-05 (9.3 CPU-hours across eleven servers) until
173
+ * someone ran ps by hand. [LOCK] [ONE-INDEXER-MANY-READERS]
174
+ */
175
+ export function formatServers(report, home = homedir(), opts = {}) {
149
176
  const short = (p) => p.startsWith(home) ? "~" + p.slice(home.length) : p;
150
177
  const lines = [];
151
178
  lines.push(`${report.servers.length} server(s) running${report.removed ? `, ${report.removed} dead record(s) removed` : ""}`);
179
+ let cpuTotal = 0, rssTotal = 0;
152
180
  for (const s of report.servers) {
153
181
  const t = s.started.slice(11, 19) + "Z";
154
182
  const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
155
183
  const role = s.role ? ` ${s.role.padEnd(7)} corpus ${s.corpus ?? "?"}` : "";
156
- lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag}${role} parent ${s.parent} cwd ${short(s.cwd)}`);
184
+ let cost = "";
185
+ if (opts.cost) {
186
+ const c = processCost(s.pid);
187
+ if (c) {
188
+ cpuTotal += c.cpuSeconds;
189
+ rssTotal += c.rssMb;
190
+ cost = ` ${fmtCpu(c.cpuSeconds).padStart(11)} ${String(c.rssMb).padStart(5)} MB`;
191
+ }
192
+ else
193
+ cost = " (cost unknown)";
194
+ }
195
+ lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag}${role}${cost} parent ${s.parent} cwd ${short(s.cwd)}`);
157
196
  }
197
+ if (opts.cost && report.servers.length > 0)
198
+ lines.push(` total: ${fmtCpu(cpuTotal)}, ${rssTotal} MB resident, across ${report.servers.length} server(s)`);
158
199
  for (const w of report.warnings)
159
200
  lines.push(` ⚠ ${w}`);
160
201
  return lines.join("\n");
@@ -0,0 +1,32 @@
1
+ export interface GateInput {
2
+ /** The repository the turn ran in (CLAUDE_PROJECT_DIR, else cwd). */
3
+ repo: string;
4
+ /** Where CE keeps sessions; default ~/.contextengine/sessions. */
5
+ sessionsDir?: string;
6
+ /** Claude Code sets this when the turn is already continuing because of a Stop hook. */
7
+ stopHookActive?: boolean;
8
+ /** Tests: a session file to use instead of the lookup. */
9
+ sessionFile?: string;
10
+ }
11
+ export interface GateResult {
12
+ block: boolean;
13
+ reason: "loop_guard" | "not_git" | "no_commits" | "fresh" | "stale";
14
+ sessionName: string;
15
+ sessionTs: number;
16
+ commitTs: number;
17
+ /** Commits under src/ since copilot-instructions.md last changed; null when unknown. */
18
+ docsBehind: number | null;
19
+ /** Newest docs/sessions/SESSION_*.md, relative to the repo; null when none. */
20
+ sessionDoc: string | null;
21
+ message: string;
22
+ }
23
+ export declare function normalizeName(s: string): string;
24
+ /** The session for a repo: the newest file whose normalized name starts with the repo's. */
25
+ export declare function findRepoSession(repo: string, sessionsDir: string): {
26
+ name: string;
27
+ ts: number;
28
+ };
29
+ export declare function evaluateSessionGate(input: GateInput): GateResult;
30
+ /** `contextengine session-gate`: the Stop hook body. Exit 2 blocks the turn end, 0 lets it end. */
31
+ export declare function cliSessionGate(args: string[]): Promise<never>;
32
+ //# sourceMappingURL=session-gate.d.ts.map
@@ -0,0 +1,167 @@
1
+ // [LOCKED] [SESSION-SAVE-IS-A-GATE] 2026-09-06 (moved here from scripts/session-gate.sh, 2.7.0)
2
+ // [NEVER] turn this back into a reminder (exit 0 with a message), gate it on a wall clock, or
3
+ // make it block outside a git repo.
4
+ // WHY: the owner had to type "check and update session and ce docs!" at the end of every session
5
+ // ("why do I have to write this systematically?", 2026-09-06). The firewall nag lives inside
6
+ // MCP tool responses and the post-push hook only reminds; on 2026-09-05 the MCP server was
7
+ // disconnected for hours and the work went through the CLI, so nothing fired. A rule that
8
+ // lives in prose is not a rule; a Stop hook that refuses is. The first version was a shell
9
+ // script copied into 33 repos, which no paying user could get; now it ships in the package
10
+ // and `install-claude-hook` wires it once, at user scope, for every repo the user opens.
11
+ // FIX: compare the repo's CE session file (newest session whose normalized name starts with the
12
+ // repo's normalized basename) with the last commit's time. Older = block (exit 2) with the
13
+ // session-doc and copilot-instructions staleness in the same message, so the docs get the
14
+ // same pass. `stop_hook_active` from Claude Code means "you are already continuing because
15
+ // of me": never block twice. Not a git repo, or no commit yet: nothing to gate, pass.
16
+ import { existsSync, readdirSync, statSync } from "fs";
17
+ import { basename, join } from "path";
18
+ import { homedir } from "os";
19
+ import { execFileSync } from "child_process";
20
+ export function normalizeName(s) {
21
+ return s.toLowerCase().replace(/[^a-z0-9]/g, "");
22
+ }
23
+ function mtime(p) {
24
+ try {
25
+ return Math.floor(statSync(p).mtimeMs / 1000);
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ function git(repo, args) {
32
+ try {
33
+ // Hardcoded argv, no shell; the only variable parts are paths and a commit hash git itself printed.
34
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** The session for a repo: the newest file whose normalized name starts with the repo's. */
41
+ export function findRepoSession(repo, sessionsDir) {
42
+ const name = basename(repo);
43
+ const want = normalizeName(name);
44
+ let best = { name, ts: 0 };
45
+ let files = [];
46
+ try {
47
+ files = readdirSync(sessionsDir);
48
+ }
49
+ catch {
50
+ return best;
51
+ }
52
+ for (const f of files) {
53
+ if (!f.endsWith(".json"))
54
+ continue;
55
+ const base = f.slice(0, -5);
56
+ if (!normalizeName(base).startsWith(want))
57
+ continue;
58
+ const ts = mtime(join(sessionsDir, f));
59
+ if (ts > best.ts)
60
+ best = { name: base, ts };
61
+ }
62
+ return best;
63
+ }
64
+ function newestSessionDoc(repo) {
65
+ const dir = join(repo, "docs", "sessions");
66
+ let files = [];
67
+ try {
68
+ files = readdirSync(dir).filter((f) => /^SESSION_.*\.md$/.test(f));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ let best = null;
74
+ for (const f of files) {
75
+ const ts = mtime(join(dir, f));
76
+ if (!best || ts > best.ts)
77
+ best = { f, ts };
78
+ }
79
+ return best ? join("docs", "sessions", best.f) : null;
80
+ }
81
+ function docsBehind(repo) {
82
+ const ci = ".github/copilot-instructions.md";
83
+ if (!existsSync(join(repo, ci)))
84
+ return null;
85
+ const last = git(repo, ["log", "-1", "--format=%H", "--", ci]);
86
+ if (!last)
87
+ return null;
88
+ const n = git(repo, ["rev-list", "--count", `${last}..HEAD`, "--", "src"]);
89
+ return n === null ? null : Number(n) || 0;
90
+ }
91
+ function fmt(ts) {
92
+ return ts === 0 ? "never saved" : new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ") + "Z";
93
+ }
94
+ export function evaluateSessionGate(input) {
95
+ const repo = input.repo;
96
+ const name = basename(repo);
97
+ const sessionsDir = input.sessionsDir ?? join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "sessions");
98
+ const base = { sessionName: name, sessionTs: 0, commitTs: 0, docsBehind: null, sessionDoc: null };
99
+ if (input.stopHookActive)
100
+ return { ...base, block: false, reason: "loop_guard", message: "" };
101
+ if (!git(repo, ["rev-parse", "--git-dir"]))
102
+ return { ...base, block: false, reason: "not_git", message: "" };
103
+ const commitRaw = git(repo, ["log", "-1", "--format=%ct"]);
104
+ const commitTs = commitRaw ? Number(commitRaw) || 0 : 0;
105
+ if (commitTs === 0)
106
+ return { ...base, block: false, reason: "no_commits", message: "" };
107
+ let session;
108
+ if (input.sessionFile)
109
+ session = { name: name, ts: existsSync(input.sessionFile) ? mtime(input.sessionFile) : 0 };
110
+ else
111
+ session = findRepoSession(repo, sessionsDir);
112
+ const doc = newestSessionDoc(repo);
113
+ const behind = docsBehind(repo);
114
+ const filled = { ...base, sessionName: session.name, sessionTs: session.ts, commitTs, docsBehind: behind, sessionDoc: doc };
115
+ if (session.ts >= commitTs)
116
+ return { ...filled, block: false, reason: "fresh", message: "" };
117
+ const lines = [
118
+ `CE session gate: session '${session.name}' (${fmt(session.ts)}) is older than the last commit (${fmt(commitTs)}). Before ending:`,
119
+ `1. save_session (MCP) session='${session.name}' keys summary and open, with what changed since the last save;`,
120
+ `2. update ${doc ?? "docs/sessions/SESSION_N.md"} if the work changed and commit it;`,
121
+ behind === null
122
+ ? `3. no .github/copilot-instructions.md here; if the project has agent docs, update them the same way;`
123
+ : `3. .github/copilot-instructions.md is ${behind} src commit(s) behind; update it if any of them changed how the project works;`,
124
+ `4. save_learning for any reusable lesson. Then end the turn; this gate passes once the session file is newer than HEAD.`,
125
+ ];
126
+ return { ...filled, block: true, reason: "stale", message: lines.join("\n") };
127
+ }
128
+ /** Read Claude Code's hook payload from stdin without ever blocking on a TTY. */
129
+ async function readStdinJson() {
130
+ if (process.stdin.isTTY)
131
+ return {};
132
+ return new Promise((resolve) => {
133
+ let buf = "";
134
+ const done = () => { try {
135
+ resolve(buf.trim() ? JSON.parse(buf) : {});
136
+ }
137
+ catch {
138
+ resolve({});
139
+ } };
140
+ process.stdin.setEncoding("utf8");
141
+ process.stdin.on("data", (d) => { buf += d; });
142
+ process.stdin.on("end", done);
143
+ process.stdin.on("error", done);
144
+ setTimeout(done, 1500).unref();
145
+ });
146
+ }
147
+ /** `contextengine session-gate`: the Stop hook body. Exit 2 blocks the turn end, 0 lets it end. */
148
+ export async function cliSessionGate(args) {
149
+ if (args.includes("-h") || args.includes("--help")) {
150
+ console.log(`Usage: contextengine session-gate (as a Claude Code Stop hook; see install-claude-hook)
151
+
152
+ Refuses to end a Claude Code turn (exit 2, reason on stderr) while the repo's CE session is
153
+ older than the last commit. The repo is CLAUDE_PROJECT_DIR or the cwd; the session is the newest
154
+ ~/.contextengine/sessions/*.json whose name starts with the repo's name. Passes (exit 0) when the
155
+ session is newer, outside a git repo, before the first commit, or when stop_hook_active is set.`);
156
+ process.exit(0);
157
+ }
158
+ const payload = await readStdinJson();
159
+ const repo = process.env.CLAUDE_PROJECT_DIR || process.cwd();
160
+ const r = evaluateSessionGate({ repo, stopHookActive: payload.stop_hook_active === true });
161
+ if (r.block) {
162
+ console.error(r.message);
163
+ process.exit(2);
164
+ }
165
+ process.exit(0);
166
+ }
167
+ //# sourceMappingURL=session-gate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -120,6 +120,7 @@ Add to your OpenClaw `openclaw.json` MCP servers section:
120
120
  | `list_projects` | Discover and analyze all projects (tech stack, git, docker, pm2) |
121
121
  | `check_ports` | Scan all projects for port conflicts |
122
122
  | `run_audit` | Compliance agent — git remotes, hooks, .env, Docker, PM2, versions |
123
+ | CLI `session-gate` | Claude Code Stop hook (installed by `install-claude-hook`): a turn cannot end while the repo's CE session is older than the last commit |
123
124
  | `score_project` | AI-readiness scoring 0-100% with anti-gaming v2 (symlink/ghost config detection) |
124
125
  | `save_session` | Save key-value entry to a named session for cross-session persistence |
125
126
  | `load_session` | Load all entries from a named session |