@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 +98 -0
- package/README.md +22 -2
- package/dist/audit.d.ts +31 -0
- package/dist/audit.js +140 -40
- package/dist/cli.js +59 -10
- package/dist/config.d.ts +9 -33
- package/dist/config.js +97 -27
- package/package.json +1 -1
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
|
-
|
|
357
|
-
|
|
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
|
|
29
|
-
//
|
|
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
|
|
33
|
-
// guarantee —
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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)
|
|
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
|
|
1725
|
-
console.error(`
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
257
|
-
* it sits under a configured workspace — that is what makes
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
317
|
+
return null; // ENOENT / EACCES — not a usable directory.
|
|
279
318
|
}
|
|
280
|
-
|
|
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
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* above
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|