@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 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
- export declare function readAuditLog(): AuditRecord[];
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
- return JSON.parse(lines[lines.length - 1]).hash;
201
+ rec = JSON.parse(line);
185
202
  }
186
203
  catch {
187
- return GENESIS_HASH;
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
- try {
198
- return JSON.parse(lines[lines.length - 1]).hash;
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
- export function readAuditLog() {
252
- const path = auditPath();
253
- if (!existsSync(path))
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
- const data = readFileSync(path, "utf-8");
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
- // Default: start MCP server
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
- all = readAuditLog();
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.1",
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",