@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 +1 -1
- package/dist/activation.d.ts +0 -28
- package/dist/activation.js +27 -148
- package/dist/audit.d.ts +59 -1
- package/dist/audit.js +194 -4
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +47 -203
- package/dist/cost-report.d.ts +22 -0
- package/dist/cost-report.js +220 -0
- package/dist/index.js +45 -1
- package/dist/tools-manifest.d.ts +2 -2
- package/dist/tools-manifest.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
|
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
|
|
package/dist/activation.d.ts
CHANGED
|
@@ -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(): {
|
package/dist/activation.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// LOCKED — verified March 3 2026 — activation +
|
|
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
|
|
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
|
|
28
|
-
* 3.
|
|
29
|
-
*
|
|
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
|
|
43
|
+
import { join } from "path";
|
|
33
44
|
import { homedir } from "os";
|
|
34
|
-
import {
|
|
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
|
-
|
|
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
|
|
63
|
-
//
|
|
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
|
|
372
|
-
if (existsSync(
|
|
373
|
-
for (const file of readdirSync(
|
|
374
|
-
unlinkSync(join(
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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)
|
package/dist/cli-commands.js
CHANGED
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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
2421
|
+
top: topRaw ? parseInt(topRaw, 10) || 10 : 10,
|
|
2422
|
+
days: daysRaw ? parseInt(daysRaw, 10) || undefined : undefined,
|
|
2441
2423
|
});
|
|
2442
|
-
if (
|
|
2443
|
-
console.log(
|
|
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
|
-
|
|
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).
|
package/dist/tools-manifest.d.ts
CHANGED
|
@@ -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:
|
|
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
|
package/dist/tools-manifest.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.5.
|
|
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",
|