@compr/opscontext-mcp 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -593,6 +593,7 @@ async function runInit() {
593
593
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot, looksLikePath } from "./config.js";
594
594
  import { ingestSources } from "./ingest.js";
595
595
  import { searchChunks } from "./search.js";
596
+ import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
596
597
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
597
598
  import { scanCodeDir } from "./code-chunker.js";
598
599
  import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
@@ -600,9 +601,10 @@ import { listLearnings, learningsToChunks, learningsStats, formatLearnings, save
600
601
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
601
602
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
602
603
  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";
604
+ import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, } from "./audit.js";
604
605
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
605
- import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, } from "./transcript-collector.js";
606
+ import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, pricingStatus, pricingFor, } from "./transcript-collector.js";
607
+ import { DEFAULT_PRICING, DEFAULT_PRICING_ASOF } from "./default-pricing.js";
606
608
  import { runTranscriptHeuristics, DEFAULT_COST_THRESHOLDS, } from "./detector.js";
607
609
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
608
610
  import { safeAppend } from "./audit.js";
@@ -1856,6 +1858,65 @@ reviewed, and validated in PR ahead of the hook wiring.`);
1856
1858
  console.error(`Unknown subcommand: ${sub}. Try 'contextengine policy --help'.`);
1857
1859
  process.exit(1);
1858
1860
  }
1861
+ function fmtBytes(n) {
1862
+ if (n >= 1024 ** 3)
1863
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
1864
+ if (n >= 1024 ** 2)
1865
+ return `${(n / 1024 ** 2).toFixed(1)} MB`;
1866
+ if (n >= 1024)
1867
+ return `${(n / 1024).toFixed(1)} KB`;
1868
+ return `${n} B`;
1869
+ }
1870
+ function cliAuditRotate(args) {
1871
+ const keepIdx = args.findIndex((a) => a === "--keep-days");
1872
+ const keepDays = keepIdx >= 0 ? Number(args[keepIdx + 1]) : 30;
1873
+ const maxIdx = args.findIndex((a) => a === "--max-records");
1874
+ const maxRecords = maxIdx >= 0 ? Number(args[maxIdx + 1]) : undefined;
1875
+ const dryRun = args.includes("--dry-run");
1876
+ if (!Number.isFinite(keepDays) || keepDays < 1) {
1877
+ console.error("--keep-days must be a number >= 1.");
1878
+ process.exit(1);
1879
+ }
1880
+ if (maxIdx >= 0 && (!Number.isFinite(maxRecords) || maxRecords < 1)) {
1881
+ console.error("--max-records must be a number >= 1.");
1882
+ process.exit(1);
1883
+ }
1884
+ if (dryRun) {
1885
+ const plan = planRotation({ keepDays, maxRecords });
1886
+ console.log(`\n📦 Audit log rotation — DRY RUN, nothing written\n`);
1887
+ console.log(` cutoff records older than ${plan.cutoff}`);
1888
+ console.log(` size ceiling ${maxRecords ?? 50000} record(s) kept live at most`);
1889
+ console.log(` would archive ${plan.archiveCount} record(s) → ${plan.segmentFile ?? "(nothing)"}`);
1890
+ console.log(` would keep live ${plan.keepCount} record(s)`);
1891
+ console.log(`\n Run again without --dry-run to perform it.\n`);
1892
+ return;
1893
+ }
1894
+ const result = rotateAuditLog({ keepDays, maxRecords });
1895
+ if (!result.rotated) {
1896
+ console.log(`\nNothing rotated: ${result.refusedReason}\n`);
1897
+ // Refusing because the chain is damaged is a failure, not a no-op.
1898
+ if (result.refusedReason?.includes("does not verify"))
1899
+ process.exit(2);
1900
+ return;
1901
+ }
1902
+ console.log(`\n📦 Audit log rotated\n`);
1903
+ console.log(` archived ${result.archiveCount} record(s) → audit-archive/${result.segmentFile}`);
1904
+ console.log(` segment size ${fmtBytes(result.bytesArchived)}`);
1905
+ console.log(` live log now ${result.keepCount + 1} record(s), ${fmtBytes(result.bytesRemaining)}`);
1906
+ console.log(` segments on disk ${listSegments().length}`);
1907
+ console.log(`\n History is unchanged: archived segments are part of the chain, and`);
1908
+ console.log(` 'audit-verify' reads them. Do not delete or edit them — that is the`);
1909
+ console.log(` one action that would turn this into missing history.\n`);
1910
+ const after = verifyChain();
1911
+ if (after.ok) {
1912
+ console.log(` ✅ post-rotation verify: ${after.total} record(s), chain intact.\n`);
1913
+ }
1914
+ else {
1915
+ console.error(` ❌ post-rotation verify FAILED: ${after.breakReason}`);
1916
+ console.error(` The segment is on disk and nothing was deleted. Do not rotate again.\n`);
1917
+ process.exit(2);
1918
+ }
1919
+ }
1859
1920
  async function cliAuditVerify() {
1860
1921
  const report = verifyChain();
1861
1922
  const forks = report.forkIndices ?? [];
@@ -2289,20 +2350,27 @@ function loadCostThresholds(cwd) {
2289
2350
  const res = loadRepoPolicy(cwd);
2290
2351
  if (res && res.ok && res.policy.agent_cost) {
2291
2352
  const a = res.policy.agent_cost;
2353
+ // [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — an agent_cost block that omits
2354
+ // `pricing` must not silently price nothing.
2355
+ const hasOwnRates = a.pricing.length > 0;
2292
2356
  return {
2293
2357
  t: {
2294
2358
  billing_mode: a.billing_mode,
2295
- pricing: a.pricing,
2359
+ pricing: hasOwnRates ? a.pricing : DEFAULT_PRICING,
2296
2360
  min_cache_efficiency: a.min_cache_efficiency,
2297
2361
  max_tool_calls_per_agent: a.max_tool_calls_per_agent,
2298
2362
  max_cost_per_agent_usd: a.max_cost_per_agent_usd,
2299
2363
  min_fanout_for_canary: a.min_fanout_for_canary,
2300
2364
  max_failed_share: a.max_failed_share,
2301
2365
  },
2302
- source: ".contextengine/policy.json",
2366
+ source: ".contextengine/policy.json" +
2367
+ (hasOwnRates ? "" : ` (rates: built-in, as of ${DEFAULT_PRICING_ASOF})`),
2303
2368
  };
2304
2369
  }
2305
- return { t: DEFAULT_COST_THRESHOLDS, source: "built-in defaults (no agent_cost in policy.json)" };
2370
+ return {
2371
+ t: DEFAULT_COST_THRESHOLDS,
2372
+ source: `built-in defaults, rates as of ${DEFAULT_PRICING_ASOF} (no agent_cost in policy.json)`,
2373
+ };
2306
2374
  }
2307
2375
  async function cliCost(argv) {
2308
2376
  const flag = (name) => {
@@ -2355,7 +2423,17 @@ async function cliCost(argv) {
2355
2423
  let vol = emptyTally();
2356
2424
  let agents = 0, toolCalls = 0, failed = 0, capacity = 0, reported = 0;
2357
2425
  let cost = 0, withoutCache = 0, unpriced = 0;
2426
+ // Which models carried tokens but matched no rate — named in the output so
2427
+ // the fix is actionable instead of "something was unpriced".
2428
+ const unpricedModels = new Set();
2358
2429
  for (const { run, m } of scored) {
2430
+ for (const a of run.agents) {
2431
+ for (const [model, tally] of a.tokensByModel) {
2432
+ if (totalTokens(tally) > 0 && !pricingFor(model, t.pricing)) {
2433
+ unpricedModels.add(model ?? "(no model recorded)");
2434
+ }
2435
+ }
2436
+ }
2359
2437
  vol = addTally(vol, run.totals);
2360
2438
  agents += m.agents;
2361
2439
  toolCalls += m.toolCalls;
@@ -2383,12 +2461,6 @@ async function cliCost(argv) {
2383
2461
  console.log("");
2384
2462
  // ── 2. VALUED COST ──────────────────────────────────────────────────────
2385
2463
  const notional = t.billing_mode === "subscription";
2386
- console.log(`VALUED COST (API list prices)${notional ? " — NOTIONAL, NOT BILLED" : ""}`);
2387
- if (notional) {
2388
- console.log(" This machine runs Claude Code on a subscription: no dollar below is");
2389
- console.log(" debited. Use these figures to compare approaches, not as spend.");
2390
- }
2391
- const costRow = (label, n) => console.log(` ${label.padEnd(16)} ${("$" + n.toFixed(2)).padStart(8)} ${cost ? ((100 * n) / cost).toFixed(1).padStart(5) : " 0.0"}%`);
2392
2464
  let ci = 0, ccw = 0, ccr = 0, co = 0;
2393
2465
  for (const { m } of scored) {
2394
2466
  ci += m.cost.input;
@@ -2396,16 +2468,42 @@ async function cliCost(argv) {
2396
2468
  ccr += m.cost.cacheRead;
2397
2469
  co += m.cost.output;
2398
2470
  }
2399
- costRow("cache read", ccr);
2400
- costRow("cache write", ccw);
2401
- costRow("input (fresh)", ci);
2402
- costRow("output", co);
2403
- console.log(` ${"total".padEnd(16)} ${("$" + cost.toFixed(2)).padStart(8)}`);
2404
- console.log(` ${"without cache".padEnd(16)} ${("$" + withoutCache.toFixed(2)).padStart(8)} ` +
2405
- `caching saved $${(withoutCache - cost).toFixed(2)} (${withoutCache ? (100 * (1 - cost / withoutCache)).toFixed(0) : "0"}%)`);
2406
- if (unpriced > 0)
2407
- console.log(` ⚠ ${fmtTok(unpriced)} tokens UNPRICED (no matching model in policy) — not counted above`);
2408
- console.log("");
2471
+ const agg = {
2472
+ input: ci, cacheWrite: ccw, cacheRead: ccr, output: co,
2473
+ total: cost, withoutCache, unpricedTokens: unpriced,
2474
+ };
2475
+ const status = pricingStatus(agg);
2476
+ console.log(`VALUED COST (API list prices)${notional && status !== "unpriced" ? " — NOTIONAL, NOT BILLED" : ""}`);
2477
+ // [NEVER-RENDER-AN-UNKNOWN-AS-A-NUMBER] with nothing priced there is no
2478
+ // cost to show. Printing a $0.00 table here reads as "this run was free"
2479
+ // and "caching saved 0%", both false.
2480
+ if (status === "unpriced") {
2481
+ console.log(` UNPRICED — no rate matched any model in this data, so no cost can be`);
2482
+ console.log(` stated. ${fmtTok(unpriced)} tokens were moved. This is an unknown, not $0.`);
2483
+ console.log("");
2484
+ console.log(` Models seen without a rate: ${[...unpricedModels].sort().join(", ") || "(unknown)"}`);
2485
+ console.log(` Add them to .contextengine/policy.json → agent_cost.pricing.`);
2486
+ console.log("");
2487
+ }
2488
+ else {
2489
+ if (notional) {
2490
+ console.log(" This machine runs Claude Code on a subscription: no dollar below is");
2491
+ console.log(" debited. Use these figures to compare approaches, not as spend.");
2492
+ }
2493
+ const costRow = (label, n) => console.log(` ${label.padEnd(16)} ${("$" + n.toFixed(2)).padStart(8)} ${cost ? ((100 * n) / cost).toFixed(1).padStart(5) : " 0.0"}%`);
2494
+ costRow("cache read", ccr);
2495
+ costRow("cache write", ccw);
2496
+ costRow("input (fresh)", ci);
2497
+ costRow("output", co);
2498
+ console.log(` ${"total".padEnd(16)} ${("$" + cost.toFixed(2)).padStart(8)}`);
2499
+ console.log(` ${"without cache".padEnd(16)} ${("$" + withoutCache.toFixed(2)).padStart(8)} ` +
2500
+ `caching saved $${(withoutCache - cost).toFixed(2)} (${withoutCache ? (100 * (1 - cost / withoutCache)).toFixed(0) : "0"}%)`);
2501
+ if (status === "partial") {
2502
+ console.log(` ⚠ ${fmtTok(unpriced)} tokens UNPRICED and NOT in the figures above` +
2503
+ ` (${[...unpricedModels].sort().join(", ") || "unknown model"}) — the total is a floor, not the cost`);
2504
+ }
2505
+ console.log("");
2506
+ }
2409
2507
  // ── 3. INTENSITY (the capacity proxy) ───────────────────────────────────
2410
2508
  console.log(`INTENSITY (capacity proxy${notional ? " — the scarce resource here" : ""})`);
2411
2509
  console.log(` subagents ${String(agents).padStart(8)}`);
@@ -2440,6 +2538,17 @@ async function cliCost(argv) {
2440
2538
  }
2441
2539
  console.log("");
2442
2540
  }
2541
+ /** Package version, read from the installed package.json rather than hardcoded. */
2542
+ function readPackageVersion() {
2543
+ try {
2544
+ const here = new URL("../package.json", import.meta.url);
2545
+ return JSON.parse(readFileSync(here, "utf-8")).version;
2546
+ }
2547
+ catch {
2548
+ // No plausible-looking fallback here: an unknown version must read as unknown.
2549
+ return "unknown";
2550
+ }
2551
+ }
2443
2552
  // ---------------------------------------------------------------------------
2444
2553
  // Main — route to init, CLI subcommand, or MCP server
2445
2554
  // ---------------------------------------------------------------------------
@@ -2477,6 +2586,12 @@ Usage:
2477
2586
  Export hash-chained audit log (evidence aligned with
2478
2587
  SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
2479
2588
  contextengine audit-verify Verify audit log chain integrity (tamper detection)
2589
+ contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
2590
+ Move old history into an archive segment. Archives
2591
+ whatever is older than N days (default 30) OR beyond
2592
+ the size ceiling (default 50000 live records),
2593
+ whichever is more. The chain stays linear: segments
2594
+ are part of the verified history, never deleted.
2480
2595
  contextengine cost [--session ID] [--project NAME] [--run wf_ID] [--days N] [--top N] [--json]
2481
2596
  Multi-agent spend from Claude Code transcripts. Always prints
2482
2597
  VOLUME (tokens), VALUED COST (API list prices — notional on a
@@ -2676,6 +2791,9 @@ else if (command === "sync-claude-md") {
2676
2791
  process.exit(1);
2677
2792
  });
2678
2793
  }
2794
+ else if (command === "audit-rotate") {
2795
+ cliAuditRotate(process.argv.slice(3));
2796
+ }
2679
2797
  else if (command === "audit-verify") {
2680
2798
  cliAuditVerify().catch((err) => {
2681
2799
  console.error("Error:", err);
@@ -2787,8 +2905,25 @@ else if (command === "status") {
2787
2905
  }
2788
2906
  console.log("");
2789
2907
  }
2790
- else {
2791
- // Default: start MCP server
2908
+ else if (command === undefined || SERVER_COMMANDS.includes(command)) {
2909
+ // Documented default: a bare invocation (or the explicit `serve` alias) starts the
2910
+ // stdio MCP server. Every launcher on this machine uses the bare form.
2792
2911
  import("./index.js");
2793
2912
  }
2913
+ else if (command === "--version" || command === "-v" || command === "version") {
2914
+ console.log(readPackageVersion());
2915
+ }
2916
+ else {
2917
+ // 🔒 [LOCK] [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] — see src/cli-commands.ts
2918
+ // An unrecognised token used to fall through to the MCP server, which then waited on
2919
+ // stdin forever: no error, no exit code, no output. Name it and fail instead.
2920
+ console.error(`Unknown command: ${command}`);
2921
+ const suggestions = suggestCommands(command);
2922
+ if (suggestions.length > 0) {
2923
+ console.error(`Did you mean: ${suggestions.join(", ")}?`);
2924
+ }
2925
+ console.error(`Run 'contextengine help' for the full list.`);
2926
+ console.error(`To start the MCP server, run 'contextengine' with no arguments.`);
2927
+ process.exit(1);
2928
+ }
2794
2929
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Built-in model rates, in dollars per million tokens.
3
+ *
4
+ * 🔒 LOCKED [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — 2026-08-20
5
+ * ⛔ NEVER ship an empty default pricing table again.
6
+ * WHY: `[PRICING-LIVES-IN-POLICY]` was read as "ship no rates at all", so
7
+ * 2.5.0 shipped `pricing: []` as the default. Every user without an
8
+ * `agent_cost` block in their own policy.json got a VALUED COST panel
9
+ * reading `total $0.00` and `caching saved $0.00 (0%)` over 1.08 BILLION
10
+ * real tokens — a confident, wrong-looking verdict on the headline feature
11
+ * of the release. The LOCK's intent was "rates must be correctable without
12
+ * a release", not "the product ships priced at nothing".
13
+ * FIX: ship rates here, as DATA in their own module, never inline in the
14
+ * collector / detector / CLI. `.contextengine/policy.json` →
15
+ * `agent_cost.pricing` still wins outright when present, and this file
16
+ * compiles to plain readable JS in `dist/`, so a rate can be corrected in
17
+ * place without waiting for a release.
18
+ *
19
+ * Rates are Anthropic API list prices. Cache read is 0.1x input, cache write
20
+ * 5m is 1.25x input, cache write 1h is 2x input.
21
+ */
22
+ import type { ModelPricing } from "./transcript-collector.js";
23
+ /**
24
+ * When these rates were last checked against published pricing. Surfaced in
25
+ * `contextengine cost` output: a rate table with no date is a rate table
26
+ * nobody knows to distrust.
27
+ */
28
+ export declare const DEFAULT_PRICING_ASOF = "2026-08-20";
29
+ /**
30
+ * Longest-prefix matched, so dated ids (`claude-haiku-4-5-20251001`) resolve
31
+ * to their family. Deliberately NO `*` catch-all: a model absent from this
32
+ * table must report as UNPRICED, never be valued at a guessed rate
33
+ * (`[ABSENCE-IS-NOT-A-VERDICT]`).
34
+ */
35
+ export declare const DEFAULT_PRICING: ModelPricing[];
36
+ //# sourceMappingURL=default-pricing.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Built-in model rates, in dollars per million tokens.
3
+ *
4
+ * 🔒 LOCKED [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — 2026-08-20
5
+ * ⛔ NEVER ship an empty default pricing table again.
6
+ * WHY: `[PRICING-LIVES-IN-POLICY]` was read as "ship no rates at all", so
7
+ * 2.5.0 shipped `pricing: []` as the default. Every user without an
8
+ * `agent_cost` block in their own policy.json got a VALUED COST panel
9
+ * reading `total $0.00` and `caching saved $0.00 (0%)` over 1.08 BILLION
10
+ * real tokens — a confident, wrong-looking verdict on the headline feature
11
+ * of the release. The LOCK's intent was "rates must be correctable without
12
+ * a release", not "the product ships priced at nothing".
13
+ * FIX: ship rates here, as DATA in their own module, never inline in the
14
+ * collector / detector / CLI. `.contextengine/policy.json` →
15
+ * `agent_cost.pricing` still wins outright when present, and this file
16
+ * compiles to plain readable JS in `dist/`, so a rate can be corrected in
17
+ * place without waiting for a release.
18
+ *
19
+ * Rates are Anthropic API list prices. Cache read is 0.1x input, cache write
20
+ * 5m is 1.25x input, cache write 1h is 2x input.
21
+ */
22
+ /**
23
+ * When these rates were last checked against published pricing. Surfaced in
24
+ * `contextengine cost` output: a rate table with no date is a rate table
25
+ * nobody knows to distrust.
26
+ */
27
+ export const DEFAULT_PRICING_ASOF = "2026-08-20";
28
+ function rate(model, input, output) {
29
+ return {
30
+ model,
31
+ input_per_mtok: input,
32
+ output_per_mtok: output,
33
+ cache_read_per_mtok: Number((input * 0.1).toFixed(4)),
34
+ cache_write_5m_per_mtok: Number((input * 1.25).toFixed(4)),
35
+ cache_write_1h_per_mtok: Number((input * 2).toFixed(4)),
36
+ };
37
+ }
38
+ /**
39
+ * Longest-prefix matched, so dated ids (`claude-haiku-4-5-20251001`) resolve
40
+ * to their family. Deliberately NO `*` catch-all: a model absent from this
41
+ * table must report as UNPRICED, never be valued at a guessed rate
42
+ * (`[ABSENCE-IS-NOT-A-VERDICT]`).
43
+ */
44
+ export const DEFAULT_PRICING = [
45
+ rate("claude-opus-5", 5, 25),
46
+ rate("claude-opus-4-8", 5, 25),
47
+ rate("claude-opus-4-7", 5, 25),
48
+ rate("claude-opus-4-6", 5, 25),
49
+ rate("claude-opus-4-5", 5, 25),
50
+ rate("claude-fable-5", 10, 50),
51
+ rate("claude-mythos-5", 10, 50),
52
+ rate("claude-sonnet-5", 3, 15),
53
+ rate("claude-sonnet-4-6", 3, 15),
54
+ rate("claude-sonnet-4-5", 3, 15),
55
+ rate("claude-haiku-4-5", 1, 5),
56
+ ];
57
+ //# sourceMappingURL=default-pricing.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 [];
@@ -334,9 +337,11 @@ export const _internal = {
334
337
  detectDrift, detectNoInsight, detectSilentFailure, detectStaleDocSignal,
335
338
  };
336
339
  import { metricsFor } from "./transcript-collector.js";
340
+ import { DEFAULT_PRICING } from "./default-pricing.js";
337
341
  export const DEFAULT_COST_THRESHOLDS = {
338
342
  billing_mode: "subscription",
339
- pricing: [],
343
+ // [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — never [] again.
344
+ pricing: DEFAULT_PRICING,
340
345
  min_cache_efficiency: 3,
341
346
  max_tool_calls_per_agent: 2,
342
347
  max_cost_per_agent_usd: 3,
@@ -130,6 +130,24 @@ export declare function pricingFor(model: string | null, table: ModelPricing[]):
130
130
  * AND intensity — never one alone.
131
131
  */
132
132
  export declare function costOf(t: TokenTally, p: ModelPricing | null): CostBreakdown;
133
+ /**
134
+ * Whether a cost figure can be presented as money at all.
135
+ *
136
+ * 🔒 LOCKED [NEVER-RENDER-AN-UNKNOWN-AS-A-NUMBER] — 2026-08-20
137
+ * ⛔ NEVER print a $0.00 cost row, total, or "caching saved" figure while
138
+ * `unpricedTokens > 0`.
139
+ * WHY: 2.5.0 rendered a full VALUED COST table of $0.00 over 1.08 billion
140
+ * unpriced tokens, including "caching saved $0.00 (0%)" — which reads as
141
+ * "your caching achieves nothing" when the true reuse was 8x. The token
142
+ * accounting was right; the PRESENTATION layer turned "I have no rates"
143
+ * into a number. That is Session 21's rule at the display layer: any
144
+ * plausible-looking value returned from a branch meaning "I could not
145
+ * determine this" is the bug, however reasonable it looks.
146
+ * FIX: branch on this before formatting. `unpriced` must render the word
147
+ * UNPRICED, never a currency amount.
148
+ */
149
+ export type PricingStatus = "priced" | "partial" | "unpriced";
150
+ export declare function pricingStatus(c: CostBreakdown): PricingStatus;
133
151
  export declare function emptyTally(): TokenTally;
134
152
  export declare function addTally(a: TokenTally, b: TokenTally): TokenTally;
135
153
  export declare function totalTokens(t: TokenTally): number;
@@ -84,6 +84,11 @@ export function costOf(t, p) {
84
84
  t.output * p.output_per_mtok) / M;
85
85
  return { input, cacheWrite, cacheRead, output, total: input + cacheWrite + cacheRead + output, withoutCache, unpricedTokens: 0 };
86
86
  }
87
+ export function pricingStatus(c) {
88
+ if (c.unpricedTokens === 0)
89
+ return "priced";
90
+ return c.total === 0 ? "unpriced" : "partial";
91
+ }
87
92
  export function emptyTally() {
88
93
  return { input: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0, output: 0 };
89
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",