@compr/opscontext-mcp 2.4.1 → 2.4.3

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,104 @@ 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.3] — 2026-08-17 — The audit verifier called concurrency "tampering" and condemned 316k records
8
+
9
+ `audit-verify` reported `❌ Audit chain BROKEN at index 2826 (of 319438)` and told the user the
10
+ log "was either edited after the fact, or a record was partially written during a crash… treat
11
+ all records from the break onward as unverified." That declared the entire SOC 2 / ISO evidence
12
+ base worthless — for a condition it had never actually tested.
13
+
14
+ Measured against the real 319k-record log: **0 records with altered content, 0 orphans, 66
15
+ concurrent-append forks.** Nothing had ever been edited. The verifier was reporting a verdict
16
+ about something it had not assessed — `[ABSENCE-IS-NOT-A-VERDICT]`, in the compliance feature
17
+ itself.
18
+
19
+ ### Fixed
20
+
21
+ - **`verifyChain` now classifies instead of bailing on the first linkage mismatch**
22
+ (`[VERIFY-FORK-IS-NOT-TAMPER]`). Three distinct findings, previously collapsed into one:
23
+ | Condition | Meaning | Verdict |
24
+ |---|---|---|
25
+ | record's own hash ≠ its content | content was altered | **TAMPER** — fail |
26
+ | `prev_hash` names a hash absent from the log | history deleted/truncated | **ORPHAN** — fail |
27
+ | `prev_hash` names a *known earlier* head | two processes appended concurrently | **FORK** — warn, still valid |
28
+ Content is now hashed against each record's **own** `prev_hash`, so a single fork no longer
29
+ cascades into false tamper reports for every record after it. The report carries
30
+ `tamperedIndices`, `orphanIndices` and `forkIndices`, and the CLI states plainly that a forked
31
+ log must **not** be rewritten to linearise it — doing so destroys the evidence it exists to provide.
32
+ - **Head-of-chain read is now O(1)** (`[AUDIT-TAIL-READ-IS-O1]`). It read the whole log and split
33
+ it on every append — **215 ms on a 120 MB log, inside the append lock**, allocating a
34
+ 319k-element array each time. Now seeks the last 64 KB. Measured **0.3 ms/append, ~700× faster**;
35
+ the lock is held for microseconds instead of a fifth of a second.
36
+ - **The in-process head cache is gone** (`[AUDIT-HEAD-FROM-DISK]`). Its safety guard compared a
37
+ locally *incremented* byte count against the real file size, so any divergence silently hashed
38
+ onto a stale head. The file's own `[audit-001-write-race]` LOCK already warned the cache was
39
+ "a perf optimization, NOT a correctness guarantee". With the O(1) read there is nothing left to
40
+ optimise, so the head is read from disk under the lock, every time.
41
+
42
+ ### Honest scope
43
+
44
+ The 64 forks dated 2026-06-10 predate the append lock (added 2026-06-24, v2.1.1). Two more dated
45
+ 2026-08-13 are **not explained** — the locked writer could not be made to fork under 16 concurrent
46
+ processes on either a small or a 120 MB log, and `emit-event` does use the locked path. The cache
47
+ removal eliminates the most plausible remaining mechanism *by construction*, but that is reasoning,
48
+ not a reproduction. Recorded rather than papered over.
49
+
50
+ **No log rewrite. No re-anchoring.** The forks are honest history.
51
+
52
+ ### Tests
53
+
54
+ 381 pass (8 new): fork-vs-tamper-vs-orphan classification, fork non-cascade, orphan/fork
55
+ disambiguation, cross-process head pickup, and tail-window overflow. Two existing tests asserted
56
+ the old prose and were re-pointed at the new classification rather than deleted.
57
+
58
+ ## [2.4.2] — 2026-08-16 — Close the three holes 2.4.1 left in "a directory is not a project"
59
+
60
+ 2.4.1 made `score`'s scope explicit but enforced it on only one of three entry points. An
61
+ adversarial review (30 agents, sandboxed) raised 26 findings, refuted 18, and confirmed 8 —
62
+ clustering into the fixes below. Every repro was reproduced against the shipped build before
63
+ being fixed, and re-run after.
64
+
65
+ ### Fixed
66
+
67
+ - **`score .` re-created the exact incident 2.4.1's LOCK forbids.** `cd ~/Projects && score .`
68
+ wrote `~/Projects/SCORE.md` — *"Projects: 27/100 (F), Not a git repo"* — into the container of
69
+ 37 repositories. The identical command *without* the `.` was correctly refused: the guard
70
+ existed, and the explicit-path entry point walked straight past it. Same for `score ~/Projects`,
71
+ `score ..` and `score /`. A path must now carry a build/VCS marker
72
+ (`.git`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `Makefile`, …) to be
73
+ scoreable. Locked as `[RESOLVE-PATH-MUST-BE-A-PROJECT]`.
74
+ - **A mistyped project name silently scored a subdirectory.** `score src`, `score dist`,
75
+ `score docs` no longer errored with the available-projects list — the bare token fell through
76
+ to path resolution against the cwd, scored the subdirectory, and wrote a `SCORE.md` into it.
77
+ In this repo `score dist` wrote `dist/SCORE.md`, which then ships inside the npm tarball. A
78
+ bare name now resolves against the fleet index **only**; the error names the directory it
79
+ found and offers `score ./src` as the explicit escape hatch.
80
+ - **`score` in a subpackage reported "Not a git repo" from inside a git repo.** `cd server &&
81
+ score` stopped at the nearest `package.json` and reported *"server — 32% (F), Not a git repo,
82
+ No CI pipeline, README.md Missing"* — every claim false about the project the user was standing
83
+ in. `.git` is now the project boundary and wins over any nearer build file; a genuinely
84
+ standalone package still resolves to itself. Locked as `[GIT-ROOT-IS-THE-PROJECT-BOUNDARY]`.
85
+ - **`CONTEXTENGINE_WORKSPACES` was read and ignored.** It was a fallback that applied only when
86
+ the config file defined no `workspaces` — so on the documented setup it did nothing, including
87
+ in every MCP config block this project ships. Precedence is now env var > config file >
88
+ auto-discovery. Locked as `[ENV-WORKSPACES-WINS]`.
89
+ - Error messages no longer say "not an existing directory" about a directory that exists. Each
90
+ of the four failure modes (unknown name / no such path / not a directory / not a project) now
91
+ names the actual reason.
92
+
93
+ ### Documentation
94
+
95
+ - README's "Config resolution order" table documented the pre-fix precedence and could not
96
+ describe both surfaces at once. Now split: config-file lookup, project-fleet lookup (env wins),
97
+ and an explicit note that the *search corpus* still prefers the config file.
98
+ - `CLAUDE.md` still told every agent in this repo that `npx . score` scores all projects.
99
+
100
+ ### Tests
101
+
102
+ 373 pass (8 new). One pre-existing test asserted the old behaviour — a marker-less directory
103
+ resolving as a project — and was corrected rather than deleted.
104
+
7
105
  ## [2.4.1] — 2026-08-16 — `score` scope is now explicit: current project by default, `--all` to opt into the fleet
8
106
 
9
107
  ### 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/audit.d.ts CHANGED
@@ -14,7 +14,38 @@ export interface IntegrityReport {
14
14
  total: number;
15
15
  breakAtIndex: number | null;
16
16
  breakReason: string | null;
17
+ /** Records whose own hash does not match their content. Non-empty = TAMPERED. */
18
+ tamperedIndices?: number[];
19
+ /** Records whose prev_hash names a hash that appears nowhere earlier in the log.
20
+ * Indicates deletion/truncation of history — treated as tampering. */
21
+ orphanIndices?: number[];
22
+ /** Records whose prev_hash names a KNOWN earlier head — a concurrent-append fork.
23
+ * Content is provably intact; only the linkage is non-linear. Not tampering. */
24
+ forkIndices?: number[];
17
25
  }
26
+ /**
27
+ * 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
28
+ * ⛔ NEVER report a forked chain as "the log was edited", and never stop at the first
29
+ * linkage mismatch without first checking whether any record's CONTENT is altered.
30
+ * WHY: the previous verifier returned on the first `prev_hash !== prev` and told the user
31
+ * the log "was either edited after the fact, or a record was partially written during
32
+ * a crash… treat all records from the break onward as unverified." On the author's log
33
+ * that meant declaring 316,000 records unverifiable — destroying the entire SOC 2 /
34
+ * ISO evidence claim — for a condition it had never actually tested. The truth, once
35
+ * measured: 0 of 319,461 records had an invalid self-hash (nothing was ever edited),
36
+ * 0 orphans (nothing was deleted), and all 66 breaks were forks where two processes
37
+ * read the same head and both appended.
38
+ * Tampering and concurrency produce DIFFERENT evidence, and conflating them is
39
+ * [ABSENCE-IS-NOT-A-VERDICT] applied to the compliance feature itself: the verifier
40
+ * reported a verdict ("edited") for something it had not assessed.
41
+ * FIX: classify every anomaly instead of bailing on the first.
42
+ * - self-hash mismatch → TAMPER (fail hard; content was altered)
43
+ * - prev_hash unknown → ORPHAN (fail hard; history was deleted/truncated)
44
+ * - prev_hash = a known earlier head → FORK (warn; concurrent append, content intact)
45
+ * `ok` is true when there are no tampered and no orphan records. Forks are surfaced
46
+ * with counts and indices so the report stays honest in both directions — it must
47
+ * never claim a forked log is pristine either.
48
+ */
18
49
  export declare function verifyChain(): IntegrityReport;
19
50
  export declare function filterByRange(records: AuditRecord[], since?: string, until?: string): AuditRecord[];
20
51
  export declare function toCsv(records: AuditRecord[]): string;
package/dist/audit.js CHANGED
@@ -25,16 +25,18 @@
25
25
  // (activation server + main MCP) reading the same prev_hash before
26
26
  // either had flushed. The lock serializes the read-then-write
27
27
  // window across processes.
28
- // ⛔ NEVER trust cachedLastHash without verifying file size hasn't
29
- // grown since cachedSize. Another process may have written between
30
- // OUR last write and OUR next read.
28
+ // ⛔ NEVER reintroduce an in-process head cache. STRENGTHENED 2026-08-17:
29
+ // the cache is GONE, not merely guarded. See [AUDIT-HEAD-FROM-DISK].
31
30
  // WHY: audit-001-write-race documented in Session 11 SCORE.md. The
32
- // in-process chain cache is a perf optimization, NOT a correctness
33
- // guarantee — correctness comes from the lock + the size-mismatch
34
- // re-read.
35
- // FIX: To raise throughput further (if profiling proves the stat() per
36
- // append is hot), batch appends within a process behind a single
37
- // lock acquisition. Don't remove the lock.
31
+ // in-process chain cache was a perf optimization, NOT a correctness
32
+ // guarantee — and the size-mismatch guard that was supposed to make it
33
+ // safe compared a locally-INCREMENTED byte count against the real file
34
+ // size, so any divergence silently hashed onto a stale head.
35
+ // The optimization is also now pointless: [AUDIT-TAIL-READ-IS-O1] made
36
+ // reading the true head ~0ms, down from 215ms on a 120MB log.
37
+ // FIX: hold the lock, read the real head from disk, append. Nothing else.
38
+ // Don't remove the lock, and don't add a cache back to "speed up" a
39
+ // path that is already O(1).
38
40
  //
39
41
  // Tamper-evident audit log — hash-chained JSONL at ~/.contextengine/audit.log.
40
42
  //
@@ -44,7 +46,7 @@
44
46
  // Records every state-changing operation. Each line carries the SHA-256 hash
45
47
  // of the previous line's canonical content, so mutation of any historical
46
48
  // record breaks chain verification at that index.
47
- import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, constants, } from "fs";
49
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, constants, } from "fs";
48
50
  import { join } from "path";
49
51
  import { homedir } from "os";
50
52
  import { createHash } from "crypto";
@@ -132,17 +134,68 @@ function ensureDir() {
132
134
  mkdirSync(dir, { recursive: true });
133
135
  }
134
136
  }
137
+ /**
138
+ * 🔒 LOCKED [AUDIT-TAIL-READ-IS-O1] — 2026-08-17
139
+ * ⛔ NEVER go back to readFileSync(whole log) + split("\n") to find the head.
140
+ * WHY: this ran INSIDE the append lock, so its cost was lock hold time. On the author's
141
+ * 319k-record / 120 MB log it measured **215 ms per append**, and it grows without
142
+ * bound. `acquireLockSync` force-breaks any lock older than STALE_LOCK_MS (10 s) to
143
+ * recover from crashed holders — which means a slow-but-perfectly-alive holder can
144
+ * have its lock STOLEN under load. Both processes then compute a hash from the same
145
+ * head and append: a forked chain. 66 forks exist in the log, all with prev_hash
146
+ * pointing at a known earlier head, none with tampered content.
147
+ * The whole-file read also allocated a 319k-element string array per append, on a
148
+ * path invoked once per PostToolUse hook firing.
149
+ * FIX: seek the tail. Read at most TAIL_READ_BYTES from the end and take the last complete
150
+ * line. O(1) in log size, ~0 ms, so the lock is held for microseconds and stale-break
151
+ * cannot fire on a live holder. Falls back to a full read only if the tail window
152
+ * somehow contains no complete line (pathologically long single record).
153
+ */
154
+ const TAIL_READ_BYTES = 64 * 1024;
135
155
  function readLastHash() {
136
156
  const path = auditPath();
137
157
  if (!existsSync(path))
138
158
  return GENESIS_HASH;
159
+ const size = statSync(path).size;
160
+ if (size === 0)
161
+ return GENESIS_HASH;
162
+ const start = Math.max(0, size - TAIL_READ_BYTES);
163
+ let tail;
164
+ const fd = openSync(path, constants.O_RDONLY);
165
+ try {
166
+ const buf = Buffer.alloc(size - start);
167
+ readSync(fd, buf, 0, buf.length, start);
168
+ tail = buf.toString("utf-8");
169
+ }
170
+ finally {
171
+ closeSync(fd);
172
+ }
173
+ // Drop a leading partial line when we started mid-record.
174
+ if (start > 0) {
175
+ const nl = tail.indexOf("\n");
176
+ tail = nl === -1 ? "" : tail.slice(nl + 1);
177
+ }
178
+ const lines = tail.split("\n").filter(Boolean);
179
+ if (lines.length === 0) {
180
+ // Tail window held no complete record — fall back to the full read.
181
+ return readLastHashFullScan();
182
+ }
183
+ try {
184
+ return JSON.parse(lines[lines.length - 1]).hash;
185
+ }
186
+ catch {
187
+ return GENESIS_HASH;
188
+ }
189
+ }
190
+ /** Fallback for the pathological case: a single record longer than TAIL_READ_BYTES. */
191
+ function readLastHashFullScan() {
192
+ const path = auditPath();
139
193
  const data = readFileSync(path, "utf-8");
140
194
  const lines = data.split("\n").filter(Boolean);
141
195
  if (lines.length === 0)
142
196
  return GENESIS_HASH;
143
197
  try {
144
- const last = JSON.parse(lines[lines.length - 1]);
145
- return last.hash;
198
+ return JSON.parse(lines[lines.length - 1]).hash;
146
199
  }
147
200
  catch {
148
201
  return GENESIS_HASH;
@@ -163,28 +216,32 @@ export function appendAudit(event, payload, actor = "system") {
163
216
  const release = acquireLockSync();
164
217
  try {
165
218
  const path = auditPath();
166
- // Cache validity check: if file size grew since OUR last write, another
167
- // process appended re-read prev hash from disk (the cache is stale).
168
- // Also handles first-ever call (cachedLastHash === null).
169
- const currentSize = existsSync(path) ? statSync(path).size : 0;
170
- if (cachedLastHash === null || currentSize !== cachedSize) {
171
- cachedLastHash = readLastHash();
172
- cachedSize = currentSize;
173
- }
219
+ // 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] 2026-08-17
220
+ // NEVER derive the head hash from an in-process cache again.
221
+ // WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
222
+ // a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
223
+ // not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
224
+ // — a partial write, a concurrent writer whose bytes happened to sum the same, an
225
+ // externally rotated/truncated log — left us hashing onto a head that is not the
226
+ // real tail, forking the chain. It was a correctness guarantee resting on a
227
+ // perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
228
+ // against ("the in-process chain cache is a perf optimization, NOT a correctness
229
+ // guarantee").
230
+ // FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
231
+ // to optimise. Read it from disk under the lock, every time. The cache is gone.
232
+ const prevHash = readLastHash();
174
233
  const ts = new Date().toISOString();
175
- const hash = computeHash(cachedLastHash, ts, event, actor, payload);
234
+ const hash = computeHash(prevHash, ts, event, actor, payload);
176
235
  const record = {
177
236
  ts,
178
237
  event,
179
238
  actor,
180
239
  payload,
181
- prev_hash: cachedLastHash,
240
+ prev_hash: prevHash,
182
241
  hash,
183
242
  };
184
243
  const line = JSON.stringify(record) + "\n";
185
244
  appendFileSync(path, line);
186
- cachedLastHash = hash;
187
- cachedSize += Buffer.byteLength(line, "utf-8");
188
245
  return record;
189
246
  }
190
247
  finally {
@@ -208,6 +265,29 @@ export function readAuditLog() {
208
265
  }
209
266
  });
210
267
  }
268
+ /**
269
+ * 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
270
+ * ⛔ NEVER report a forked chain as "the log was edited", and never stop at the first
271
+ * linkage mismatch without first checking whether any record's CONTENT is altered.
272
+ * WHY: the previous verifier returned on the first `prev_hash !== prev` and told the user
273
+ * the log "was either edited after the fact, or a record was partially written during
274
+ * a crash… treat all records from the break onward as unverified." On the author's log
275
+ * that meant declaring 316,000 records unverifiable — destroying the entire SOC 2 /
276
+ * ISO evidence claim — for a condition it had never actually tested. The truth, once
277
+ * measured: 0 of 319,461 records had an invalid self-hash (nothing was ever edited),
278
+ * 0 orphans (nothing was deleted), and all 66 breaks were forks where two processes
279
+ * read the same head and both appended.
280
+ * Tampering and concurrency produce DIFFERENT evidence, and conflating them is
281
+ * [ABSENCE-IS-NOT-A-VERDICT] applied to the compliance feature itself: the verifier
282
+ * reported a verdict ("edited") for something it had not assessed.
283
+ * FIX: classify every anomaly instead of bailing on the first.
284
+ * - self-hash mismatch → TAMPER (fail hard; content was altered)
285
+ * - prev_hash unknown → ORPHAN (fail hard; history was deleted/truncated)
286
+ * - prev_hash = a known earlier head → FORK (warn; concurrent append, content intact)
287
+ * `ok` is true when there are no tampered and no orphan records. Forks are surfaced
288
+ * with counts and indices so the report stays honest in both directions — it must
289
+ * never claim a forked log is pristine either.
290
+ */
211
291
  export function verifyChain() {
212
292
  let records;
213
293
  try {
@@ -221,29 +301,49 @@ export function verifyChain() {
221
301
  breakReason: e instanceof Error ? e.message : String(e),
222
302
  };
223
303
  }
304
+ const tampered = [];
305
+ const orphans = [];
306
+ const forks = [];
307
+ // Every hash observed so far, so a fork (parent = a known earlier head) can be told
308
+ // apart from an orphan (parent never existed in this log).
309
+ const seen = new Set([GENESIS_HASH]);
224
310
  let prev = GENESIS_HASH;
225
311
  for (let i = 0; i < records.length; i++) {
226
312
  const r = records[i];
313
+ // 1. Content integrity — the only check that can prove tampering. Computed against
314
+ // the record's OWN prev_hash, so a fork does not cascade into false tamper reports
315
+ // for every record after it.
316
+ const expected = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
317
+ if (r.hash !== expected)
318
+ tampered.push(i);
319
+ // 2. Linkage — fork vs orphan.
227
320
  if (r.prev_hash !== prev) {
228
- return {
229
- ok: false,
230
- total: records.length,
231
- breakAtIndex: i,
232
- breakReason: `prev_hash mismatch at index ${i}`,
233
- };
234
- }
235
- const expected = computeHash(prev, r.ts, r.event, r.actor, r.payload);
236
- if (r.hash !== expected) {
237
- return {
238
- ok: false,
239
- total: records.length,
240
- breakAtIndex: i,
241
- breakReason: `hash mismatch at index ${i} (record tampered)`,
242
- };
321
+ if (seen.has(r.prev_hash))
322
+ forks.push(i);
323
+ else
324
+ orphans.push(i);
243
325
  }
326
+ seen.add(r.hash);
244
327
  prev = r.hash;
245
328
  }
246
- return { ok: true, total: records.length, breakAtIndex: null, breakReason: null };
329
+ const ok = tampered.length === 0 && orphans.length === 0;
330
+ const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
331
+ let reason = null;
332
+ if (tampered.length > 0) {
333
+ reason = `${tampered.length} record(s) with altered content — first at index ${tampered[0]}`;
334
+ }
335
+ else if (orphans.length > 0) {
336
+ reason = `${orphans.length} record(s) whose parent is absent from the log (deleted or truncated history) — first at index ${orphans[0]}`;
337
+ }
338
+ return {
339
+ ok,
340
+ total: records.length,
341
+ breakAtIndex: firstProblem,
342
+ breakReason: reason,
343
+ tamperedIndices: tampered,
344
+ orphanIndices: orphans,
345
+ forkIndices: forks,
346
+ };
247
347
  }
248
348
  export function filterByRange(records, since, until) {
249
349
  return records.filter((r) => {
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)];
@@ -1717,15 +1742,39 @@ reviewed, and validated in PR ahead of the hook wiring.`);
1717
1742
  }
1718
1743
  async function cliAuditVerify() {
1719
1744
  const report = verifyChain();
1745
+ const forks = report.forkIndices ?? [];
1720
1746
  if (report.ok) {
1721
- console.log(`✅ Audit chain verified — ${report.total} record(s), hash chain intact.`);
1747
+ console.log(`✅ Audit chain verified — ${report.total} record(s).`);
1748
+ console.log(` No record was altered, and no history is missing.`);
1749
+ if (forks.length > 0) {
1750
+ // [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
1751
+ console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
1752
+ console.log(` Two processes read the same chain head and both appended, so the log is`);
1753
+ console.log(` branched rather than strictly linear at: ${forks.slice(0, 8).join(", ")}${forks.length > 8 ? `, … (+${forks.length - 8} more)` : ""}`);
1754
+ console.log(` Every record's content still hashes correctly, so the tamper-evidence`);
1755
+ console.log(` property holds. Do NOT rewrite the log to linearise it — that would destroy`);
1756
+ console.log(` the evidence it exists to provide.`);
1757
+ }
1722
1758
  return;
1723
1759
  }
1724
- console.error(`❌ Audit chain BROKEN at index ${report.breakAtIndex} (of ${report.total}).`);
1725
- console.error(` Reason: ${report.breakReason}`);
1726
- console.error(`\nA broken chain means the log was either edited after the fact, or a record was`);
1727
- console.error(`partially written during a crash. For compliance-graded evidence, treat all`);
1728
- console.error(`records from the break onward as unverified.`);
1760
+ console.error(`❌ Audit chain FAILED ${report.total} record(s) checked.`);
1761
+ console.error(` ${report.breakReason}`);
1762
+ if ((report.tamperedIndices ?? []).length > 0) {
1763
+ const t = report.tamperedIndices;
1764
+ console.error(`\n Altered records (content does not match its own hash):`);
1765
+ console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
1766
+ console.error(` This is tampering: the record's bytes were changed after it was written.`);
1767
+ }
1768
+ if ((report.orphanIndices ?? []).length > 0) {
1769
+ const o = report.orphanIndices;
1770
+ console.error(`\n Orphaned records (parent hash absent from the log):`);
1771
+ console.error(` ${o.slice(0, 10).join(", ")}${o.length > 10 ? `, … (+${o.length - 10} more)` : ""}`);
1772
+ console.error(` This means earlier records were deleted or the log was truncated.`);
1773
+ }
1774
+ if (forks.length > 0) {
1775
+ console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
1776
+ }
1777
+ console.error(`\nFor compliance-graded evidence, treat the affected records as unverified.`);
1729
1778
  process.exit(2);
1730
1779
  }
1731
1780
  async function cliEndSession() {
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.3",
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",