@compr/opscontext-mcp 2.7.1 → 2.8.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.8.0] 2026-09-06: health is measured, never estimated
8
+
9
+ ### Added
10
+
11
+ - **Fleet health** (`[HEALTH-IS-MEASURED-NEVER-ESTIMATED]`, `src/fleet-health.ts`): the indexing
12
+ server writes `~/.contextengine/fleet-health.json` once a minute: servers on an old build
13
+ (version drift), shared-index writes in the last hour (reindex rate, ceiling 30), today's
14
+ pre-commit blocks with file and reason, learnings-store refusals, learnings saved (creates,
15
+ not a sweep's updates), and the last release for which `verify-release` passed. Every number
16
+ is counted from the audit log, the server registry or a file on disk. Warnings list measured
17
+ problems only. Shown by `contextengine servers` (exit 1 on any warning) and by `end-session`
18
+ § 3b; each server logs "N server(s) on an old build" when that number changes.
19
+ - `index.write` audit events carry the writer's pid.
20
+
21
+ ### VS Code extension 0.12.0
22
+
23
+ - **Status bar redesign** (Session 25 item 4): warning colour only on a measured problem (a
24
+ store refusal, servers on an old build, an index-write storm, commits made since the last
25
+ saved CE session, no MCP session, too many uncommitted files). Never on a timer. Default text
26
+ `CE` plus blocks prevented and recalls surfaced. Tooltip: measured problems, today's counts,
27
+ the last blocks with file and reason, servers and roles, version and last verified release,
28
+ the repo's CE session and commits since it was saved. "Time saved" is gone from the bar and
29
+ the info panel: it was a multiplier nobody could check.
30
+ - `fleetHealthPoller.ts` reads the health file every 15 s and treats a file older than 5 min as
31
+ absent.
32
+
7
33
  ## [2.7.1] 2026-09-06: the autostart agent is the standing indexer; servers --cost
8
34
 
9
35
  ### Changed
package/dist/cli.js CHANGED
@@ -656,6 +656,7 @@ import { buildCostReport } from "./cost-report.js";
656
656
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
657
657
  import { safeAppend } from "./audit.js";
658
658
  import { listServers, formatServers } from "./server-registry.js";
659
+ import { computeFleetHealth, formatFleetHealth } from "./fleet-health.js";
659
660
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
660
661
  import { fileURLToPath } from "url";
661
662
  // ---------------------------------------------------------------------------
@@ -2233,7 +2234,7 @@ async function cliEndSession() {
2233
2234
  // --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
2234
2235
  checks.push("## 3b. Running servers\n");
2235
2236
  const fleet = listServers();
2236
- checks.push("```\n" + formatServers(fleet) + "\n```");
2237
+ checks.push("```\n" + formatServers(fleet) + "\n" + formatFleetHealth(computeFleetHealth({ version: readPackageVersion(), report: fleet })) + "\n```");
2237
2238
  if (fleet.warnings.length > 0)
2238
2239
  failCount += fleet.warnings.length;
2239
2240
  checks.push("");
@@ -2742,8 +2743,10 @@ else if (command === "audit-rotate") {
2742
2743
  }
2743
2744
  else if (command === "servers") {
2744
2745
  const fleet = listServers();
2746
+ const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
2745
2747
  console.log(formatServers(fleet, undefined, { cost: process.argv.includes("--cost") }));
2746
- process.exit(fleet.warnings.length > 0 ? 1 : 0);
2748
+ console.log(formatFleetHealth(health));
2749
+ process.exit(fleet.warnings.length > 0 || health.warnings.length > 0 ? 1 : 0);
2747
2750
  }
2748
2751
  else if (command === "audit-verify") {
2749
2752
  cliAuditVerify().catch((err) => {
@@ -0,0 +1,57 @@
1
+ import { type ServerReport } from "./server-registry.js";
2
+ export interface FleetHealth {
3
+ generatedAt: string;
4
+ version: string;
5
+ writerPid: number;
6
+ servers: {
7
+ total: number;
8
+ indexers: number;
9
+ readers: number;
10
+ /** Servers whose loaded build differs from the file on disk. */
11
+ stale: Array<{
12
+ pid: number;
13
+ version: string;
14
+ build: string;
15
+ cwd: string;
16
+ }>;
17
+ diskBuild: string | null;
18
+ };
19
+ reindex: {
20
+ /** Shared-index writes in the last hour, all corpora. */
21
+ lastHourWrites: number;
22
+ perCorpus: Record<string, number>;
23
+ /** Above this many writes per hour a warning is raised. */
24
+ threshold: number;
25
+ };
26
+ today: {
27
+ /** Pre-commit blocks (hook.block) since local midnight. */
28
+ blocks: number;
29
+ /** Store refusals (unreadable, shrink refused, growth refused) since local midnight. */
30
+ refusals: number;
31
+ learningsSaved: number;
32
+ /** Newest last: time, kind, one-line detail. */
33
+ lastBlocks: Array<{
34
+ ts: string;
35
+ kind: string;
36
+ detail: string;
37
+ }>;
38
+ };
39
+ /** The newest release for which verify-release passed on this machine, or null. */
40
+ lastVerifiedRelease: string | null;
41
+ /** Measured problems only. Empty means green. */
42
+ warnings: string[];
43
+ }
44
+ export declare const REINDEX_PER_HOUR_WARN = 30;
45
+ export declare function fleetHealthPath(): string;
46
+ /** The highest version with a verify-release marker; version order, not file time (ties). */
47
+ export declare function lastVerifiedRelease(dir?: string): string | null;
48
+ export declare function computeFleetHealth(opts?: {
49
+ now?: Date;
50
+ version?: string;
51
+ auditPath?: string;
52
+ report?: ServerReport;
53
+ }): FleetHealth;
54
+ /** Temp file + rename: a reader never sees half a file. */
55
+ export declare function writeFleetHealth(h: FleetHealth): string;
56
+ export declare function formatFleetHealth(h: FleetHealth): string;
57
+ //# sourceMappingURL=fleet-health.d.ts.map
@@ -0,0 +1,172 @@
1
+ // [LOCKED] [HEALTH-IS-MEASURED-NEVER-ESTIMATED] 2026-09-06
2
+ // [NEVER] put a number in this file that comes from a timer, a multiplier or a guess; every
3
+ // field is counted from the audit log, the server registry, git, or a file on disk.
4
+ // WHY: the VS Code status bar showed "CE SAVE SESSION" in yellow on a wall-clock timer and
5
+ // "~N min saved" from a multiplier nobody could check (Session 25 item 4). Meanwhile the
6
+ // real problems of 2026-09-05 (eleven servers re-embedding, load average 230; two servers
7
+ // on a stale build re-importing 1,766 records; a store growth of 1,766 in one minute) had no
8
+ // surface at all. A nag without evidence trains the user to ignore the bar; a fact does not.
9
+ // FIX: one function computes the fleet's health from evidence, one writer (the indexer) drops
10
+ // it into ~/.contextengine/fleet-health.json every minute, and every surface (servers CLI,
11
+ // end-session, the status bar) reads the same file. Warnings list measured problems only.
12
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, writeFileSync } from "fs";
13
+ import { join } from "path";
14
+ import { homedir } from "os";
15
+ import { listServers } from "./server-registry.js";
16
+ export const REINDEX_PER_HOUR_WARN = 30;
17
+ const TAIL_BYTES = 8 * 1024 * 1024;
18
+ function ceHome() {
19
+ return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
20
+ }
21
+ export function fleetHealthPath() {
22
+ return join(ceHome(), "fleet-health.json");
23
+ }
24
+ /** The last `bytes` of a file as complete lines (the first partial line is dropped). */
25
+ function tailLines(path, bytes) {
26
+ if (!existsSync(path))
27
+ return [];
28
+ let fd = null;
29
+ try {
30
+ fd = openSync(path, "r");
31
+ const size = fstatSync(fd).size;
32
+ const start = Math.max(0, size - bytes);
33
+ const buf = Buffer.alloc(size - start);
34
+ readSync(fd, buf, 0, buf.length, start);
35
+ let text = buf.toString("utf8");
36
+ if (start > 0) {
37
+ const nl = text.indexOf("\n");
38
+ text = nl === -1 ? "" : text.slice(nl + 1);
39
+ }
40
+ return text.split("\n").filter(Boolean);
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ finally {
46
+ if (fd !== null)
47
+ closeSync(fd);
48
+ }
49
+ }
50
+ function parseRecords(lines) {
51
+ const out = [];
52
+ for (const l of lines) {
53
+ try {
54
+ const r = JSON.parse(l);
55
+ if (r && typeof r.ts === "string" && typeof r.event === "string")
56
+ out.push(r);
57
+ }
58
+ catch {
59
+ /* a torn line, ignored */
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function localMidnight(now) {
65
+ const d = new Date(now);
66
+ d.setHours(0, 0, 0, 0);
67
+ return d;
68
+ }
69
+ function blockDetail(p = {}) {
70
+ const check = String(p.check ?? "block");
71
+ if (p.file)
72
+ return `${check}: ${String(p.file)}${p.line ? `:${String(p.line)}` : ""}${p.pattern_id ? ` (${String(p.pattern_id)})` : ""}`;
73
+ if (p.requires_section)
74
+ return `${check}: ${String(p.requires_section)}`;
75
+ if (p.reason)
76
+ return `${check}: ${String(p.reason)}`;
77
+ return check;
78
+ }
79
+ /** The highest version with a verify-release marker; version order, not file time (ties). */
80
+ export function lastVerifiedRelease(dir = ceHome()) {
81
+ let files = [];
82
+ try {
83
+ files = readdirSync(dir);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ const versions = files.map((f) => /^verified-(\d+\.\d+\.\d+)$/.exec(f)?.[1]).filter((v) => !!v);
89
+ if (versions.length === 0)
90
+ return null;
91
+ const key = (v) => v.split(".").map(Number);
92
+ versions.sort((a, b) => { const x = key(a), y = key(b); return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]); });
93
+ return versions[versions.length - 1];
94
+ }
95
+ export function computeFleetHealth(opts = {}) {
96
+ const now = opts.now ?? new Date();
97
+ const report = opts.report ?? listServers();
98
+ const audit = opts.auditPath ?? join(ceHome(), "audit.log");
99
+ const records = parseRecords(tailLines(audit, TAIL_BYTES));
100
+ const midnight = localMidnight(now).getTime();
101
+ const hourAgo = now.getTime() - 3_600_000;
102
+ const perCorpus = {};
103
+ let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0;
104
+ const lastBlocks = [];
105
+ for (const r of records) {
106
+ const t = Date.parse(r.ts);
107
+ if (Number.isNaN(t))
108
+ continue;
109
+ if (r.event === "index.write" && t >= hourAgo) {
110
+ lastHourWrites++;
111
+ const c = String(r.payload?.corpus ?? "?");
112
+ perCorpus[c] = (perCorpus[c] ?? 0) + 1;
113
+ }
114
+ if (t < midnight)
115
+ continue;
116
+ if (r.event === "hook.block") {
117
+ blocks++;
118
+ lastBlocks.push({ ts: r.ts, kind: "pre-commit", detail: blockDetail(r.payload) });
119
+ }
120
+ else if (r.event === "learning.store_unreadable" || r.event === "learning.store_shrink_refused" || r.event === "learning.store_growth_refused") {
121
+ refusals++;
122
+ lastBlocks.push({ ts: r.ts, kind: "store", detail: r.event.replace("learning.store_", "").replace(/_/g, " ") });
123
+ }
124
+ else if (r.event === "learning.save" && r.payload?.mode !== "update")
125
+ learningsSaved++; // a sweep's updates are not saves
126
+ }
127
+ const stale = report.servers.filter((s) => s.staleBuild).map((s) => ({ pid: s.pid, version: s.version, build: s.build, cwd: s.cwd }));
128
+ const diskBuild = report.servers.find((s) => s.currentBuild)?.currentBuild ?? null;
129
+ const indexers = report.servers.filter((s) => s.role !== "reader").length;
130
+ const warnings = [];
131
+ if (stale.length > 0)
132
+ warnings.push(`${stale.length} server(s) on an old build (pid ${stale.map((s) => s.pid).join(", ")}): reload their windows`);
133
+ if (lastHourWrites > REINDEX_PER_HOUR_WARN)
134
+ warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
135
+ if (refusals > 0)
136
+ warnings.push(`${refusals} learnings-store refusal(s) today: a write looked like a wipe or a runaway import`);
137
+ for (const w of report.warnings)
138
+ if (/index on their own/.test(w))
139
+ warnings.push(w);
140
+ return {
141
+ generatedAt: now.toISOString(),
142
+ version: opts.version ?? "unknown",
143
+ writerPid: process.pid,
144
+ servers: { total: report.servers.length, indexers, readers: report.servers.length - indexers, stale, diskBuild },
145
+ reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
146
+ today: { blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
147
+ lastVerifiedRelease: lastVerifiedRelease(),
148
+ warnings,
149
+ };
150
+ }
151
+ /** Temp file + rename: a reader never sees half a file. */
152
+ export function writeFleetHealth(h) {
153
+ const path = fleetHealthPath();
154
+ mkdirSync(join(path, ".."), { recursive: true });
155
+ const tmp = `${path}.tmp-${process.pid}`;
156
+ writeFileSync(tmp, JSON.stringify(h, null, 2) + "\n");
157
+ renameSync(tmp, path);
158
+ return path;
159
+ }
160
+ export function formatFleetHealth(h) {
161
+ const lines = [];
162
+ lines.push(`health: ${h.warnings.length === 0 ? "green" : `${h.warnings.length} measured problem(s)`} (v${h.version}, last verified release ${h.lastVerifiedRelease ?? "none"}, ${h.generatedAt.slice(11, 19)}Z)`);
163
+ lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
164
+ lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
165
+ lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
166
+ for (const b of h.today.lastBlocks)
167
+ lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
168
+ for (const w of h.warnings)
169
+ lines.push(` ⚠ ${w}`);
170
+ return lines.join("\n");
171
+ }
172
+ //# sourceMappingURL=fleet-health.js.map
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { listProjects, checkPorts, runComplianceAudit, formatProjectList, format
13
13
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
14
14
  import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
15
15
  import { registerServer, listServers, formatServers } from "./server-registry.js";
16
+ import { computeFleetHealth, writeFleetHealth } from "./fleet-health.js";
16
17
  import { startEventIngestServer } from "./http-server.js";
17
18
  import { detect } from "./detector.js";
18
19
  import { buildCostReport } from "./cost-report.js";
@@ -210,7 +211,7 @@ function publishIndex() {
210
211
  keys: chunks.map(embedKeyOf),
211
212
  });
212
213
  lastIndexMtime = sharedIndexMtime(corpus);
213
- safeAppend("index.write", { corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
214
+ safeAppend("index.write", { pid: process.pid, corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
214
215
  console.error(`[ContextEngine] 📤 Shared index written: seq ${indexSeq}, ${chunks.length} chunks, ${Math.round(r.bytes / 1024)} KB, ${r.ms} ms`);
215
216
  }
216
217
  catch (err) {
@@ -422,11 +423,39 @@ function evaluateRole(reason) {
422
423
  startIndexPolling();
423
424
  }
424
425
  }
426
+ let healthTick = 0;
427
+ let lastStaleCount = -1;
428
+ /**
429
+ * The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
430
+ * rate, today's blocks and refusals, the last verified release. Every surface reads that file.
431
+ * [LOCK] [HEALTH-IS-MEASURED-NEVER-ESTIMATED]
432
+ */
433
+ function publishHealth() {
434
+ try {
435
+ const h = computeFleetHealth({ version: PKG_VERSION });
436
+ if (h.servers.stale.length !== lastStaleCount) {
437
+ lastStaleCount = h.servers.stale.length;
438
+ if (lastStaleCount > 0)
439
+ console.error(`[ContextEngine] 🧭 ${lastStaleCount} server(s) on an old build: pid ${h.servers.stale.map((s) => s.pid).join(", ")}`);
440
+ }
441
+ if (role === "indexer")
442
+ writeFleetHealth(h);
443
+ }
444
+ catch (err) {
445
+ console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
446
+ }
447
+ }
425
448
  function startRolePolling() {
426
- if (rolePoll || !corpus)
449
+ if (rolePoll)
427
450
  return;
428
- rolePoll = setInterval(() => evaluateRole("periodic"), ROLE_POLL_MS);
451
+ rolePoll = setInterval(() => {
452
+ if (corpus)
453
+ evaluateRole("periodic");
454
+ if (++healthTick % 4 === 0)
455
+ publishHealth(); // every 60 s
456
+ }, ROLE_POLL_MS);
429
457
  rolePoll.unref();
458
+ setTimeout(publishHealth, 5_000).unref();
430
459
  }
431
460
  // ---------------------------------------------------------------------------
432
461
  // MCP Server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
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",