@compr/opscontext-mcp 2.7.1 → 2.8.1

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,60 @@ 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.1] 2026-09-07: a push is not done until its CI is read
8
+
9
+ ### Added
10
+
11
+ - **`end-session` check 3c, CI on HEAD** (`[PUSHED-MEANS-CI-READ]`, `src/ci-status.ts`): every
12
+ workflow run for the exact HEAD sha through `gh run list`; a failed run is a FAIL item (exit 1).
13
+ No gh, no remote, or no runs yet is "not checked", never a pass. Why: main's CI had been red
14
+ on every commit since 2026-09-04 and the Telegram alert fired each time; thirty commits and
15
+ five releases went by with nobody reading it.
16
+
17
+ ### Changed
18
+
19
+ - CI installs the activation server's own packages too: the first time the Test step actually
20
+ ran, `server/src/community-rules-server.test.ts` could not load (`ERR_MODULE_NOT_FOUND`).
21
+ - The Doc Freshness gate fails only when 20 or more source lines change without a doc change; a
22
+ one-line lint fix is not a documented change (its first run paged Telegram for a dash).
23
+ - CI runs on Node 20 and 22 with `fail-fast: false`; Node 18 is EOL and eslint 10 needs 20.19+,
24
+ and the 18 job's failure was cancelling the others before tests ran. `engines.node` is now
25
+ `>=20.19.0`, which is what is tested.
26
+
27
+ ### Fixed
28
+
29
+ - CI was red on every pull request for one `prefer-const` lint error in `src/firewall.ts`.
30
+ - The Doc Freshness workflow failed any push or PR made more than 8 hours after the last
31
+ SKILLS.md commit, whatever the change (two Dependabot bumps on 2026-09-07). It now measures
32
+ the change itself: source touched without a doc touched fails, anything else passes
33
+ (`[DOC-GATE-MEASURES-THE-DIFF]`).
34
+
35
+ ## [2.8.0] 2026-09-06: health is measured, never estimated
36
+
37
+ ### Added
38
+
39
+ - **Fleet health** (`[HEALTH-IS-MEASURED-NEVER-ESTIMATED]`, `src/fleet-health.ts`): the indexing
40
+ server writes `~/.contextengine/fleet-health.json` once a minute: servers on an old build
41
+ (version drift), shared-index writes in the last hour (reindex rate, ceiling 30), today's
42
+ pre-commit blocks with file and reason, learnings-store refusals, learnings saved (creates,
43
+ not a sweep's updates), and the last release for which `verify-release` passed. Every number
44
+ is counted from the audit log, the server registry or a file on disk. Warnings list measured
45
+ problems only. Shown by `contextengine servers` (exit 1 on any warning) and by `end-session`
46
+ § 3b; each server logs "N server(s) on an old build" when that number changes.
47
+ - `index.write` audit events carry the writer's pid.
48
+
49
+ ### VS Code extension 0.12.0
50
+
51
+ - **Status bar redesign** (Session 25 item 4): warning colour only on a measured problem (a
52
+ store refusal, servers on an old build, an index-write storm, commits made since the last
53
+ saved CE session, no MCP session, too many uncommitted files). Never on a timer. Default text
54
+ `CE` plus blocks prevented and recalls surfaced. Tooltip: measured problems, today's counts,
55
+ the last blocks with file and reason, servers and roles, version and last verified release,
56
+ the repo's CE session and commits since it was saved. "Time saved" is gone from the bar and
57
+ the info panel: it was a multiplier nobody could check.
58
+ - `fleetHealthPoller.ts` reads the health file every 15 s and treats a file older than 5 min as
59
+ absent.
60
+
7
61
  ## [2.7.1] 2026-09-06: the autostart agent is the standing indexer; servers --cost
8
62
 
9
63
  ### Changed
@@ -0,0 +1,16 @@
1
+ export interface CiRun {
2
+ name: string;
3
+ status: string;
4
+ conclusion: string | null;
5
+ url: string;
6
+ }
7
+ export interface CiStatus {
8
+ sha: string;
9
+ state: "ok" | "failed" | "pending" | "no-runs" | "unavailable";
10
+ runs: CiRun[];
11
+ note?: string;
12
+ }
13
+ export type Runner = (cmd: string, args: string[], cwd: string) => string;
14
+ export declare function ciStatusForHead(cwd: string, run?: Runner): CiStatus;
15
+ export declare function formatCiStatus(s: CiStatus): string[];
16
+ //# sourceMappingURL=ci-status.d.ts.map
@@ -0,0 +1,55 @@
1
+ // [LOCKED] [PUSHED-MEANS-CI-READ] 2026-09-07
2
+ // [NEVER] let end-session pass while a workflow run for HEAD has failed, and [NEVER] count
3
+ // "no runs found" as green.
4
+ // WHY: main's CI had been red on every commit since 2026-09-04 (one lint error, then a Node 18
5
+ // job that eslint 10 cannot run on) and the Telegram alert fired each time. Thirty commits,
6
+ // five releases, nobody read it: the post-commit hook pushes, end-session ran after every
7
+ // push, and nothing in that loop looked at the result. A push is not done until its CI is.
8
+ // FIX: end-session check 3c lists every workflow run for the exact HEAD sha through `gh run
9
+ // list` (hardcoded argv, no shell) and counts a failure as a FAIL item. gh missing, no
10
+ // GitHub remote, or no runs yet is reported as "not checked", never as pass.
11
+ import { execFileSync } from "child_process";
12
+ const defaultRunner = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }).trim();
13
+ export function ciStatusForHead(cwd, run = defaultRunner) {
14
+ let sha = "";
15
+ try {
16
+ sha = run("git", ["rev-parse", "HEAD"], cwd);
17
+ }
18
+ catch {
19
+ return { sha, state: "unavailable", runs: [], note: "not a git repository" };
20
+ }
21
+ let raw = "";
22
+ try {
23
+ raw = run("gh", ["run", "list", "--limit", "40", "--json", "name,status,conclusion,url,headSha"], cwd);
24
+ }
25
+ catch {
26
+ return { sha, state: "unavailable", runs: [], note: "gh not available, not logged in, or no GitHub remote" };
27
+ }
28
+ let all = [];
29
+ try {
30
+ all = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return { sha, state: "unavailable", runs: [], note: "gh returned no JSON" };
34
+ }
35
+ const runs = all.filter((r) => r.headSha === sha).map(({ name, status, conclusion, url }) => ({ name, status, conclusion, url }));
36
+ if (runs.length === 0)
37
+ return { sha, state: "no-runs", runs, note: "no workflow run for HEAD yet: pushed seconds ago, or CI not wired" };
38
+ const failed = runs.some((r) => r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure");
39
+ const pending = runs.some((r) => r.status !== "completed");
40
+ return { sha, state: failed ? "failed" : pending ? "pending" : "ok", runs };
41
+ }
42
+ export function formatCiStatus(s) {
43
+ const lines = [];
44
+ if (s.state === "unavailable" || s.state === "no-runs") {
45
+ lines.push(`- ⚠️ CI on HEAD${s.sha ? ` ${s.sha.slice(0, 7)}` : ""} not checked: ${s.note}`);
46
+ return lines;
47
+ }
48
+ for (const r of s.runs) {
49
+ const bad = r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure";
50
+ const icon = bad ? "❌ FAIL" : r.status !== "completed" ? "⏳" : r.conclusion === "success" ? "✅" : "▫️";
51
+ lines.push(`- ${icon} ${r.name}: ${r.conclusion ?? r.status}${bad ? ` ${r.url}` : ""}`);
52
+ }
53
+ return lines;
54
+ }
55
+ //# sourceMappingURL=ci-status.js.map
package/dist/cli.js CHANGED
@@ -656,6 +656,8 @@ 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";
660
+ import { ciStatusForHead, formatCiStatus } from "./ci-status.js";
659
661
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
660
662
  import { fileURLToPath } from "url";
661
663
  // ---------------------------------------------------------------------------
@@ -2233,10 +2235,21 @@ async function cliEndSession() {
2233
2235
  // --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
2234
2236
  checks.push("## 3b. Running servers\n");
2235
2237
  const fleet = listServers();
2236
- checks.push("```\n" + formatServers(fleet) + "\n```");
2238
+ checks.push("```\n" + formatServers(fleet) + "\n" + formatFleetHealth(computeFleetHealth({ version: readPackageVersion(), report: fleet })) + "\n```");
2237
2239
  if (fleet.warnings.length > 0)
2238
2240
  failCount += fleet.warnings.length;
2239
2241
  checks.push("");
2242
+ // --- Check 3c: CI on HEAD ([LOCK] [PUSHED-MEANS-CI-READ]) ---
2243
+ checks.push("## 3c. CI on HEAD\n");
2244
+ const ci = ciStatusForHead(process.cwd());
2245
+ checks.push(...formatCiStatus(ci));
2246
+ if (ci.state === "failed") {
2247
+ failCount++;
2248
+ checks.push("- ❌ FAIL: a workflow run for HEAD failed; a push is not done until its CI is");
2249
+ }
2250
+ else if (ci.state === "ok")
2251
+ passCount++;
2252
+ checks.push("");
2240
2253
  checks.push("## 4. Sessions\n");
2241
2254
  const sessions = listSessions();
2242
2255
  if (sessions.length > 0) {
@@ -2742,8 +2755,10 @@ else if (command === "audit-rotate") {
2742
2755
  }
2743
2756
  else if (command === "servers") {
2744
2757
  const fleet = listServers();
2758
+ const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
2745
2759
  console.log(formatServers(fleet, undefined, { cost: process.argv.includes("--cost") }));
2746
- process.exit(fleet.warnings.length > 0 ? 1 : 0);
2760
+ console.log(formatFleetHealth(health));
2761
+ process.exit(fleet.warnings.length > 0 || health.warnings.length > 0 ? 1 : 0);
2747
2762
  }
2748
2763
  else if (command === "audit-verify") {
2749
2764
  cliAuditVerify().catch((err) => {
package/dist/firewall.js CHANGED
@@ -185,7 +185,7 @@ export class ProtocolFirewall {
185
185
  if (sessionUrgent && level === "footer")
186
186
  level = "header";
187
187
  // Prepend learning injection to response (always, if available)
188
- let text = injection ? injection + "\n\n" + responseText : responseText;
188
+ const text = injection ? injection + "\n\n" + responseText : responseText;
189
189
  // Build session urgency block (always prepended when overdue)
190
190
  const urgentBlock = sessionUrgent ? this.buildSessionUrgentBlock() : null;
191
191
  if (level === "silent" && !urgentBlock)
@@ -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.1",
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",
@@ -59,7 +59,7 @@
59
59
  "email": "yannick@compr.ch"
60
60
  },
61
61
  "engines": {
62
- "node": ">=18.0.0"
62
+ "node": ">=20.19.0"
63
63
  },
64
64
  "files": [
65
65
  "dist/",