@compr/opscontext-mcp 2.5.3 → 2.5.4

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/README.md CHANGED
@@ -539,7 +539,7 @@ Everything happens locally — search, scoring, learnings, sessions, embeddings.
539
539
  | Email | Activation only | Tie the licence to an account |
540
540
  | Package version | Activation only | Serve a compatible module bundle |
541
541
  | Platform/arch (e.g., `darwin/arm64`) | Activation only | Compatibility check |
542
- | Delta bundle version | Daily heartbeat | Detect an out-of-date module cache |
542
+ | Licence bundle version | Daily heartbeat | Compatibility marker carried in the signed licence |
543
543
 
544
544
  That is the complete list. The activation request sends exactly six fields and the heartbeat exactly three — enforced by a lock comment in `src/activation.ts` that forbids adding a seventh field reflecting usage.
545
545
 
@@ -1,4 +1,3 @@
1
- export declare const PREMIUM_MODULES: readonly ["agents", "search-adv"];
2
1
  export declare const PREMIUM_TOOLS: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
3
2
  export interface LicenseInfo {
4
3
  key: string;
@@ -17,33 +16,6 @@ export declare function activate(licenseKey: string, email: string): Promise<{
17
16
  message: string;
18
17
  plan?: string;
19
18
  }>;
20
- /**
21
- * Check if delta modules are installed and valid.
22
- */
23
- export declare function isDeltaInstalled(): boolean;
24
- /**
25
- * Version of the delta bundle currently cached on disk, or null if none/unreadable.
26
- * Exported so callers can report the mismatch rather than guess at it.
27
- */
28
- export declare function installedDeltaVersion(): string | null;
29
- /**
30
- * Dynamically import a delta module.
31
- * Returns null if not activated, module missing, or the cached delta is stale.
32
- *
33
- * 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
34
- * ⛔ NEVER import a delta module without checking its manifest version against this package.
35
- * WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
36
- * the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
37
- * two months and three sessions of scorer fixes out of date. Because this function imported
38
- * whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
39
- * inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
40
- * catch this — a stale delta carries its own stale canary and its own stale pins, so it
41
- * passes against itself.
42
- * FIX: refuse to load a delta whose version does not match the running package, and say so on
43
- * stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
44
- * applied to code delivery rather than to a check result.
45
- */
46
- export declare function loadDeltaModule(name: string): Promise<any | null>;
47
19
  export declare function heartbeat(): Promise<boolean>;
48
20
  export declare function deactivate(): void;
49
21
  export declare function getActivationStatus(): {
@@ -1,7 +1,18 @@
1
- // LOCKED — verified March 3 2026 — activation + delta decryption + machine fingerprint + heartbeat
1
+ // LOCKED — verified March 3 2026 — activation + machine fingerprint + heartbeat
2
2
  // DO NOT RE-AUDIT — E2E tested Feb 23 2026, all 4 Pro tools verified
3
+ //
4
+ // [LOCKED] [DELTA-RETIRED] — 2026-08-21
5
+ // [NEVER] reintroduce a client-side "delta bundle" (download, decrypt, cache, import premium code).
6
+ // WHY: from 0f12967 (2026-02-20) to 2.5.3 the client fetched an AES-encrypted bundle on activation,
7
+ // wrote it to ~/.contextengine/delta/, and never imported it: loadDeltaModule() had no caller,
8
+ // index.ts and cli.ts import agents.js / search.js / firewall.js from the package. Yet gateCheck
9
+ // refused premium tools when the unused cache was missing, and a stale cache needed its own
10
+ // guard (the former [DELTA-VERSION-PIN], 2026-08-14). Dead weight with live failure modes.
11
+ // FIX: the gate is the signed licence alone (Ed25519, machine-bound, expiry, daily heartbeat).
12
+ // The moat is the gate plus BSL-1.1, as CLAUDE.md rule 3 states. Retired on Yan's decision,
13
+ // SESSION_23. deactivate() still empties a legacy ~/.contextengine/delta/ so old caches go away.
3
14
  /**
4
- * Activation & Delta Module System
15
+ * Activation System
5
16
  *
6
17
  * The npm package ships with core functionality (search, sessions, learnings,
7
18
  * operational collectors). PRO unlocks the four high-value tools that consume
@@ -24,48 +35,29 @@
24
35
  *
25
36
  * On activation:
26
37
  * 1. License key is validated against the ContextEngine API
27
- * 2. Server returns a signed delta bundle (encrypted JS modules)
28
- * 3. Delta is cached locally at ~/.contextengine/delta/
29
- * 4. Premium tools become available
38
+ * 2. Server returns a signed licence (Ed25519), saved to ~/.contextengine/license.json
39
+ * 3. Premium tools become available
40
+ * The server may still include a `delta` field in its response; it is ignored. [LOCK] [DELTA-RETIRED]
30
41
  */
31
42
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
32
- import { join, dirname } from "path";
43
+ import { join } from "path";
33
44
  import { homedir } from "os";
34
- import { fileURLToPath } from "url";
35
- import { createHash, createDecipheriv } from "crypto";
45
+ import { createHash } from "crypto";
36
46
  import { safeAppend } from "./audit.js";
37
47
  import { verifyLicenseSignature } from "./license-sig.js";
38
48
  // ---------------------------------------------------------------------------
39
49
  // Constants
40
50
  // ---------------------------------------------------------------------------
41
- const DELTA_DIR = join(homedir(), ".contextengine", "delta");
42
- /**
43
- * Version of the running package. Read from package.json at module load, the same way
44
- * agents.ts does it, so [DELTA-VERSION-PIN] compares against the real installed version
45
- * rather than a constant someone forgets to bump.
46
- */
47
- const PACKAGE_VERSION = (() => {
48
- try {
49
- const here = dirname(fileURLToPath(import.meta.url));
50
- return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "unknown";
51
- }
52
- catch {
53
- return "unknown";
54
- }
55
- })();
51
+ // Legacy cache location, only ever cleaned now. [LOCK] [DELTA-RETIRED]
52
+ const LEGACY_DELTA_DIR = join(homedir(), ".contextengine", "delta");
56
53
  const LICENSE_FILE = join(homedir(), ".contextengine", "license.json");
57
54
  const ACTIVATION_API_BASE = process.env.CONTEXTENGINE_API || "https://api.compr.ch/contextengine";
58
55
  const ACTIVATION_API = `${ACTIVATION_API_BASE}/activate`;
59
56
  const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily check
60
- // Premium modules that require activation.
61
57
  // NOTE: collectors.ts runs unconditionally during reindex for all users
62
- // (operational data feeds search_context for everyone). The PRO tools below
63
- // in PREMIUM_TOOLS are what consume that data for scoring/audit/cross-project
58
+ // (operational data feeds search_context for everyone). The PRO tools in
59
+ // PREMIUM_TOOLS are what consume that data for scoring/audit/cross-project
64
60
  // reports. Keep the gate at the tool layer, not the data-collection layer.
65
- export const PREMIUM_MODULES = [
66
- "agents", // scorer, auditor, port checker, HTML report formatters
67
- "search-adv", // advanced BM25 with tuned parameters
68
- ];
69
61
  // Tools that require activation. Re-exported from the central manifest so
70
62
  // the count and the name list have a SINGLE source of truth. Adding a new
71
63
  // PRO tool requires editing src/tools-manifest.ts (which also feeds the
@@ -194,8 +186,6 @@ export async function activate(licenseKey, email) {
194
186
  }
195
187
  // Save license
196
188
  saveLicense(data.license);
197
- // Decrypt and store delta modules
198
- await installDelta(data.delta, data.license.key);
199
189
  safeAppend("activation.activate", {
200
190
  plan: data.license.plan,
201
191
  email: data.license.email,
@@ -214,112 +204,6 @@ export async function activate(licenseKey, email) {
214
204
  }
215
205
  }
216
206
  // ---------------------------------------------------------------------------
217
- // Delta module management
218
- // ---------------------------------------------------------------------------
219
- async function installDelta(delta, licenseKey) {
220
- if (!existsSync(DELTA_DIR))
221
- mkdirSync(DELTA_DIR, { recursive: true });
222
- // Derive decryption key from license key
223
- const derivedKey = createHash("sha256")
224
- .update(licenseKey + getMachineId())
225
- .digest();
226
- const iv = Buffer.from(delta.iv, "hex");
227
- for (const mod of delta.modules) {
228
- const encrypted = Buffer.from(mod.payload, "base64");
229
- // AES-256-CBC decrypt
230
- const decipher = createDecipheriv("aes-256-cbc", derivedKey, iv);
231
- let decrypted = decipher.update(encrypted);
232
- decrypted = Buffer.concat([decrypted, decipher.final()]);
233
- const content = decrypted.toString("utf-8");
234
- // Verify checksum
235
- const checksum = createHash("sha256").update(content).digest("hex");
236
- if (checksum !== mod.checksum) {
237
- throw new Error(`Delta module ${mod.name} checksum mismatch — possible tampering`);
238
- }
239
- // Write to delta directory
240
- writeFileSync(join(DELTA_DIR, `${mod.name}.mjs`), content);
241
- }
242
- // Write version marker
243
- writeFileSync(join(DELTA_DIR, "manifest.json"), JSON.stringify({
244
- version: delta.version,
245
- installedAt: new Date().toISOString(),
246
- modules: delta.modules.map((m) => m.name),
247
- }));
248
- console.error(`[ContextEngine] 📦 Delta v${delta.version} installed (${delta.modules.length} modules)`);
249
- }
250
- /**
251
- * Check if delta modules are installed and valid.
252
- */
253
- export function isDeltaInstalled() {
254
- const manifestPath = join(DELTA_DIR, "manifest.json");
255
- if (!existsSync(manifestPath))
256
- return false;
257
- try {
258
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
259
- // Verify all expected module files exist
260
- for (const modName of manifest.modules) {
261
- if (!existsSync(join(DELTA_DIR, `${modName}.mjs`)))
262
- return false;
263
- }
264
- return true;
265
- }
266
- catch {
267
- return false;
268
- }
269
- }
270
- /**
271
- * Version of the delta bundle currently cached on disk, or null if none/unreadable.
272
- * Exported so callers can report the mismatch rather than guess at it.
273
- */
274
- export function installedDeltaVersion() {
275
- try {
276
- const manifest = JSON.parse(readFileSync(join(DELTA_DIR, "manifest.json"), "utf-8"));
277
- return typeof manifest.version === "string" ? manifest.version : null;
278
- }
279
- catch {
280
- return null;
281
- }
282
- }
283
- /**
284
- * Dynamically import a delta module.
285
- * Returns null if not activated, module missing, or the cached delta is stale.
286
- *
287
- * 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
288
- * ⛔ NEVER import a delta module without checking its manifest version against this package.
289
- * WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
290
- * the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
291
- * two months and three sessions of scorer fixes out of date. Because this function imported
292
- * whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
293
- * inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
294
- * catch this — a stale delta carries its own stale canary and its own stale pins, so it
295
- * passes against itself.
296
- * FIX: refuse to load a delta whose version does not match the running package, and say so on
297
- * stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
298
- * applied to code delivery rather than to a check result.
299
- */
300
- export async function loadDeltaModule(name) {
301
- if (!isDeltaInstalled())
302
- return null;
303
- const cached = installedDeltaVersion();
304
- if (cached !== PACKAGE_VERSION) {
305
- console.error(`[ContextEngine] ⚠ Delta module "${name}" is version ${cached ?? "unknown"} but this package is ` +
306
- `${PACKAGE_VERSION} — refusing to load a stale module. Re-run \`contextengine activate\` to refresh.`);
307
- return null;
308
- }
309
- const modulePath = join(DELTA_DIR, `${name}.mjs`);
310
- if (!existsSync(modulePath))
311
- return null;
312
- try {
313
- // Dynamic import of the decrypted module
314
- const moduleUrl = `file://${modulePath}`;
315
- return await import(moduleUrl);
316
- }
317
- catch (err) {
318
- console.error(`[ContextEngine] ⚠ Failed to load delta module ${name}:`, err.message);
319
- return null;
320
- }
321
- }
322
- // ---------------------------------------------------------------------------
323
207
  // Heartbeat — periodic license validation
324
208
  // ---------------------------------------------------------------------------
325
209
  export async function heartbeat() {
@@ -368,10 +252,10 @@ export function deactivate() {
368
252
  // Remove license
369
253
  if (existsSync(LICENSE_FILE))
370
254
  unlinkSync(LICENSE_FILE);
371
- // Remove delta modules
372
- if (existsSync(DELTA_DIR)) {
373
- for (const file of readdirSync(DELTA_DIR)) {
374
- unlinkSync(join(DELTA_DIR, file));
255
+ // Remove a legacy delta cache if one is still around. [LOCK] [DELTA-RETIRED]
256
+ if (existsSync(LEGACY_DELTA_DIR)) {
257
+ for (const file of readdirSync(LEGACY_DELTA_DIR)) {
258
+ unlinkSync(join(LEGACY_DELTA_DIR, file));
375
259
  }
376
260
  }
377
261
  safeAppend("activation.deactivate", {
@@ -386,8 +270,7 @@ export function deactivate() {
386
270
  // ---------------------------------------------------------------------------
387
271
  export function getActivationStatus() {
388
272
  const license = loadLicense();
389
- const deltaInstalled = isDeltaInstalled();
390
- if (!license || !deltaInstalled) {
273
+ if (!license) {
391
274
  return {
392
275
  activated: false,
393
276
  plan: "community",
@@ -428,10 +311,6 @@ export function gateCheck(toolName) {
428
311
  `save_session, load_session, list_sessions, end_session, save_learning, ` +
429
312
  `list_learnings, import_learnings`;
430
313
  }
431
- if (!isDeltaInstalled()) {
432
- return `🔒 Premium modules not installed. Re-activate:\n` +
433
- `npx contextengine activate ${license.key} ${license.email}`;
434
- }
435
314
  return null;
436
315
  }
437
316
  // ---------------------------------------------------------------------------
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" | "audit.rotate";
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" | "audit.redact";
2
2
  export interface AuditRecord {
3
3
  ts: string;
4
4
  event: AuditEvent;
@@ -66,6 +66,35 @@ export declare function planRotation(opts?: RotateOptions): RotationPlan;
66
66
  * cause unrecoverable. Forks are fine — they are concurrency, not tampering.
67
67
  */
68
68
  export declare function rotateAuditLog(opts?: RotateOptions): RotationResult;
69
+ /**
70
+ * Startup auto-rotation for the MCP server.
71
+ *
72
+ * [LOCKED] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER] — 2026-08-21
73
+ * [NEVER] trigger at the same count the rotation keeps, and never let two servers rotate at once.
74
+ * WHY: rotation was manual and the live log crossed the 50k ceiling in ~15h, so the drift
75
+ * detectors' per-tick read slowed by the day between hand runs. A startup hook fixes the
76
+ * cadence, but (a) triggering at 50k and rotating down to 50k would cut a handful of
77
+ * records into a new segment on every start, and (b) three MCP servers start together
78
+ * on this machine (VS Code, launchd, Claude Code); each would plan on the same oversized
79
+ * file and the late ones would archive records the first one had already kept.
80
+ * FIX: trigger at AUTO_ROTATE_TRIGGER (2x the ceiling), rotate down to the ceiling, so a
81
+ * rotation buys ~a day of quiet. A dedicated rotate lock (O_EXCL, stale after 10 min,
82
+ * long enough to verify a 500k-record chain) makes late starters return "in progress"
83
+ * without touching the log. Opt out with CONTEXTENGINE_AUTO_ROTATE=0.
84
+ */
85
+ export declare const AUTO_ROTATE_TRIGGER: number;
86
+ /** Count newline-terminated lines without parsing. The live log is small by construction. */
87
+ export declare function countLiveRecords(): number;
88
+ export interface AutoRotateOutcome {
89
+ action: "disabled" | "below_trigger" | "in_progress" | "rotated" | "refused" | "error";
90
+ liveRecords: number;
91
+ detail: string;
92
+ result?: RotationResult;
93
+ }
94
+ export declare function autoRotateAuditLog(opts?: {
95
+ trigger?: number;
96
+ maxRecords?: number;
97
+ }): AutoRotateOutcome;
69
98
  export interface IntegrityReport {
70
99
  ok: boolean;
71
100
  total: number;
@@ -79,6 +108,10 @@ export interface IntegrityReport {
79
108
  /** Records whose prev_hash names a KNOWN earlier head — a concurrent-append fork.
80
109
  * Content is provably intact; only the linkage is non-linear. Not tampering. */
81
110
  forkIndices?: number[];
111
+ /** Records whose content was altered AND whose alteration is acknowledged by a later, intact
112
+ * `audit.redact` record binding the original hash to the current content. Not counted as
113
+ * tampering. */
114
+ redactedIndices?: number[];
82
115
  }
83
116
  /**
84
117
  * 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
@@ -104,6 +137,31 @@ export interface IntegrityReport {
104
137
  * never claim a forked log is pristine either.
105
138
  */
106
139
  export declare function verifyChain(): IntegrityReport;
140
+ /**
141
+ * Acknowledge that records were deliberately redacted (a secret removed from their content).
142
+ *
143
+ * [LOCKED] [REDACTION-IS-A-CHAINED-RECORD] — 2026-08-21
144
+ * [NEVER] let the verifier accept an allow-list that lives outside the chain (a file, an env
145
+ * var, a CLI flag) as grounds to stop calling an altered record altered.
146
+ * WHY: on 2026-08-20 a credentials sweep replaced a password with [REDACTED_SECRET] in 3
147
+ * archived records. Correct, but the chain can only see "content no longer matches its
148
+ * hash", so the compliance report read "tampering" for a deliberate act nobody recorded.
149
+ * An out-of-band allow-list would fix the wording and destroy the property: anyone who
150
+ * can edit the log can edit the list.
151
+ * FIX: the acknowledgement is an `audit.redact` record, appended to the chain like any other,
152
+ * naming the original hash of each redacted record and the hash of its redacted content.
153
+ * It can only be written after the fact, only for records that are actually altered, and
154
+ * a further edit breaks the binding. The verifier reports such records as "redacted",
155
+ * counts them separately, and `ok` ignores them; everything else stays "altered".
156
+ */
157
+ export declare function acknowledgeRedaction(indices: number[], reason: string, actor?: string): {
158
+ acknowledged: number[];
159
+ rejected: Array<{
160
+ index: number;
161
+ why: string;
162
+ }>;
163
+ record: AuditRecord | null;
164
+ };
107
165
  export declare function filterByRange(records: AuditRecord[], since?: string, until?: string): AuditRecord[];
108
166
  export declare function toCsv(records: AuditRecord[]): string;
109
167
  export declare function resetCacheForTest(): void;
package/dist/audit.js CHANGED
@@ -407,10 +407,28 @@ export function rotateAuditLog(opts = {}) {
407
407
  }
408
408
  const integrity = verifyChain();
409
409
  if (!integrity.ok) {
410
- return {
411
- ...empty,
412
- refusedReason: `chain does not verify (${integrity.breakReason}) refusing to archive a damaged log`,
413
- };
410
+ // [LOCKED] [ROTATE-REFUSES-LIVE-DAMAGE-ONLY] — 2026-08-21
411
+ // [NEVER] refuse a rotation for damage that sits entirely inside segments already archived.
412
+ // WHY: on 2026-08-20 a credentials sweep replaced a password literal with [REDACTED_SECRET]
413
+ // in 3 records of audit-0001.jsonl. Correct, deliberate, and permanent: the verifier
414
+ // reports them as altered for good. A whole-chain refusal then blocks every future
415
+ // rotation, the live log grows without bound (170k records a day later, 3x the
416
+ // ceiling) and the detectors slow down again, which is the failure rotation exists to
417
+ // prevent. Archived segments are already immutable by policy; refusing to archive new
418
+ // records protects nothing there.
419
+ // FIX: refuse only when an altered or orphaned record is in the live log, the part about to
420
+ // be rewritten. Damage confined to segments is reported, not treated as a veto.
421
+ const firstLive = integrity.total - plan.archiveCount - plan.keepCount;
422
+ const bad = [...(integrity.tamperedIndices ?? []), ...(integrity.orphanIndices ?? [])];
423
+ const inLive = bad.filter((i) => i >= firstLive);
424
+ if (inLive.length > 0) {
425
+ return {
426
+ ...empty,
427
+ refusedReason: `chain does not verify (${integrity.breakReason}) — ${inLive.length} damaged record(s) in the live log, refusing to archive a damaged log`,
428
+ };
429
+ }
430
+ console.error(`[ContextEngine] ⚠ audit rotation: ${bad.length} known damaged record(s) in archived segments ` +
431
+ `(first at ${bad[0]}); none in the live log, rotating.`);
414
432
  }
415
433
  if (opts.dryRun)
416
434
  return { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
@@ -472,6 +490,99 @@ export function rotateAuditLog(opts = {}) {
472
490
  bytesRemaining: statSync(path).size,
473
491
  };
474
492
  }
493
+ /**
494
+ * Startup auto-rotation for the MCP server.
495
+ *
496
+ * [LOCKED] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER] — 2026-08-21
497
+ * [NEVER] trigger at the same count the rotation keeps, and never let two servers rotate at once.
498
+ * WHY: rotation was manual and the live log crossed the 50k ceiling in ~15h, so the drift
499
+ * detectors' per-tick read slowed by the day between hand runs. A startup hook fixes the
500
+ * cadence, but (a) triggering at 50k and rotating down to 50k would cut a handful of
501
+ * records into a new segment on every start, and (b) three MCP servers start together
502
+ * on this machine (VS Code, launchd, Claude Code); each would plan on the same oversized
503
+ * file and the late ones would archive records the first one had already kept.
504
+ * FIX: trigger at AUTO_ROTATE_TRIGGER (2x the ceiling), rotate down to the ceiling, so a
505
+ * rotation buys ~a day of quiet. A dedicated rotate lock (O_EXCL, stale after 10 min,
506
+ * long enough to verify a 500k-record chain) makes late starters return "in progress"
507
+ * without touching the log. Opt out with CONTEXTENGINE_AUTO_ROTATE=0.
508
+ */
509
+ export const AUTO_ROTATE_TRIGGER = 2 * DEFAULT_MAX_LIVE_RECORDS;
510
+ const ROTATE_LOCK_STALE_MS = 10 * 60_000;
511
+ function rotateLockPath() {
512
+ return join(auditDir(), "audit.rotate.lock");
513
+ }
514
+ /** Count newline-terminated lines without parsing. The live log is small by construction. */
515
+ export function countLiveRecords() {
516
+ const path = auditPath();
517
+ if (!existsSync(path))
518
+ return 0;
519
+ const buf = readFileSync(path);
520
+ let n = 0;
521
+ for (let i = 0; i < buf.length; i++)
522
+ if (buf[i] === 10)
523
+ n++;
524
+ return n;
525
+ }
526
+ export function autoRotateAuditLog(opts = {}) {
527
+ const trigger = opts.trigger ?? AUTO_ROTATE_TRIGGER;
528
+ const maxRecords = opts.maxRecords ?? DEFAULT_MAX_LIVE_RECORDS;
529
+ if (process.env.CONTEXTENGINE_AUTO_ROTATE === "0") {
530
+ return { action: "disabled", liveRecords: -1, detail: "CONTEXTENGINE_AUTO_ROTATE=0" };
531
+ }
532
+ const liveRecords = countLiveRecords();
533
+ if (liveRecords <= trigger) {
534
+ return { action: "below_trigger", liveRecords, detail: `${liveRecords} live record(s), trigger is ${trigger}` };
535
+ }
536
+ // One runner at a time. O_EXCL create is the primitive; a stale file is an orphan from a
537
+ // crashed rotation, not a live one.
538
+ const lock = rotateLockPath();
539
+ let fd;
540
+ try {
541
+ ensureDir();
542
+ try {
543
+ fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
544
+ }
545
+ catch (e) {
546
+ if (e.code !== "EEXIST")
547
+ throw e;
548
+ const age = Date.now() - statSync(lock).mtimeMs;
549
+ if (age < ROTATE_LOCK_STALE_MS) {
550
+ return { action: "in_progress", liveRecords, detail: `another rotation holds ${lock} (${Math.round(age / 1000)}s old)` };
551
+ }
552
+ unlinkSync(lock);
553
+ fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
554
+ }
555
+ }
556
+ catch (e) {
557
+ return { action: "error", liveRecords, detail: `rotate lock: ${e.message}` };
558
+ }
559
+ try {
560
+ try {
561
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
562
+ }
563
+ catch { /* contents are a courtesy */ }
564
+ closeSync(fd);
565
+ const result = rotateAuditLog({ maxRecords });
566
+ if (!result.rotated) {
567
+ return { action: "refused", liveRecords, detail: result.refusedReason ?? "not rotated", result };
568
+ }
569
+ return {
570
+ action: "rotated",
571
+ liveRecords,
572
+ detail: `archived ${result.archiveCount} record(s) to ${result.segmentFile}, ${result.keepCount + 1} live`,
573
+ result,
574
+ };
575
+ }
576
+ catch (e) {
577
+ return { action: "error", liveRecords, detail: e.message };
578
+ }
579
+ finally {
580
+ try {
581
+ unlinkSync(lock);
582
+ }
583
+ catch { /* already gone */ }
584
+ }
585
+ }
475
586
  function writeFileAndSync(target, body) {
476
587
  const fd = openSync(target, "w");
477
588
  try {
@@ -543,6 +654,37 @@ export function verifyChain() {
543
654
  seen.add(r.hash);
544
655
  prev = r.hash;
545
656
  }
657
+ // 3. Acknowledged redactions. [LOCK] [REDACTION-IS-A-CHAINED-RECORD]
658
+ // An `audit.redact` record that is itself intact binds (original hash -> hash of the
659
+ // redacted content). A tampered record matching such a binding is "redacted", not
660
+ // "altered". Binding to the current content means a second edit after the acknowledgement
661
+ // makes it tampered again.
662
+ const tamperedSet = new Set(tampered);
663
+ const acks = new Map();
664
+ for (let i = 0; i < records.length; i++) {
665
+ const r = records[i];
666
+ if (r.event !== "audit.redact" || tamperedSet.has(i))
667
+ continue;
668
+ const list = r.payload.redacted;
669
+ if (!Array.isArray(list))
670
+ continue;
671
+ for (const e of list) {
672
+ if (typeof e.hash === "string" && typeof e.content_hash === "string")
673
+ acks.set(e.hash, e.content_hash);
674
+ }
675
+ }
676
+ const redacted = [];
677
+ const stillTampered = [];
678
+ for (const i of tampered) {
679
+ const r = records[i];
680
+ const bound = acks.get(r.hash);
681
+ if (bound && bound === computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload))
682
+ redacted.push(i);
683
+ else
684
+ stillTampered.push(i);
685
+ }
686
+ tampered.length = 0;
687
+ tampered.push(...stillTampered);
546
688
  const ok = tampered.length === 0 && orphans.length === 0;
547
689
  const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
548
690
  let reason = null;
@@ -560,8 +702,56 @@ export function verifyChain() {
560
702
  tamperedIndices: tampered,
561
703
  orphanIndices: orphans,
562
704
  forkIndices: forks,
705
+ redactedIndices: redacted,
563
706
  };
564
707
  }
708
+ /**
709
+ * Acknowledge that records were deliberately redacted (a secret removed from their content).
710
+ *
711
+ * [LOCKED] [REDACTION-IS-A-CHAINED-RECORD] — 2026-08-21
712
+ * [NEVER] let the verifier accept an allow-list that lives outside the chain (a file, an env
713
+ * var, a CLI flag) as grounds to stop calling an altered record altered.
714
+ * WHY: on 2026-08-20 a credentials sweep replaced a password with [REDACTED_SECRET] in 3
715
+ * archived records. Correct, but the chain can only see "content no longer matches its
716
+ * hash", so the compliance report read "tampering" for a deliberate act nobody recorded.
717
+ * An out-of-band allow-list would fix the wording and destroy the property: anyone who
718
+ * can edit the log can edit the list.
719
+ * FIX: the acknowledgement is an `audit.redact` record, appended to the chain like any other,
720
+ * naming the original hash of each redacted record and the hash of its redacted content.
721
+ * It can only be written after the fact, only for records that are actually altered, and
722
+ * a further edit breaks the binding. The verifier reports such records as "redacted",
723
+ * counts them separately, and `ok` ignores them; everything else stays "altered".
724
+ */
725
+ export function acknowledgeRedaction(indices, reason, actor = "system") {
726
+ if (!reason.trim())
727
+ throw new Error("a reason is required");
728
+ const records = readAuditLog();
729
+ const acknowledged = [];
730
+ const rejected = [];
731
+ const entries = [];
732
+ for (const i of [...new Set(indices)].sort((a, b) => a - b)) {
733
+ const r = records[i];
734
+ if (!r) {
735
+ rejected.push({ index: i, why: "no such record" });
736
+ continue;
737
+ }
738
+ const current = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
739
+ if (current === r.hash) {
740
+ rejected.push({ index: i, why: "content is intact, nothing to acknowledge" });
741
+ continue;
742
+ }
743
+ if (r.event === "audit.redact") {
744
+ rejected.push({ index: i, why: "an acknowledgement cannot itself be redacted" });
745
+ continue;
746
+ }
747
+ acknowledged.push(i);
748
+ entries.push({ index: i, hash: r.hash, content_hash: current, ts: r.ts, event: r.event });
749
+ }
750
+ if (entries.length === 0)
751
+ return { acknowledged, rejected, record: null };
752
+ const record = appendAudit("audit.redact", { reason, redacted: entries }, actor);
753
+ return { acknowledged, rejected, record };
754
+ }
565
755
  export function filterByRange(records, since, until) {
566
756
  return records.filter((r) => {
567
757
  if (since && r.ts < since)
@@ -25,6 +25,7 @@ export const KNOWN_COMMANDS = [
25
25
  "audit",
26
26
  "audit-export",
27
27
  "audit-rotate",
28
+ "audit-redact-ack",
28
29
  "audit-verify",
29
30
  "autostart-status",
30
31
  "cost",
package/dist/cli.js CHANGED
@@ -650,11 +650,9 @@ import { listLearnings, learningsToChunks, learningsStats, formatLearnings, save
650
650
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
651
651
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
652
652
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
653
- import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, } from "./audit.js";
653
+ import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, } from "./audit.js";
654
654
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
655
- import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, pricingStatus, pricingFor, } from "./transcript-collector.js";
656
- import { DEFAULT_PRICING, DEFAULT_PRICING_ASOF } from "./default-pricing.js";
657
- import { runTranscriptHeuristics, DEFAULT_COST_THRESHOLDS, } from "./detector.js";
655
+ import { buildCostReport } from "./cost-report.js";
658
656
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
659
657
  import { safeAppend } from "./audit.js";
660
658
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
@@ -1966,12 +1964,39 @@ function cliAuditRotate(args) {
1966
1964
  process.exit(2);
1967
1965
  }
1968
1966
  }
1967
+ /** Acknowledge deliberately redacted audit records on the chain. [LOCK] [REDACTION-IS-A-CHAINED-RECORD] */
1968
+ function cliAuditRedactAck(args) {
1969
+ const idxAt = args.indexOf("--index");
1970
+ const reasonAt = args.indexOf("--reason");
1971
+ const raw = idxAt >= 0 ? args[idxAt + 1] ?? "" : "";
1972
+ const reason = reasonAt >= 0 ? args[reasonAt + 1] ?? "" : "";
1973
+ const indices = raw.split(",").map((x) => Number(x.trim())).filter((n) => Number.isInteger(n) && n >= 0);
1974
+ if (indices.length === 0 || !reason.trim()) {
1975
+ console.error(`usage: contextengine audit-redact-ack --index <i,j,k> --reason "<what was removed and why>"`);
1976
+ console.error(` Indices are the ones 'audit-verify' lists as altered. Only altered records can be acknowledged.`);
1977
+ process.exit(1);
1978
+ }
1979
+ const r = acknowledgeRedaction(indices, reason, "cli");
1980
+ for (const x of r.rejected)
1981
+ console.error(` ✗ ${x.index}: ${x.why}`);
1982
+ if (!r.record) {
1983
+ console.error(`\nNothing acknowledged.`);
1984
+ process.exit(1);
1985
+ }
1986
+ console.log(`\n✅ Acknowledged ${r.acknowledged.length} redacted record(s): ${r.acknowledged.join(", ")}`);
1987
+ console.log(` Chained as audit.redact, hash ${r.record.hash.slice(0, 16)}…`);
1988
+ const after = verifyChain();
1989
+ console.log(` audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${(after.redactedIndices ?? []).length} redacted, ${(after.tamperedIndices ?? []).length} altered.`);
1990
+ }
1969
1991
  async function cliAuditVerify() {
1970
1992
  const report = verifyChain();
1971
1993
  const forks = report.forkIndices ?? [];
1994
+ const redacted = report.redactedIndices ?? [];
1972
1995
  if (report.ok) {
1973
1996
  console.log(`✅ Audit chain verified — ${report.total} record(s).`);
1974
- console.log(` No record was altered, and no history is missing.`);
1997
+ console.log(redacted.length === 0
1998
+ ? ` No record was altered, and no history is missing.`
1999
+ : ` No history is missing. ${redacted.length} record(s) redacted and acknowledged on the chain (indices ${redacted.slice(0, 8).join(", ")}${redacted.length > 8 ? ", …" : ""}), 0 altered.`);
1975
2000
  if (forks.length > 0) {
1976
2001
  // [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
1977
2002
  console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
@@ -1990,6 +2015,11 @@ async function cliAuditVerify() {
1990
2015
  console.error(`\n Altered records (content does not match its own hash):`);
1991
2016
  console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
1992
2017
  console.error(` This is tampering: the record's bytes were changed after it was written.`);
2018
+ console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain:`);
2019
+ console.error(` contextengine audit-redact-ack --index ${t.slice(0, 3).join(",")} --reason "<what was removed and why>"`);
2020
+ }
2021
+ if (redacted.length > 0) {
2022
+ console.error(`\n Also ${redacted.length} redacted record(s), acknowledged on the chain, not counted above.`);
1993
2023
  }
1994
2024
  if ((report.orphanIndices ?? []).length > 0) {
1995
2025
  const o = report.orphanIndices;
@@ -2374,218 +2404,28 @@ function cliStats() {
2374
2404
  }
2375
2405
  }
2376
2406
  // ---------------------------------------------------------------------------
2377
- // cost — multi-agent spend, read from Claude Code's own transcripts
2407
+ // cost — multi-agent spend, read from Claude Code's own transcripts.
2408
+ // Rendered by src/cost-report.ts, shared with the MCP tool. [LOCK] [COST-REPORT-ONE-RENDERER]
2378
2409
  // ---------------------------------------------------------------------------
2379
- function fmtTok(n) {
2380
- if (n >= 1e6)
2381
- return `${(n / 1e6).toFixed(1)}M`;
2382
- if (n >= 1e3)
2383
- return `${(n / 1e3).toFixed(0)}k`;
2384
- return String(n);
2385
- }
2386
- function fmtDur(ms) {
2387
- if (ms === null || !Number.isFinite(ms))
2388
- return "—";
2389
- const s = Math.round(ms / 1000);
2390
- if (s < 60)
2391
- return `${s}s`;
2392
- const m = Math.floor(s / 60);
2393
- if (m < 60)
2394
- return `${m}m${String(s % 60).padStart(2, "0")}s`;
2395
- return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
2396
- }
2397
- /** Resolve cost thresholds + pricing from policy, falling back to defaults. */
2398
- function loadCostThresholds(cwd) {
2399
- const res = loadRepoPolicy(cwd);
2400
- if (res && res.ok && res.policy.agent_cost) {
2401
- const a = res.policy.agent_cost;
2402
- // [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — an agent_cost block that omits
2403
- // `pricing` must not silently price nothing.
2404
- const hasOwnRates = a.pricing.length > 0;
2405
- return {
2406
- t: {
2407
- billing_mode: a.billing_mode,
2408
- pricing: hasOwnRates ? a.pricing : DEFAULT_PRICING,
2409
- min_cache_efficiency: a.min_cache_efficiency,
2410
- max_tool_calls_per_agent: a.max_tool_calls_per_agent,
2411
- max_cost_per_agent_usd: a.max_cost_per_agent_usd,
2412
- min_fanout_for_canary: a.min_fanout_for_canary,
2413
- max_failed_share: a.max_failed_share,
2414
- },
2415
- source: ".contextengine/policy.json" +
2416
- (hasOwnRates ? "" : ` (rates: built-in, as of ${DEFAULT_PRICING_ASOF})`),
2417
- };
2418
- }
2419
- return {
2420
- t: DEFAULT_COST_THRESHOLDS,
2421
- source: `built-in defaults, rates as of ${DEFAULT_PRICING_ASOF} (no agent_cost in policy.json)`,
2422
- };
2423
- }
2424
2410
  async function cliCost(argv) {
2425
2411
  const flag = (name) => {
2426
2412
  const i = argv.indexOf(`--${name}`);
2427
2413
  return i >= 0 ? argv[i + 1] : undefined;
2428
2414
  };
2429
- const json = argv.includes("--json");
2430
2415
  const topRaw = flag("top");
2431
- const top = topRaw ? Math.max(1, parseInt(topRaw, 10) || 10) : 10;
2432
2416
  const daysRaw = flag("days");
2433
- const since = daysRaw ? Date.now() - parseInt(daysRaw, 10) * 86_400_000 : undefined;
2434
- const cwd = process.cwd();
2435
- const { t, source } = loadCostThresholds(cwd);
2436
- const runs = collectRuns({
2417
+ const report = buildCostReport({
2437
2418
  session: flag("session"),
2438
2419
  project: flag("project"),
2439
2420
  run: flag("run"),
2440
- since,
2421
+ top: topRaw ? parseInt(topRaw, 10) || 10 : 10,
2422
+ days: daysRaw ? parseInt(daysRaw, 10) || undefined : undefined,
2441
2423
  });
2442
- if (!runs.length) {
2443
- console.log("No multi-agent runs found in " + transcriptRoot());
2444
- console.log("(fan-outs only: parent sessions are not counted — this measures delegation)");
2445
- return;
2446
- }
2447
- const scored = runs
2448
- .map((r) => ({ run: r, m: metricsFor(r, t.pricing) }))
2449
- .sort((a, b) => b.m.cost.total - a.m.cost.total);
2450
- const signals = runTranscriptHeuristics(runs, t);
2451
- if (json) {
2452
- console.log(JSON.stringify({
2453
- billing_mode: t.billing_mode,
2454
- cost_is_notional: t.billing_mode === "subscription",
2455
- thresholds_source: source,
2456
- runs: scored.map(({ run, m }) => ({
2457
- runId: run.runId, kind: run.kind, project: run.project, sessionId: run.sessionId,
2458
- volume: run.totals, intensity: {
2459
- agents: m.agents, reported: m.reported, failed: m.failed,
2460
- capacityExhausted: m.capacityExhausted, toolCalls: m.toolCalls,
2461
- medianToolCalls: m.medianToolCalls, durationMs: run.durationMs,
2462
- launchedBeforeFirstReport: m.launchedBeforeFirstReport,
2463
- },
2464
- cost: m.cost, cacheEfficiency: Number.isFinite(m.cacheEfficiency) ? m.cacheEfficiency : null,
2465
- outputShare: m.outputShare,
2466
- })),
2467
- signals,
2468
- }, null, 2));
2424
+ if (argv.includes("--json") && report.json) {
2425
+ console.log(JSON.stringify(report.json, null, 2));
2469
2426
  return;
2470
2427
  }
2471
- // Aggregate across everything in scope.
2472
- let vol = emptyTally();
2473
- let agents = 0, toolCalls = 0, failed = 0, capacity = 0, reported = 0;
2474
- let cost = 0, withoutCache = 0, unpriced = 0;
2475
- // Which models carried tokens but matched no rate — named in the output so
2476
- // the fix is actionable instead of "something was unpriced".
2477
- const unpricedModels = new Set();
2478
- for (const { run, m } of scored) {
2479
- for (const a of run.agents) {
2480
- for (const [model, tally] of a.tokensByModel) {
2481
- if (totalTokens(tally) > 0 && !pricingFor(model, t.pricing)) {
2482
- unpricedModels.add(model ?? "(no model recorded)");
2483
- }
2484
- }
2485
- }
2486
- vol = addTally(vol, run.totals);
2487
- agents += m.agents;
2488
- toolCalls += m.toolCalls;
2489
- failed += m.failed;
2490
- capacity += m.capacityExhausted;
2491
- reported += m.reported;
2492
- cost += m.cost.total;
2493
- withoutCache += m.cost.withoutCache;
2494
- unpriced += m.cost.unpricedTokens;
2495
- }
2496
- const allTok = totalTokens(vol);
2497
- const cw = vol.cacheWrite5m + vol.cacheWrite1h;
2498
- console.log("");
2499
- console.log(`MULTI-AGENT COST — ${scored.length} run(s), ${agents} subagents`);
2500
- console.log(`thresholds: ${source}`);
2501
- console.log("");
2502
- // ── 1. VOLUME ───────────────────────────────────────────────────────────
2503
- console.log("VOLUME (tokens moved)");
2504
- const volRow = (label, n) => console.log(` ${label.padEnd(16)} ${fmtTok(n).padStart(8)} ${allTok ? ((100 * n) / allTok).toFixed(1).padStart(5) : " 0.0"}%`);
2505
- volRow("cache read", vol.cacheRead);
2506
- volRow("cache write", cw);
2507
- volRow("input (fresh)", vol.input);
2508
- volRow("output", vol.output);
2509
- console.log(` ${"total".padEnd(16)} ${fmtTok(allTok).padStart(8)}`);
2510
- console.log("");
2511
- // ── 2. VALUED COST ──────────────────────────────────────────────────────
2512
- const notional = t.billing_mode === "subscription";
2513
- let ci = 0, ccw = 0, ccr = 0, co = 0;
2514
- for (const { m } of scored) {
2515
- ci += m.cost.input;
2516
- ccw += m.cost.cacheWrite;
2517
- ccr += m.cost.cacheRead;
2518
- co += m.cost.output;
2519
- }
2520
- const agg = {
2521
- input: ci, cacheWrite: ccw, cacheRead: ccr, output: co,
2522
- total: cost, withoutCache, unpricedTokens: unpriced,
2523
- };
2524
- const status = pricingStatus(agg);
2525
- console.log(`VALUED COST (API list prices)${notional && status !== "unpriced" ? " — NOTIONAL, NOT BILLED" : ""}`);
2526
- // [NEVER-RENDER-AN-UNKNOWN-AS-A-NUMBER] — with nothing priced there is no
2527
- // cost to show. Printing a $0.00 table here reads as "this run was free"
2528
- // and "caching saved 0%", both false.
2529
- if (status === "unpriced") {
2530
- console.log(` UNPRICED — no rate matched any model in this data, so no cost can be`);
2531
- console.log(` stated. ${fmtTok(unpriced)} tokens were moved. This is an unknown, not $0.`);
2532
- console.log("");
2533
- console.log(` Models seen without a rate: ${[...unpricedModels].sort().join(", ") || "(unknown)"}`);
2534
- console.log(` Add them to .contextengine/policy.json → agent_cost.pricing.`);
2535
- console.log("");
2536
- }
2537
- else {
2538
- if (notional) {
2539
- console.log(" This machine runs Claude Code on a subscription: no dollar below is");
2540
- console.log(" debited. Use these figures to compare approaches, not as spend.");
2541
- }
2542
- const costRow = (label, n) => console.log(` ${label.padEnd(16)} ${("$" + n.toFixed(2)).padStart(8)} ${cost ? ((100 * n) / cost).toFixed(1).padStart(5) : " 0.0"}%`);
2543
- costRow("cache read", ccr);
2544
- costRow("cache write", ccw);
2545
- costRow("input (fresh)", ci);
2546
- costRow("output", co);
2547
- console.log(` ${"total".padEnd(16)} ${("$" + cost.toFixed(2)).padStart(8)}`);
2548
- console.log(` ${"without cache".padEnd(16)} ${("$" + withoutCache.toFixed(2)).padStart(8)} ` +
2549
- `caching saved $${(withoutCache - cost).toFixed(2)} (${withoutCache ? (100 * (1 - cost / withoutCache)).toFixed(0) : "0"}%)`);
2550
- if (status === "partial") {
2551
- console.log(` ⚠ ${fmtTok(unpriced)} tokens UNPRICED and NOT in the figures above` +
2552
- ` (${[...unpricedModels].sort().join(", ") || "unknown model"}) — the total is a floor, not the cost`);
2553
- }
2554
- console.log("");
2555
- }
2556
- // ── 3. INTENSITY (the capacity proxy) ───────────────────────────────────
2557
- console.log(`INTENSITY (capacity proxy${notional ? " — the scarce resource here" : ""})`);
2558
- console.log(` subagents ${String(agents).padStart(8)}`);
2559
- console.log(` reported ${String(reported).padStart(8)}`);
2560
- console.log(` returned nothing ${String(failed).padStart(8)}${failed ? ` (${((100 * failed) / agents).toFixed(0)}% of the fleet)` : ""}`);
2561
- console.log(` died at window ${String(capacity).padStart(8)}${capacity ? " ← capacity spent for no result" : ""}`);
2562
- console.log(` tool calls ${String(toolCalls).padStart(8)} (${(toolCalls / Math.max(1, agents)).toFixed(1)}/agent)`);
2563
- console.log(` cache reuse ${(cw ? (vol.cacheRead / cw).toFixed(1) + "x" : "—").padStart(8)} ${cw && vol.cacheRead / cw < t.min_cache_efficiency ? "← below floor, prefix is being rebuilt" : "(higher is better)"}`);
2564
- console.log("");
2565
- // ── Top runs ────────────────────────────────────────────────────────────
2566
- console.log(`TOP RUNS BY VALUED COST (${Math.min(top, scored.length)} of ${scored.length})`);
2567
- console.log(` ${"cost".padStart(8)} ${"agents".padStart(6)} ${"dead".padStart(4)} ${"tools".padStart(5)} ${"reuse".padStart(6)} ${"dur".padStart(7)} run`);
2568
- for (const { run, m } of scored.slice(0, top)) {
2569
- const reuse = Number.isFinite(m.cacheEfficiency) ? m.cacheEfficiency.toFixed(1) + "x" : "—";
2570
- console.log(` ${("$" + m.cost.total.toFixed(2)).padStart(8)} ${String(m.agents).padStart(6)} ` +
2571
- `${String(m.failed).padStart(4)} ${String(m.medianToolCalls).padStart(5)} ${reuse.padStart(6)} ` +
2572
- `${fmtDur(run.durationMs).padStart(7)} ${run.runId} ${run.project.replace(/^-Users-yan-/, "")}`);
2573
- }
2574
- console.log("");
2575
- // ── Signals ─────────────────────────────────────────────────────────────
2576
- if (!signals.length) {
2577
- console.log("✅ No context_burn or fanout_without_canary signals.");
2578
- }
2579
- else {
2580
- const crit = signals.filter((s) => s.severity === "critical");
2581
- console.log(`SIGNALS — ${signals.length} (${crit.length} critical)`);
2582
- for (const s of signals.slice(0, 20)) {
2583
- console.log(` ${s.severity === "critical" ? "🔴" : "⚠️ "} [${s.kind}] ${s.reason}`);
2584
- }
2585
- if (signals.length > 20)
2586
- console.log(` … ${signals.length - 20} more (use --json)`);
2587
- }
2588
- console.log("");
2428
+ console.log(report.text);
2589
2429
  }
2590
2430
  /** Package version, read from the installed package.json rather than hardcoded. */
2591
2431
  function readPackageVersion() {
@@ -2635,6 +2475,7 @@ Usage:
2635
2475
  Export hash-chained audit log (evidence aligned with
2636
2476
  SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
2637
2477
  contextengine audit-verify Verify audit log chain integrity (tamper detection)
2478
+ contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
2638
2479
  contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
2639
2480
  Move old history into an archive segment. Archives
2640
2481
  whatever is older than N days (default 30) OR beyond
@@ -2840,6 +2681,9 @@ else if (command === "sync-claude-md") {
2840
2681
  process.exit(1);
2841
2682
  });
2842
2683
  }
2684
+ else if (command === "audit-redact-ack") {
2685
+ cliAuditRedactAck(process.argv.slice(3));
2686
+ }
2843
2687
  else if (command === "audit-rotate") {
2844
2688
  cliAuditRotate(process.argv.slice(3));
2845
2689
  }
@@ -0,0 +1,22 @@
1
+ import { type CostThresholds } from "./detector.js";
2
+ export interface CostReportOptions {
3
+ session?: string;
4
+ project?: string;
5
+ run?: string;
6
+ days?: number;
7
+ top?: number;
8
+ }
9
+ export interface CostReport {
10
+ /** Human-readable report, what the CLI prints. */
11
+ text: string;
12
+ /** Structured report, what `--json` prints. null when no runs were found. */
13
+ json: Record<string, unknown> | null;
14
+ runs: number;
15
+ }
16
+ /** Resolve cost thresholds + pricing from policy, falling back to defaults. */
17
+ export declare function loadCostThresholds(cwd: string): {
18
+ t: CostThresholds;
19
+ source: string;
20
+ };
21
+ export declare function buildCostReport(opts?: CostReportOptions, cwd?: string): CostReport;
22
+ //# sourceMappingURL=cost-report.d.ts.map
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Multi-agent cost report, shared by the CLI (`contextengine cost`) and the MCP tool
3
+ * (`agent_cost`). One renderer, two surfaces.
4
+ *
5
+ * [LOCKED] [COST-REPORT-ONE-RENDERER] — 2026-08-21
6
+ * [NEVER] render the cost report in cli.ts or index.ts directly.
7
+ * WHY: the CLI shipped on 2026-08-20 as 170 lines of console.log; an MCP tool written the same
8
+ * way would have been a second copy of every threshold, label and guard (NOTIONAL, UNPRICED,
9
+ * floor-not-cost) that drifts the first time one of them is edited.
10
+ * FIX: buildCostReport() returns { text, json }; cli.ts prints, index.ts responds. Both surfaces
11
+ * read the same thresholds from .contextengine/policy.json via loadCostThresholds().
12
+ */
13
+ import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, pricingStatus, pricingFor, } from "./transcript-collector.js";
14
+ import { DEFAULT_PRICING, DEFAULT_PRICING_ASOF } from "./default-pricing.js";
15
+ import { runTranscriptHeuristics, DEFAULT_COST_THRESHOLDS, } from "./detector.js";
16
+ import { loadRepoPolicy } from "./policy.js";
17
+ function fmtTok(n) {
18
+ if (n >= 1e6)
19
+ return `${(n / 1e6).toFixed(1)}M`;
20
+ if (n >= 1e3)
21
+ return `${(n / 1e3).toFixed(0)}k`;
22
+ return String(n);
23
+ }
24
+ function fmtDur(ms) {
25
+ if (ms === null || !Number.isFinite(ms))
26
+ return "—";
27
+ const s = Math.round(ms / 1000);
28
+ if (s < 60)
29
+ return `${s}s`;
30
+ const m = Math.floor(s / 60);
31
+ if (m < 60)
32
+ return `${m}m${String(s % 60).padStart(2, "0")}s`;
33
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
34
+ }
35
+ /** Resolve cost thresholds + pricing from policy, falling back to defaults. */
36
+ export function loadCostThresholds(cwd) {
37
+ const res = loadRepoPolicy(cwd);
38
+ if (res && res.ok && res.policy.agent_cost) {
39
+ const a = res.policy.agent_cost;
40
+ // [DEFAULT-RATES-SHIP-WITH-THE-PACKAGE] — an agent_cost block that omits
41
+ // `pricing` must not silently price nothing.
42
+ const hasOwnRates = a.pricing.length > 0;
43
+ return {
44
+ t: {
45
+ billing_mode: a.billing_mode,
46
+ pricing: hasOwnRates ? a.pricing : DEFAULT_PRICING,
47
+ min_cache_efficiency: a.min_cache_efficiency,
48
+ max_tool_calls_per_agent: a.max_tool_calls_per_agent,
49
+ max_cost_per_agent_usd: a.max_cost_per_agent_usd,
50
+ min_fanout_for_canary: a.min_fanout_for_canary,
51
+ max_failed_share: a.max_failed_share,
52
+ },
53
+ source: ".contextengine/policy.json" +
54
+ (hasOwnRates ? "" : ` (rates: built-in, as of ${DEFAULT_PRICING_ASOF})`),
55
+ };
56
+ }
57
+ return {
58
+ t: DEFAULT_COST_THRESHOLDS,
59
+ source: `built-in defaults, rates as of ${DEFAULT_PRICING_ASOF} (no agent_cost in policy.json)`,
60
+ };
61
+ }
62
+ export function buildCostReport(opts = {}, cwd = process.cwd()) {
63
+ const out = [];
64
+ const line = (s = "") => { out.push(s); };
65
+ const top = Math.max(1, opts.top ?? 10);
66
+ const since = opts.days ? Date.now() - opts.days * 86_400_000 : undefined;
67
+ const { t, source } = loadCostThresholds(cwd);
68
+ const runs = collectRuns({
69
+ session: opts.session,
70
+ project: opts.project,
71
+ run: opts.run,
72
+ since,
73
+ });
74
+ if (!runs.length) {
75
+ line("No multi-agent runs found in " + transcriptRoot());
76
+ line("(fan-outs only: parent sessions are not counted — this measures delegation)");
77
+ return { text: out.join("\n"), json: null, runs: 0 };
78
+ }
79
+ const scored = runs
80
+ .map((r) => ({ run: r, m: metricsFor(r, t.pricing) }))
81
+ .sort((a, b) => b.m.cost.total - a.m.cost.total);
82
+ const signals = runTranscriptHeuristics(runs, t);
83
+ const json = {
84
+ billing_mode: t.billing_mode,
85
+ cost_is_notional: t.billing_mode === "subscription",
86
+ thresholds_source: source,
87
+ runs: scored.map(({ run, m }) => ({
88
+ runId: run.runId, kind: run.kind, project: run.project, sessionId: run.sessionId,
89
+ volume: run.totals, intensity: {
90
+ agents: m.agents, reported: m.reported, failed: m.failed,
91
+ capacityExhausted: m.capacityExhausted, toolCalls: m.toolCalls,
92
+ medianToolCalls: m.medianToolCalls, durationMs: run.durationMs,
93
+ launchedBeforeFirstReport: m.launchedBeforeFirstReport,
94
+ },
95
+ cost: m.cost, cacheEfficiency: Number.isFinite(m.cacheEfficiency) ? m.cacheEfficiency : null,
96
+ outputShare: m.outputShare,
97
+ })),
98
+ signals,
99
+ };
100
+ // Aggregate across everything in scope.
101
+ let vol = emptyTally();
102
+ let agents = 0, toolCalls = 0, failed = 0, capacity = 0, reported = 0;
103
+ let cost = 0, withoutCache = 0, unpriced = 0;
104
+ // Which models carried tokens but matched no rate — named in the output so
105
+ // the fix is actionable instead of "something was unpriced".
106
+ const unpricedModels = new Set();
107
+ for (const { run, m } of scored) {
108
+ for (const a of run.agents) {
109
+ for (const [model, tally] of a.tokensByModel) {
110
+ if (totalTokens(tally) > 0 && !pricingFor(model, t.pricing)) {
111
+ unpricedModels.add(model ?? "(no model recorded)");
112
+ }
113
+ }
114
+ }
115
+ vol = addTally(vol, run.totals);
116
+ agents += m.agents;
117
+ toolCalls += m.toolCalls;
118
+ failed += m.failed;
119
+ capacity += m.capacityExhausted;
120
+ reported += m.reported;
121
+ cost += m.cost.total;
122
+ withoutCache += m.cost.withoutCache;
123
+ unpriced += m.cost.unpricedTokens;
124
+ }
125
+ const allTok = totalTokens(vol);
126
+ const cw = vol.cacheWrite5m + vol.cacheWrite1h;
127
+ line();
128
+ line(`MULTI-AGENT COST — ${scored.length} run(s), ${agents} subagents`);
129
+ line(`thresholds: ${source}`);
130
+ line();
131
+ // ── 1. VOLUME ───────────────────────────────────────────────────────────
132
+ line("VOLUME (tokens moved)");
133
+ const volRow = (label, n) => line(` ${label.padEnd(16)} ${fmtTok(n).padStart(8)} ${allTok ? ((100 * n) / allTok).toFixed(1).padStart(5) : " 0.0"}%`);
134
+ volRow("cache read", vol.cacheRead);
135
+ volRow("cache write", cw);
136
+ volRow("input (fresh)", vol.input);
137
+ volRow("output", vol.output);
138
+ line(` ${"total".padEnd(16)} ${fmtTok(allTok).padStart(8)}`);
139
+ line();
140
+ // ── 2. VALUED COST ──────────────────────────────────────────────────────
141
+ const notional = t.billing_mode === "subscription";
142
+ let ci = 0, ccw = 0, ccr = 0, co = 0;
143
+ for (const { m } of scored) {
144
+ ci += m.cost.input;
145
+ ccw += m.cost.cacheWrite;
146
+ ccr += m.cost.cacheRead;
147
+ co += m.cost.output;
148
+ }
149
+ const agg = {
150
+ input: ci, cacheWrite: ccw, cacheRead: ccr, output: co,
151
+ total: cost, withoutCache, unpricedTokens: unpriced,
152
+ };
153
+ const status = pricingStatus(agg);
154
+ line(`VALUED COST (API list prices)${notional && status !== "unpriced" ? " — NOTIONAL, NOT BILLED" : ""}`);
155
+ // [NEVER-RENDER-AN-UNKNOWN-AS-A-NUMBER] — with nothing priced there is no
156
+ // cost to show. Printing a $0.00 table here reads as "this run was free"
157
+ // and "caching saved 0%", both false.
158
+ if (status === "unpriced") {
159
+ line(` UNPRICED — no rate matched any model in this data, so no cost can be`);
160
+ line(` stated. ${fmtTok(unpriced)} tokens were moved. This is an unknown, not $0.`);
161
+ line();
162
+ line(` Models seen without a rate: ${[...unpricedModels].sort().join(", ") || "(unknown)"}`);
163
+ line(` Add them to .contextengine/policy.json → agent_cost.pricing.`);
164
+ line();
165
+ }
166
+ else {
167
+ if (notional) {
168
+ line(" This machine runs Claude Code on a subscription: no dollar below is");
169
+ line(" debited. Use these figures to compare approaches, not as spend.");
170
+ }
171
+ const costRow = (label, n) => line(` ${label.padEnd(16)} ${("$" + n.toFixed(2)).padStart(8)} ${cost ? ((100 * n) / cost).toFixed(1).padStart(5) : " 0.0"}%`);
172
+ costRow("cache read", ccr);
173
+ costRow("cache write", ccw);
174
+ costRow("input (fresh)", ci);
175
+ costRow("output", co);
176
+ line(` ${"total".padEnd(16)} ${("$" + cost.toFixed(2)).padStart(8)}`);
177
+ line(` ${"without cache".padEnd(16)} ${("$" + withoutCache.toFixed(2)).padStart(8)} ` +
178
+ `caching saved $${(withoutCache - cost).toFixed(2)} (${withoutCache ? (100 * (1 - cost / withoutCache)).toFixed(0) : "0"}%)`);
179
+ if (status === "partial") {
180
+ line(` ⚠ ${fmtTok(unpriced)} tokens UNPRICED and NOT in the figures above` +
181
+ ` (${[...unpricedModels].sort().join(", ") || "unknown model"}) — the total is a floor, not the cost`);
182
+ }
183
+ line();
184
+ }
185
+ // ── 3. INTENSITY (the capacity proxy) ───────────────────────────────────
186
+ line(`INTENSITY (capacity proxy${notional ? " — the scarce resource here" : ""})`);
187
+ line(` subagents ${String(agents).padStart(8)}`);
188
+ line(` reported ${String(reported).padStart(8)}`);
189
+ line(` returned nothing ${String(failed).padStart(8)}${failed ? ` (${((100 * failed) / agents).toFixed(0)}% of the fleet)` : ""}`);
190
+ line(` died at window ${String(capacity).padStart(8)}${capacity ? " ← capacity spent for no result" : ""}`);
191
+ line(` tool calls ${String(toolCalls).padStart(8)} (${(toolCalls / Math.max(1, agents)).toFixed(1)}/agent)`);
192
+ line(` cache reuse ${(cw ? (vol.cacheRead / cw).toFixed(1) + "x" : "—").padStart(8)} ${cw && vol.cacheRead / cw < t.min_cache_efficiency ? "← below floor, prefix is being rebuilt" : "(higher is better)"}`);
193
+ line();
194
+ // ── Top runs ────────────────────────────────────────────────────────────
195
+ line(`TOP RUNS BY VALUED COST (${Math.min(top, scored.length)} of ${scored.length})`);
196
+ line(` ${"cost".padStart(8)} ${"agents".padStart(6)} ${"dead".padStart(4)} ${"tools".padStart(5)} ${"reuse".padStart(6)} ${"dur".padStart(7)} run`);
197
+ for (const { run, m } of scored.slice(0, top)) {
198
+ const reuse = Number.isFinite(m.cacheEfficiency) ? m.cacheEfficiency.toFixed(1) + "x" : "—";
199
+ line(` ${("$" + m.cost.total.toFixed(2)).padStart(8)} ${String(m.agents).padStart(6)} ` +
200
+ `${String(m.failed).padStart(4)} ${String(m.medianToolCalls).padStart(5)} ${reuse.padStart(6)} ` +
201
+ `${fmtDur(run.durationMs).padStart(7)} ${run.runId} ${run.project.replace(/^-Users-yan-/, "")}`);
202
+ }
203
+ line();
204
+ // ── Signals ─────────────────────────────────────────────────────────────
205
+ if (!signals.length) {
206
+ line("✅ No context_burn or fanout_without_canary signals.");
207
+ }
208
+ else {
209
+ const crit = signals.filter((s) => s.severity === "critical");
210
+ line(`SIGNALS — ${signals.length} (${crit.length} critical)`);
211
+ for (const s of signals.slice(0, 20)) {
212
+ line(` ${s.severity === "critical" ? "🔴" : "⚠️ "} [${s.kind}] ${s.reason}`);
213
+ }
214
+ if (signals.length > 20)
215
+ line(` … ${signals.length - 20} more (use --json)`);
216
+ }
217
+ line();
218
+ return { text: out.join("\n"), json, runs: scored.length };
219
+ }
220
+ //# sourceMappingURL=cost-report.js.map
package/dist/index.js CHANGED
@@ -10,9 +10,10 @@ import { collectProjectOps, collectSystemOps } from "./collectors.js";
10
10
  import { loadCache, saveCache } from "./cache.js";
11
11
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
12
12
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
13
- import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
13
+ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog } from "./audit.js";
14
14
  import { startEventIngestServer } from "./http-server.js";
15
15
  import { detect } from "./detector.js";
16
+ import { buildCostReport } from "./cost-report.js";
16
17
  import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
17
18
  import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
18
19
  import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
@@ -609,6 +610,9 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
609
610
  const summary = [];
610
611
  summary.push(`Audit chain: ${report.ok ? "✅ INTACT" : "❌ BROKEN"}`);
611
612
  summary.push(`Total records: ${report.total}`);
613
+ if ((report.redactedIndices ?? []).length > 0) {
614
+ summary.push(`Redacted and acknowledged on the chain: ${report.redactedIndices.length} record(s), not counted as altered`);
615
+ }
612
616
  if (since || until) {
613
617
  summary.push(`Range filter: ${since ?? "start"} → ${until ?? "now"} (${filtered.length} record(s) in range)`);
614
618
  }
@@ -623,6 +627,28 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
623
627
  return respond("audit_verify", summary.join("\n"));
624
628
  });
625
629
  // ---------------------------------------------------------------------------
630
+ // Tool: agent_cost (multi-agent token / cost / capacity report)
631
+ // ---------------------------------------------------------------------------
632
+ // Same renderer as `contextengine cost`. [LOCK] [COST-REPORT-ONE-RENDERER]
633
+ // Free tool: it reads the caller's own Claude Code transcripts on this machine,
634
+ // nothing leaves it. Added 2026-08-21, one day after the CLI (707fcc8).
635
+ server.tool("agent_cost", "Multi-agent cost report from Claude Code's own transcripts on this machine: tokens moved (cache read/write, fresh input, output), valued cost at API list prices (marked NOTIONAL on a subscription, UNPRICED when no rate matches), capacity intensity (subagents, failed, died at window, tool calls per agent, cache reuse), top runs, and context_burn / fanout_without_canary signals. Call it after a fan-out to read what it consumed, or before one to compare with the last. Thresholds come from .contextengine/policy.json agent_cost, else built-in defaults.", {
636
+ days: z.number().int().positive().optional().describe("Only runs started within the last N days"),
637
+ project: z.string().optional().describe("Filter by project slug as it appears in ~/.claude/projects (e.g. -Users-yan-Projects-ContextEngine)"),
638
+ session: z.string().optional().describe("Filter by parent session id"),
639
+ run: z.string().optional().describe("Filter by run id (wf_... or task group id)"),
640
+ top: z.number().int().positive().max(50).optional().describe("How many runs to list (default 10)"),
641
+ json: z.boolean().optional().describe("Return the structured JSON report instead of the text one"),
642
+ policy_dir: z.string().optional().describe("Absolute path of the repo whose .contextengine/policy.json supplies agent_cost thresholds and rates. Default: the MCP server's working directory, which under launchd is the home dir, not a repo; the report names which source it used on its 'thresholds:' line"),
643
+ }, async ({ days, project, session, run, top, json, policy_dir }) => {
644
+ // [COST-POLICY-DIR-IS-EXPLICIT] — the daemon's cwd is not a project. Without this the MCP
645
+ // surface silently priced with built-in defaults while the CLI in the repo read policy.json.
646
+ const report = buildCostReport({ days, project, session, run, top }, policy_dir || process.cwd());
647
+ if (json && report.json)
648
+ return respond("agent_cost", JSON.stringify(report.json, null, 2));
649
+ return respond("agent_cost", report.text);
650
+ });
651
+ // ---------------------------------------------------------------------------
626
652
  // Tool: drift_status (Detector — read current drift signals)
627
653
  // ---------------------------------------------------------------------------
628
654
  // Agents should call this between major task phases. If any 'critical' signal
@@ -1131,6 +1157,24 @@ async function main() {
1131
1157
  const transport = new StdioServerTransport();
1132
1158
  await server.connect(transport);
1133
1159
  console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
1160
+ // 3a. Audit log auto-rotation. Deferred so the first requests are answered before the
1161
+ // synchronous verify + rewrite (a few seconds on a 500k-record chain) blocks the loop.
1162
+ // [LOCK] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER]
1163
+ // Measured 2026-08-21: ~13k records/hour on this machine, so the 100k trigger is hours
1164
+ // away, not a day; a server that is never restarted must still rotate. Hourly recheck.
1165
+ const runAutoRotate = () => {
1166
+ try {
1167
+ const o = autoRotateAuditLog();
1168
+ if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress") {
1169
+ console.error(`[ContextEngine] 📦 audit auto-rotate (${o.action}): ${o.detail}`);
1170
+ }
1171
+ }
1172
+ catch (err) {
1173
+ console.error(`[ContextEngine] ⚠ audit auto-rotate failed: ${err.message}`);
1174
+ }
1175
+ };
1176
+ setTimeout(runAutoRotate, 3_000).unref();
1177
+ setInterval(runAutoRotate, 60 * 60_000).unref();
1134
1178
  // 3b. Write server-meta.json so the VS Code extension can read tool count
1135
1179
  // without needing an active MCP session. Single source of truth =
1136
1180
  // src/tools-manifest.ts (asserted by tests/tools-manifest.test.ts).
@@ -24,14 +24,14 @@
24
24
  * Every tool name registered on the MCP server, in registration order.
25
25
  * Order is not load-bearing — kept stable for easier diffs.
26
26
  */
27
- export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
27
+ export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "agent_cost", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
28
28
  /**
29
29
  * The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
30
30
  * Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
31
31
  */
32
32
  export declare const PREMIUM_TOOL_NAMES: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
33
33
  /** Total count — what users see as "Active on all N MCP tools". */
34
- export declare const TOOL_COUNT: 21;
34
+ export declare const TOOL_COUNT: 22;
35
35
  /** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
36
36
  export declare const FREE_TOOL_COUNT: number;
37
37
  //# sourceMappingURL=tools-manifest.d.ts.map
@@ -39,6 +39,7 @@ export const ALL_TOOLS = [
39
39
  "delete_session",
40
40
  "audit_verify",
41
41
  "drift_status",
42
+ "agent_cost",
42
43
  "end_session",
43
44
  "save_learning",
44
45
  "list_learnings",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.3",
3
+ "version": "2.5.4",
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",