@compr/opscontext-mcp 2.4.0 → 2.4.2

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,111 @@
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.2] — 2026-08-16 — Close the three holes 2.4.1 left in "a directory is not a project"
8
+
9
+ 2.4.1 made `score`'s scope explicit but enforced it on only one of three entry points. An
10
+ adversarial review (30 agents, sandboxed) raised 26 findings, refuted 18, and confirmed 8 —
11
+ clustering into the fixes below. Every repro was reproduced against the shipped build before
12
+ being fixed, and re-run after.
13
+
14
+ ### Fixed
15
+
16
+ - **`score .` re-created the exact incident 2.4.1's LOCK forbids.** `cd ~/Projects && score .`
17
+ wrote `~/Projects/SCORE.md` — *"Projects: 27/100 (F), Not a git repo"* — into the container of
18
+ 37 repositories. The identical command *without* the `.` was correctly refused: the guard
19
+ existed, and the explicit-path entry point walked straight past it. Same for `score ~/Projects`,
20
+ `score ..` and `score /`. A path must now carry a build/VCS marker
21
+ (`.git`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `Makefile`, …) to be
22
+ scoreable. Locked as `[RESOLVE-PATH-MUST-BE-A-PROJECT]`.
23
+ - **A mistyped project name silently scored a subdirectory.** `score src`, `score dist`,
24
+ `score docs` no longer errored with the available-projects list — the bare token fell through
25
+ to path resolution against the cwd, scored the subdirectory, and wrote a `SCORE.md` into it.
26
+ In this repo `score dist` wrote `dist/SCORE.md`, which then ships inside the npm tarball. A
27
+ bare name now resolves against the fleet index **only**; the error names the directory it
28
+ found and offers `score ./src` as the explicit escape hatch.
29
+ - **`score` in a subpackage reported "Not a git repo" from inside a git repo.** `cd server &&
30
+ score` stopped at the nearest `package.json` and reported *"server — 32% (F), Not a git repo,
31
+ No CI pipeline, README.md Missing"* — every claim false about the project the user was standing
32
+ in. `.git` is now the project boundary and wins over any nearer build file; a genuinely
33
+ standalone package still resolves to itself. Locked as `[GIT-ROOT-IS-THE-PROJECT-BOUNDARY]`.
34
+ - **`CONTEXTENGINE_WORKSPACES` was read and ignored.** It was a fallback that applied only when
35
+ the config file defined no `workspaces` — so on the documented setup it did nothing, including
36
+ in every MCP config block this project ships. Precedence is now env var > config file >
37
+ auto-discovery. Locked as `[ENV-WORKSPACES-WINS]`.
38
+ - Error messages no longer say "not an existing directory" about a directory that exists. Each
39
+ of the four failure modes (unknown name / no such path / not a directory / not a project) now
40
+ names the actual reason.
41
+
42
+ ### Documentation
43
+
44
+ - README's "Config resolution order" table documented the pre-fix precedence and could not
45
+ describe both surfaces at once. Now split: config-file lookup, project-fleet lookup (env wins),
46
+ and an explicit note that the *search corpus* still prefers the config file.
47
+ - `CLAUDE.md` still told every agent in this repo that `npx . score` scores all projects.
48
+
49
+ ### Tests
50
+
51
+ 373 pass (8 new). One pre-existing test asserted the old behaviour — a marker-less directory
52
+ resolving as a project — and was corrected rather than deleted.
53
+
54
+ ## [2.4.1] — 2026-08-16 — `score` scope is now explicit: current project by default, `--all` to opt into the fleet
55
+
56
+ ### Changed (behaviour — read this before upgrading)
57
+
58
+ - **`score` with no argument now scores only the current project**, walking up from the working
59
+ directory to the enclosing repo root. It previously scored all discovered projects **and wrote
60
+ a `SCORE.md` into every one of them** — which dirtied 26 repositories on the author's machine,
61
+ several of them auto-pushing on commit. Fleet-wide scoring now requires `--all`.
62
+ Locked as `[SCORE-FLEET-IS-OPT-IN]`.
63
+ - `score --all` prints how many projects it is about to write into before doing it, and
64
+ `--all` combined with a project argument is now a hard error rather than a silently ignored flag.
65
+ - The MCP `score_project` tool keeps its fleet-wide default — it never writes `SCORE.md`, so it
66
+ does not carry the hazard. The write is what must be asked for, not the scan.
67
+
68
+ - **`score` with no argument now refuses to run outside a project** instead of scoring whatever
69
+ directory it is in. Run from `~/Projects` — a *container* of projects — it previously scored
70
+ the container and wrote a `SCORE.md` claiming `Projects: 27/100 (F)`; from `/` it would do the
71
+ same to the filesystem root. It now exits 1 and names the three ways to proceed.
72
+ Locked as `[SCORE-CWD-MUST-BE-A-PROJECT]`.
73
+
74
+ ### Fixed (documentation that shipped a working footgun)
75
+
76
+ - **`skills/opscontext/SKILL.md` told agents to run `npx @compr/contextengine-mcp`** — a package
77
+ name npm freezes at **1.23.1**. Following the shipped skill downloaded a June scorer and ran a
78
+ fleet-wide write with a 94-point rubric that reports root-level `copilot-instructions.md` as
79
+ `Missing`. Ten call sites corrected, including three MCP-config blocks that would have
80
+ installed the frozen version as a long-running server. `skills/` now has zero references to it.
81
+ - Same correction to the `.claude/settings.json` post-push reminder.
82
+
83
+ ### Added
84
+
85
+ - **`score` accepts a path as well as a project name** — `score ~/Projects/PLANK.io`,
86
+ `score ../PLANK.io`, or an absolute path. Paths outside configured workspaces work too.
87
+ Previously `score /Users/yan/Projects/PLANK.io` failed with "Project not found" while listing
88
+ `PLANK.io` among the available projects — the lookup only ever compared basenames, so the
89
+ directory was never inspected. Locked as `[SCORE-ACCEPTS-PATH]`.
90
+ - Same path support in the MCP `score_project` tool (strictly more accepting; no default changed).
91
+ - `tests/project-resolution.test.ts` — 11 regression tests covering name/path/symlink/
92
+ file-not-a-directory/case-insensitivity/dotted names, and `findProjectRoot` termination.
93
+
94
+ ### Fixed
95
+
96
+ - Polyglot repositories are scored against the languages they actually use (released here; committed
97
+ in 2.4.0's wake): test directories are aggregated across ecosystems rather than the first match
98
+ winning, `.dart` tests are counted, and Dart tooling (`analysis_options.yaml`) satisfies the
99
+ type-checking check instead of the project being marked down for a missing `tsconfig.json`.
100
+ PLANK.io 98%; KONIVE.com correctly reports 102 test files across two directories.
101
+
102
+ ### Investigated — no change
103
+
104
+ - A report that the generated pre-commit hook regenerates and re-stages `SCORE.md` mid-commit
105
+ **does not reproduce**. `generatePreCommitHook()` only stats mtimes and greps staged files for
106
+ secrets; there is no score invocation and no `git add` in it, nor in the older custom hook the
107
+ reporting repo actually has installed. The observed empty commit is consistent with the
108
+ fleet-wide-write default above, which this release removes.
109
+
5
110
  ## [2.1.3] — 2026-06-26 — Tool manifest as single source of truth + server-meta.json for VS Code extension
6
111
 
7
112
  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
@@ -343,13 +348,33 @@ For full control, create a `contextengine.json`:
343
348
 
344
349
  ### Config resolution order
345
350
 
351
+ Which **config file** is read (both the search corpus and the project fleet):
352
+
346
353
  | Priority | Source |
347
354
  |----------|--------|
348
355
  | 1 | `CONTEXTENGINE_CONFIG` env var |
349
356
  | 2 | `./contextengine.json` |
350
357
  | 3 | `~/.contextengine.json` |
351
- | 4 | `CONTEXTENGINE_WORKSPACES` env var |
352
- | 5 | `~/Projects` auto-discover |
358
+
359
+ Which **project fleet** is scanned — this is what `score --all`, `audit`, `list_projects`
360
+ and `check_ports` operate on:
361
+
362
+ | Priority | Source |
363
+ |----------|--------|
364
+ | 1 | `CONTEXTENGINE_WORKSPACES` env var (colon-separated) |
365
+ | 2 | `workspaces` in the config file |
366
+ | 3 | `~/Projects` auto-discover |
367
+
368
+ **The env var wins.** It is set per-invocation, so it is the most specific statement of
369
+ intent — and it is what the MCP config blocks in this README set. Use it to scope a run:
370
+
371
+ ```bash
372
+ CONTEXTENGINE_WORKSPACES=/tmp/sandbox npx @compr/opscontext-mcp score --all
373
+ ```
374
+
375
+ > Note: the **search corpus** (`search`, `reindex`, `list-sources`) still prefers the config
376
+ > file's `workspaces` over the env var. If you rely on the env var to scope indexing, set
377
+ > `CONTEXTENGINE_CONFIG` to a config without `workspaces`, or unset `workspaces` there.
353
378
 
354
379
  ## Plugin Adapters
355
380
 
@@ -436,6 +461,22 @@ The `score` command evaluates project AI-readiness across **documentation, infra
436
461
 
437
462
  **Grade scale:** A+ (90%+) · A (80%+) · B (70%+) · C (60%+) · D (50%+) · F (<50%)
438
463
 
464
+ ### What gets scored, and what gets written
465
+
466
+ `score` writes a `SCORE.md` into each project it scores. Because that is a write into your
467
+ repositories, the scope is never inferred:
468
+
469
+ | Command | Scores | Writes `SCORE.md` to |
470
+ |---|---|---|
471
+ | `score` | the project you are standing in (walks up to the repo root) | that one project |
472
+ | `score <name>` / `score <path>` | that one project | that one project |
473
+ | `score --all` | every discovered project | **every** discovered project |
474
+ | any of the above `--no-save` | as above | nothing |
475
+
476
+ A project argument may be a **name** (`PLANK.io`) or a **path** (`~/Projects/PLANK.io`,
477
+ `../PLANK.io`, or an absolute path). A path also works for projects outside your configured
478
+ workspaces.
479
+
439
480
  ### Project Naming & Structure Tips
440
481
 
441
482
  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, looksLikePath } 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,69 @@ 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
- console.error(`❌ Project not found: "${project}"`);
725
- console.error(`Available: ${projectDirs.map((d) => d.name).join(", ")}`);
754
+ // Say which of the three failures actually happened. "Not found" for a
755
+ // directory that plainly exists is the same absence-as-verdict mistake
756
+ // [SCORE-ACCEPTS-PATH] was written to fix — do not reintroduce it here.
757
+ const abs = resolve(project.replace(/^~/, homedir()));
758
+ const exists = existsSync(abs);
759
+ const isDir = exists && statSync(abs).isDirectory();
760
+ if (!looksLikePath(project)) {
761
+ // A bare name that missed the fleet index. Deliberately NOT resolved as a
762
+ // relative directory — that is what silently scored ./src and ./dist.
763
+ console.error(`❌ Project not found: "${project}"`);
764
+ if (isDir) {
765
+ console.error(` A directory named "${project}" exists here, but it is not a`);
766
+ console.error(` project in your fleet. To score it anyway: contextengine score ./${project}`);
767
+ }
768
+ console.error(`Available: ${projectDirs.map((d) => d.name).join(", ")}`);
769
+ }
770
+ else if (!exists) {
771
+ console.error(`❌ No such directory: ${abs}`);
772
+ }
773
+ else if (!isDir) {
774
+ console.error(`❌ Not a directory: ${abs}`);
775
+ }
776
+ else {
777
+ console.error(`❌ Not a project: ${abs}`);
778
+ console.error(` The directory exists, but has no .git, package.json, pyproject.toml`);
779
+ console.error(` or other build file — so it looks like a container, not a project.`);
780
+ console.error(` If you meant the projects INSIDE it, use: contextengine score --all`);
781
+ }
726
782
  process.exit(1);
727
783
  }
728
784
  scores = [scoreProject(dir)];
729
785
  }
730
786
  else {
731
- scores = projectDirs.map((d) => scoreProject(d));
787
+ // [SCORE-FLEET-IS-OPT-IN] no argument means "the project I am standing in",
788
+ // never "every project on this machine".
789
+ // [SCORE-CWD-MUST-BE-A-PROJECT] — and if I am not standing in a project, say so
790
+ // rather than scoring whatever directory happens to be here.
791
+ const root = findProjectRoot(process.cwd());
792
+ if (!root) {
793
+ console.error(`❌ Not inside a project: ${process.cwd()}`);
794
+ console.error(` No .git or package.json found here or in any parent directory.`);
795
+ console.error(` Do one of:`);
796
+ console.error(` • cd into a project, then run: contextengine score`);
797
+ console.error(` • name it: contextengine score <name|path>`);
798
+ console.error(` • score the whole fleet: contextengine score --all`);
799
+ process.exit(1);
800
+ }
801
+ const known = projectDirs.find((d) => resolve(d.path) === resolve(root));
802
+ const dir = known ?? { name: basename(root), path: root };
803
+ console.error(`📍 Scoring current project: ${dir.name} (${dir.path})`);
804
+ console.error(` Use --all to score every discovered project.\n`);
805
+ scores = [scoreProject(dir)];
732
806
  }
733
807
  if (html) {
734
808
  const htmlContent = generateScoreHTML(scores);
@@ -2111,7 +2185,11 @@ Usage:
2111
2185
  Fetch community-contributed learnings (Tier A = GitHub
2112
2186
  public, Tier B = api.compr.ch Pro). Daily run recommended.
2113
2187
  Network failures fall back to cached store.
2114
- contextengine score [project] [--html] [--no-save] AI-readiness score (Pro, writes SCORE.md)
2188
+ contextengine score [project|path] [--all] [--html] [--no-save]
2189
+ AI-readiness score (Pro, writes SCORE.md).
2190
+ No argument scores the CURRENT project only.
2191
+ Accepts a project name or a directory path.
2192
+ --all scores every discovered project (writes to each).
2115
2193
  contextengine audit Run compliance audit (Pro)
2116
2194
  contextengine activate <key> <email> Activate a Pro license
2117
2195
  contextengine deactivate Remove license and premium modules
@@ -2195,8 +2273,9 @@ else if (command === "score") {
2195
2273
  const args = process.argv.slice(3);
2196
2274
  const htmlFlag = args.includes("--html");
2197
2275
  const noSaveFlag = args.includes("--no-save");
2276
+ const allFlag = args.includes("--all");
2198
2277
  const project = args.filter(a => !a.startsWith("--"))[0];
2199
- cliScore(project, htmlFlag, !noSaveFlag).catch((err) => {
2278
+ cliScore(project, htmlFlag, !noSaveFlag, allFlag).catch((err) => {
2200
2279
  console.error("Error:", err);
2201
2280
  process.exit(1);
2202
2281
  });
package/dist/config.d.ts CHANGED
@@ -66,6 +66,19 @@ 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
+ * Does this token look like a filesystem path rather than a bare project name?
71
+ *
72
+ * Deliberately conservative: only strings that CANNOT be a directory basename
73
+ * (they contain a separator, or start with `~`/`.`) are treated as
74
+ * path-only. Everything else stays eligible for name lookup first, so
75
+ * `score KONIVE.com` keeps resolving exactly as it did before this existed.
76
+ */
77
+ export declare function looksLikePath(token: string): boolean;
78
+ /** Does this directory carry any build/VCS marker that makes it a project? */
79
+ export declare function hasProjectMarker(dir: string): boolean;
80
+ export declare function resolveProjectDir(token: string, dirs: ProjectDirectory[]): ProjectDirectory | null;
81
+ export declare function findProjectRoot(start: string): string | null;
69
82
  /**
70
83
  * Load the raw config (for checking flags like collectSystemOps).
71
84
  */
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";
@@ -188,12 +188,27 @@ export function loadProjectDirs() {
188
188
  workspaceDirs = config.workspaces.map((w) => resolve(configPath, "..", w.replace(/^~/, homedir())));
189
189
  }
190
190
  }
191
- // Env var fallback
192
- if (workspaceDirs.length === 0) {
193
- const envWorkspaces = process.env.CONTEXTENGINE_WORKSPACES;
194
- if (envWorkspaces) {
195
- workspaceDirs = envWorkspaces.split(":").filter(Boolean);
196
- }
191
+ /**
192
+ * 🔒 LOCKED [ENV-WORKSPACES-WINS] — 2026-08-16
193
+ * NEVER demote CONTEXTENGINE_WORKSPACES back to a fallback that only applies when the
194
+ * config file happens not to define `workspaces`.
195
+ * WHY: it WAS such a fallback (`if (workspaceDirs.length === 0)`), so on any machine with a
196
+ * contextengine.json defining workspaces — which is the documented setup — the env var
197
+ * was read, ignored, and never reported. Every MCP config block we ship in
198
+ * skills/opscontext/SKILL.md sets `env: { CONTEXTENGINE_WORKSPACES: ... }`, so our own
199
+ * documented integration silently did nothing.
200
+ * Caught the hard way: an attempt to sandbox a review agent by pointing this variable at
201
+ * a scratch directory was ignored, and `score --all` wrote SCORE.md into 28 real
202
+ * repositories instead. The sandbox reported success because the variable was accepted
203
+ * without complaint — absence of an error read as confirmation.
204
+ * FIX: standard precedence — an explicit env var beats a config file beats auto-discovery.
205
+ * It is set per-invocation and is therefore the most specific statement of intent.
206
+ */
207
+ const envWorkspaces = process.env.CONTEXTENGINE_WORKSPACES;
208
+ if (envWorkspaces) {
209
+ const fromEnv = envWorkspaces.split(":").filter(Boolean);
210
+ if (fromEnv.length > 0)
211
+ workspaceDirs = fromEnv;
197
212
  }
198
213
  // Auto-discover fallback
199
214
  if (workspaceDirs.length === 0) {
@@ -227,6 +242,145 @@ export function loadProjectDirs() {
227
242
  }
228
243
  return dirs;
229
244
  }
245
+ /**
246
+ * Does this token look like a filesystem path rather than a bare project name?
247
+ *
248
+ * Deliberately conservative: only strings that CANNOT be a directory basename
249
+ * (they contain a separator, or start with `~`/`.`) are treated as
250
+ * path-only. Everything else stays eligible for name lookup first, so
251
+ * `score KONIVE.com` keeps resolving exactly as it did before this existed.
252
+ */
253
+ export function looksLikePath(token) {
254
+ return (token.includes("/") ||
255
+ token.includes(sep) ||
256
+ token.startsWith("~") ||
257
+ token === "." ||
258
+ token === "..");
259
+ }
260
+ /**
261
+ * 🔒 LOCKED [SCORE-ACCEPTS-PATH] — 2026-08-16
262
+ * ⛔ NEVER narrow this back to `dirs.find(d => d.name === token)` alone.
263
+ * WHY: `contextengine score /Users/yan/Projects/PLANK.io` failed with
264
+ * "Project not found: /Users/yan/Projects/PLANK.io" while listing PLANK.io
265
+ * among the available projects. A path is the natural first guess for a
266
+ * tool that prints absolute paths in its own output, and the error named
267
+ * the one thing the user had clearly just given it. The directory was
268
+ * never inspected — the lookup only ever compared basenames, so this was
269
+ * [ABSENCE-IS-NOT-A-VERDICT] at the argument-parsing layer: "not in my
270
+ * name index" was reported as "does not exist".
271
+ * FIX: resolve names AND paths. A path that exists AND carries a project marker is a
272
+ * project, whether or not it sits under a configured workspace — that is what makes
273
+ * the tool usable outside `~/Projects`.
274
+ *
275
+ * 🔒 LOCKED [RESOLVE-PATH-MUST-BE-A-PROJECT] — 2026-08-16
276
+ * ⛔ NEVER accept "it is a directory that exists" as proof that a path is a project, and
277
+ * NEVER let a bare name that missed the index fall through to path resolution.
278
+ * WHY: the first cut did both, and an adversarial review reproduced three consequences.
279
+ * 1. `cd ~/Projects && score .` wrote `~/Projects/SCORE.md` — "Projects: 27/100 (F),
280
+ * Not a git repo" — into the CONTAINER of 37 repositories. The identical command
281
+ * WITHOUT the `.` was correctly refused, so the guard existed and one entry point
282
+ * walked straight past it. Same for `score ~/Projects`, `score ..`, and `score /`.
283
+ * 2. `score src`, `score dist`, `score docs` — a typo or a half-remembered name — no
284
+ * longer errored with "Available: …". The bare token fell through to
285
+ * `resolve(token)` against the cwd, so it silently scored a SUBDIRECTORY and wrote
286
+ * a SCORE.md into it. In this repo `score dist` writes `dist/SCORE.md`, which then
287
+ * ships inside the npm tarball.
288
+ * 3. It made the sibling LOCK a half-truth: [SCORE-CWD-MUST-BE-A-PROJECT] promises a
289
+ * non-project is never scored, but enforced it on the no-argument path only.
290
+ * FIX: a directory must carry a build/VCS marker to be scoreable, and a bare name resolves
291
+ * against the fleet index ONLY. Configured projects always pass — they are the fleet
292
+ * by definition. Absence of a marker is a measurement, not permission to write.
293
+ */
294
+ const PROJECT_MARKERS = [
295
+ ".git", "package.json", "pyproject.toml", "requirements.txt", "setup.py",
296
+ "composer.json", "pubspec.yaml", "go.mod", "Cargo.toml", "Gemfile",
297
+ "pom.xml", "build.gradle", "Makefile", "CMakeLists.txt",
298
+ ];
299
+ /** Does this directory carry any build/VCS marker that makes it a project? */
300
+ export function hasProjectMarker(dir) {
301
+ return PROJECT_MARKERS.some((m) => existsSync(join(dir, m)));
302
+ }
303
+ export function resolveProjectDir(token, dirs) {
304
+ // A bare name resolves against the fleet index ONLY. It must never silently
305
+ // become a cwd-relative directory — that is how `score src` wrote src/SCORE.md
306
+ // instead of printing "Project not found. Available: …". Use `./src` to mean a path.
307
+ if (!looksLikePath(token)) {
308
+ return (dirs.find((d) => d.name.toLowerCase() === token.toLowerCase()) ?? null);
309
+ }
310
+ // Path resolution — absolute, relative, or `~`-prefixed.
311
+ const abs = resolve(token.replace(/^~/, homedir()));
312
+ try {
313
+ if (!statSync(abs).isDirectory())
314
+ return null;
315
+ }
316
+ catch {
317
+ return null; // ENOENT / EACCES — not a usable directory.
318
+ }
319
+ // A configured project is a project by definition, marker or not.
320
+ const known = dirs.find((d) => resolve(d.path) === abs);
321
+ if (known)
322
+ return known;
323
+ // Otherwise it must look like a project. `~/Projects` and `/` do not.
324
+ return hasProjectMarker(abs) ? { name: basename(abs), path: abs } : null;
325
+ }
326
+ /**
327
+ * 🔒 LOCKED [SCORE-CWD-MUST-BE-A-PROJECT] — 2026-08-16
328
+ * ⛔ NEVER fall back to returning `start` when no project marker is found. A directory
329
+ * that is not a project must produce null, and the caller must refuse to score it.
330
+ * WHY: the first cut of this returned `start` on failure, reasoning that "an un-versioned
331
+ * directory is still scoreable — it just scores badly." That is exactly the
332
+ * absence-as-verdict mistake this codebase keeps relearning. Running `score` from
333
+ * `~/Projects` — a CONTAINER of 37 projects, not a project — walked to the filesystem
334
+ * root, found nothing, fell back, scored the container as though it were a project,
335
+ * and wrote `~/Projects/SCORE.md` claiming "Projects: 27/100 (F)". Run from `/` it
336
+ * would do the same to the filesystem root. "I cannot tell which project you mean" is
337
+ * an unknown, and the safe response to an unknown scope is to ask, never to write.
338
+ * FIX: return null and let the caller error out with the three things the user can do
339
+ * instead (cd into a project, name one, or --all). Found by an adversarial review
340
+ * agent that ran the real CLI from `/` and `~/Projects`.
341
+ *
342
+ * 🔒 LOCKED [GIT-ROOT-IS-THE-PROJECT-BOUNDARY] — 2026-08-16
343
+ * ⛔ NEVER return the nearest `package.json` directory without first checking whether a
344
+ * `.git` sits above it.
345
+ * WHY: stopping at the nearest marker meant `cd ContextEngine/server && score` reported
346
+ * **"Scoring current project: server ... 32% (F) — Not a git repo, No CI pipeline,
347
+ * README.md Missing"** and wrote `server/SCORE.md`. Every one of those statements is
348
+ * false about the project the user is standing in: the repo root has `.git`, CI, and
349
+ * a README. A build file marks a *package*; `.git` marks the *project*. Reporting
350
+ * "Not a git repo" from inside a git repo is the scorer describing a boundary it
351
+ * invented — absence-as-verdict again, this time about where the project ends.
352
+ * FIX: `.git` wins. Walk up looking for it, remembering the nearest build file on the way,
353
+ * and fall back to that remembered directory only if no `.git` exists anywhere above.
354
+ * A genuinely standalone package (no git anywhere) still resolves to itself.
355
+ *
356
+ * Walk up from `start` to the enclosing project root. Returns null when nothing is
357
+ * found anywhere above `start`.
358
+ */
359
+ const BUILD_FILE_MARKERS = [
360
+ "package.json", "pyproject.toml", "requirements.txt", "setup.py",
361
+ "composer.json", "pubspec.yaml", "go.mod", "Cargo.toml", "Gemfile",
362
+ "pom.xml", "build.gradle", "Makefile", "CMakeLists.txt",
363
+ ];
364
+ export function findProjectRoot(start) {
365
+ let dir = resolve(start);
366
+ let nearestBuildFile = null;
367
+ for (;;) {
368
+ // .git is the project boundary and always wins, however far up it sits.
369
+ if (existsSync(join(dir, ".git")))
370
+ return dir;
371
+ if (nearestBuildFile === null &&
372
+ BUILD_FILE_MARKERS.some((m) => existsSync(join(dir, m)))) {
373
+ nearestBuildFile = dir;
374
+ }
375
+ const parent = dirname(dir);
376
+ if (parent === dir)
377
+ break; // reached filesystem root
378
+ dir = parent;
379
+ }
380
+ // No .git anywhere above — a standalone package resolves to itself; a plain
381
+ // directory (a container, or /) resolves to nothing at all.
382
+ return nearestBuildFile;
383
+ }
230
384
  /**
231
385
  * Load the raw config (for checking flags like collectSystemOps).
232
386
  */
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.2",
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