@compr/opscontext-mcp 2.4.0 → 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`).
package/dist/agents.js CHANGED
@@ -86,22 +86,48 @@ 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.
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
+ }
98
124
  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";
125
+ for (const c of candidates) {
126
+ if (existsSync(join(p, sub, c)))
127
+ return `${sub}/${c}`;
128
+ }
103
129
  }
104
- return "other";
130
+ return null;
105
131
  }
106
132
  /** Immediate subdirectories worth searching — skips vendored, hidden and build output. */
107
133
  function safeSubdirs(p) {
@@ -184,9 +210,15 @@ function countTestFiles(dirPath, depth = 0) {
184
210
  count += countTestFiles(fullPath, depth + 1);
185
211
  }
186
212
  else if (entry.isFile()) {
187
- if (/\.(test|spec|_test)\.(ts|tsx|js|jsx|py|php)$/.test(entry.name) ||
188
- entry.name.startsWith("test_") ||
189
- 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_")) {
190
222
  count++;
191
223
  }
192
224
  }
@@ -1112,7 +1144,7 @@ export function scoreProject(dir) {
1112
1144
  const checks = [];
1113
1145
  const p = dir.path;
1114
1146
  // Language decides which tooling checks apply at all — see [SCORE-LANGUAGE-AWARE].
1115
- const lang = detectLanguage(p);
1147
+ const langs = detectLanguages(p);
1116
1148
  // --- Documentation (30 points max) ---
1117
1149
  // copilot-instructions.md (6 pts) — scored on CONTENT, not length.
1118
1150
  // 🔒 LOCKED [SCORE-CONTENT-NOT-LENGTH] — 2026-08-14
@@ -1427,74 +1459,86 @@ export function scoreProject(dir) {
1427
1459
  ];
1428
1460
  const foundTests = testDirs.filter(td => existsSync(join(p, td)));
1429
1461
  if (foundTests.length > 0) {
1430
- const testDirPath = join(p, foundTests[0]);
1431
- const testIsSymlink = isSymlink(testDirPath);
1432
- const testFileCount = countTestFiles(testDirPath);
1433
- if (testIsSymlink) {
1434
- 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` });
1435
1476
  }
1436
1477
  else if (testFileCount >= RUBRIC.testsFull) {
1437
- 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}` });
1438
1479
  }
1439
1480
  else if (testFileCount > RUBRIC.testsPartial) {
1440
- 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}` });
1441
1482
  }
1442
1483
  else {
1443
- try {
1444
- const hasAnyFiles = readdirSync(testDirPath).length > 0;
1445
- if (hasAnyFiles) {
1446
- checks.push({ name: "Tests", category: "Code Quality", points: 4, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ has files but no standard test files detected` });
1447
- }
1448
- else {
1449
- 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
+ }
1450
1491
  }
1492
+ catch { /* unreadable dir counts as empty */ }
1451
1493
  }
1452
- catch {
1453
- checks.push({ name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ exists but unreadable` });
1454
- }
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` });
1455
1497
  }
1456
1498
  }
1457
1499
  else {
1458
1500
  checks.push({ name: "Tests", category: "Code Quality", points: 0, maxPoints: 8, status: "fail", detail: "No test directory" });
1459
1501
  }
1460
- // TypeScript / type checking (5 pts)
1461
- const tsconfigPath = join(p, "tsconfig.json");
1462
- if (existsSync(tsconfigPath)) {
1463
- const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
1464
- const tsconfigIsSymlink = isSymlink(tsconfigPath);
1465
- // Detect minimal/reference-only tsconfigs (just project references with no real config)
1466
- const isSubstantive = tsconfigContent.length > RUBRIC.tsconfigSubstantive && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1467
- if (tsconfigIsSymlink) {
1468
- 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
+ }
1469
1524
  }
1470
- else if (isSubstantive) {
1471
- checks.push({ name: "TypeScript", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: "tsconfig.json present" });
1525
+ else if (dartCfg) {
1526
+ checks.push({ name: "Type checking", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: `${dartCfg} Dart analyzer configured` });
1472
1527
  }
1473
- else {
1474
- checks.push({ name: "TypeScript", category: "Code Quality", points: 3, maxPoints: 5, status: "partial", detail: "tsconfig.json is minimaladd compilerOptions for full type safety" });
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` });
1475
1530
  }
1476
- }
1477
- else if (existsSync(join(p, "jsconfig.json"))) {
1478
- checks.push({ name: "Type checking", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "jsconfig.json only" });
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` });
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" });
1533
+ }
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` });
1487
1537
  }
1488
1538
  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" });
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" });
1490
1540
  }
1491
1541
  }
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
- }
1495
- else {
1496
- checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: "No tsconfig/jsconfig" });
1497
- }
1498
1542
  // Linting config (4 pts) — verifies linting tools are installed, not just config
1499
1543
  const lintConfigs = [".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", ".prettierrc", "phpcs.xml"];
1500
1544
  const foundLint = lintConfigs.filter(l => existsSync(join(p, l)));
@@ -1511,10 +1555,16 @@ export function scoreProject(dir) {
1511
1555
  checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: foundLint.join(", ") });
1512
1556
  }
1513
1557
  }
1514
- else if (lang === "python") {
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")) {
1515
1563
  // 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") : "";
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") : "";
1518
1568
  if (pyLint.length > 0 || /\[tool\.(ruff|flake8|pylint|black)\]/.test(pyproject)) {
1519
1569
  checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: pyLint[0] ?? "pyproject.toml" });
1520
1570
  }
@@ -1522,7 +1572,7 @@ export function scoreProject(dir) {
1522
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" });
1523
1573
  }
1524
1574
  }
1525
- else if (lang === "other") {
1575
+ else if (langs.has("other") && langs.size === 1) {
1526
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" });
1527
1577
  }
1528
1578
  else {
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
  */
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.4.0",
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