@compr/opscontext-mcp 2.5.1 ā 2.5.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/dist/audit.d.ts +59 -2
- package/dist/audit.js +231 -14
- package/dist/cli-commands.d.ts +30 -0
- package/dist/cli-commands.js +102 -0
- package/dist/cli.js +152 -6
- package/dist/detector.js +4 -1
- package/package.json +1 -1
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
|
@@ -8,7 +8,64 @@ export interface AuditRecord {
|
|
|
8
8
|
hash: string;
|
|
9
9
|
}
|
|
10
10
|
export declare function appendAudit(event: AuditEvent, payload: Record<string, unknown>, actor?: string): AuditRecord;
|
|
11
|
-
|
|
11
|
+
/** Archived segment filenames in chain order (oldest first). */
|
|
12
|
+
export declare function listSegments(): string[];
|
|
13
|
+
export interface ReadOptions {
|
|
14
|
+
/** Include archived segments. Default true ā callers asking for "the audit log" mean
|
|
15
|
+
* the whole history. Hot paths that only care about a recent window pass false. */
|
|
16
|
+
includeArchives?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function readAuditLog(opts?: ReadOptions): AuditRecord[];
|
|
19
|
+
export interface RotationPlan {
|
|
20
|
+
/** Records that would move to a segment. */
|
|
21
|
+
archiveCount: number;
|
|
22
|
+
/** Records that would stay in the live log. */
|
|
23
|
+
keepCount: number;
|
|
24
|
+
/** Timestamp cutoff: records strictly older than this are archived. */
|
|
25
|
+
cutoff: string;
|
|
26
|
+
segmentFile: string | null;
|
|
27
|
+
/** Set when the rotation must not run, with the reason. */
|
|
28
|
+
refusedReason: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface RotationResult extends RotationPlan {
|
|
31
|
+
rotated: boolean;
|
|
32
|
+
bytesArchived: number;
|
|
33
|
+
bytesRemaining: number;
|
|
34
|
+
}
|
|
35
|
+
export interface RotateOptions {
|
|
36
|
+
/** Archive records older than this many days. Minimum 1. */
|
|
37
|
+
keepDays?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Hard ceiling on how many records stay in the live log, whatever the dates say.
|
|
40
|
+
*
|
|
41
|
+
* š LOCKED [DATE-RETENTION-DOES-NOT-BOUND-SIZE] ā 2026-08-20
|
|
42
|
+
* ā NEVER ship rotation with a date rule alone.
|
|
43
|
+
* WHY: measured on the real log before shipping this ā at 80,000 records/day, a 30-day
|
|
44
|
+
* window left 390,445 records live and even a 3-day window left 205,422. Date
|
|
45
|
+
* retention bounds AGE, not SIZE, so on a busy machine it rotates and changes
|
|
46
|
+
* nothing that matters: readAuditLog() still costs seconds and hundreds of MB.
|
|
47
|
+
* The feature would have looked like it worked while leaving the problem in place.
|
|
48
|
+
* FIX: cut at whichever rule archives more, date or count. Count is what actually caps
|
|
49
|
+
* the file.
|
|
50
|
+
*/
|
|
51
|
+
maxRecords?: number;
|
|
52
|
+
/** Report what would happen and write nothing. */
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
now?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Plan a rotation without writing anything. Exported so the CLI's dry-run and the real
|
|
58
|
+
* run share one implementation and cannot disagree.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planRotation(opts?: RotateOptions): RotationPlan;
|
|
61
|
+
/**
|
|
62
|
+
* Move everything older than the cutoff into a numbered archive segment.
|
|
63
|
+
*
|
|
64
|
+
* Refuses to run on a chain that does not currently verify: rotating a log with altered
|
|
65
|
+
* or orphaned records would bake the damage into an append-only segment and make the
|
|
66
|
+
* cause unrecoverable. Forks are fine ā they are concurrency, not tampering.
|
|
67
|
+
*/
|
|
68
|
+
export declare function rotateAuditLog(opts?: RotateOptions): RotationResult;
|
|
12
69
|
export interface IntegrityReport {
|
|
13
70
|
ok: boolean;
|
|
14
71
|
total: number;
|
package/dist/audit.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// Records every state-changing operation. Each line carries the SHA-256 hash
|
|
47
47
|
// of the previous line's canonical content, so mutation of any historical
|
|
48
48
|
// record breaks chain verification at that index.
|
|
49
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, constants, } from "fs";
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, readdirSync, constants, } from "fs";
|
|
50
50
|
import { join } from "path";
|
|
51
51
|
import { homedir } from "os";
|
|
52
52
|
import { createHash } from "crypto";
|
|
@@ -180,12 +180,35 @@ function readLastHash() {
|
|
|
180
180
|
// Tail window held no complete record ā fall back to the full read.
|
|
181
181
|
return readLastHashFullScan();
|
|
182
182
|
}
|
|
183
|
+
return parseHeadOrThrow(lines[lines.length - 1]);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* š LOCKED [UNREADABLE-HEAD-IS-NOT-GENESIS] ā 2026-08-20
|
|
187
|
+
* ā NEVER return GENESIS_HASH because the last line failed to parse.
|
|
188
|
+
* WHY: both head readers ended in `catch { return GENESIS_HASH }`. A truncated or corrupt
|
|
189
|
+
* final record ā a partial write, a full disk, a killed process ā therefore made the
|
|
190
|
+
* next append chain onto genesis instead of onto the real head. verifyChain() reports
|
|
191
|
+
* that as an ORPHAN, i.e. "history was deleted", the hardest failure the log can
|
|
192
|
+
* produce, and it would be caused by the writer itself rather than by tampering.
|
|
193
|
+
* It is [ABSENCE-IS-NOT-A-VERDICT] on the bedrock path: "I could not read the head"
|
|
194
|
+
* was rendered as the specific, plausible claim "there is no history".
|
|
195
|
+
* FIX: throw. appendAudit() must surface problems loudly (see [AUDIT-CHAIN]); call sites
|
|
196
|
+
* that need isolation already use safeAppend(), which logs to stderr and continues.
|
|
197
|
+
*/
|
|
198
|
+
function parseHeadOrThrow(line) {
|
|
199
|
+
let rec;
|
|
183
200
|
try {
|
|
184
|
-
|
|
201
|
+
rec = JSON.parse(line);
|
|
185
202
|
}
|
|
186
203
|
catch {
|
|
187
|
-
|
|
204
|
+
throw new Error("Audit log tail is not valid JSON ā refusing to append onto an unknown head. " +
|
|
205
|
+
"Inspect the last line of ~/.contextengine/audit.log; a partial final record can be " +
|
|
206
|
+
"removed by hand, which verifyChain() will then confirm.");
|
|
188
207
|
}
|
|
208
|
+
if (typeof rec.hash !== "string" || rec.hash.length !== 64) {
|
|
209
|
+
throw new Error("Audit log tail has no usable hash ā refusing to append onto an unknown head.");
|
|
210
|
+
}
|
|
211
|
+
return rec.hash;
|
|
189
212
|
}
|
|
190
213
|
/** Fallback for the pathological case: a single record longer than TAIL_READ_BYTES. */
|
|
191
214
|
function readLastHashFullScan() {
|
|
@@ -194,12 +217,8 @@ function readLastHashFullScan() {
|
|
|
194
217
|
const lines = data.split("\n").filter(Boolean);
|
|
195
218
|
if (lines.length === 0)
|
|
196
219
|
return GENESIS_HASH;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
return GENESIS_HASH;
|
|
202
|
-
}
|
|
220
|
+
// [LOCK] [UNREADABLE-HEAD-IS-NOT-GENESIS] ā same rule as the tail reader.
|
|
221
|
+
return parseHeadOrThrow(lines[lines.length - 1]);
|
|
203
222
|
}
|
|
204
223
|
function computeHash(prevHash, ts, event, actor, payload) {
|
|
205
224
|
// Canonical serialization ā keys in fixed order so independent verifiers get
|
|
@@ -248,11 +267,43 @@ export function appendAudit(event, payload, actor = "system") {
|
|
|
248
267
|
release();
|
|
249
268
|
}
|
|
250
269
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
270
|
+
/**
|
|
271
|
+
* š LOCKED [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN] ā 2026-08-20
|
|
272
|
+
* ā NEVER rotate by truncating, moving or deleting audit.log. NEVER let a rotated log
|
|
273
|
+
* read as "history was deleted".
|
|
274
|
+
* WHY: verifyChain() classifies a record whose prev_hash names a hash absent from the log
|
|
275
|
+
* as an ORPHAN, which is a hard failure meaning deleted or truncated history ā the
|
|
276
|
+
* exact evidence claim SOC 2 CC7.2 / ISO 27001 A.12.4.1 rest on. A `mv audit.log
|
|
277
|
+
* audit.log.1` makes the very first record of the new file an orphan, so the naive
|
|
278
|
+
* rotation turns a healthy log into a permanent "TAMPERED" verdict for every future
|
|
279
|
+
* audit. The log reached 195 MB / 533,987 records with no rotation path precisely
|
|
280
|
+
* because the safe shape was never built.
|
|
281
|
+
* FIX: rotation MOVES a prefix of history into a numbered segment under audit-archive/
|
|
282
|
+
* and the canonical history is `segments in order ++ live log`. readAuditLog() reads
|
|
283
|
+
* that concatenation by default, so the chain stays linear and verification is
|
|
284
|
+
* unchanged. Segments are append-only and never rewritten.
|
|
285
|
+
*
|
|
286
|
+
* š LOCKED [ROTATE-ARCHIVE-BEFORE-TRUNCATE] ā 2026-08-20
|
|
287
|
+
* ā NEVER truncate the live log before the segment file is durably renamed into place.
|
|
288
|
+
* WHY: the reverse order loses records permanently on a crash between the two steps.
|
|
289
|
+
* This order can only ever produce a DUPLICATE (records in both the segment and the
|
|
290
|
+
* live log), which the seam de-dup below removes and which loses nothing.
|
|
291
|
+
* FIX: write segment tmp ā fsync ā rename ā write live remainder tmp ā fsync ā rename.
|
|
292
|
+
*/
|
|
293
|
+
function archiveDir() {
|
|
294
|
+
return join(auditDir(), "audit-archive");
|
|
295
|
+
}
|
|
296
|
+
const SEGMENT_RE = /^audit-(\d{4,})\.jsonl$/;
|
|
297
|
+
/** Archived segment filenames in chain order (oldest first). */
|
|
298
|
+
export function listSegments() {
|
|
299
|
+
const dir = archiveDir();
|
|
300
|
+
if (!existsSync(dir))
|
|
254
301
|
return [];
|
|
255
|
-
|
|
302
|
+
return readdirSync(dir)
|
|
303
|
+
.filter((f) => SEGMENT_RE.test(f))
|
|
304
|
+
.sort((a, b) => Number(SEGMENT_RE.exec(a)[1]) - Number(SEGMENT_RE.exec(b)[1]));
|
|
305
|
+
}
|
|
306
|
+
function parseLines(data, label) {
|
|
256
307
|
return data
|
|
257
308
|
.split("\n")
|
|
258
309
|
.filter(Boolean)
|
|
@@ -261,10 +312,176 @@ export function readAuditLog() {
|
|
|
261
312
|
return JSON.parse(line);
|
|
262
313
|
}
|
|
263
314
|
catch {
|
|
264
|
-
throw new Error(`Corrupt audit line ${i + 1}: not valid JSON`);
|
|
315
|
+
throw new Error(`Corrupt audit line ${i + 1} in ${label}: not valid JSON`);
|
|
265
316
|
}
|
|
266
317
|
});
|
|
267
318
|
}
|
|
319
|
+
export function readAuditLog(opts = {}) {
|
|
320
|
+
const includeArchives = opts.includeArchives !== false;
|
|
321
|
+
const path = auditPath();
|
|
322
|
+
const live = existsSync(path) ? parseLines(readFileSync(path, "utf-8"), "audit.log") : [];
|
|
323
|
+
if (!includeArchives)
|
|
324
|
+
return live;
|
|
325
|
+
const segments = listSegments();
|
|
326
|
+
if (segments.length === 0)
|
|
327
|
+
return live;
|
|
328
|
+
const history = [];
|
|
329
|
+
let lastSegmentHashes = new Set();
|
|
330
|
+
for (const f of segments) {
|
|
331
|
+
const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f);
|
|
332
|
+
// š LOCKED [NO-SPREAD-OVER-A-SEGMENT] ā 2026-08-20
|
|
333
|
+
// ā NEVER use push(...records) on a segment. Found on the first real rotation:
|
|
334
|
+
// a 494,152-record segment threw "Maximum call stack size exceeded" because the
|
|
335
|
+
// spread passes every element as a separate argument. Every unit test passed ā
|
|
336
|
+
// they used chains of a few thousand. Push in a loop, whatever the size.
|
|
337
|
+
for (const r of recs)
|
|
338
|
+
history.push(r);
|
|
339
|
+
lastSegmentHashes = new Set(recs.map((r) => r.hash));
|
|
340
|
+
}
|
|
341
|
+
// Seam de-dup ā see [ROTATE-ARCHIVE-BEFORE-TRUNCATE]. A crash after the segment was
|
|
342
|
+
// renamed but before the live log was truncated leaves the archived prefix present in
|
|
343
|
+
// both files. Drop only the LEADING run of live records already in the last segment;
|
|
344
|
+
// anything else is real history and must never be dropped.
|
|
345
|
+
let start = 0;
|
|
346
|
+
while (start < live.length && lastSegmentHashes.has(live[start].hash))
|
|
347
|
+
start++;
|
|
348
|
+
// [LOCK] [NO-SPREAD-OVER-A-SEGMENT] ā same reason.
|
|
349
|
+
for (let i = start; i < live.length; i++)
|
|
350
|
+
history.push(live[i]);
|
|
351
|
+
return history;
|
|
352
|
+
}
|
|
353
|
+
/** Never archive below this many most-recent records, whatever the date cutoff says.
|
|
354
|
+
* The live log has to keep enough context for the drift detectors' window. */
|
|
355
|
+
const MIN_LIVE_RECORDS = 2000;
|
|
356
|
+
/** Live-log ceiling when the caller does not set one. ~50k records ā 18 MB. */
|
|
357
|
+
const DEFAULT_MAX_LIVE_RECORDS = 50_000;
|
|
358
|
+
/**
|
|
359
|
+
* Plan a rotation without writing anything. Exported so the CLI's dry-run and the real
|
|
360
|
+
* run share one implementation and cannot disagree.
|
|
361
|
+
*/
|
|
362
|
+
export function planRotation(opts = {}) {
|
|
363
|
+
const keepDays = Math.max(1, Math.floor(opts.keepDays ?? 30));
|
|
364
|
+
const now = opts.now ?? Date.now();
|
|
365
|
+
const cutoff = new Date(now - keepDays * 86_400_000).toISOString();
|
|
366
|
+
const live = existsSync(auditPath())
|
|
367
|
+
? parseLines(readFileSync(auditPath(), "utf-8"), "audit.log")
|
|
368
|
+
: [];
|
|
369
|
+
let cutByDate = live.findIndex((r) => r.ts >= cutoff);
|
|
370
|
+
if (cutByDate === -1)
|
|
371
|
+
cutByDate = live.length; // every record is older than the cutoff
|
|
372
|
+
// [DATE-RETENTION-DOES-NOT-BOUND-SIZE] ā whichever rule archives more wins.
|
|
373
|
+
const maxRecords = Math.max(1, Math.floor(opts.maxRecords ?? DEFAULT_MAX_LIVE_RECORDS));
|
|
374
|
+
const cutByCount = Math.max(0, live.length - maxRecords);
|
|
375
|
+
let cut = Math.max(cutByDate, cutByCount);
|
|
376
|
+
// Keep the tail intact regardless of either rule.
|
|
377
|
+
cut = Math.min(cut, Math.max(0, live.length - MIN_LIVE_RECORDS));
|
|
378
|
+
const next = listSegments().length + 1;
|
|
379
|
+
return {
|
|
380
|
+
archiveCount: cut,
|
|
381
|
+
keepCount: live.length - cut,
|
|
382
|
+
cutoff,
|
|
383
|
+
segmentFile: cut > 0 ? `audit-${String(next).padStart(4, "0")}.jsonl` : null,
|
|
384
|
+
refusedReason: null,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Move everything older than the cutoff into a numbered archive segment.
|
|
389
|
+
*
|
|
390
|
+
* Refuses to run on a chain that does not currently verify: rotating a log with altered
|
|
391
|
+
* or orphaned records would bake the damage into an append-only segment and make the
|
|
392
|
+
* cause unrecoverable. Forks are fine ā they are concurrency, not tampering.
|
|
393
|
+
*/
|
|
394
|
+
export function rotateAuditLog(opts = {}) {
|
|
395
|
+
const path = auditPath();
|
|
396
|
+
const plan = planRotation(opts);
|
|
397
|
+
const empty = { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
|
|
398
|
+
if (!existsSync(path)) {
|
|
399
|
+
return { ...empty, refusedReason: "no audit log on disk" };
|
|
400
|
+
}
|
|
401
|
+
if (plan.archiveCount === 0) {
|
|
402
|
+
return {
|
|
403
|
+
...empty,
|
|
404
|
+
bytesRemaining: statSync(path).size,
|
|
405
|
+
refusedReason: `nothing to archive: ${plan.keepCount} record(s) live, within both the retention window and the size ceiling`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const integrity = verifyChain();
|
|
409
|
+
if (!integrity.ok) {
|
|
410
|
+
return {
|
|
411
|
+
...empty,
|
|
412
|
+
refusedReason: `chain does not verify (${integrity.breakReason}) ā refusing to archive a damaged log`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (opts.dryRun)
|
|
416
|
+
return { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
|
|
417
|
+
ensureDir();
|
|
418
|
+
const adir = archiveDir();
|
|
419
|
+
if (!existsSync(adir))
|
|
420
|
+
mkdirSync(adir, { recursive: true });
|
|
421
|
+
// Snapshot outside the lock: parsing 500k records is far too slow to hold the append
|
|
422
|
+
// lock for, and acquireLockSync() force-breaks locks older than STALE_LOCK_MS.
|
|
423
|
+
const snapshotSize = statSync(path).size;
|
|
424
|
+
const live = parseLines(readFileSync(path, "utf-8"), "audit.log");
|
|
425
|
+
const archived = live.slice(0, plan.archiveCount);
|
|
426
|
+
const remainder = live.slice(plan.archiveCount);
|
|
427
|
+
const segName = plan.segmentFile;
|
|
428
|
+
const segTmp = join(adir, `.${segName}.tmp`);
|
|
429
|
+
const segBody = archived.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
430
|
+
writeFileAndSync(segTmp, segBody);
|
|
431
|
+
renameSync(segTmp, join(adir, segName));
|
|
432
|
+
// [ROTATE-ARCHIVE-BEFORE-TRUNCATE]: the segment is durable from here on. Only now may
|
|
433
|
+
// the live log shrink.
|
|
434
|
+
const release = acquireLockSync();
|
|
435
|
+
let remainderBody = remainder.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
436
|
+
try {
|
|
437
|
+
const currentSize = statSync(path).size;
|
|
438
|
+
if (currentSize > snapshotSize) {
|
|
439
|
+
// Appends landed while we were writing the segment. They are newer than the cutoff
|
|
440
|
+
// by construction, so they belong to the remainder. Copy the raw bytes across
|
|
441
|
+
// rather than re-parsing the whole file.
|
|
442
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
443
|
+
try {
|
|
444
|
+
const buf = Buffer.alloc(currentSize - snapshotSize);
|
|
445
|
+
readSync(fd, buf, 0, buf.length, snapshotSize);
|
|
446
|
+
remainderBody += buf.toString("utf-8");
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
closeSync(fd);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const liveTmp = join(auditDir(), ".audit.log.tmp");
|
|
453
|
+
writeFileAndSync(liveTmp, remainderBody);
|
|
454
|
+
renameSync(liveTmp, path);
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
release();
|
|
458
|
+
}
|
|
459
|
+
// Self-documenting evidence: the rotation itself is an audited event, chained onto the
|
|
460
|
+
// new head like any other record.
|
|
461
|
+
appendAudit("audit.rotate", {
|
|
462
|
+
segment: segName,
|
|
463
|
+
archived_records: archived.length,
|
|
464
|
+
first_hash: archived[0].hash,
|
|
465
|
+
last_hash: archived[archived.length - 1].hash,
|
|
466
|
+
cutoff: plan.cutoff,
|
|
467
|
+
}, "system");
|
|
468
|
+
return {
|
|
469
|
+
...plan,
|
|
470
|
+
rotated: true,
|
|
471
|
+
bytesArchived: Buffer.byteLength(segBody),
|
|
472
|
+
bytesRemaining: statSync(path).size,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function writeFileAndSync(target, body) {
|
|
476
|
+
const fd = openSync(target, "w");
|
|
477
|
+
try {
|
|
478
|
+
writeSync(fd, body);
|
|
479
|
+
fsyncSync(fd);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
closeSync(fd);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
268
485
|
/**
|
|
269
486
|
* š LOCKED [VERIFY-FORK-IS-NOT-TAMPER] ā 2026-08-17
|
|
270
487
|
* ā NEVER report a forked chain as "the log was edited", and never stop at the first
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* š LOCKED [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā 2026-08-20
|
|
3
|
+
* ā NEVER route an unrecognised argv[2] to the MCP server again. The MCP server starts
|
|
4
|
+
* ONLY on a bare invocation, or on the explicit `serve` alias.
|
|
5
|
+
* WHY: `cli.ts` dispatched with a long if/else chain ending in `else { import("./index.js") }`,
|
|
6
|
+
* so ANY unknown token started a stdio server that silently waits on stdin. A typo
|
|
7
|
+
* (`contextengine scor`), a flag-first invocation (`contextengine --version`) or a
|
|
8
|
+
* renamed subcommand produced no error, no exit code, and no output ā it hung.
|
|
9
|
+
* This is [ABSENCE-IS-NOT-A-VERDICT] at the dispatch layer: "I do not recognise this"
|
|
10
|
+
* was rendered as "start the default mode", a plausible action chosen from a branch
|
|
11
|
+
* that had determined nothing. It has already cost a wrong finding: SESSION_22 §E3
|
|
12
|
+
* recorded "check_ports is ungated on the CLI" when there is no `check-ports` command
|
|
13
|
+
* at all ā what got measured was an MCP server booting.
|
|
14
|
+
* FIX: KNOWN_COMMANDS below is the single source of truth. Unknown token ā name it on
|
|
15
|
+
* stderr, suggest the nearest command, exit 1. A parity test asserts this list matches
|
|
16
|
+
* the literals the dispatcher actually handles, so the two cannot drift.
|
|
17
|
+
*/
|
|
18
|
+
/** Every token `cli.ts` dispatches on, including flag-style aliases. */
|
|
19
|
+
export declare const KNOWN_COMMANDS: readonly string[];
|
|
20
|
+
/** Commands that start the stdio MCP server. A bare invocation (argv[2] undefined)
|
|
21
|
+
* does the same ā that is the documented default and every launcher on disk uses it. */
|
|
22
|
+
export declare const SERVER_COMMANDS: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Closest known commands to `input`, nearest first, at most `limit`.
|
|
25
|
+
* Returns [] when nothing is close enough ā an empty suggestion list is honest,
|
|
26
|
+
* a wrong suggestion is not.
|
|
27
|
+
*/
|
|
28
|
+
export declare function suggestCommands(input: string, limit?: number): string[];
|
|
29
|
+
export declare function isKnownCommand(token: string): boolean;
|
|
30
|
+
//# sourceMappingURL=cli-commands.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* š LOCKED [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā 2026-08-20
|
|
3
|
+
* ā NEVER route an unrecognised argv[2] to the MCP server again. The MCP server starts
|
|
4
|
+
* ONLY on a bare invocation, or on the explicit `serve` alias.
|
|
5
|
+
* WHY: `cli.ts` dispatched with a long if/else chain ending in `else { import("./index.js") }`,
|
|
6
|
+
* so ANY unknown token started a stdio server that silently waits on stdin. A typo
|
|
7
|
+
* (`contextengine scor`), a flag-first invocation (`contextengine --version`) or a
|
|
8
|
+
* renamed subcommand produced no error, no exit code, and no output ā it hung.
|
|
9
|
+
* This is [ABSENCE-IS-NOT-A-VERDICT] at the dispatch layer: "I do not recognise this"
|
|
10
|
+
* was rendered as "start the default mode", a plausible action chosen from a branch
|
|
11
|
+
* that had determined nothing. It has already cost a wrong finding: SESSION_22 §E3
|
|
12
|
+
* recorded "check_ports is ungated on the CLI" when there is no `check-ports` command
|
|
13
|
+
* at all ā what got measured was an MCP server booting.
|
|
14
|
+
* FIX: KNOWN_COMMANDS below is the single source of truth. Unknown token ā name it on
|
|
15
|
+
* stderr, suggest the nearest command, exit 1. A parity test asserts this list matches
|
|
16
|
+
* the literals the dispatcher actually handles, so the two cannot drift.
|
|
17
|
+
*/
|
|
18
|
+
/** Every token `cli.ts` dispatches on, including flag-style aliases. */
|
|
19
|
+
export const KNOWN_COMMANDS = [
|
|
20
|
+
"--help",
|
|
21
|
+
"--version",
|
|
22
|
+
"-h",
|
|
23
|
+
"-v",
|
|
24
|
+
"activate",
|
|
25
|
+
"audit",
|
|
26
|
+
"audit-export",
|
|
27
|
+
"audit-rotate",
|
|
28
|
+
"audit-verify",
|
|
29
|
+
"autostart-status",
|
|
30
|
+
"cost",
|
|
31
|
+
"deactivate",
|
|
32
|
+
"delete-learning",
|
|
33
|
+
"delete-session",
|
|
34
|
+
"emit-event",
|
|
35
|
+
"end-session",
|
|
36
|
+
"export-learnings",
|
|
37
|
+
"help",
|
|
38
|
+
"hook",
|
|
39
|
+
"import-learnings",
|
|
40
|
+
"init",
|
|
41
|
+
"init-extension-secret",
|
|
42
|
+
"install-autostart",
|
|
43
|
+
"install-claude-hook",
|
|
44
|
+
"install-skill",
|
|
45
|
+
"list-learnings",
|
|
46
|
+
"list-projects",
|
|
47
|
+
"list-sessions",
|
|
48
|
+
"list-sources",
|
|
49
|
+
"load-session",
|
|
50
|
+
"policy",
|
|
51
|
+
"save-learning",
|
|
52
|
+
"save-session",
|
|
53
|
+
"score",
|
|
54
|
+
"search",
|
|
55
|
+
"serve",
|
|
56
|
+
"stats",
|
|
57
|
+
"status",
|
|
58
|
+
"sync-claude-md",
|
|
59
|
+
"sync-community-rules",
|
|
60
|
+
"uninstall-autostart",
|
|
61
|
+
"uninstall-claude-hook",
|
|
62
|
+
"version",
|
|
63
|
+
"watch",
|
|
64
|
+
];
|
|
65
|
+
/** Commands that start the stdio MCP server. A bare invocation (argv[2] undefined)
|
|
66
|
+
* does the same ā that is the documented default and every launcher on disk uses it. */
|
|
67
|
+
export const SERVER_COMMANDS = ["serve"];
|
|
68
|
+
/** Levenshtein distance, capped early ā only used to build a "did you mean" line. */
|
|
69
|
+
function editDistance(a, b) {
|
|
70
|
+
const m = a.length;
|
|
71
|
+
const n = b.length;
|
|
72
|
+
if (Math.abs(m - n) > 4)
|
|
73
|
+
return 99;
|
|
74
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
75
|
+
for (let i = 1; i <= m; i++) {
|
|
76
|
+
const cur = [i];
|
|
77
|
+
for (let j = 1; j <= n; j++) {
|
|
78
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
79
|
+
}
|
|
80
|
+
prev = cur;
|
|
81
|
+
}
|
|
82
|
+
return prev[n];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Closest known commands to `input`, nearest first, at most `limit`.
|
|
86
|
+
* Returns [] when nothing is close enough ā an empty suggestion list is honest,
|
|
87
|
+
* a wrong suggestion is not.
|
|
88
|
+
*/
|
|
89
|
+
export function suggestCommands(input, limit = 3) {
|
|
90
|
+
const candidates = KNOWN_COMMANDS.filter((c) => !c.startsWith("-"));
|
|
91
|
+
const scored = candidates
|
|
92
|
+
.map((c) => ({ c, d: editDistance(input.toLowerCase(), c) }))
|
|
93
|
+
// A prefix match is always relevant however long the tail ("audit" ā "audit-export").
|
|
94
|
+
.map((s) => ({ ...s, d: s.c.startsWith(input.toLowerCase()) ? Math.min(s.d, 2) : s.d }))
|
|
95
|
+
.filter((s) => s.d <= 3)
|
|
96
|
+
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c));
|
|
97
|
+
return scored.slice(0, limit).map((s) => s.c);
|
|
98
|
+
}
|
|
99
|
+
export function isKnownCommand(token) {
|
|
100
|
+
return KNOWN_COMMANDS.includes(token);
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=cli-commands.js.map
|
package/dist/cli.js
CHANGED
|
@@ -226,6 +226,52 @@ function generateMcpJson() {
|
|
|
226
226
|
// ---------------------------------------------------------------------------
|
|
227
227
|
// Template for pre-commit hook (CE doc freshness + secret scanner)
|
|
228
228
|
// ---------------------------------------------------------------------------
|
|
229
|
+
/**
|
|
230
|
+
* š LOCKED [SKIPPING-A-HOOK-MUST-NAME-WHAT-IS-UNENFORCED] ā 2026-08-20
|
|
231
|
+
* ā NEVER print a bare "already exists, skipping" for a hook slot. NEVER overwrite or
|
|
232
|
+
* append to a foreign hook either.
|
|
233
|
+
* WHY: `init` refuses to clobber an existing hook, which is right, but it said so with one
|
|
234
|
+
* grey line among a column of green ticks. The user reads "init done" and believes the
|
|
235
|
+
* policy gates are live; in that repo they were never installed and enforce nothing.
|
|
236
|
+
* This is exactly §E1 arriving through a different door: there, the rule existed and no
|
|
237
|
+
* hook called it; here, a hook exists and does not call the rule. Both produce a gate
|
|
238
|
+
* that is present in every document and absent at runtime, and neither produces an error.
|
|
239
|
+
* A real instance is on this machine: invocme-odoo-connector has a hand-written
|
|
240
|
+
* pre-commit, so `init` skipped it, and none of that repo's CE gates have ever run.
|
|
241
|
+
* Appending instead is not the fix, and is how that repo ended up with a whole secret
|
|
242
|
+
* scanner sitting unreachable behind an earlier `exit 0`.
|
|
243
|
+
* FIX: on skip, read the existing hook. If it does not invoke the CE CLI, say which gates are
|
|
244
|
+
* therefore unenforced and print the one line that wires them in. Silence here reads as
|
|
245
|
+
* success.
|
|
246
|
+
*/
|
|
247
|
+
function existingHookInvokesCE(path) {
|
|
248
|
+
try {
|
|
249
|
+
const body = readFileSync(path, "utf-8");
|
|
250
|
+
return /\b(contextengine|opscontext)\b/.test(body);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Unreadable is not proof of absence; say nothing rather than claim a verdict.
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function warnSkippedHook(path, slot) {
|
|
258
|
+
if (existingHookInvokesCE(path))
|
|
259
|
+
return;
|
|
260
|
+
const gates = {
|
|
261
|
+
"pre-commit": "secret-scan, doc-coverage, rule-parity",
|
|
262
|
+
"commit-msg": "commit-message-required",
|
|
263
|
+
"post-commit": "audit trail of the push",
|
|
264
|
+
};
|
|
265
|
+
const wire = {
|
|
266
|
+
"pre-commit": 'contextengine hook secret-scan && contextengine hook doc-coverage && contextengine hook rule-parity',
|
|
267
|
+
"commit-msg": 'contextengine hook commit-message-required "$1"',
|
|
268
|
+
"post-commit": "contextengine end-session",
|
|
269
|
+
};
|
|
270
|
+
console.log(` ā ļø that hook never calls ContextEngine, so these enforce NOTHING here:`);
|
|
271
|
+
console.log(` ${gates[slot]}`);
|
|
272
|
+
console.log(` Wire them by adding this line to ${path}:`);
|
|
273
|
+
console.log(` ${wire[slot]}`);
|
|
274
|
+
}
|
|
229
275
|
function generatePreCommitHook() {
|
|
230
276
|
const lines = [];
|
|
231
277
|
lines.push("#!/bin/zsh");
|
|
@@ -508,7 +554,8 @@ async function runInit() {
|
|
|
508
554
|
const postCommitDest = join(hooksDir, "post-commit");
|
|
509
555
|
const commitMsgDest = join(hooksDir, "commit-msg");
|
|
510
556
|
if (existsSync(preCommitDest)) {
|
|
511
|
-
console.log(" ā .git/hooks/pre-commit already exists ā skipping");
|
|
557
|
+
console.log(" ā .git/hooks/pre-commit already exists ā skipping (never overwritten)");
|
|
558
|
+
warnSkippedHook(preCommitDest, "pre-commit");
|
|
512
559
|
skipped++;
|
|
513
560
|
}
|
|
514
561
|
else {
|
|
@@ -523,7 +570,8 @@ async function runInit() {
|
|
|
523
570
|
// [COMMIT-MSG-HOOK-MUST-BE-INSTALLED] ā without this, every
|
|
524
571
|
// commit_message_required rule is silently unenforced.
|
|
525
572
|
if (existsSync(commitMsgDest)) {
|
|
526
|
-
console.log(" ā .git/hooks/commit-msg already exists ā skipping");
|
|
573
|
+
console.log(" ā .git/hooks/commit-msg already exists ā skipping (never overwritten)");
|
|
574
|
+
warnSkippedHook(commitMsgDest, "commit-msg");
|
|
527
575
|
skipped++;
|
|
528
576
|
}
|
|
529
577
|
else {
|
|
@@ -536,7 +584,8 @@ async function runInit() {
|
|
|
536
584
|
}
|
|
537
585
|
}
|
|
538
586
|
if (existsSync(postCommitDest)) {
|
|
539
|
-
console.log(" ā .git/hooks/post-commit already exists ā skipping");
|
|
587
|
+
console.log(" ā .git/hooks/post-commit already exists ā skipping (never overwritten)");
|
|
588
|
+
warnSkippedHook(postCommitDest, "post-commit");
|
|
540
589
|
skipped++;
|
|
541
590
|
}
|
|
542
591
|
else {
|
|
@@ -593,6 +642,7 @@ async function runInit() {
|
|
|
593
642
|
import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot, looksLikePath } from "./config.js";
|
|
594
643
|
import { ingestSources } from "./ingest.js";
|
|
595
644
|
import { searchChunks } from "./search.js";
|
|
645
|
+
import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
|
|
596
646
|
import { collectProjectOps, collectSystemOps } from "./collectors.js";
|
|
597
647
|
import { scanCodeDir } from "./code-chunker.js";
|
|
598
648
|
import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
|
|
@@ -600,7 +650,7 @@ import { listLearnings, learningsToChunks, learningsStats, formatLearnings, save
|
|
|
600
650
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
601
651
|
import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
|
|
602
652
|
import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
|
|
603
|
-
import { readAuditLog, verifyChain, filterByRange, toCsv, } from "./audit.js";
|
|
653
|
+
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, } from "./audit.js";
|
|
604
654
|
import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
|
|
605
655
|
import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, pricingStatus, pricingFor, } from "./transcript-collector.js";
|
|
606
656
|
import { DEFAULT_PRICING, DEFAULT_PRICING_ASOF } from "./default-pricing.js";
|
|
@@ -1857,6 +1907,65 @@ reviewed, and validated in PR ahead of the hook wiring.`);
|
|
|
1857
1907
|
console.error(`Unknown subcommand: ${sub}. Try 'contextengine policy --help'.`);
|
|
1858
1908
|
process.exit(1);
|
|
1859
1909
|
}
|
|
1910
|
+
function fmtBytes(n) {
|
|
1911
|
+
if (n >= 1024 ** 3)
|
|
1912
|
+
return `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
1913
|
+
if (n >= 1024 ** 2)
|
|
1914
|
+
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
1915
|
+
if (n >= 1024)
|
|
1916
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1917
|
+
return `${n} B`;
|
|
1918
|
+
}
|
|
1919
|
+
function cliAuditRotate(args) {
|
|
1920
|
+
const keepIdx = args.findIndex((a) => a === "--keep-days");
|
|
1921
|
+
const keepDays = keepIdx >= 0 ? Number(args[keepIdx + 1]) : 30;
|
|
1922
|
+
const maxIdx = args.findIndex((a) => a === "--max-records");
|
|
1923
|
+
const maxRecords = maxIdx >= 0 ? Number(args[maxIdx + 1]) : undefined;
|
|
1924
|
+
const dryRun = args.includes("--dry-run");
|
|
1925
|
+
if (!Number.isFinite(keepDays) || keepDays < 1) {
|
|
1926
|
+
console.error("--keep-days must be a number >= 1.");
|
|
1927
|
+
process.exit(1);
|
|
1928
|
+
}
|
|
1929
|
+
if (maxIdx >= 0 && (!Number.isFinite(maxRecords) || maxRecords < 1)) {
|
|
1930
|
+
console.error("--max-records must be a number >= 1.");
|
|
1931
|
+
process.exit(1);
|
|
1932
|
+
}
|
|
1933
|
+
if (dryRun) {
|
|
1934
|
+
const plan = planRotation({ keepDays, maxRecords });
|
|
1935
|
+
console.log(`\nš¦ Audit log rotation ā DRY RUN, nothing written\n`);
|
|
1936
|
+
console.log(` cutoff records older than ${plan.cutoff}`);
|
|
1937
|
+
console.log(` size ceiling ${maxRecords ?? 50000} record(s) kept live at most`);
|
|
1938
|
+
console.log(` would archive ${plan.archiveCount} record(s) ā ${plan.segmentFile ?? "(nothing)"}`);
|
|
1939
|
+
console.log(` would keep live ${plan.keepCount} record(s)`);
|
|
1940
|
+
console.log(`\n Run again without --dry-run to perform it.\n`);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
const result = rotateAuditLog({ keepDays, maxRecords });
|
|
1944
|
+
if (!result.rotated) {
|
|
1945
|
+
console.log(`\nNothing rotated: ${result.refusedReason}\n`);
|
|
1946
|
+
// Refusing because the chain is damaged is a failure, not a no-op.
|
|
1947
|
+
if (result.refusedReason?.includes("does not verify"))
|
|
1948
|
+
process.exit(2);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
console.log(`\nš¦ Audit log rotated\n`);
|
|
1952
|
+
console.log(` archived ${result.archiveCount} record(s) ā audit-archive/${result.segmentFile}`);
|
|
1953
|
+
console.log(` segment size ${fmtBytes(result.bytesArchived)}`);
|
|
1954
|
+
console.log(` live log now ${result.keepCount + 1} record(s), ${fmtBytes(result.bytesRemaining)}`);
|
|
1955
|
+
console.log(` segments on disk ${listSegments().length}`);
|
|
1956
|
+
console.log(`\n History is unchanged: archived segments are part of the chain, and`);
|
|
1957
|
+
console.log(` 'audit-verify' reads them. Do not delete or edit them ā that is the`);
|
|
1958
|
+
console.log(` one action that would turn this into missing history.\n`);
|
|
1959
|
+
const after = verifyChain();
|
|
1960
|
+
if (after.ok) {
|
|
1961
|
+
console.log(` ā
post-rotation verify: ${after.total} record(s), chain intact.\n`);
|
|
1962
|
+
}
|
|
1963
|
+
else {
|
|
1964
|
+
console.error(` ā post-rotation verify FAILED: ${after.breakReason}`);
|
|
1965
|
+
console.error(` The segment is on disk and nothing was deleted. Do not rotate again.\n`);
|
|
1966
|
+
process.exit(2);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1860
1969
|
async function cliAuditVerify() {
|
|
1861
1970
|
const report = verifyChain();
|
|
1862
1971
|
const forks = report.forkIndices ?? [];
|
|
@@ -2478,6 +2587,17 @@ async function cliCost(argv) {
|
|
|
2478
2587
|
}
|
|
2479
2588
|
console.log("");
|
|
2480
2589
|
}
|
|
2590
|
+
/** Package version, read from the installed package.json rather than hardcoded. */
|
|
2591
|
+
function readPackageVersion() {
|
|
2592
|
+
try {
|
|
2593
|
+
const here = new URL("../package.json", import.meta.url);
|
|
2594
|
+
return JSON.parse(readFileSync(here, "utf-8")).version;
|
|
2595
|
+
}
|
|
2596
|
+
catch {
|
|
2597
|
+
// No plausible-looking fallback here: an unknown version must read as unknown.
|
|
2598
|
+
return "unknown";
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2481
2601
|
// ---------------------------------------------------------------------------
|
|
2482
2602
|
// Main ā route to init, CLI subcommand, or MCP server
|
|
2483
2603
|
// ---------------------------------------------------------------------------
|
|
@@ -2515,6 +2635,12 @@ Usage:
|
|
|
2515
2635
|
Export hash-chained audit log (evidence aligned with
|
|
2516
2636
|
SOC 2 CC7.2 + ISO 27001 A.12.4.1 ā not a certification)
|
|
2517
2637
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
2638
|
+
contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
|
|
2639
|
+
Move old history into an archive segment. Archives
|
|
2640
|
+
whatever is older than N days (default 30) OR beyond
|
|
2641
|
+
the size ceiling (default 50000 live records),
|
|
2642
|
+
whichever is more. The chain stays linear: segments
|
|
2643
|
+
are part of the verified history, never deleted.
|
|
2518
2644
|
contextengine cost [--session ID] [--project NAME] [--run wf_ID] [--days N] [--top N] [--json]
|
|
2519
2645
|
Multi-agent spend from Claude Code transcripts. Always prints
|
|
2520
2646
|
VOLUME (tokens), VALUED COST (API list prices ā notional on a
|
|
@@ -2714,6 +2840,9 @@ else if (command === "sync-claude-md") {
|
|
|
2714
2840
|
process.exit(1);
|
|
2715
2841
|
});
|
|
2716
2842
|
}
|
|
2843
|
+
else if (command === "audit-rotate") {
|
|
2844
|
+
cliAuditRotate(process.argv.slice(3));
|
|
2845
|
+
}
|
|
2717
2846
|
else if (command === "audit-verify") {
|
|
2718
2847
|
cliAuditVerify().catch((err) => {
|
|
2719
2848
|
console.error("Error:", err);
|
|
@@ -2825,8 +2954,25 @@ else if (command === "status") {
|
|
|
2825
2954
|
}
|
|
2826
2955
|
console.log("");
|
|
2827
2956
|
}
|
|
2828
|
-
else {
|
|
2829
|
-
//
|
|
2957
|
+
else if (command === undefined || SERVER_COMMANDS.includes(command)) {
|
|
2958
|
+
// Documented default: a bare invocation (or the explicit `serve` alias) starts the
|
|
2959
|
+
// stdio MCP server. Every launcher on this machine uses the bare form.
|
|
2830
2960
|
import("./index.js");
|
|
2831
2961
|
}
|
|
2962
|
+
else if (command === "--version" || command === "-v" || command === "version") {
|
|
2963
|
+
console.log(readPackageVersion());
|
|
2964
|
+
}
|
|
2965
|
+
else {
|
|
2966
|
+
// š [LOCK] [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā see src/cli-commands.ts
|
|
2967
|
+
// An unrecognised token used to fall through to the MCP server, which then waited on
|
|
2968
|
+
// stdin forever: no error, no exit code, no output. Name it and fail instead.
|
|
2969
|
+
console.error(`Unknown command: ${command}`);
|
|
2970
|
+
const suggestions = suggestCommands(command);
|
|
2971
|
+
if (suggestions.length > 0) {
|
|
2972
|
+
console.error(`Did you mean: ${suggestions.join(", ")}?`);
|
|
2973
|
+
}
|
|
2974
|
+
console.error(`Run 'contextengine help' for the full list.`);
|
|
2975
|
+
console.error(`To start the MCP server, run 'contextengine' with no arguments.`);
|
|
2976
|
+
process.exit(1);
|
|
2977
|
+
}
|
|
2832
2978
|
//# sourceMappingURL=cli.js.map
|
package/dist/detector.js
CHANGED
|
@@ -25,7 +25,10 @@ import { homedir } from "os";
|
|
|
25
25
|
export function scanRecentEvents(windowSeconds = 300, now = Date.now()) {
|
|
26
26
|
let all;
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// Live log only. The window is minutes; archived segments are days old by
|
|
29
|
+
// construction (see [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN], MIN_LIVE_RECORDS floor),
|
|
30
|
+
// so reading them here would add the whole history to a hot path for zero hits.
|
|
31
|
+
all = readAuditLog({ includeArchives: false });
|
|
29
32
|
}
|
|
30
33
|
catch {
|
|
31
34
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.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",
|