@compr/opscontext-mcp 2.4.1 → 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
@@ -4,6 +4,53 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.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
+
7
54
  ## [2.4.1] — 2026-08-16 — `score` scope is now explicit: current project by default, `--all` to opt into the fleet
8
55
 
9
56
  ### Changed (behaviour — read this before upgrading)
package/README.md CHANGED
@@ -348,13 +348,33 @@ For full control, create a `contextengine.json`:
348
348
 
349
349
  ### Config resolution order
350
350
 
351
+ Which **config file** is read (both the search corpus and the project fleet):
352
+
351
353
  | Priority | Source |
352
354
  |----------|--------|
353
355
  | 1 | `CONTEXTENGINE_CONFIG` env var |
354
356
  | 2 | `./contextengine.json` |
355
357
  | 3 | `~/.contextengine.json` |
356
- | 4 | `CONTEXTENGINE_WORKSPACES` env var |
357
- | 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.
358
378
 
359
379
  ## Plugin Adapters
360
380
 
package/dist/cli.js CHANGED
@@ -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, resolveProjectDir, findProjectRoot } 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";
@@ -751,9 +751,34 @@ async function cliScore(project, html = false, save = true, all = false) {
751
751
  // [SCORE-ACCEPTS-PATH] — a path is as valid an identifier as a name.
752
752
  const dir = resolveProjectDir(project, projectDirs);
753
753
  if (!dir) {
754
- console.error(`❌ Project not found: "${project}"`);
755
- console.error(` Not a known project name, and not an existing directory.`);
756
- 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
+ }
757
782
  process.exit(1);
758
783
  }
759
784
  scores = [scoreProject(dir)];
package/dist/config.d.ts CHANGED
@@ -67,41 +67,17 @@ export declare function loadSources(): KnowledgeSource[];
67
67
  */
68
68
  export declare function loadProjectDirs(): ProjectDirectory[];
69
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`.
70
+ * Does this token look like a filesystem path rather than a bare project name?
100
71
  *
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`.
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.
104
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;
105
81
  export declare function findProjectRoot(start: string): string | null;
106
82
  /**
107
83
  * Load the raw config (for checking flags like collectSystemOps).
package/dist/config.js CHANGED
@@ -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) {
@@ -235,7 +250,7 @@ export function loadProjectDirs() {
235
250
  * path-only. Everything else stays eligible for name lookup first, so
236
251
  * `score KONIVE.com` keeps resolving exactly as it did before this existed.
237
252
  */
238
- function looksLikePath(token) {
253
+ export function looksLikePath(token) {
239
254
  return (token.includes("/") ||
240
255
  token.includes(sep) ||
241
256
  token.startsWith("~") ||
@@ -253,31 +268,60 @@ function looksLikePath(token) {
253
268
  * never inspected — the lookup only ever compared basenames, so this was
254
269
  * [ABSENCE-IS-NOT-A-VERDICT] at the argument-parsing layer: "not in my
255
270
  * 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`.
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.
259
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
+ }
260
303
  export function resolveProjectDir(token, dirs) {
261
- // Name lookup first preserves pre-existing behaviour exactly.
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.
262
307
  if (!looksLikePath(token)) {
263
- const byName = dirs.find((d) => d.name.toLowerCase() === token.toLowerCase());
264
- if (byName)
265
- return byName;
308
+ return (dirs.find((d) => d.name.toLowerCase() === token.toLowerCase()) ?? null);
266
309
  }
267
310
  // Path resolution — absolute, relative, or `~`-prefixed.
268
311
  const abs = resolve(token.replace(/^~/, homedir()));
269
312
  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
- }
313
+ if (!statSync(abs).isDirectory())
314
+ return null;
276
315
  }
277
316
  catch {
278
- // ENOENT / EACCES — not a usable directory. Fall through to null.
317
+ return null; // ENOENT / EACCES — not a usable directory.
279
318
  }
280
- return null;
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;
281
325
  }
282
326
  /**
283
327
  * 🔒 LOCKED [SCORE-CWD-MUST-BE-A-PROJECT] — 2026-08-16
@@ -295,21 +339,47 @@ export function resolveProjectDir(token, dirs) {
295
339
  * instead (cd into a project, name one, or --all). Found by an adversarial review
296
340
  * agent that ran the real CLI from `/` and `~/Projects`.
297
341
  *
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`.
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`.
301
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
+ ];
302
364
  export function findProjectRoot(start) {
303
365
  let dir = resolve(start);
366
+ let nearestBuildFile = null;
304
367
  for (;;) {
305
- if (existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"))) {
368
+ // .git is the project boundary and always wins, however far up it sits.
369
+ if (existsSync(join(dir, ".git")))
306
370
  return dir;
371
+ if (nearestBuildFile === null &&
372
+ BUILD_FILE_MARKERS.some((m) => existsSync(join(dir, m)))) {
373
+ nearestBuildFile = dir;
307
374
  }
308
375
  const parent = dirname(dir);
309
376
  if (parent === dir)
310
- return null; // reached filesystem root, no marker seen
377
+ break; // reached filesystem root
311
378
  dir = parent;
312
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;
313
383
  }
314
384
  /**
315
385
  * Load the raw config (for checking flags like collectSystemOps).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.4.1",
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",