@compr/opscontext-mcp 2.3.1 → 2.4.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
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to OpsContext for AI Agents (previously ContextEngine — MCP server + CLI) are documented here.
4
4
 
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
+
7
+ ## [2.4.1] — 2026-08-16 — `score` scope is now explicit: current project by default, `--all` to opt into the fleet
8
+
9
+ ### Changed (behaviour — read this before upgrading)
10
+
11
+ - **`score` with no argument now scores only the current project**, walking up from the working
12
+ directory to the enclosing repo root. It previously scored all discovered projects **and wrote
13
+ a `SCORE.md` into every one of them** — which dirtied 26 repositories on the author's machine,
14
+ several of them auto-pushing on commit. Fleet-wide scoring now requires `--all`.
15
+ Locked as `[SCORE-FLEET-IS-OPT-IN]`.
16
+ - `score --all` prints how many projects it is about to write into before doing it, and
17
+ `--all` combined with a project argument is now a hard error rather than a silently ignored flag.
18
+ - The MCP `score_project` tool keeps its fleet-wide default — it never writes `SCORE.md`, so it
19
+ does not carry the hazard. The write is what must be asked for, not the scan.
20
+
21
+ - **`score` with no argument now refuses to run outside a project** instead of scoring whatever
22
+ directory it is in. Run from `~/Projects` — a *container* of projects — it previously scored
23
+ the container and wrote a `SCORE.md` claiming `Projects: 27/100 (F)`; from `/` it would do the
24
+ same to the filesystem root. It now exits 1 and names the three ways to proceed.
25
+ Locked as `[SCORE-CWD-MUST-BE-A-PROJECT]`.
26
+
27
+ ### Fixed (documentation that shipped a working footgun)
28
+
29
+ - **`skills/opscontext/SKILL.md` told agents to run `npx @compr/contextengine-mcp`** — a package
30
+ name npm freezes at **1.23.1**. Following the shipped skill downloaded a June scorer and ran a
31
+ fleet-wide write with a 94-point rubric that reports root-level `copilot-instructions.md` as
32
+ `Missing`. Ten call sites corrected, including three MCP-config blocks that would have
33
+ installed the frozen version as a long-running server. `skills/` now has zero references to it.
34
+ - Same correction to the `.claude/settings.json` post-push reminder.
35
+
36
+ ### Added
37
+
38
+ - **`score` accepts a path as well as a project name** — `score ~/Projects/PLANK.io`,
39
+ `score ../PLANK.io`, or an absolute path. Paths outside configured workspaces work too.
40
+ Previously `score /Users/yan/Projects/PLANK.io` failed with "Project not found" while listing
41
+ `PLANK.io` among the available projects — the lookup only ever compared basenames, so the
42
+ directory was never inspected. Locked as `[SCORE-ACCEPTS-PATH]`.
43
+ - Same path support in the MCP `score_project` tool (strictly more accepting; no default changed).
44
+ - `tests/project-resolution.test.ts` — 11 regression tests covering name/path/symlink/
45
+ file-not-a-directory/case-insensitivity/dotted names, and `findProjectRoot` termination.
46
+
47
+ ### Fixed
48
+
49
+ - Polyglot repositories are scored against the languages they actually use (released here; committed
50
+ in 2.4.0's wake): test directories are aggregated across ecosystems rather than the first match
51
+ winning, `.dart` tests are counted, and Dart tooling (`analysis_options.yaml`) satisfies the
52
+ type-checking check instead of the project being marked down for a missing `tsconfig.json`.
53
+ PLANK.io 98%; KONIVE.com correctly reports 102 test files across two directories.
54
+
55
+ ### Investigated — no change
56
+
57
+ - A report that the generated pre-commit hook regenerates and re-stages `SCORE.md` mid-commit
58
+ **does not reproduce**. `generatePreCommitHook()` only stats mtimes and greps staged files for
59
+ secrets; there is no score invocation and no `git add` in it, nor in the older custom hook the
60
+ reporting repo actually has installed. The observed empty commit is consistent with the
61
+ fleet-wide-write default above, which this release removes.
62
+
5
63
  ## [2.1.3] — 2026-06-26 — Tool manifest as single source of truth + server-meta.json for VS Code extension
6
64
 
7
65
  Tactical fix for a class of silent display drift: the VS Code info panel hardcoded "Active on all 17 MCP tools" while the npm package was at 21 tools. The README was at 20. None of these were tied to the actual `server.tool(...)` registrations, so adding a tool (e.g. `drift_status` in 2.1.0) left all displays stale.
package/README.md CHANGED
@@ -248,9 +248,14 @@ npx @compr/opscontext-mcp list-sources
248
248
  # Discover and analyze all projects
249
249
  npx @compr/opscontext-mcp list-projects
250
250
 
251
- # AI-readiness score (one or all projects)
251
+ # AI-readiness score no argument scores the CURRENT project only
252
252
  npx @compr/opscontext-mcp score
253
- npx @compr/opscontext-mcp score ContextEngine
253
+ npx @compr/opscontext-mcp score ContextEngine # by project name
254
+ npx @compr/opscontext-mcp score ~/Projects/PLANK.io # or by path
255
+
256
+ # Score every discovered project (writes a SCORE.md into each — opt in explicitly)
257
+ npx @compr/opscontext-mcp score --all
258
+ npx @compr/opscontext-mcp score --all --no-save # scan without writing
254
259
 
255
260
  # Visual HTML report (opens in browser)
256
261
  npx @compr/opscontext-mcp score --html
@@ -436,6 +441,22 @@ The `score` command evaluates project AI-readiness across **documentation, infra
436
441
 
437
442
  **Grade scale:** A+ (90%+) · A (80%+) · B (70%+) · C (60%+) · D (50%+) · F (<50%)
438
443
 
444
+ ### What gets scored, and what gets written
445
+
446
+ `score` writes a `SCORE.md` into each project it scores. Because that is a write into your
447
+ repositories, the scope is never inferred:
448
+
449
+ | Command | Scores | Writes `SCORE.md` to |
450
+ |---|---|---|
451
+ | `score` | the project you are standing in (walks up to the repo root) | that one project |
452
+ | `score <name>` / `score <path>` | that one project | that one project |
453
+ | `score --all` | every discovered project | **every** discovered project |
454
+ | any of the above `--no-save` | as above | nothing |
455
+
456
+ A project argument may be a **name** (`PLANK.io`) or a **path** (`~/Projects/PLANK.io`,
457
+ `../PLANK.io`, or an absolute path). A path also works for projects outside your configured
458
+ workspaces.
459
+
439
460
  ### Project Naming & Structure Tips
440
461
 
441
462
  The scorer discovers projects from your configured `workspaces` directories (default: `~/Projects`).
@@ -495,10 +516,23 @@ Everything happens locally — search, scoring, learnings, sessions, embeddings.
495
516
  |---|---|---|
496
517
  | License key (`CE-XXXX-...`) | Activation + daily heartbeat | Validate subscription |
497
518
  | Machine ID (SHA-256 hash) | Activation + daily heartbeat | Enforce machine limit |
519
+ | Email | Activation only | Tie the licence to an account |
520
+ | Package version | Activation only | Serve a compatible module bundle |
498
521
  | Platform/arch (e.g., `darwin/arm64`) | Activation only | Compatibility check |
522
+ | Delta bundle version | Daily heartbeat | Detect an out-of-date module cache |
523
+
524
+ 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
525
 
500
526
  **The server never receives:** project names, file contents, learnings, sessions, git history, dependencies, code, .env variables, or anything about your actual work.
501
527
 
528
+ **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/`.
529
+
530
+ ### What's obfuscated, and what isn't
531
+
532
+ 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.
533
+
534
+ **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.
535
+
502
536
  ### Why this matters
503
537
 
504
538
  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,62 @@ 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
+ /**
90
+ * ALL languages present, root and one level down — not just a winner.
91
+ *
92
+ * A single "primary language" is the wrong model for this fleet. PLANK.io is a Flutter app with a
93
+ * Node backend: picking one meant scoring a Dart codebase against `tsconfig.json` and calling its
94
+ * analyzer config missing. Returning the set lets each tooling check pass on whichever ecosystem
95
+ * actually configures it. Part of [SCORE-LANGUAGE-AWARE].
96
+ */
97
+ const LANGUAGE_MARKERS = [
98
+ { lang: "dart", files: ["pubspec.yaml"] },
99
+ { lang: "python", files: ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "__manifest__.py"] },
100
+ { lang: "js", files: ["package.json"] },
101
+ { lang: "php", files: ["composer.json"] },
102
+ ];
103
+ function detectLanguages(p) {
104
+ const found = new Set();
105
+ const scan = (base) => {
106
+ for (const { lang, files } of LANGUAGE_MARKERS) {
107
+ if (files.some(f => existsSync(join(base, f))))
108
+ found.add(lang);
109
+ }
110
+ };
111
+ scan(p);
112
+ for (const sub of safeSubdirs(p))
113
+ scan(join(p, sub));
114
+ if (found.size === 0)
115
+ found.add("other");
116
+ return found;
117
+ }
118
+ /** First existing path among `candidates`, searched at the root and one level down. */
119
+ function findConfig(p, candidates) {
120
+ for (const c of candidates) {
121
+ if (existsSync(join(p, c)))
122
+ return c;
123
+ }
124
+ for (const sub of safeSubdirs(p)) {
125
+ for (const c of candidates) {
126
+ if (existsSync(join(p, sub, c)))
127
+ return `${sub}/${c}`;
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+ /** Immediate subdirectories worth searching — skips vendored, hidden and build output. */
133
+ function safeSubdirs(p) {
134
+ const SKIP = new Set(["node_modules", "vendor", "dist", "build", ".git", "__pycache__", "venv", ".venv", "coverage", "_deprecated"]);
135
+ try {
136
+ return readdirSync(p, { withFileTypes: true })
137
+ .filter(d => d.isDirectory() && !d.name.startsWith(".") && !SKIP.has(d.name))
138
+ .map(d => d.name)
139
+ .slice(0, 24); // bounded — this runs for every project on every fleet scan
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ }
89
145
  /** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
90
146
  function readlinkSafe(filePath) {
91
147
  try {
@@ -154,9 +210,15 @@ function countTestFiles(dirPath, depth = 0) {
154
210
  count += countTestFiles(fullPath, depth + 1);
155
211
  }
156
212
  else if (entry.isFile()) {
157
- if (/\.(test|spec|_test)\.(ts|tsx|js|jsx|py|php)$/.test(entry.name) ||
158
- entry.name.startsWith("test_") ||
159
- entry.name.endsWith("_test.py")) {
213
+ // 🔒 [SCORE-LANGUAGE-AWARE] — the extension list is part of the language assumption.
214
+ // `dart` was absent, so PLANK.io's 68 Flutter tests in plank_app/test/ counted as ZERO:
215
+ // the directory was found, every file was skipped, and the row credited the Node backend
216
+ // alone. A test counter that silently ignores a language reports "no tests" for a suite
217
+ // that runs on every build. Add the extension when adding a language, not after someone
218
+ // notices their tests are invisible.
219
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|php|dart)$/.test(entry.name) ||
220
+ /_(test|spec)\.(py|dart|go|rb)$/.test(entry.name) ||
221
+ entry.name.startsWith("test_")) {
160
222
  count++;
161
223
  }
162
224
  }
@@ -1081,6 +1143,8 @@ export function runScoreCanary() {
1081
1143
  export function scoreProject(dir) {
1082
1144
  const checks = [];
1083
1145
  const p = dir.path;
1146
+ // Language decides which tooling checks apply at all — see [SCORE-LANGUAGE-AWARE].
1147
+ const langs = detectLanguages(p);
1084
1148
  // --- Documentation (30 points max) ---
1085
1149
  // copilot-instructions.md (6 pts) — scored on CONTENT, not length.
1086
1150
  // 🔒 LOCKED [SCORE-CONTENT-NOT-LENGTH] — 2026-08-14
@@ -1384,62 +1448,97 @@ export function scoreProject(dir) {
1384
1448
  }
1385
1449
  // --- Code Quality (20 points max) ---
1386
1450
  // Tests directory (8 pts) — checks for real test files, detects symlinks
1387
- const testDirs = ["tests", "test", "__tests__", "spec", "src/__tests__"];
1451
+ // Search the repo root first, then one level down — Odoo addons, src-layout packages and
1452
+ // single-package monorepos keep tests at `<module>/tests/`. Reporting "No test directory" for
1453
+ // a project with 15 passing tests is [ABSENCE-IS-NOT-A-VERDICT] applied to a path assumption;
1454
+ // it is the same defect as [DOC-PATH-DUAL], one directory deeper.
1455
+ const testDirNames = ["tests", "test", "__tests__", "spec", "src/__tests__"];
1456
+ const testDirs = [
1457
+ ...testDirNames,
1458
+ ...safeSubdirs(p).flatMap(sub => testDirNames.map(td => `${sub}/${td}`)),
1459
+ ];
1388
1460
  const foundTests = testDirs.filter(td => existsSync(join(p, td)));
1389
1461
  if (foundTests.length > 0) {
1390
- const testDirPath = join(p, foundTests[0]);
1391
- const testIsSymlink = isSymlink(testDirPath);
1392
- const testFileCount = countTestFiles(testDirPath);
1393
- if (testIsSymlink) {
1394
- checks.push({ name: "Tests", category: "Code Quality", points: 3, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ is a symlink (${testFileCount} test files) — should be real test directory` });
1462
+ // Count across EVERY test directory, not just the first match. PLANK.io has 68 Flutter tests
1463
+ // in plank_app/test/ and 38 backend tests in backend/__tests__/; taking foundTests[0] credited
1464
+ // the backend alone and rendered the larger codebase invisible. Any polyglot or multi-package
1465
+ // repo hit this — the bug was the `[0]`, not the search.
1466
+ const perDir = foundTests.map(td => ({ rel: td, count: countTestFiles(join(p, td)), symlink: isSymlink(join(p, td)) }));
1467
+ const testFileCount = perDir.reduce((sum, d) => sum + d.count, 0);
1468
+ const contributing = perDir.filter(d => d.count > 0);
1469
+ const where = (contributing.length > 0 ? contributing : perDir)
1470
+ .map(d => `${d.rel}/ (${d.count})`)
1471
+ .slice(0, 3)
1472
+ .join(", ") + (perDir.length > 3 ? `, +${perDir.length - 3} more` : "");
1473
+ const allSymlinks = perDir.every(d => d.symlink);
1474
+ if (allSymlinks) {
1475
+ checks.push({ name: "Tests", category: "Code Quality", points: 3, maxPoints: 8, status: "partial", detail: `${where} — symlinked, should be real test directories` });
1395
1476
  }
1396
1477
  else if (testFileCount >= RUBRIC.testsFull) {
1397
- checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${foundTests[0]}/ ${testFileCount} test files` });
1478
+ checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${testFileCount} test files across ${contributing.length} dir(s): ${where}` });
1398
1479
  }
1399
1480
  else if (testFileCount > RUBRIC.testsPartial) {
1400
- checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ — only ${testFileCount} test files` });
1481
+ checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `only ${testFileCount} test files: ${where}` });
1401
1482
  }
1402
1483
  else {
1403
- try {
1404
- const hasAnyFiles = readdirSync(testDirPath).length > 0;
1405
- if (hasAnyFiles) {
1406
- checks.push({ name: "Tests", category: "Code Quality", points: 4, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ has files but no standard test files detected` });
1407
- }
1408
- else {
1409
- checks.push({ name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ exists but empty` });
1484
+ let hasAnyFiles = false;
1485
+ for (const d of perDir) {
1486
+ try {
1487
+ if (readdirSync(join(p, d.rel)).length > 0) {
1488
+ hasAnyFiles = true;
1489
+ break;
1490
+ }
1410
1491
  }
1492
+ catch { /* unreadable dir counts as empty */ }
1411
1493
  }
1412
- catch {
1413
- checks.push({ name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ exists but unreadable` });
1414
- }
1494
+ checks.push(hasAnyFiles
1495
+ ? { name: "Tests", category: "Code Quality", points: 4, maxPoints: 8, status: "partial", detail: `${where} has files but no standard test files detected` }
1496
+ : { name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${where} exists but empty` });
1415
1497
  }
1416
1498
  }
1417
1499
  else {
1418
1500
  checks.push({ name: "Tests", category: "Code Quality", points: 0, maxPoints: 8, status: "fail", detail: "No test directory" });
1419
1501
  }
1420
- // TypeScript / type checking (5 pts)
1421
- const tsconfigPath = join(p, "tsconfig.json");
1422
- if (existsSync(tsconfigPath)) {
1423
- const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
1424
- const tsconfigIsSymlink = isSymlink(tsconfigPath);
1425
- // Detect minimal/reference-only tsconfigs (just project references with no real config)
1426
- const isSubstantive = tsconfigContent.length > RUBRIC.tsconfigSubstantive && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1427
- if (tsconfigIsSymlink) {
1428
- checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "tsconfig.json is a symlink — create root config" });
1502
+ // TypeScript / type checking (5 pts) — see [SCORE-LANGUAGE-AWARE].
1503
+ // Checks every ecosystem the repo actually uses, so a Flutter app with a Node backend is not
1504
+ // told its Dart analyzer config is a missing tsconfig.
1505
+ {
1506
+ const tsCfg = findConfig(p, ["tsconfig.json"]);
1507
+ const dartCfg = langs.has("dart") ? findConfig(p, ["analysis_options.yaml"]) : null;
1508
+ const pyCfg = langs.has("python") ? findConfig(p, ["mypy.ini", ".mypy.ini", "pyrightconfig.json"]) : null;
1509
+ const pyprojectPath = findConfig(p, ["pyproject.toml"]);
1510
+ const pyproject = pyprojectPath ? readFileSync(join(p, pyprojectPath), "utf-8") : "";
1511
+ const pyInline = langs.has("python") && /\[tool\.(mypy|pyright)\]/.test(pyproject);
1512
+ if (tsCfg) {
1513
+ const content = readFileSync(join(p, tsCfg), "utf-8").trim();
1514
+ const substantive = content.length > RUBRIC.tsconfigSubstantive && (content.includes('"compilerOptions"') || content.includes('"extends"'));
1515
+ if (isSymlink(join(p, tsCfg))) {
1516
+ checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: `${tsCfg} is a symlink — create a real config` });
1517
+ }
1518
+ else if (substantive) {
1519
+ checks.push({ name: "TypeScript", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: `${tsCfg} present` });
1520
+ }
1521
+ else {
1522
+ checks.push({ name: "TypeScript", category: "Code Quality", points: 3, maxPoints: 5, status: "partial", detail: `${tsCfg} is minimal — add compilerOptions for full type safety` });
1523
+ }
1524
+ }
1525
+ else if (dartCfg) {
1526
+ checks.push({ name: "Type checking", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: `${dartCfg} — Dart analyzer configured` });
1527
+ }
1528
+ else if (pyCfg || pyInline) {
1529
+ checks.push({ name: "Type checking", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: `${pyCfg ?? pyprojectPath} — static type checking configured` });
1530
+ }
1531
+ else if (existsSync(join(p, "jsconfig.json"))) {
1532
+ checks.push({ name: "Type checking", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "jsconfig.json only" });
1429
1533
  }
1430
- else if (isSubstantive) {
1431
- checks.push({ name: "TypeScript", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: "tsconfig.json present" });
1534
+ else if (langs.has("js") || langs.has("dart") || langs.has("python")) {
1535
+ const want = [langs.has("js") && "tsconfig.json", langs.has("dart") && "analysis_options.yaml", langs.has("python") && "mypy/pyright"].filter(Boolean).join(" or ");
1536
+ checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: `No ${want} found — add static type checking` });
1432
1537
  }
1433
1538
  else {
1434
- checks.push({ name: "TypeScript", category: "Code Quality", points: 3, maxPoints: 5, status: "partial", detail: "tsconfig.json is minimal add compilerOptions for full type safety" });
1539
+ checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "unknown", detail: " No type-checking convention known for this project type — not assessed" });
1435
1540
  }
1436
1541
  }
1437
- else if (existsSync(join(p, "jsconfig.json"))) {
1438
- checks.push({ name: "Type checking", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "jsconfig.json only" });
1439
- }
1440
- else {
1441
- checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: "No tsconfig/jsconfig" });
1442
- }
1443
1542
  // Linting config (4 pts) — verifies linting tools are installed, not just config
1444
1543
  const lintConfigs = [".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", ".prettierrc", "phpcs.xml"];
1445
1544
  const foundLint = lintConfigs.filter(l => existsSync(join(p, l)));
@@ -1456,8 +1555,28 @@ export function scoreProject(dir) {
1456
1555
  checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: foundLint.join(", ") });
1457
1556
  }
1458
1557
  }
1558
+ else if (langs.has("dart") && findConfig(p, ["analysis_options.yaml"])) {
1559
+ // Dart's analyzer IS the linter — analysis_options.yaml is enforced on every build.
1560
+ checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: `${findConfig(p, ["analysis_options.yaml"])} — Dart analyzer lints` });
1561
+ }
1562
+ else if (langs.has("python")) {
1563
+ // See [SCORE-LANGUAGE-AWARE]. Python linting is ruff/flake8/pylint, not eslint.
1564
+ const pyLintCfg = findConfig(p, ["ruff.toml", ".ruff.toml", ".flake8", ".pylintrc", "tox.ini", "setup.cfg"]);
1565
+ const pyLint = pyLintCfg ? [pyLintCfg] : [];
1566
+ const pyprojectRel = findConfig(p, ["pyproject.toml"]);
1567
+ const pyproject = pyprojectRel ? readFileSync(join(p, pyprojectRel), "utf-8") : "";
1568
+ if (pyLint.length > 0 || /\[tool\.(ruff|flake8|pylint|black)\]/.test(pyproject)) {
1569
+ checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: pyLint[0] ?? "pyproject.toml" });
1570
+ }
1571
+ else {
1572
+ checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "Python project with no ruff/flake8/pylint config — add one" });
1573
+ }
1574
+ }
1575
+ else if (langs.has("other") && langs.size === 1) {
1576
+ checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "unknown", detail: "❔ No linting convention known for this project type — not assessed" });
1577
+ }
1459
1578
  else {
1460
- checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "No lint config" });
1579
+ 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
1580
  }
1462
1581
  // Package scripts / build commands (3 pts)
1463
1582
  const pkgPath = join(p, "package.json");
@@ -1722,7 +1841,7 @@ export function generateProjectScoreMD(score) {
1722
1841
  lines.push(`- ❔ **${u.name}**: ${u.detail}`);
1723
1842
  }
1724
1843
  }
1725
- lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
1844
+ lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/opscontext-mcp) on ${date}*\n`);
1726
1845
  return lines.join("\n");
1727
1846
  }
1728
1847
  /**
@@ -1917,7 +2036,7 @@ export function generateScoreHTML(scores) {
1917
2036
  ${projectCards}
1918
2037
 
1919
2038
  <div class="footer">
1920
- <p>ContextEngine · <a href="https://www.npmjs.com/package/@compr/contextengine-mcp" style="color:var(--accent)">npm</a></p>
2039
+ <p>ContextEngine · <a href="https://www.npmjs.com/package/@compr/opscontext-mcp" style="color:var(--accent)">npm</a></p>
1921
2040
  <p style="margin-top:4px">Scoring: Documentation (30pts) · Infrastructure (30pts) · Code Quality (20pts) · Security (20pts)</p>
1922
2041
  </div>
1923
2042
  </body>
package/dist/cli.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * contextengine list-projects Discover and analyze all projects
11
11
  * contextengine list-learnings List all permanent learnings
12
12
  * contextengine save-learning Save a learning (terminal fallback for MCP)
13
- * contextengine score [project] AI-readiness score (writes SCORE.md to each project)
13
+ * contextengine score [project|path] AI-readiness score for one project (default: cwd; --all for fleet)
14
14
  * contextengine audit Run compliance audit across all projects
15
15
  * contextengine help Show this message
16
16
  */
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * contextengine list-projects Discover and analyze all projects
11
11
  * contextengine list-learnings List all permanent learnings
12
12
  * contextengine save-learning Save a learning (terminal fallback for MCP)
13
- * contextengine score [project] AI-readiness score (writes SCORE.md to each project)
13
+ * contextengine score [project|path] AI-readiness score for one project (default: cwd; --all for fleet)
14
14
  * contextengine audit Run compliance audit across all projects
15
15
  * contextengine help Show this message
16
16
  */
@@ -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
  };
@@ -517,7 +517,7 @@ async function runInit() {
517
517
  // ---------------------------------------------------------------------------
518
518
  // CLI Engine — shared initialization for all CLI subcommands
519
519
  // ---------------------------------------------------------------------------
520
- import { loadSources, loadProjectDirs, loadConfig } from "./config.js";
520
+ import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot } from "./config.js";
521
521
  import { ingestSources } from "./ingest.js";
522
522
  import { searchChunks } from "./search.js";
523
523
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
@@ -697,12 +697,34 @@ async function cliDeleteLearning(id) {
697
697
  process.exit(1);
698
698
  }
699
699
  }
700
- async function cliScore(project, html = false, save = true) {
700
+ /**
701
+ * 🔒 LOCKED [SCORE-FLEET-IS-OPT-IN] — 2026-08-16
702
+ * ⛔ NEVER make a bare `contextengine score` iterate the whole fleet again, and never
703
+ * let the no-argument path write SCORE.md anywhere but the resolved project root.
704
+ * WHY: `score` with no argument scored all 37 discovered projects AND wrote a SCORE.md
705
+ * into every one of them. Three separate agent sessions ran it expecting to score
706
+ * the repo they were standing in, and dirtied 26 unrelated repositories — several of
707
+ * which auto-push to Google Drive on commit, so the noise propagated off-machine
708
+ * before anyone noticed. The blast radius of the default was the entire fleet while
709
+ * the intent behind typing it was almost always a single project.
710
+ * FIX: the destructive-at-scale operation must be *asked for*. `--all` opts in; no
711
+ * argument scores the current working directory's project root. A fleet-wide write
712
+ * is a deliberate choice, never a default the user backs into.
713
+ * Note the MCP `score_project` tool is read-only (it never writes SCORE.md), which
714
+ * is why its fleet-wide default is left alone — the hazard here is the write, not
715
+ * the scan.
716
+ */
717
+ async function cliScore(project, html = false, save = true, all = false) {
701
718
  const gate = gateCheck("score_project");
702
719
  if (gate) {
703
720
  console.error(gate);
704
721
  process.exit(1);
705
722
  }
723
+ if (project && all) {
724
+ console.error(`❌ Cannot combine --all with a project argument ("${project}").`);
725
+ console.error(` Use --all for the whole fleet, or name one project/path.`);
726
+ process.exit(1);
727
+ }
706
728
  // [SCORE-CANARY] — every health signal must read exactly as pinned before we are allowed to
707
729
  // write a single SCORE.md. A drifting scorer that silently rewrites 37 reports is the failure
708
730
  // this blocks; it does not test for one known bug, it refuses to proceed on ANY deviation.
@@ -718,17 +740,44 @@ async function cliScore(project, html = false, save = true) {
718
740
  }
719
741
  const projectDirs = loadProjectDirs();
720
742
  let scores;
721
- if (project) {
722
- const dir = projectDirs.find((d) => d.name.toLowerCase() === project.toLowerCase());
743
+ if (all) {
744
+ if (save) {
745
+ console.error(`⚠️ --all: scoring ${projectDirs.length} projects and writing a SCORE.md into each.`);
746
+ console.error(` Use --no-save to scan without writing.\n`);
747
+ }
748
+ scores = projectDirs.map((d) => scoreProject(d));
749
+ }
750
+ else if (project) {
751
+ // [SCORE-ACCEPTS-PATH] — a path is as valid an identifier as a name.
752
+ const dir = resolveProjectDir(project, projectDirs);
723
753
  if (!dir) {
724
754
  console.error(`❌ Project not found: "${project}"`);
755
+ console.error(` Not a known project name, and not an existing directory.`);
725
756
  console.error(`Available: ${projectDirs.map((d) => d.name).join(", ")}`);
726
757
  process.exit(1);
727
758
  }
728
759
  scores = [scoreProject(dir)];
729
760
  }
730
761
  else {
731
- scores = projectDirs.map((d) => scoreProject(d));
762
+ // [SCORE-FLEET-IS-OPT-IN] no argument means "the project I am standing in",
763
+ // never "every project on this machine".
764
+ // [SCORE-CWD-MUST-BE-A-PROJECT] — and if I am not standing in a project, say so
765
+ // rather than scoring whatever directory happens to be here.
766
+ const root = findProjectRoot(process.cwd());
767
+ if (!root) {
768
+ console.error(`❌ Not inside a project: ${process.cwd()}`);
769
+ console.error(` No .git or package.json found here or in any parent directory.`);
770
+ console.error(` Do one of:`);
771
+ console.error(` • cd into a project, then run: contextengine score`);
772
+ console.error(` • name it: contextengine score <name|path>`);
773
+ console.error(` • score the whole fleet: contextengine score --all`);
774
+ process.exit(1);
775
+ }
776
+ const known = projectDirs.find((d) => resolve(d.path) === resolve(root));
777
+ const dir = known ?? { name: basename(root), path: root };
778
+ console.error(`📍 Scoring current project: ${dir.name} (${dir.path})`);
779
+ console.error(` Use --all to score every discovered project.\n`);
780
+ scores = [scoreProject(dir)];
732
781
  }
733
782
  if (html) {
734
783
  const htmlContent = generateScoreHTML(scores);
@@ -2111,7 +2160,11 @@ Usage:
2111
2160
  Fetch community-contributed learnings (Tier A = GitHub
2112
2161
  public, Tier B = api.compr.ch Pro). Daily run recommended.
2113
2162
  Network failures fall back to cached store.
2114
- contextengine score [project] [--html] [--no-save] AI-readiness score (Pro, writes SCORE.md)
2163
+ contextengine score [project|path] [--all] [--html] [--no-save]
2164
+ AI-readiness score (Pro, writes SCORE.md).
2165
+ No argument scores the CURRENT project only.
2166
+ Accepts a project name or a directory path.
2167
+ --all scores every discovered project (writes to each).
2115
2168
  contextengine audit Run compliance audit (Pro)
2116
2169
  contextengine activate <key> <email> Activate a Pro license
2117
2170
  contextengine deactivate Remove license and premium modules
@@ -2123,17 +2176,17 @@ Flags:
2123
2176
  --yes, -y Skip all interactive prompts (auto-accept defaults)
2124
2177
 
2125
2178
  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
2179
+ npx @compr/opscontext-mcp search "docker nginx"
2180
+ npx @compr/opscontext-mcp score ContextEngine
2181
+ npx @compr/opscontext-mcp score --html
2182
+ npx @compr/opscontext-mcp save-session my-project summary "Deployed v2, fixed auth"
2183
+ npx @compr/opscontext-mcp load-session my-project
2184
+ npx @compr/opscontext-mcp end-session
2185
+ npx @compr/opscontext-mcp import-learnings rules.md -c deployment
2186
+ npx @compr/opscontext-mcp init --yes
2187
+ echo "value" | npx @compr/opscontext-mcp save-session my-project notes --stdin
2135
2188
 
2136
- npm: https://www.npmjs.com/package/@compr/contextengine-mcp
2189
+ npm: https://www.npmjs.com/package/@compr/opscontext-mcp
2137
2190
  `);
2138
2191
  }
2139
2192
  else if (command === "search") {
@@ -2195,8 +2248,9 @@ else if (command === "score") {
2195
2248
  const args = process.argv.slice(3);
2196
2249
  const htmlFlag = args.includes("--html");
2197
2250
  const noSaveFlag = args.includes("--no-save");
2251
+ const allFlag = args.includes("--all");
2198
2252
  const project = args.filter(a => !a.startsWith("--"))[0];
2199
- cliScore(project, htmlFlag, !noSaveFlag).catch((err) => {
2253
+ cliScore(project, htmlFlag, !noSaveFlag, allFlag).catch((err) => {
2200
2254
  console.error("Error:", err);
2201
2255
  process.exit(1);
2202
2256
  });
package/dist/config.d.ts CHANGED
@@ -66,6 +66,43 @@ export declare function loadSources(): KnowledgeSource[];
66
66
  * Returns one entry per top-level project found.
67
67
  */
68
68
  export declare function loadProjectDirs(): ProjectDirectory[];
69
+ /**
70
+ * 🔒 LOCKED [SCORE-ACCEPTS-PATH] — 2026-08-16
71
+ * ⛔ NEVER narrow this back to `dirs.find(d => d.name === token)` alone.
72
+ * WHY: `contextengine score /Users/yan/Projects/PLANK.io` failed with
73
+ * "Project not found: /Users/yan/Projects/PLANK.io" while listing PLANK.io
74
+ * among the available projects. A path is the natural first guess for a
75
+ * tool that prints absolute paths in its own output, and the error named
76
+ * the one thing the user had clearly just given it. The directory was
77
+ * never inspected — the lookup only ever compared basenames, so this was
78
+ * [ABSENCE-IS-NOT-A-VERDICT] at the argument-parsing layer: "not in my
79
+ * name index" was reported as "does not exist".
80
+ * FIX: resolve names AND paths. A path that exists is a project, whether or not
81
+ * it sits under a configured workspace — that is what makes the tool usable
82
+ * outside `~/Projects`.
83
+ */
84
+ export declare function resolveProjectDir(token: string, dirs: ProjectDirectory[]): ProjectDirectory | null;
85
+ /**
86
+ * 🔒 LOCKED [SCORE-CWD-MUST-BE-A-PROJECT] — 2026-08-16
87
+ * ⛔ NEVER fall back to returning `start` when no project marker is found. A directory
88
+ * that is not a project must produce null, and the caller must refuse to score it.
89
+ * WHY: the first cut of this returned `start` on failure, reasoning that "an un-versioned
90
+ * directory is still scoreable — it just scores badly." That is exactly the
91
+ * absence-as-verdict mistake this codebase keeps relearning. Running `score` from
92
+ * `~/Projects` — a CONTAINER of 37 projects, not a project — walked to the filesystem
93
+ * root, found nothing, fell back, scored the container as though it were a project,
94
+ * and wrote `~/Projects/SCORE.md` claiming "Projects: 27/100 (F)". Run from `/` it
95
+ * would do the same to the filesystem root. "I cannot tell which project you mean" is
96
+ * an unknown, and the safe response to an unknown scope is to ask, never to write.
97
+ * FIX: return null and let the caller error out with the three things the user can do
98
+ * instead (cd into a project, name one, or --all). Found by an adversarial review
99
+ * agent that ran the real CLI from `/` and `~/Projects`.
100
+ *
101
+ * Walk up from `start` to the enclosing project root. Stops at the first directory
102
+ * holding a `.git` or a `package.json`. Returns null when neither is found anywhere
103
+ * above `start`.
104
+ */
105
+ export declare function findProjectRoot(start: string): string | null;
69
106
  /**
70
107
  * Load the raw config (for checking flags like collectSystemOps).
71
108
  */
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve, join } from "path";
1
+ import { resolve, join, basename, dirname, sep } from "path";
2
2
  import { homedir } from "os";
3
3
  import { readFileSync, existsSync, readdirSync, statSync } from "fs";
4
4
  import { discoverClaudeMemory } from "./claude-integration.js";
@@ -227,6 +227,90 @@ export function loadProjectDirs() {
227
227
  }
228
228
  return dirs;
229
229
  }
230
+ /**
231
+ * Does this token look like a filesystem path rather than a bare project name?
232
+ *
233
+ * Deliberately conservative: only strings that CANNOT be a directory basename
234
+ * (they contain a separator, or start with `~`/`.`) are treated as
235
+ * path-only. Everything else stays eligible for name lookup first, so
236
+ * `score KONIVE.com` keeps resolving exactly as it did before this existed.
237
+ */
238
+ function looksLikePath(token) {
239
+ return (token.includes("/") ||
240
+ token.includes(sep) ||
241
+ token.startsWith("~") ||
242
+ token === "." ||
243
+ token === "..");
244
+ }
245
+ /**
246
+ * 🔒 LOCKED [SCORE-ACCEPTS-PATH] — 2026-08-16
247
+ * ⛔ NEVER narrow this back to `dirs.find(d => d.name === token)` alone.
248
+ * WHY: `contextengine score /Users/yan/Projects/PLANK.io` failed with
249
+ * "Project not found: /Users/yan/Projects/PLANK.io" while listing PLANK.io
250
+ * among the available projects. A path is the natural first guess for a
251
+ * tool that prints absolute paths in its own output, and the error named
252
+ * the one thing the user had clearly just given it. The directory was
253
+ * never inspected — the lookup only ever compared basenames, so this was
254
+ * [ABSENCE-IS-NOT-A-VERDICT] at the argument-parsing layer: "not in my
255
+ * name index" was reported as "does not exist".
256
+ * FIX: resolve names AND paths. A path that exists is a project, whether or not
257
+ * it sits under a configured workspace — that is what makes the tool usable
258
+ * outside `~/Projects`.
259
+ */
260
+ export function resolveProjectDir(token, dirs) {
261
+ // Name lookup first — preserves pre-existing behaviour exactly.
262
+ if (!looksLikePath(token)) {
263
+ const byName = dirs.find((d) => d.name.toLowerCase() === token.toLowerCase());
264
+ if (byName)
265
+ return byName;
266
+ }
267
+ // Path resolution — absolute, relative, or `~`-prefixed.
268
+ const abs = resolve(token.replace(/^~/, homedir()));
269
+ try {
270
+ if (statSync(abs).isDirectory()) {
271
+ // Prefer the configured entry when the path points at a known project,
272
+ // so the reported name matches the rest of the fleet output.
273
+ const known = dirs.find((d) => resolve(d.path) === abs);
274
+ return known ?? { name: basename(abs), path: abs };
275
+ }
276
+ }
277
+ catch {
278
+ // ENOENT / EACCES — not a usable directory. Fall through to null.
279
+ }
280
+ return null;
281
+ }
282
+ /**
283
+ * 🔒 LOCKED [SCORE-CWD-MUST-BE-A-PROJECT] — 2026-08-16
284
+ * ⛔ NEVER fall back to returning `start` when no project marker is found. A directory
285
+ * that is not a project must produce null, and the caller must refuse to score it.
286
+ * WHY: the first cut of this returned `start` on failure, reasoning that "an un-versioned
287
+ * directory is still scoreable — it just scores badly." That is exactly the
288
+ * absence-as-verdict mistake this codebase keeps relearning. Running `score` from
289
+ * `~/Projects` — a CONTAINER of 37 projects, not a project — walked to the filesystem
290
+ * root, found nothing, fell back, scored the container as though it were a project,
291
+ * and wrote `~/Projects/SCORE.md` claiming "Projects: 27/100 (F)". Run from `/` it
292
+ * would do the same to the filesystem root. "I cannot tell which project you mean" is
293
+ * an unknown, and the safe response to an unknown scope is to ask, never to write.
294
+ * FIX: return null and let the caller error out with the three things the user can do
295
+ * instead (cd into a project, name one, or --all). Found by an adversarial review
296
+ * agent that ran the real CLI from `/` and `~/Projects`.
297
+ *
298
+ * Walk up from `start` to the enclosing project root. Stops at the first directory
299
+ * holding a `.git` or a `package.json`. Returns null when neither is found anywhere
300
+ * above `start`.
301
+ */
302
+ export function findProjectRoot(start) {
303
+ let dir = resolve(start);
304
+ for (;;) {
305
+ if (existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"))) {
306
+ return dir;
307
+ }
308
+ const parent = dirname(dir);
309
+ if (parent === dir)
310
+ return null; // reached filesystem root, no marker seen
311
+ dir = parent;
312
+ }
313
+ }
230
314
  /**
231
315
  * Load the raw config (for checking flags like collectSystemOps).
232
316
  */
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { loadSources, loadProjectDirs, loadConfig } from "./config.js";
5
+ import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
6
6
  import { ingestSources } from "./ingest.js";
7
7
  import { searchChunks } from "./search.js";
8
8
  import { initEmbeddings, embedChunks, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
@@ -474,7 +474,7 @@ server.tool("score_project", "Score one or all projects on AI-readiness (0-100%)
474
474
  project: z
475
475
  .string()
476
476
  .optional()
477
- .describe("Project name to score. Omit to score all projects."),
477
+ .describe("Project name OR absolute directory path to score. Omit to score all projects."),
478
478
  }, async ({ project }) => {
479
479
  const gate = gateCheck("score_project");
480
480
  if (gate)
@@ -482,13 +482,15 @@ server.tool("score_project", "Score one or all projects on AI-readiness (0-100%)
482
482
  const projectDirs = loadProjectDirs();
483
483
  let scores;
484
484
  if (project) {
485
- const dir = projectDirs.find((d) => d.name.toLowerCase() === project.toLowerCase());
485
+ // [SCORE-ACCEPTS-PATH] resolve names and paths alike. This tool never writes
486
+ // SCORE.md, so unlike the CLI its fleet-wide default is harmless and is kept.
487
+ const dir = resolveProjectDir(project, projectDirs);
486
488
  if (!dir) {
487
489
  return {
488
490
  content: [
489
491
  {
490
492
  type: "text",
491
- text: `Project "${project}" not found. Available: ${projectDirs.map((d) => d.name).join(", ")}`,
493
+ text: `Project "${project}" not found — not a known project name, and not an existing directory. Available: ${projectDirs.map((d) => d.name).join(", ")}`,
492
494
  },
493
495
  ],
494
496
  };
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.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",
@@ -14,7 +14,7 @@ ContextEngine turns your project documentation into a **queryable knowledge base
14
14
  ### 1. Initialize (one-time per project)
15
15
 
16
16
  ```bash
17
- npx @compr/contextengine-mcp init
17
+ npx @compr/opscontext-mcp init
18
18
  ```
19
19
 
20
20
  Creates `contextengine.json` config + `.github/copilot-instructions.md` template in the current directory.
@@ -39,14 +39,16 @@ ContextEngine auto-discovers documentation files from 7 common patterns:
39
39
  ContextEngine also works as a standalone CLI tool — no MCP client needed:
40
40
 
41
41
  ```bash
42
- npx @compr/contextengine-mcp search "docker nginx" # Search knowledge base
43
- npx @compr/contextengine-mcp list-sources # Show indexed sources
44
- npx @compr/contextengine-mcp list-projects # Discover all projects
45
- npx @compr/contextengine-mcp score # AI-readiness score
46
- npx @compr/contextengine-mcp score --html # Visual HTML report
47
- npx @compr/contextengine-mcp list-learnings security # List learnings by category
48
- npx @compr/contextengine-mcp audit # Compliance audit
49
- npx @compr/contextengine-mcp help # Show all commands
42
+ npx @compr/opscontext-mcp search "docker nginx" # Search knowledge base
43
+ npx @compr/opscontext-mcp list-sources # Show indexed sources
44
+ npx @compr/opscontext-mcp list-projects # Discover all projects
45
+ npx @compr/opscontext-mcp score # Score the CURRENT project (writes its SCORE.md)
46
+ npx @compr/opscontext-mcp score ~/Projects/PLANK.io # Score one project by name or path
47
+ npx @compr/opscontext-mcp score --all --no-save # Score every project, write nothing
48
+ npx @compr/opscontext-mcp score --html # Visual HTML report
49
+ npx @compr/opscontext-mcp list-learnings security # List learnings by category
50
+ npx @compr/opscontext-mcp audit # Compliance audit
51
+ npx @compr/opscontext-mcp help # Show all commands
50
52
  ```
51
53
 
52
54
  ## MCP Server Setup
@@ -62,7 +64,7 @@ Add to `.vscode/mcp.json` in your project root:
62
64
  "servers": {
63
65
  "contextengine": {
64
66
  "command": "npx",
65
- "args": ["-y", "@compr/contextengine-mcp"],
67
+ "args": ["-y", "@compr/opscontext-mcp"],
66
68
  "env": {
67
69
  "CONTEXTENGINE_WORKSPACES": "/path/to/your/projects"
68
70
  }
@@ -80,7 +82,7 @@ Add to Claude Desktop MCP config:
80
82
  "mcpServers": {
81
83
  "contextengine": {
82
84
  "command": "npx",
83
- "args": ["-y", "@compr/contextengine-mcp"],
85
+ "args": ["-y", "@compr/opscontext-mcp"],
84
86
  "env": {
85
87
  "CONTEXTENGINE_WORKSPACES": "/path/to/your/projects"
86
88
  }
@@ -98,7 +100,7 @@ Add to your OpenClaw `openclaw.json` MCP servers section:
98
100
  "mcpServers": {
99
101
  "contextengine": {
100
102
  "command": "npx",
101
- "args": ["-y", "@compr/contextengine-mcp"],
103
+ "args": ["-y", "@compr/opscontext-mcp"],
102
104
  "env": {
103
105
  "CONTEXTENGINE_WORKSPACES": "/path/to/your/projects"
104
106
  }
@@ -186,7 +188,7 @@ load_session --name "project-x"
186
188
 
187
189
  ## Configuration
188
190
 
189
- Create `contextengine.json` in your project root (or run `npx @compr/contextengine-mcp init`):
191
+ Create `contextengine.json` in your project root (or run `npx @compr/opscontext-mcp init`):
190
192
 
191
193
  ```json
192
194
  {
@@ -220,14 +222,14 @@ Create `contextengine.json` in your project root (or run `npx @compr/contextengi
220
222
  - First run downloads the embedding model (~22MB) — subsequent runs use cache
221
223
  - Keyword search is available instantly at startup; semantic search becomes available once the model loads
222
224
  - License: BSL-1.1 (Business Source License)
223
- - npm: `@compr/contextengine-mcp`
225
+ - npm: `@compr/opscontext-mcp`
224
226
 
225
227
  ### Post-Commit Verification
226
228
 
227
229
  After every `git push`, call `end_session` to verify nothing was missed:
228
230
 
229
231
  - **MCP tool**: `end_session` — use when ContextEngine MCP server is connected
230
- - **CLI fallback**: `npx @compr/contextengine-mcp end-session` — use when MCP is not connected (Cursor, Copilot, terminal sessions)
232
+ - **CLI fallback**: `npx @compr/opscontext-mcp end-session` — use when MCP is not connected (Cursor, Copilot, terminal sessions)
231
233
 
232
234
  Resolve any FAIL items before finishing. Workflow: `commit → push → end_session → verify → fix anything it catches → re-commit if needed`.
233
235