@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 +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 +158 -23
- package/dist/default-pricing.d.ts +36 -0
- package/dist/default-pricing.js +57 -0
- package/dist/detector.js +7 -2
- package/dist/transcript-collector.d.ts +18 -0
- package/dist/transcript-collector.js +5 -0
- 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
|
@@ -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 {
|
|
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
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
console.log(`
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|