@compr/opscontext-mcp 2.5.7 → 2.5.9

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,35 @@ 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.5.9] — 2026-09-05 — The servers inventory themselves; growth is a tripwire too
8
+
9
+ The evening 2.5.7 shipped, two MCP servers that had started before the build kept the old
10
+ importer for two hours and re-imported 1,766 records the owner had just had deleted. `ps` found
11
+ them on the second look only. Nine more servers, one per open chat, were each re-embedding the
12
+ corpus after every doc change (load average 230). Nothing in the product could say any of this.
13
+
14
+ ### Added
15
+
16
+ - **Server registry** (`[SERVERS-ARE-INVENTORIED]`, `src/server-registry.ts`): every server writes
17
+ `~/.contextengine/servers/<pid>.json` at the top of `main()` with pid, parent process, start time,
18
+ version, a sha256 of the script it loaded and its cwd; heartbeats every 60 s; removes the record on
19
+ exit; emits `server.start` to the audit log. `contextengine servers` and the end-session checklist
20
+ (§ 3b) list live servers, drop dead records, flag **STALE BUILD** when the script on disk no longer
21
+ matches the hash a server loaded, and warn above 3 concurrent servers. Exit 1 on any warning.
22
+ - **Growth tripwire** (`[STORE-GROWTH-IS-A-TRIPWIRE-TOO]`): a write that adds more than 200 records
23
+ to the learnings store is refused (`learning.store_growth_refused`), the mirror of the shrink guard.
24
+ The auto-import reports `refused` and the server keeps running; `import_learnings` and the CLI
25
+ print the refusal. `CONTEXTENGINE_ALLOW_BULK=1` for one deliberate bulk import.
26
+
27
+ ## [2.5.8] — 2026-09-05 — "rules" is not a learnings heading
28
+
29
+ ### Fixed
30
+
31
+ - `LEARNINGS_HEADING` no longer matches the word "rules". "## Key rules" and "### Security Rules"
32
+ are ordinary doc sections; on the evening 2.5.7 shipped the word let 231 subsection headings back
33
+ into the store. "## Essential Rules, distilled from Learnings" still qualifies through "learnings".
34
+ Replayed from a foreign cwd with the default patterns over ~/Projects: 99 records from 819 sources.
35
+
7
36
  ## [2.5.7] — 2026-09-05 — The learnings store was a pile of headings
8
37
 
9
38
  Of 3,005 records, about 2,760 had been produced by the doc importer from ~160 ordinary docs
@@ -17,7 +46,7 @@ secrets" under mobile because "expose" contains "expo".
17
46
 
18
47
  - **Auto-import takes only marked learnings** (`[AUTO-IMPORT-ONLY-MARKED-LEARNINGS]`): inline
19
48
  `- [category] rule → context` bullets anywhere; every shape inside a `*LEARNINGS.md` file; every
20
- shape under a heading that says learnings / lessons / gotchas / pitfalls / rules / anti-patterns /
49
+ shape under a heading that says learnings / lessons / gotchas / pitfalls / anti-patterns /
21
50
  "never repeat" / "the hard way"; JSON. Bare H3 headings, bold bullets and table rows in ordinary
22
51
  docs are reported as `ignored` and left alone; the docs stay searchable as docs. Replayed over the
23
52
  818 discovered sources: 129 records instead of 1,879. `import_learnings` gains `permissive: true`
package/dist/audit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "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" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact";
1
+ export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.store_growth_refused" | "server.start" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "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" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact";
2
2
  export interface AuditRecord {
3
3
  ts: string;
4
4
  event: AuditEvent;
@@ -27,6 +27,7 @@ export const KNOWN_COMMANDS = [
27
27
  "audit-rotate",
28
28
  "audit-redact-ack",
29
29
  "audit-verify",
30
+ "servers",
30
31
  "autostart-status",
31
32
  "cost",
32
33
  "deactivate",
package/dist/cli.js CHANGED
@@ -655,6 +655,7 @@ import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationError
655
655
  import { buildCostReport } from "./cost-report.js";
656
656
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
657
657
  import { safeAppend } from "./audit.js";
658
+ import { listServers, formatServers } from "./server-registry.js";
658
659
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
659
660
  import { fileURLToPath } from "url";
660
661
  // ---------------------------------------------------------------------------
@@ -2208,6 +2209,10 @@ async function cliEndSession() {
2208
2209
  if (autoImport.imported > 0) {
2209
2210
  checks.push(`📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources\n`);
2210
2211
  }
2212
+ if (autoImport.refused) {
2213
+ checks.push(`- ⛔ Auto-import write refused: ${autoImport.refused}`);
2214
+ failCount++;
2215
+ }
2211
2216
  // --- Check 3: Learnings Store ---
2212
2217
  checks.push("## 3. Learnings Store\n");
2213
2218
  const stats = learningsStats();
@@ -2225,6 +2230,13 @@ async function cliEndSession() {
2225
2230
  passCount++;
2226
2231
  checks.push("");
2227
2232
  // --- Check 4: Sessions ---
2233
+ // --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
2234
+ checks.push("## 3b. Running servers\n");
2235
+ const fleet = listServers();
2236
+ checks.push("```\n" + formatServers(fleet) + "\n```");
2237
+ if (fleet.warnings.length > 0)
2238
+ failCount += fleet.warnings.length;
2239
+ checks.push("");
2228
2240
  checks.push("## 4. Sessions\n");
2229
2241
  const sessions = listSessions();
2230
2242
  if (sessions.length > 0) {
@@ -2290,7 +2302,14 @@ async function cliImportLearnings(args) {
2290
2302
  console.error(" --permissive: every H3 heading, bold bullet and table row too.");
2291
2303
  process.exit(1);
2292
2304
  }
2293
- const result = importLearningsFromFile(filePath, category, project, { permissive });
2305
+ let result;
2306
+ try {
2307
+ result = importLearningsFromFile(filePath, category, project, { permissive });
2308
+ }
2309
+ catch (e) {
2310
+ console.error(`⛔ Import refused: ${e?.message || e}`);
2311
+ process.exit(1);
2312
+ }
2294
2313
  console.log(`\n📥 Import Results:`);
2295
2314
  console.log(` Imported: ${result.imported}`);
2296
2315
  console.log(` Updated: ${result.updated}`);
@@ -2508,6 +2527,7 @@ Usage:
2508
2527
  Export hash-chained audit log (evidence aligned with
2509
2528
  SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
2510
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
2511
2531
  contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
2512
2532
  contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
2513
2533
  Move old history into an archive segment. Archives
@@ -2719,6 +2739,11 @@ else if (command === "audit-redact-ack") {
2719
2739
  else if (command === "audit-rotate") {
2720
2740
  cliAuditRotate(process.argv.slice(3));
2721
2741
  }
2742
+ else if (command === "servers") {
2743
+ const fleet = listServers();
2744
+ console.log(formatServers(fleet));
2745
+ process.exit(fleet.warnings.length > 0 ? 1 : 0);
2746
+ }
2722
2747
  else if (command === "audit-verify") {
2723
2748
  cliAuditVerify().catch((err) => {
2724
2749
  console.error("Error:", err);
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ import { collectProjectOps, collectSystemOps } from "./collectors.js";
10
10
  import { loadCache, saveCache } from "./cache.js";
11
11
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
12
12
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
13
- import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog } from "./audit.js";
13
+ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
14
+ import { registerServer, listServers, formatServers } from "./server-registry.js";
14
15
  import { startEventIngestServer } from "./http-server.js";
15
16
  import { detect } from "./detector.js";
16
17
  import { buildCostReport } from "./cost-report.js";
@@ -110,6 +111,9 @@ async function reindex() {
110
111
  if (autoImport.imported > 0) {
111
112
  console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
112
113
  }
114
+ if (autoImport.refused) {
115
+ console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
116
+ }
113
117
  // Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
114
118
  const learningChunks = learningsToChunks(activeProjectNames);
115
119
  if (learningChunks.length > 0) {
@@ -973,7 +977,13 @@ server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON f
973
977
  .optional()
974
978
  .describe("Import every heading, bold bullet and table row as a rule (the pre-2.5.7 behaviour). Default false: only marked learnings."),
975
979
  }, async ({ file_path, default_category, project, permissive }) => {
976
- const result = importLearningsFromFile(file_path, default_category || "other", project, { permissive: permissive === true });
980
+ let result;
981
+ try {
982
+ result = importLearningsFromFile(file_path, default_category || "other", project, { permissive: permissive === true });
983
+ }
984
+ catch (e) {
985
+ return respond("import_learnings", `⛔ Import refused: ${e?.message || e}`);
986
+ }
977
987
  // Re-inject learnings into search index (project-scoped)
978
988
  const newChunks = learningsToChunks(activeProjectNames);
979
989
  const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
@@ -1078,6 +1088,17 @@ function registerResources() {
1078
1088
  // Start
1079
1089
  // ---------------------------------------------------------------------------
1080
1090
  async function main() {
1091
+ // 0. Inventory this server FIRST, before indexing takes minutes: a server exists the moment it
1092
+ // starts. [LOCK] [SERVERS-ARE-INVENTORIED]
1093
+ try {
1094
+ const { record } = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url) });
1095
+ const fleet = listServers();
1096
+ console.error(`[ContextEngine] 🧭 ${formatServers(fleet)}`);
1097
+ safeAppend("server.start", { pid: record.pid, parent: record.parent, version: record.version, build: record.build, cwd: record.cwd, servers_running: fleet.servers.length, stale_builds: fleet.servers.filter((x) => x.staleBuild).length });
1098
+ }
1099
+ catch (err) {
1100
+ console.error("[ContextEngine] ⚠ Server registry failed:", err);
1101
+ }
1081
1102
  // 1. Ingest all sources (fast — keyword search available immediately)
1082
1103
  sources = loadSources();
1083
1104
  chunks = ingestSources(sources);
@@ -1126,6 +1147,9 @@ async function main() {
1126
1147
  if (autoImport.imported > 0) {
1127
1148
  console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
1128
1149
  }
1150
+ if (autoImport.refused) {
1151
+ console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
1152
+ }
1129
1153
  // 1e. Inject learnings into search index (project-scoped)
1130
1154
  const learningChunks = learningsToChunks(activeProjectNames);
1131
1155
  if (learningChunks.length > 0) {
@@ -26,6 +26,7 @@ export declare function withStoreLock<T>(fn: () => T): T;
26
26
  * loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
27
27
  */
28
28
  export declare function withStoreBatch<T>(fn: () => T): T;
29
+ export declare const MAX_GROWTH_PER_WRITE = 200;
29
30
  /** Test seam for the writer's tripwire; not part of the API. */
30
31
  export declare function __writeStoreForTests(store: LearningsStore): void;
31
32
  /**
@@ -120,6 +121,7 @@ export declare function autoImportFromSources(sources: Array<{
120
121
  imported: number;
121
122
  updated: number;
122
123
  ignored: number;
124
+ refused?: string;
123
125
  };
124
126
  /**
125
127
  * Get the store stats.
package/dist/learnings.js CHANGED
@@ -266,6 +266,19 @@ function dailyBackup() {
266
266
  }
267
267
  catch { /* a missing backup must never block a save */ }
268
268
  }
269
+ // [LOCKED] [STORE-GROWTH-IS-A-TRIPWIRE-TOO] 2026-09-05
270
+ // [NEVER] let one write add more than MAX_GROWTH_PER_WRITE records to the store without the
271
+ // explicit override, and never raise the limit to make an import "just work".
272
+ // WHY: the shrink guard below caught the wipe of 2026-09-05; the same evening two stale servers
273
+ // wrote 1,766 records in one minute and nothing objected, because only shrinking was
274
+ // guarded. Every legitimate write is small: an agent saves one rule, the strict auto-import
275
+ // of a whole workspace produced 99 (replayed 2026-09-05), the largest real learnings file
276
+ // a few dozen. Thousands in one write is a bug or old code, never a lesson.
277
+ // FIX: a write that grows the store by more than MAX_GROWTH_PER_WRITE over the file on disk
278
+ // is refused with an audit event, unless CONTEXTENGINE_ALLOW_BULK=1 (set knowingly, for
279
+ // one deliberate bulk import). The auto-import catches the refusal and reports it; the
280
+ // server keeps running.
281
+ export const MAX_GROWTH_PER_WRITE = 200;
269
282
  function writeStoreToDisk(store) {
270
283
  ensureDir();
271
284
  store.count = store.learnings.length;
@@ -284,6 +297,21 @@ function writeStoreToDisk(store) {
284
297
  throw new Error(`refusing to write ${store.learnings.length} learnings over a store of ${onDisk}: that is the shape of a wipe, not an edit. Set CONTEXTENGINE_ALLOW_SHRINK=1 if this is deliberate.`);
285
298
  }
286
299
  }
300
+ // Growth tripwire. [LOCK] [STORE-GROWTH-IS-A-TRIPWIRE-TOO]
301
+ if (existsSync(LEARNINGS_PATH) && process.env.CONTEXTENGINE_ALLOW_BULK !== "1") {
302
+ let onDisk = -1;
303
+ try {
304
+ onDisk = (JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8")).learnings || []).length;
305
+ }
306
+ catch {
307
+ onDisk = -1;
308
+ }
309
+ const growth = store.learnings.length - onDisk;
310
+ if (onDisk >= 0 && growth > MAX_GROWTH_PER_WRITE) {
311
+ safeAppend("learning.store_growth_refused", { on_disk: onDisk, attempted: store.learnings.length, growth });
312
+ throw new Error(`refusing to add ${growth} learnings in one write (store ${onDisk}, limit ${MAX_GROWTH_PER_WRITE}): that is the shape of a runaway import, not a lesson. Set CONTEXTENGINE_ALLOW_BULK=1 for one deliberate bulk import.`);
313
+ }
314
+ }
287
315
  dailyBackup();
288
316
  const tmp = `${LEARNINGS_PATH}.tmp-${process.pid}-${Date.now()}`;
289
317
  writeFileSync(tmp, JSON.stringify(store, null, 2));
@@ -501,13 +529,15 @@ function deleteLearningUnlocked(id) {
501
529
  // FIX: a candidate becomes a learning only when the author marked it as one:
502
530
  // (1) an inline-category bullet `- [category] rule → context`, anywhere;
503
531
  // (2) any shape inside a file whose name says learnings (AGENT-LEARNINGS.md, LEARNINGS.md);
504
- // (3) any shape under a heading that says learnings / lessons / gotchas / pitfalls / rules /
505
- // anti-patterns / "never repeat" / "the hard way" (LEARNINGS_HEADING);
532
+ // (3) any shape under a heading that says learnings / lessons / gotchas / pitfalls /
533
+ // anti-patterns / "never repeat" / "the hard way" / mistakes (LEARNINGS_HEADING).
534
+ // Not "rules": "## Key rules" and "### Security Rules" are ordinary doc sections, and
535
+ // the word let 231 subsection headings back in on the evening this shipped;
506
536
  // (4) JSON files, which are explicit by construction.
507
537
  // `permissive: true` (MCP `import_learnings`, CLI `--permissive`) restores the old parser for
508
538
  // a file the user chose on purpose. Every imported record now carries `source`.
509
539
  export const LEARNINGS_FILE_NAME = /learnings?\.md$/i;
510
- export const LEARNINGS_HEADING = /\b(learnings?|lessons?|gotchas?|pitfalls?|anti-?patterns?|never repeat|do not repeat|don'?t repeat|the hard way|hard way|mistakes?|rules?)\b/i;
540
+ export const LEARNINGS_HEADING = /\b(learnings?|lessons?|gotchas?|pitfalls?|anti-?patterns?|never repeat|do not repeat|don'?t repeat|the hard way|hard way|mistakes?)\b/i;
511
541
  export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject, opts = {}) {
512
542
  if (!existsSync(filePath)) {
513
543
  return { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [`File not found: ${filePath}`] };
@@ -973,27 +1003,38 @@ export function autoImportFromSources(sources) {
973
1003
  let totalUpdated = 0;
974
1004
  let totalIgnored = 0;
975
1005
  let processed = 0;
1006
+ let refused;
976
1007
  // One load and one save for the whole sweep (~880 files), instead of one full-file
977
1008
  // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
978
- withStoreBatch(() => {
979
- for (const source of sources) {
980
- // Only process markdown files
981
- if (!source.path.endsWith(".md"))
982
- continue;
983
- if (!existsSync(source.path))
984
- continue;
985
- // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
986
- const project = source.name.split(" — ")[0]?.trim() || undefined;
987
- // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
988
- const result = importLearningsFromFile(source.path, "other", project);
989
- totalImported += result.imported;
990
- totalUpdated += result.updated;
991
- totalIgnored += result.ignored;
992
- if (result.imported > 0 || result.updated > 0)
993
- processed++;
994
- }
995
- });
996
- return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored };
1009
+ try {
1010
+ withStoreBatch(() => {
1011
+ for (const source of sources) {
1012
+ // Only process markdown files
1013
+ if (!source.path.endsWith(".md"))
1014
+ continue;
1015
+ if (!existsSync(source.path))
1016
+ continue;
1017
+ // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
1018
+ const project = source.name.split(" — ")[0]?.trim() || undefined;
1019
+ // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
1020
+ const result = importLearningsFromFile(source.path, "other", project);
1021
+ totalImported += result.imported;
1022
+ totalUpdated += result.updated;
1023
+ totalIgnored += result.ignored;
1024
+ if (result.imported > 0 || result.updated > 0)
1025
+ processed++;
1026
+ }
1027
+ });
1028
+ }
1029
+ catch (e) {
1030
+ // A refused write (growth or shrink tripwire, lock timeout) must not take the server down;
1031
+ // it is reported to the caller and the store is left as it was. [LOCK] [STORE-GROWTH-IS-A-TRIPWIRE-TOO]
1032
+ refused = String(e?.message || e);
1033
+ totalImported = 0;
1034
+ totalUpdated = 0;
1035
+ processed = 0;
1036
+ }
1037
+ return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored, refused };
997
1038
  }
998
1039
  /**
999
1040
  * Get the store stats.
@@ -0,0 +1,40 @@
1
+ export interface ServerRecord {
2
+ pid: number;
3
+ ppid: number;
4
+ parent: string;
5
+ started: string;
6
+ heartbeat: string;
7
+ version: string;
8
+ script: string;
9
+ build: string;
10
+ cwd: string;
11
+ node: string;
12
+ }
13
+ export interface ServerReport {
14
+ servers: Array<ServerRecord & {
15
+ alive: true;
16
+ currentBuild: string | null;
17
+ staleBuild: boolean;
18
+ }>;
19
+ removed: number;
20
+ warnings: string[];
21
+ }
22
+ /** More concurrent servers than this and every doc change costs that many re-embeds. */
23
+ export declare const SERVER_COUNT_WARN = 3;
24
+ /** Short content hash of the script a server loaded; the build identity. */
25
+ export declare function buildHashOf(scriptPath: string): string | null;
26
+ export declare function isAlive(pid: number): boolean;
27
+ /**
28
+ * Register the running server. Returns a stop() that removes the record; exit handlers call it too.
29
+ */
30
+ export declare function registerServer(opts: {
31
+ version: string;
32
+ script: string;
33
+ }): {
34
+ record: ServerRecord;
35
+ stop: () => void;
36
+ };
37
+ /** Read every record, drop the dead ones, compare builds with the files on disk now. */
38
+ export declare function listServers(): ServerReport;
39
+ export declare function formatServers(report: ServerReport, home?: string): string;
40
+ //# sourceMappingURL=server-registry.d.ts.map
@@ -0,0 +1,155 @@
1
+ // [LOCKED] [SERVERS-ARE-INVENTORIED] 2026-09-05
2
+ // [NEVER] let an MCP server start without writing its registry record, or answer "which
3
+ // servers run and on which build?" from a `ps` grep instead of this registry.
4
+ // WHY: on 2026-09-05 two servers started before a build kept running the old importer for
5
+ // two hours and re-imported 1,766 records the owner had just had deleted. `ps` found them
6
+ // only on the second look: the first grep matched the absolute script path and the two
7
+ // had been started with a relative one. Nine other servers, one per open chat, were each
8
+ // re-embedding the whole corpus after every doc change (load average 230) and nothing
9
+ // said so. `server-meta.json` held one version: the last server to start.
10
+ // FIX: every server writes ~/.contextengine/servers/<pid>.json on start (pid, parent, start
11
+ // time, version, a hash of the script it loaded, cwd) and refreshes a heartbeat; the file
12
+ // goes on exit, and a lister removes records whose pid is dead. `contextengine servers`
13
+ // and the end-session checklist compare each record's build hash with the file on disk
14
+ // now, and warn when more than SERVER_COUNT_WARN servers run at once.
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
16
+ import { join } from "path";
17
+ import { homedir } from "os";
18
+ import { createHash } from "crypto";
19
+ import { execFileSync } from "child_process";
20
+ /** More concurrent servers than this and every doc change costs that many re-embeds. */
21
+ export const SERVER_COUNT_WARN = 3;
22
+ const HEARTBEAT_MS = 60_000;
23
+ function registryDir() {
24
+ return join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "servers");
25
+ }
26
+ /** Short content hash of the script a server loaded; the build identity. */
27
+ export function buildHashOf(scriptPath) {
28
+ try {
29
+ return createHash("sha256").update(readFileSync(scriptPath)).digest("hex").slice(0, 12);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function parentName(ppid) {
36
+ try {
37
+ // Hardcoded argv, no shell: the only variable is a number.
38
+ return execFileSync("ps", ["-o", "comm=", "-p", String(ppid)], { encoding: "utf8", timeout: 2000 })
39
+ .trim().split("/").pop() || "?";
40
+ }
41
+ catch {
42
+ return "?";
43
+ }
44
+ }
45
+ export function isAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch (e) {
51
+ return e?.code === "EPERM"; // exists, not ours
52
+ }
53
+ }
54
+ /**
55
+ * Register the running server. Returns a stop() that removes the record; exit handlers call it too.
56
+ */
57
+ export function registerServer(opts) {
58
+ const dir = registryDir();
59
+ mkdirSync(dir, { recursive: true });
60
+ const now = new Date().toISOString();
61
+ const record = {
62
+ pid: process.pid,
63
+ ppid: process.ppid,
64
+ parent: parentName(process.ppid),
65
+ started: now,
66
+ heartbeat: now,
67
+ version: opts.version,
68
+ script: opts.script,
69
+ build: buildHashOf(opts.script) || "unknown",
70
+ cwd: process.cwd(),
71
+ node: process.version,
72
+ };
73
+ const file = join(dir, `${process.pid}.json`);
74
+ const write = () => { try {
75
+ writeFileSync(file, JSON.stringify(record, null, 2));
76
+ }
77
+ catch { /* registry is diagnostics, never fatal */ } };
78
+ write();
79
+ const timer = setInterval(() => { record.heartbeat = new Date().toISOString(); write(); }, HEARTBEAT_MS);
80
+ timer.unref();
81
+ let stopped = false;
82
+ const stop = () => {
83
+ if (stopped)
84
+ return;
85
+ stopped = true;
86
+ clearInterval(timer);
87
+ try {
88
+ unlinkSync(file);
89
+ }
90
+ catch { /* already gone */ }
91
+ };
92
+ process.on("exit", stop);
93
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
94
+ process.on(sig, () => { stop(); process.exit(0); });
95
+ }
96
+ return { record, stop };
97
+ }
98
+ /** Read every record, drop the dead ones, compare builds with the files on disk now. */
99
+ export function listServers() {
100
+ const dir = registryDir();
101
+ const report = { servers: [], removed: 0, warnings: [] };
102
+ if (!existsSync(dir))
103
+ return report;
104
+ for (const f of readdirSync(dir)) {
105
+ if (!f.endsWith(".json"))
106
+ continue;
107
+ const path = join(dir, f);
108
+ let rec;
109
+ try {
110
+ rec = JSON.parse(readFileSync(path, "utf8"));
111
+ }
112
+ catch {
113
+ try {
114
+ unlinkSync(path);
115
+ }
116
+ catch { /* */ }
117
+ report.removed++;
118
+ continue;
119
+ }
120
+ if (!isAlive(rec.pid)) {
121
+ try {
122
+ unlinkSync(path);
123
+ }
124
+ catch { /* */ }
125
+ report.removed++;
126
+ continue;
127
+ }
128
+ const currentBuild = buildHashOf(rec.script);
129
+ const staleBuild = currentBuild !== null && rec.build !== "unknown" && currentBuild !== rec.build;
130
+ report.servers.push({ ...rec, alive: true, currentBuild, staleBuild });
131
+ }
132
+ report.servers.sort((a, b) => a.started.localeCompare(b.started));
133
+ const stale = report.servers.filter((s) => s.staleBuild);
134
+ if (stale.length > 0) {
135
+ report.warnings.push(`${stale.length} server(s) run a build older than the file on disk (pid ${stale.map((s) => s.pid).join(", ")}): restart them or they keep the old behaviour`);
136
+ }
137
+ if (report.servers.length > SERVER_COUNT_WARN) {
138
+ report.warnings.push(`${report.servers.length} servers run at once; every doc change makes each of them re-index and re-embed the corpus (${SERVER_COUNT_WARN} is the comfortable ceiling)`);
139
+ }
140
+ return report;
141
+ }
142
+ export function formatServers(report, home = homedir()) {
143
+ const short = (p) => p.startsWith(home) ? "~" + p.slice(home.length) : p;
144
+ const lines = [];
145
+ lines.push(`${report.servers.length} server(s) running${report.removed ? `, ${report.removed} dead record(s) removed` : ""}`);
146
+ for (const s of report.servers) {
147
+ const t = s.started.slice(11, 19) + "Z";
148
+ const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
149
+ lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag} parent ${s.parent} cwd ${short(s.cwd)}`);
150
+ }
151
+ for (const w of report.warnings)
152
+ lines.push(` ⚠ ${w}`);
153
+ return lines.join("\n");
154
+ }
155
+ //# sourceMappingURL=server-registry.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.7",
3
+ "version": "2.5.9",
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",