@compr/opscontext-mcp 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -495,10 +495,23 @@ Everything happens locally — search, scoring, learnings, sessions, embeddings.
495
495
  |---|---|---|
496
496
  | License key (`CE-XXXX-...`) | Activation + daily heartbeat | Validate subscription |
497
497
  | Machine ID (SHA-256 hash) | Activation + daily heartbeat | Enforce machine limit |
498
+ | Email | Activation only | Tie the licence to an account |
499
+ | Package version | Activation only | Serve a compatible module bundle |
498
500
  | Platform/arch (e.g., `darwin/arm64`) | Activation only | Compatibility check |
501
+ | Delta bundle version | Daily heartbeat | Detect an out-of-date module cache |
502
+
503
+ That is the complete list. The activation request sends exactly six fields and the heartbeat exactly three — enforced by a lock comment in `src/activation.ts` that forbids adding a seventh field reflecting usage.
499
504
 
500
505
  **The server never receives:** project names, file contents, learnings, sessions, git history, dependencies, code, .env variables, or anything about your actual work.
501
506
 
507
+ **These are the only two network calls the tool makes.** `activate` and `heartbeat`, both in `src/activation.ts`. Nothing else in the codebase opens a connection — verify it yourself with `grep -rn "fetch(" src/`.
508
+
509
+ ### What's obfuscated, and what isn't
510
+
511
+ One file in the published package is deliberately unreadable: `dist/rubric.js`, which holds the scoring thresholds (what earns which points). Those values are commercial IP under [BSL-1.1](LICENSE), and knowing them exactly makes an AI-readiness score easy to game by padding files to hit a number rather than doing the work.
512
+
513
+ **What that hides: values. What it does not hide: behaviour.** No code path, network call, file access, or data flow is concealed anywhere in this package. The scoring logic itself, every collector, the search ranker, and both network calls above ship as readable JavaScript — and the full source is public at [FASTPROD/ContextEngine](https://github.com/FASTPROD/ContextEngine). If a privacy claim on this page were false, the code that broke it would be right there to find.
514
+
502
515
  ### Why this matters
503
516
 
504
517
  Most AI coding tools (Copilot, Cursor, Codeium) send your code to external servers for processing. ContextEngine takes the opposite approach — **embeddings run locally on CPU**, search runs locally, and all persistent state stays in `~/.contextengine/` on your disk. The only network call is a lightweight license check for PRO users.
@@ -21,9 +21,27 @@ export declare function activate(licenseKey: string, email: string): Promise<{
21
21
  * Check if delta modules are installed and valid.
22
22
  */
23
23
  export declare function isDeltaInstalled(): boolean;
24
+ /**
25
+ * Version of the delta bundle currently cached on disk, or null if none/unreadable.
26
+ * Exported so callers can report the mismatch rather than guess at it.
27
+ */
28
+ export declare function installedDeltaVersion(): string | null;
24
29
  /**
25
30
  * Dynamically import a delta module.
26
- * Returns null if not activated or module not found.
31
+ * Returns null if not activated, module missing, or the cached delta is stale.
32
+ *
33
+ * 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
34
+ * ⛔ NEVER import a delta module without checking its manifest version against this package.
35
+ * WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
36
+ * the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
37
+ * two months and three sessions of scorer fixes out of date. Because this function imported
38
+ * whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
39
+ * inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
40
+ * catch this — a stale delta carries its own stale canary and its own stale pins, so it
41
+ * passes against itself.
42
+ * FIX: refuse to load a delta whose version does not match the running package, and say so on
43
+ * stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
44
+ * applied to code delivery rather than to a check result.
27
45
  */
28
46
  export declare function loadDeltaModule(name: string): Promise<any | null>;
29
47
  export declare function heartbeat(): Promise<boolean>;
@@ -29,8 +29,9 @@
29
29
  * 4. Premium tools become available
30
30
  */
31
31
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
32
- import { join } from "path";
32
+ import { join, dirname } from "path";
33
33
  import { homedir } from "os";
34
+ import { fileURLToPath } from "url";
34
35
  import { createHash, createDecipheriv } from "crypto";
35
36
  import { safeAppend } from "./audit.js";
36
37
  import { verifyLicenseSignature } from "./license-sig.js";
@@ -38,6 +39,20 @@ import { verifyLicenseSignature } from "./license-sig.js";
38
39
  // Constants
39
40
  // ---------------------------------------------------------------------------
40
41
  const DELTA_DIR = join(homedir(), ".contextengine", "delta");
42
+ /**
43
+ * Version of the running package. Read from package.json at module load, the same way
44
+ * agents.ts does it, so [DELTA-VERSION-PIN] compares against the real installed version
45
+ * rather than a constant someone forgets to bump.
46
+ */
47
+ const PACKAGE_VERSION = (() => {
48
+ try {
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "unknown";
51
+ }
52
+ catch {
53
+ return "unknown";
54
+ }
55
+ })();
41
56
  const LICENSE_FILE = join(homedir(), ".contextengine", "license.json");
42
57
  const ACTIVATION_API_BASE = process.env.CONTEXTENGINE_API || "https://api.compr.ch/contextengine";
43
58
  const ACTIVATION_API = `${ACTIVATION_API_BASE}/activate`;
@@ -252,13 +267,45 @@ export function isDeltaInstalled() {
252
267
  return false;
253
268
  }
254
269
  }
270
+ /**
271
+ * Version of the delta bundle currently cached on disk, or null if none/unreadable.
272
+ * Exported so callers can report the mismatch rather than guess at it.
273
+ */
274
+ export function installedDeltaVersion() {
275
+ try {
276
+ const manifest = JSON.parse(readFileSync(join(DELTA_DIR, "manifest.json"), "utf-8"));
277
+ return typeof manifest.version === "string" ? manifest.version : null;
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
255
283
  /**
256
284
  * Dynamically import a delta module.
257
- * Returns null if not activated or module not found.
285
+ * Returns null if not activated, module missing, or the cached delta is stale.
286
+ *
287
+ * 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
288
+ * ⛔ NEVER import a delta module without checking its manifest version against this package.
289
+ * WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
290
+ * the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
291
+ * two months and three sessions of scorer fixes out of date. Because this function imported
292
+ * whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
293
+ * inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
294
+ * catch this — a stale delta carries its own stale canary and its own stale pins, so it
295
+ * passes against itself.
296
+ * FIX: refuse to load a delta whose version does not match the running package, and say so on
297
+ * stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
298
+ * applied to code delivery rather than to a check result.
258
299
  */
259
300
  export async function loadDeltaModule(name) {
260
301
  if (!isDeltaInstalled())
261
302
  return null;
303
+ const cached = installedDeltaVersion();
304
+ if (cached !== PACKAGE_VERSION) {
305
+ console.error(`[ContextEngine] ⚠ Delta module "${name}" is version ${cached ?? "unknown"} but this package is ` +
306
+ `${PACKAGE_VERSION} — refusing to load a stale module. Re-run \`contextengine activate\` to refresh.`);
307
+ return null;
308
+ }
262
309
  const modulePath = join(DELTA_DIR, `${name}.mjs`);
263
310
  if (!existsSync(modulePath))
264
311
  return null;
package/dist/agents.js CHANGED
@@ -86,6 +86,36 @@ const AGENT_DOC_TOPICS = [
86
86
  function matchDocTopics(content, topics) {
87
87
  return topics.filter(t => t.patterns.test(content)).map(t => t.label);
88
88
  }
89
+ function detectLanguage(p) {
90
+ if (existsSync(join(p, "package.json")))
91
+ return "js";
92
+ const pythonMarkers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "__manifest__.py"];
93
+ if (pythonMarkers.some(m => existsSync(join(p, m))))
94
+ return "python";
95
+ if (existsSync(join(p, "composer.json")))
96
+ return "php";
97
+ // Odoo addons and src-layout packages keep their markers one level down.
98
+ for (const sub of safeSubdirs(p)) {
99
+ if (pythonMarkers.some(m => existsSync(join(p, sub, m))))
100
+ return "python";
101
+ if (existsSync(join(p, sub, "package.json")))
102
+ return "js";
103
+ }
104
+ return "other";
105
+ }
106
+ /** Immediate subdirectories worth searching — skips vendored, hidden and build output. */
107
+ function safeSubdirs(p) {
108
+ const SKIP = new Set(["node_modules", "vendor", "dist", "build", ".git", "__pycache__", "venv", ".venv", "coverage", "_deprecated"]);
109
+ try {
110
+ return readdirSync(p, { withFileTypes: true })
111
+ .filter(d => d.isDirectory() && !d.name.startsWith(".") && !SKIP.has(d.name))
112
+ .map(d => d.name)
113
+ .slice(0, 24); // bounded — this runs for every project on every fleet scan
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ }
89
119
  /** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
90
120
  function readlinkSafe(filePath) {
91
121
  try {
@@ -1081,6 +1111,8 @@ export function runScoreCanary() {
1081
1111
  export function scoreProject(dir) {
1082
1112
  const checks = [];
1083
1113
  const p = dir.path;
1114
+ // Language decides which tooling checks apply at all — see [SCORE-LANGUAGE-AWARE].
1115
+ const lang = detectLanguage(p);
1084
1116
  // --- Documentation (30 points max) ---
1085
1117
  // copilot-instructions.md (6 pts) — scored on CONTENT, not length.
1086
1118
  // 🔒 LOCKED [SCORE-CONTENT-NOT-LENGTH] — 2026-08-14
@@ -1384,7 +1416,15 @@ export function scoreProject(dir) {
1384
1416
  }
1385
1417
  // --- Code Quality (20 points max) ---
1386
1418
  // Tests directory (8 pts) — checks for real test files, detects symlinks
1387
- const testDirs = ["tests", "test", "__tests__", "spec", "src/__tests__"];
1419
+ // Search the repo root first, then one level down — Odoo addons, src-layout packages and
1420
+ // single-package monorepos keep tests at `<module>/tests/`. Reporting "No test directory" for
1421
+ // a project with 15 passing tests is [ABSENCE-IS-NOT-A-VERDICT] applied to a path assumption;
1422
+ // it is the same defect as [DOC-PATH-DUAL], one directory deeper.
1423
+ const testDirNames = ["tests", "test", "__tests__", "spec", "src/__tests__"];
1424
+ const testDirs = [
1425
+ ...testDirNames,
1426
+ ...safeSubdirs(p).flatMap(sub => testDirNames.map(td => `${sub}/${td}`)),
1427
+ ];
1388
1428
  const foundTests = testDirs.filter(td => existsSync(join(p, td)));
1389
1429
  if (foundTests.length > 0) {
1390
1430
  const testDirPath = join(p, foundTests[0]);
@@ -1437,6 +1477,21 @@ export function scoreProject(dir) {
1437
1477
  else if (existsSync(join(p, "jsconfig.json"))) {
1438
1478
  checks.push({ name: "Type checking", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "jsconfig.json only" });
1439
1479
  }
1480
+ else if (lang === "python") {
1481
+ // See [SCORE-LANGUAGE-AWARE]. Python type checking is mypy/pyright, not tsconfig.
1482
+ const pyTypeMarkers = ["mypy.ini", ".mypy.ini", "pyrightconfig.json"];
1483
+ const foundPyType = pyTypeMarkers.filter(m => existsSync(join(p, m)));
1484
+ const pyproject = existsSync(join(p, "pyproject.toml")) ? readFileSync(join(p, "pyproject.toml"), "utf-8") : "";
1485
+ if (foundPyType.length > 0 || /\[tool\.(mypy|pyright)\]/.test(pyproject)) {
1486
+ checks.push({ name: "Type checking", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: `${foundPyType[0] ?? "pyproject.toml"} — static type checking configured` });
1487
+ }
1488
+ else {
1489
+ checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: "Python project with no mypy/pyright config — add one for static type checking" });
1490
+ }
1491
+ }
1492
+ else if (lang === "php" || lang === "other") {
1493
+ checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "unknown", detail: `❔ No type-checking convention known for this project type (${lang}) — not assessed` });
1494
+ }
1440
1495
  else {
1441
1496
  checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: "No tsconfig/jsconfig" });
1442
1497
  }
@@ -1456,8 +1511,22 @@ export function scoreProject(dir) {
1456
1511
  checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: foundLint.join(", ") });
1457
1512
  }
1458
1513
  }
1514
+ else if (lang === "python") {
1515
+ // See [SCORE-LANGUAGE-AWARE]. Python linting is ruff/flake8/pylint, not eslint.
1516
+ const pyLint = ["ruff.toml", ".ruff.toml", ".flake8", ".pylintrc", "tox.ini", "setup.cfg"].filter(l => existsSync(join(p, l)));
1517
+ const pyproject = existsSync(join(p, "pyproject.toml")) ? readFileSync(join(p, "pyproject.toml"), "utf-8") : "";
1518
+ if (pyLint.length > 0 || /\[tool\.(ruff|flake8|pylint|black)\]/.test(pyproject)) {
1519
+ checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: pyLint[0] ?? "pyproject.toml" });
1520
+ }
1521
+ else {
1522
+ checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "Python project with no ruff/flake8/pylint config — add one" });
1523
+ }
1524
+ }
1525
+ else if (lang === "other") {
1526
+ checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "unknown", detail: "❔ No linting convention known for this project type — not assessed" });
1527
+ }
1459
1528
  else {
1460
- checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "No lint config" });
1529
+ checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "No lint config (looked for eslint/prettier/phpcs at repo root)" });
1461
1530
  }
1462
1531
  // Package scripts / build commands (3 pts)
1463
1532
  const pkgPath = join(p, "package.json");
@@ -1722,7 +1791,7 @@ export function generateProjectScoreMD(score) {
1722
1791
  lines.push(`- ❔ **${u.name}**: ${u.detail}`);
1723
1792
  }
1724
1793
  }
1725
- lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
1794
+ lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/opscontext-mcp) on ${date}*\n`);
1726
1795
  return lines.join("\n");
1727
1796
  }
1728
1797
  /**
@@ -1917,7 +1986,7 @@ export function generateScoreHTML(scores) {
1917
1986
  ${projectCards}
1918
1987
 
1919
1988
  <div class="footer">
1920
- <p>ContextEngine · <a href="https://www.npmjs.com/package/@compr/contextengine-mcp" style="color:var(--accent)">npm</a></p>
1989
+ <p>ContextEngine · <a href="https://www.npmjs.com/package/@compr/opscontext-mcp" style="color:var(--accent)">npm</a></p>
1921
1990
  <p style="margin-top:4px">Scoring: Documentation (30pts) · Infrastructure (30pts) · Code Quality (20pts) · Security (20pts)</p>
1922
1991
  </div>
1923
1992
  </body>
package/dist/cli.js CHANGED
@@ -217,7 +217,7 @@ function generateMcpJson() {
217
217
  contextengine: {
218
218
  type: "stdio",
219
219
  command: npxPath,
220
- args: ["-y", "@compr/contextengine-mcp"],
220
+ args: ["-y", "@compr/opscontext-mcp"],
221
221
  },
222
222
  },
223
223
  };
@@ -2123,17 +2123,17 @@ Flags:
2123
2123
  --yes, -y Skip all interactive prompts (auto-accept defaults)
2124
2124
 
2125
2125
  Examples:
2126
- npx @compr/contextengine-mcp search "docker nginx"
2127
- npx @compr/contextengine-mcp score ContextEngine
2128
- npx @compr/contextengine-mcp score --html
2129
- npx @compr/contextengine-mcp save-session my-project summary "Deployed v2, fixed auth"
2130
- npx @compr/contextengine-mcp load-session my-project
2131
- npx @compr/contextengine-mcp end-session
2132
- npx @compr/contextengine-mcp import-learnings rules.md -c deployment
2133
- npx @compr/contextengine-mcp init --yes
2134
- echo "value" | npx @compr/contextengine-mcp save-session my-project notes --stdin
2126
+ npx @compr/opscontext-mcp search "docker nginx"
2127
+ npx @compr/opscontext-mcp score ContextEngine
2128
+ npx @compr/opscontext-mcp score --html
2129
+ npx @compr/opscontext-mcp save-session my-project summary "Deployed v2, fixed auth"
2130
+ npx @compr/opscontext-mcp load-session my-project
2131
+ npx @compr/opscontext-mcp end-session
2132
+ npx @compr/opscontext-mcp import-learnings rules.md -c deployment
2133
+ npx @compr/opscontext-mcp init --yes
2134
+ echo "value" | npx @compr/opscontext-mcp save-session my-project notes --stdin
2135
2135
 
2136
- npm: https://www.npmjs.com/package/@compr/contextengine-mcp
2136
+ npm: https://www.npmjs.com/package/@compr/opscontext-mcp
2137
2137
  `);
2138
2138
  }
2139
2139
  else if (command === "search") {
package/dist/rubric.js CHANGED
@@ -1,4 +1,19 @@
1
1
  /*__RUBRIC_ENCODED__*/
2
+ /*
3
+ * Scoring thresholds — encoded deliberately.
4
+ *
5
+ * WHAT THIS IS: the point values and cut-offs behind the AI-readiness score. They are commercial
6
+ * IP under BSL-1.1, and publishing them exactly makes the score trivial to game by padding files
7
+ * to hit a number instead of doing the work.
8
+ *
9
+ * WHAT THIS IS NOT: hidden behaviour. This file contains numbers and nothing else — no network
10
+ * calls, no file access, no data collection. Every code path in this package ships readable,
11
+ * including the two (and only two) network calls the tool ever makes: licence activation and the
12
+ * daily heartbeat, both in activation.js. Full source: https://github.com/FASTPROD/ContextEngine
13
+ *
14
+ * Your project data never leaves your machine. See the Privacy section of the README, and verify
15
+ * it in the source rather than taking our word for it.
16
+ */
2
17
  const _k = "ce-rubric-v1";
3
18
  const _d = (b) => {
4
19
  const raw = Buffer.from(b, "base64").toString("binary");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
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",