@compr/opscontext-mcp 2.10.0 → 2.12.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/dist/cli.js CHANGED
@@ -651,7 +651,7 @@ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLea
651
651
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
652
652
  import { activate, deactivate, getActivationStatus, gateCheckFresh, } from "./activation.js";
653
653
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
654
- import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, } from "./audit.js";
654
+ import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, recordVerifyState, acquireVerifyLock, } from "./audit.js";
655
655
  import { redactPayload, redactChunk } from "./secret-shapes.js";
656
656
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
657
657
  import { buildCostReport } from "./cost-report.js";
@@ -1751,7 +1751,16 @@ safeAppend), visible via 'contextengine audit-verify' and consumed by the
1751
1751
  console.error(`❌ ${refused}`);
1752
1752
  process.exit(1);
1753
1753
  }
1754
- safeAppend(eventKind, prepareCapturedPayload(payload, eventKind), actor);
1754
+ // [LOCKED] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] - 2026-09-27
1755
+ // [NEVER] report "Appended" or exit 0 for an entry the audit log refused.
1756
+ // WHY: on a stuck log (a record cut short by a full disk) this printed "Appended vscode.tool_call to
1757
+ // audit log." and exited 0 while nothing landed (E2E_REVIEW_2026-09 B1-1); the VS Code
1758
+ // extension emits through this command and believed it.
1759
+ // FIX: safeAppend() returns whether it wrote; a refusal exits 1. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
1760
+ if (!safeAppend(eventKind, prepareCapturedPayload(payload, eventKind), actor)) {
1761
+ console.error(`❌ Not recorded: the audit log refused ${eventKind} (reason above). The refusal is counted and will be noted on the chain.`);
1762
+ process.exit(1);
1763
+ }
1755
1764
  console.log(`✅ Appended ${eventKind} to audit log.`);
1756
1765
  }
1757
1766
  async function cliWatch(args) {
@@ -2129,14 +2138,50 @@ function cliAuditRedactAck(args) {
2129
2138
  console.log(` audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${(after.redactedIndices ?? []).length} redacted, ${(after.tamperedIndices ?? []).length} altered.`);
2130
2139
  }
2131
2140
  async function cliAuditVerify() {
2141
+ // [LOCK] [HEALTH-SEES-THE-CHAIN]: every full check leaves its result for fleet health. The
2142
+ // daily one (--scheduled, started by the indexing server) runs one at a time and prints nothing.
2143
+ const scheduled = process.argv.includes("--scheduled");
2144
+ let releaseVerifyLock = null;
2145
+ if (scheduled) {
2146
+ releaseVerifyLock = acquireVerifyLock();
2147
+ if (!releaseVerifyLock)
2148
+ return; // another check is running
2149
+ }
2150
+ const t0 = Date.now();
2132
2151
  const report = verifyChain();
2152
+ recordVerifyState(report, Date.now() - t0, scheduled ? "scheduled" : "cli");
2153
+ if (scheduled) {
2154
+ releaseVerifyLock?.();
2155
+ return;
2156
+ }
2133
2157
  const forks = report.forkIndices ?? [];
2134
2158
  const redacted = report.redactedIndices ?? [];
2159
+ const dups = report.duplicateIndices ?? [];
2160
+ // [LOCK] [VERIFY-FORK-IS-NOT-TAMPER]: a second copy of a record is counted once and named as a copy.
2161
+ const copiesNote = () => {
2162
+ if (dups.length === 0)
2163
+ return;
2164
+ console.log(`\n⚠️ ${dups.length} record(s) appear twice (a second copy of a record already in the log).`);
2165
+ console.log(` Counted once. Content intact, nothing missing. Usually a log trim that was interrupted or`);
2166
+ console.log(` ran while entries arrived. At: ${dups.slice(0, 8).join(", ")}${dups.length > 8 ? `, … (+${dups.length - 8} more)` : ""}`);
2167
+ };
2168
+ // [LOCK] [REDACTION-IS-A-CHAINED-RECORD]: an acknowledgement is a statement by whoever ran it; show each one used.
2169
+ const acksNote = (log) => {
2170
+ const acks = report.acknowledgements ?? [];
2171
+ if (acks.length === 0)
2172
+ return;
2173
+ log(` Acknowledged by (a statement by whoever ran it; the chain cannot tell a removal from a rewrite):`);
2174
+ for (const a of acks.slice(0, 10))
2175
+ log(` ${a.ts.slice(0, 19).replace("T", " ")}Z ${a.actor} ${a.records} record(s) "${a.reason.slice(0, 80)}"`);
2176
+ if (acks.length > 10)
2177
+ log(` … (+${acks.length - 10} more)`);
2178
+ };
2135
2179
  if (report.ok) {
2136
- console.log(`✅ Audit chain verified — ${report.total} record(s).`);
2180
+ console.log(`✅ Audit chain verified — ${report.total - dups.length} record(s).`);
2137
2181
  console.log(redacted.length === 0
2138
2182
  ? ` No record was altered, and no history is missing.`
2139
2183
  : ` 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.`);
2184
+ acksNote((x) => console.log(x));
2140
2185
  if (forks.length > 0) {
2141
2186
  // [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
2142
2187
  console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
@@ -2146,6 +2191,7 @@ async function cliAuditVerify() {
2146
2191
  console.log(` property holds. Do NOT rewrite the log to linearise it — that would destroy`);
2147
2192
  console.log(` the evidence it exists to provide.`);
2148
2193
  }
2194
+ copiesNote();
2149
2195
  return;
2150
2196
  }
2151
2197
  console.error(`❌ Audit chain FAILED — ${report.total} record(s) checked.`);
@@ -2155,11 +2201,24 @@ async function cliAuditVerify() {
2155
2201
  console.error(`\n Altered records (content does not match its own hash):`);
2156
2202
  console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
2157
2203
  console.error(` This is tampering: the record's bytes were changed after it was written.`);
2158
- console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain:`);
2159
- console.error(` contextengine audit-redact-ack --index ${t.slice(0, 3).join(",")} --reason "<what was removed and why>"`);
2204
+ console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain`);
2205
+ console.error(` (every altered index is listed, so the command can be pasted as it is):`);
2206
+ console.error(` contextengine audit-redact-ack --index ${t.join(",")} --reason "<what was removed and why>"`);
2160
2207
  }
2161
2208
  if (redacted.length > 0) {
2162
2209
  console.error(`\n Also ${redacted.length} redacted record(s), acknowledged on the chain, not counted above.`);
2210
+ acksNote((x) => console.error(x));
2211
+ }
2212
+ // [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]: say where, and that the rest was checked.
2213
+ const unreadable = report.unreadable ?? [];
2214
+ if (unreadable.length > 0) {
2215
+ console.error(`\n Lines that are not records (every other record was checked):`);
2216
+ for (const u of unreadable.slice(0, 10))
2217
+ console.error(` ${u.file} line ${u.line}`);
2218
+ if (unreadable.length > 10)
2219
+ console.error(` … (+${unreadable.length - 10} more)`);
2220
+ console.error(` A last record cut short by a full disk is set aside by the next append on its own;`);
2221
+ console.error(` a line like these is damage: keep a copy of the file before touching it.`);
2163
2222
  }
2164
2223
  if ((report.orphanIndices ?? []).length > 0) {
2165
2224
  const o = report.orphanIndices;
@@ -2172,6 +2231,9 @@ async function cliAuditVerify() {
2172
2231
  if (forks.length > 0) {
2173
2232
  console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
2174
2233
  }
2234
+ if (dups.length > 0) {
2235
+ console.error(`\n (Also ${dups.length} second copies of records, counted once, benign.)`);
2236
+ }
2175
2237
  console.error(`\nFor compliance-graded evidence, treat the affected records as unverified.`);
2176
2238
  process.exit(2);
2177
2239
  }
package/dist/firewall.js CHANGED
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // This is the first MCP server that enforces agent behavior
13
13
  // through progressive response degradation.
14
- import { execSync } from "child_process";
14
+ import { execSync, execFileSync } from "child_process";
15
15
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "fs";
16
16
  import { join } from "path";
17
17
  import { homedir } from "os";
@@ -513,19 +513,29 @@ export class ProtocolFirewall {
513
513
  this.gitCache.timestamp = now;
514
514
  this.gitCache.data = [];
515
515
  for (const dir of this.projectDirs.slice(0, 5)) {
516
+ // [LOCKED] [GIT-FAILURE-IS-UNKNOWN-NOT-CLEAN] - 2026-09-27 (Yan's GO for this one line, file under DO NOT RE-AUDIT)
517
+ // [NEVER] count dirty files through a shell pipe that turns a failed git into "0".
518
+ // WHY: `git status --porcelain 2>/dev/null | wc -l` printed 0 when git failed, so a repository
519
+ // git could not read (a corrupt index: exit 128, 8 changed files) was reported "Git: ok,
520
+ // clean" (E2E_REVIEW_2026-09 B6-2). [EXEC-FAILURE-IS-NOT-EMPTY] fixed the same shape in agents.ts.
521
+ // FIX: git by argument list, lines counted here; a folder that is not a repository is skipped as
522
+ // before; any other failure (unreadable repo, timeout) is reported as unknown, never clean.
516
523
  try {
517
- const out = execSync("git status --porcelain 2>/dev/null | wc -l", {
524
+ const out = execFileSync("git", ["status", "--porcelain"], {
518
525
  cwd: dir.path,
519
526
  encoding: "utf-8",
520
527
  timeout: 3000,
521
- stdio: ["pipe", "pipe", "pipe"],
522
- }).trim();
523
- const n = parseInt(out);
528
+ stdio: ["ignore", "pipe", "pipe"],
529
+ });
530
+ const n = out.split("\n").filter(Boolean).length;
524
531
  if (n > 0)
525
532
  this.gitCache.data.push(`${dir.name}(${n})`);
526
533
  }
527
- catch {
528
- /* skip */
534
+ catch (e) {
535
+ const stderr = String(e.stderr ?? "");
536
+ if (/not a git repository/i.test(stderr))
537
+ continue;
538
+ this.gitCache.data.push(`${dir.name}(unknown: git could not read it)`);
529
539
  }
530
540
  }
531
541
  }
@@ -45,6 +45,27 @@ export interface FleetHealth {
45
45
  detail: string;
46
46
  }>;
47
47
  };
48
+ /** The last full check of the audit chain (`audit-verify`, run by hand or daily by the indexing
49
+ * server), or null when it never ran here. [LOCK] [HEALTH-SEES-THE-CHAIN] */
50
+ chain: {
51
+ checkedAt: string;
52
+ ageHours: number;
53
+ ok: boolean;
54
+ unique: number;
55
+ altered: number;
56
+ orphans: number;
57
+ unreadable: number;
58
+ duplicates: number;
59
+ reason: string | null;
60
+ } | null;
61
+ /** Entries the audit log refused today (chained as audit.append_failed, or still pending), and
62
+ * records cut short today (audit.torn_tail). */
63
+ auditLog: {
64
+ refusedToday: number;
65
+ lastRefusal: string | null;
66
+ tornToday: number;
67
+ lastTornKept: string | null;
68
+ };
48
69
  /** The newest release for which verify-release passed on this machine, or null. */
49
70
  lastVerifiedRelease: string | null;
50
71
  /** Measured problems only. Empty means green. */
@@ -56,6 +77,8 @@ export declare const REINDEX_PER_HOUR_WARN = 30;
56
77
  export declare const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
57
78
  export declare const DOUBLED_MIN_EVENTS = 10;
58
79
  export declare const DOUBLED_WINDOW_MS = 2000;
80
+ /** A full check older than this is reported: the indexing server runs one a day. */
81
+ export declare const CHAIN_CHECK_STALE_HOURS = 48;
59
82
  export declare function fleetHealthPath(): string;
60
83
  /** The highest version with a verify-release marker; version order, not file time (ties). */
61
84
  export declare function lastVerifiedRelease(dir?: string): string | null;
@@ -13,6 +13,7 @@ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdi
13
13
  import { join } from "path";
14
14
  import { homedir } from "os";
15
15
  import { listServers } from "./server-registry.js";
16
+ import { readVerifyState, pendingRefusals } from "./audit.js";
16
17
  import { claudeHookRegistrations } from "./install-claude-hook.js";
17
18
  export const REINDEX_PER_HOUR_WARN = 30;
18
19
  /** Doubled hook events above this share of the day's hook events raise a warning, once the day
@@ -20,6 +21,8 @@ export const REINDEX_PER_HOUR_WARN = 30;
20
21
  export const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
21
22
  export const DOUBLED_MIN_EVENTS = 10;
22
23
  export const DOUBLED_WINDOW_MS = 2000;
24
+ /** A full check older than this is reported: the indexing server runs one a day. */
25
+ export const CHAIN_CHECK_STALE_HOURS = 48;
23
26
  const TAIL_BYTES = 8 * 1024 * 1024;
24
27
  function ceHome() {
25
28
  return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
@@ -109,6 +112,8 @@ export function computeFleetHealth(opts = {}) {
109
112
  let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0, hookEvents = 0, doubledHookEvents = 0;
110
113
  const lastBlocks = [];
111
114
  let prevHook = null;
115
+ let refusedToday = 0, tornToday = 0;
116
+ let lastRefusal = null, lastTornKept = null;
112
117
  for (const r of records) {
113
118
  const t = Date.parse(r.ts);
114
119
  if (Number.isNaN(t))
@@ -127,6 +132,16 @@ export function computeFleetHealth(opts = {}) {
127
132
  }
128
133
  if (t < midnight)
129
134
  continue;
135
+ if (r.event === "audit.append_failed") {
136
+ refusedToday += Number(r.payload?.count ?? 0) || 0;
137
+ const errs = r.payload?.errors;
138
+ if (errs && Object.keys(errs).length > 0)
139
+ lastRefusal = Object.keys(errs)[0];
140
+ }
141
+ if (r.event === "audit.torn_tail") {
142
+ tornToday++;
143
+ lastTornKept = String(r.payload?.kept ?? "");
144
+ }
130
145
  if (r.event === "hook.block") {
131
146
  blocks++;
132
147
  lastBlocks.push({ ts: r.ts, kind: "pre-commit", detail: blockDetail(r.payload) });
@@ -161,6 +176,42 @@ export function computeFleetHealth(opts = {}) {
161
176
  warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
162
177
  if (refusals > 0)
163
178
  warnings.push(`${refusals} learnings-store refusal(s) today: a write looked like a wipe or a runaway import`);
179
+ // [LOCKED] [HEALTH-SEES-THE-CHAIN] - 2026-09-27
180
+ // [NEVER] report green while the audit chain failed its last check, or while the log refuses entries.
181
+ // WHY: fleet health read the audit log for counts only. On three broken logs (a stuck log whose
182
+ // appends were all refused, missing history, 100 altered records) the verifier said FAILED
183
+ // and health said 0 warnings (E2E_REVIEW_2026-09 B6-1). The chain is the product's claim.
184
+ // FIX: health never runs the check (26 s and 3.5 GB on 4.9M records, measured): it reads the
185
+ // result every `audit-verify` leaves, which the indexing server refreshes daily in a separate
186
+ // low-priority process. A failed check, a check older than CHAIN_CHECK_STALE_HOURS, refused
187
+ // entries (chained as audit.append_failed or still pending) and records cut short today are
188
+ // each a warning. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED] [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED]
189
+ const vs = readVerifyState();
190
+ const chain = vs
191
+ ? {
192
+ checkedAt: vs.checkedAt,
193
+ ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(vs.checkedAt)) / 3_600_000)),
194
+ ok: vs.ok,
195
+ unique: vs.unique,
196
+ altered: vs.altered,
197
+ orphans: vs.orphans,
198
+ unreadable: vs.unreadable,
199
+ duplicates: vs.duplicates,
200
+ reason: vs.reason,
201
+ }
202
+ : null;
203
+ if (chain && !chain.ok)
204
+ warnings.push(`the audit chain did not verify at its last check (${chain.checkedAt.slice(0, 16).replace("T", " ")}Z): ${chain.reason ?? "see contextengine audit-verify"}`);
205
+ else if (chain && chain.ageHours > CHAIN_CHECK_STALE_HOURS)
206
+ warnings.push(`the audit chain was last checked ${chain.ageHours} h ago: run contextengine audit-verify`);
207
+ const pending = pendingRefusals();
208
+ if (pending.count > 0 && pending.error)
209
+ lastRefusal = pending.error; // the newest refusal wins
210
+ const refusedTotal = refusedToday + pending.count;
211
+ if (refusedTotal > 0)
212
+ warnings.push(`the audit log refused ${refusedTotal} entr${refusedTotal === 1 ? "y" : "ies"} today${lastRefusal ? ` (${lastRefusal})` : ""}: they are not in the log, and the gap is noted on the chain`);
213
+ if (tornToday > 0)
214
+ warnings.push(`${tornToday} audit record(s) were cut short today (a full disk?); the bytes are kept in ${lastTornKept}`);
164
215
  for (const w of report.warnings)
165
216
  if (/index on their own/.test(w))
166
217
  warnings.push(w);
@@ -172,6 +223,8 @@ export function computeFleetHealth(opts = {}) {
172
223
  reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
173
224
  claudeHooks,
174
225
  today: { hookEvents, doubledHookEvents, blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
226
+ chain,
227
+ auditLog: { refusedToday: refusedTotal, lastRefusal, tornToday, lastTornKept },
175
228
  lastVerifiedRelease: lastVerifiedRelease(),
176
229
  warnings,
177
230
  };
@@ -191,6 +244,9 @@ export function formatFleetHealth(h) {
191
244
  lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
192
245
  lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
193
246
  lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
247
+ lines.push(h.chain
248
+ ? ` audit chain: ${h.chain.ok ? "verified" : "FAILED"} ${h.chain.ageHours} h ago, ${h.chain.unique} record(s)${h.chain.duplicates ? `, ${h.chain.duplicates} copies counted once` : ""}; ${h.auditLog?.refusedToday ?? 0} entr(ies) refused today`
249
+ : ` audit chain: not checked on this machine yet (the indexing server runs a full check daily; or run contextengine audit-verify)`);
194
250
  lines.push(` claude code: ${h.today.hookEvents} hook event(s) today, ${h.today.doubledHookEvents} doubled; registrations ${h.claudeHooks ? Object.entries(h.claudeHooks).map(([ev, n]) => `${ev}=${n}`).join(" ") : "no settings.json"}`);
195
251
  for (const b of h.today.lastBlocks)
196
252
  lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
@@ -266,13 +266,23 @@ function handleEvents(req, res) {
266
266
  return;
267
267
  }
268
268
  // All valid — write them to audit log via safeAppend.
269
+ // [LOCK] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] (src/cli.ts emit-event): count what the log took, and
270
+ // answer 503 when it refused any. On 2026-09-27 a stuck log answered {"ok":true,"written":25}
271
+ // with 0 of 25 recorded (E2E_REVIEW_2026-09 B1-1).
269
272
  let written = 0;
273
+ let failed = 0;
270
274
  for (const ev of batch.events) {
271
275
  const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
272
276
  // event/payload were validated above — cast is safe.
273
277
  // [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR]: redact before the append, never after.
274
- safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
275
- written++;
278
+ if (safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor))
279
+ written++;
280
+ else
281
+ failed++;
282
+ }
283
+ if (failed > 0) {
284
+ sendJson(req, res, 503, { ok: false, error: "audit_append_failed", written, failed });
285
+ return;
276
286
  }
277
287
  sendJson(req, res, 200, { ok: true, written });
278
288
  });
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { loadEmbeddingStore, compactEmbeddingStore } from "./embedding-store.js"
13
13
  import { sharedIndexEnabled, corpusId, electIndexer, writeSharedIndex, readSharedIndex, sharedIndexMtime, } from "./shared-index.js";
14
14
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
15
15
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
16
- import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
16
+ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend, readVerifyState } from "./audit.js";
17
17
  import { registerServer, listServers, formatServers, liveDaemonPid } from "./server-registry.js";
18
18
  import { secureCeHome } from "./ce-home.js";
19
19
  import { QUOTED_TEXT_NOTE } from "./framing.js";
@@ -26,7 +26,8 @@ import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./c
26
26
  import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
27
27
  import { basename, join, dirname } from "path";
28
28
  import { homedir } from "os";
29
- import { execSync } from "child_process";
29
+ import { execSync, spawn } from "child_process";
30
+ import { setPriority } from "os";
30
31
  import { scanCodeDir } from "./code-chunker.js";
31
32
  import { fileURLToPath } from "url";
32
33
  import { TOOL_COUNT, FREE_TOOL_COUNT, PREMIUM_TOOL_NAMES } from "./tools-manifest.js";
@@ -460,6 +461,64 @@ function evaluateRole(reason) {
460
461
  }
461
462
  let healthTick = 0;
462
463
  let lastStaleCount = -1;
464
+ let stopRegistry = null;
465
+ let ending = false;
466
+ /**
467
+ * End this server. Everything that matters is already on disk (audit appends and store writes are
468
+ * synchronous); the registry record is removed first. With the embedding model loaded, a normal exit
469
+ * aborts in the model runtime's native teardown ("libc++abi: ... mutex lock failed", SIGABRT, 3 of 3
470
+ * runs, releasing the model does not help) and leaves a macOS crash report per exit, so such a server
471
+ * ends with SIGKILL, which skips that teardown. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
472
+ */
473
+ function endThisServer() {
474
+ if (ending)
475
+ return;
476
+ ending = true;
477
+ try {
478
+ stopRegistry?.();
479
+ }
480
+ catch { /* the lister removes a dead record anyway */ }
481
+ if (isEmbeddingsReady())
482
+ process.kill(process.pid, "SIGKILL");
483
+ else
484
+ process.exit(0);
485
+ }
486
+ const SERVER_STARTED_AT = Date.now();
487
+ let lastChainCheckSpawn = 0;
488
+ /**
489
+ * The indexing server starts the daily full check of the audit chain, in its own process.
490
+ * [LOCK] [HEALTH-SEES-THE-CHAIN] (src/fleet-health.ts): the check costs 26 s and 3.5 GB on 4.9M
491
+ * records (measured 2026-09-27), so it never runs in this long-lived process, whose event loop
492
+ * serves the receiver and the tools. A separate low-priority `audit-verify --scheduled`, at most
493
+ * one attempt an hour, never in the first 10 minutes after a start, one at a time across processes
494
+ * (its own lock). CONTEXTENGINE_CHAIN_CHECK=0 turns it off.
495
+ */
496
+ function maybeScheduleChainCheck() {
497
+ if (role !== "indexer" || process.env.CONTEXTENGINE_CHAIN_CHECK === "0")
498
+ return;
499
+ const now = Date.now();
500
+ if (now - SERVER_STARTED_AT < 10 * 60_000 || now - lastChainCheckSpawn < 60 * 60_000)
501
+ return;
502
+ const state = readVerifyState();
503
+ if (state && now - Date.parse(state.checkedAt) < 24 * 3_600_000)
504
+ return;
505
+ lastChainCheckSpawn = now;
506
+ try {
507
+ const cli = join(dirname(fileURLToPath(import.meta.url)), "cli.js");
508
+ const child = spawn(process.execPath, [cli, "audit-verify", "--scheduled"], { stdio: "ignore", detached: true, env: process.env });
509
+ if (child.pid) {
510
+ try {
511
+ setPriority(child.pid, 10);
512
+ }
513
+ catch { /* the check still runs, at normal priority */ }
514
+ }
515
+ child.unref();
516
+ console.error(`[ContextEngine] 🔎 daily audit chain check started (pid ${child.pid ?? "?"})`);
517
+ }
518
+ catch (err) {
519
+ console.error(`[ContextEngine] ⚠ could not start the daily audit chain check: ${err.message}`);
520
+ }
521
+ }
463
522
  /**
464
523
  * The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
465
524
  * rate, today's blocks and refusals, the last verified release. Every surface reads that file.
@@ -475,6 +534,7 @@ function publishHealth() {
475
534
  }
476
535
  if (role === "indexer")
477
536
  writeFleetHealth(h);
537
+ maybeScheduleChainCheck();
478
538
  }
479
539
  catch (err) {
480
540
  console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
@@ -1356,7 +1416,8 @@ async function main() {
1356
1416
  }
1357
1417
  }
1358
1418
  try {
1359
- const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
1419
+ const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1", onSignal: endThisServer });
1420
+ stopRegistry = reg.stop;
1360
1421
  setRegistryRole = reg.setRole;
1361
1422
  setRegistryEventPort = reg.setEventPort;
1362
1423
  const fleet = listServers();
@@ -1415,6 +1476,26 @@ async function main() {
1415
1476
  const transport = new StdioServerTransport();
1416
1477
  await server.connect(transport);
1417
1478
  console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
1479
+ // [LOCKED] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT] - 2026-09-27
1480
+ // [NEVER] let a server whose client closed its stdin keep running, unless it is the launchd agent.
1481
+ // WHY: the file watchers and the event receiver keep the event loop alive, and nothing listened for
1482
+ // the end of stdin. When a chat's client died without stopping its server (3 of 3 runs,
1483
+ // E2E_REVIEW_2026-09 B5-2), the server lived on, adopted by launchd, still the indexer, still
1484
+ // holding the event port, running its build for good; new servers became its readers.
1485
+ // FIX: stdin's end or close ends a chat server (its registry record goes with it, and a reader takes
1486
+ // over the index within one role poll, 15 s). The launchd agent (OPSCONTEXT_DAEMON=1) has
1487
+ // stdin on /dev/null by design and is exempt. [LOCK] [AUTOSTART-IS-THE-STANDING-INDEXER]
1488
+ // Ending goes through endThisServer(): with the embedding model loaded, a normal exit aborts in
1489
+ // native code and leaves a macOS crash report each time, so such a server ends with SIGKILL
1490
+ // after its record is removed; the stop signals (SIGTERM, SIGINT, SIGHUP) take the same path.
1491
+ if (process.env.OPSCONTEXT_DAEMON !== "1") {
1492
+ const leave = () => {
1493
+ console.error("[ContextEngine] 👋 the client closed the connection: this server stops");
1494
+ endThisServer();
1495
+ };
1496
+ process.stdin.once("end", leave);
1497
+ process.stdin.once("close", leave);
1498
+ }
1418
1499
  // 3a. Audit log auto-rotation. Deferred so the first requests are answered before the
1419
1500
  // synchronous verify + rewrite (a few seconds on a 500k-record chain) blocks the loop.
1420
1501
  // [LOCK] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER]
@@ -1423,7 +1504,7 @@ async function main() {
1423
1504
  const runAutoRotate = () => {
1424
1505
  try {
1425
1506
  const o = autoRotateAuditLog();
1426
- if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress") {
1507
+ if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress" || o.action === "finished") {
1427
1508
  console.error(`[ContextEngine] 📦 audit auto-rotate (${o.action}): ${o.detail}`);
1428
1509
  }
1429
1510
  }
@@ -15,6 +15,9 @@ export interface LearningsStore {
15
15
  version: number;
16
16
  count: number;
17
17
  learnings: Learning[];
18
+ /** Bundled default rules (lowercased, trimmed) the owner deleted: never merged back.
19
+ * [LOCK] [A-READ-NEVER-WRITES-THE-STORE] */
20
+ dismissed_defaults?: string[];
18
21
  }
19
22
  /** Valid categories for learnings */
20
23
  export declare const LEARNING_CATEGORIES: readonly ["deployment", "api", "database", "frontend", "backend", "devops", "security", "performance", "testing", "debugging", "tooling", "git", "dependencies", "architecture", "data", "infrastructure", "mobile", "other"];
package/dist/learnings.js CHANGED
@@ -76,10 +76,13 @@ function loadBundledDefaults() {
76
76
  function mergeDefaults(store) {
77
77
  const bundled = loadBundledDefaults();
78
78
  if (bundled.length === 0)
79
- return false;
79
+ return 0;
80
80
  const existingRules = new Set(store.learnings
81
81
  .filter((l) => typeof l.rule === "string")
82
82
  .map((l) => l.rule.toLowerCase().trim()));
83
+ // A default the owner deleted stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
84
+ for (const r of store.dismissed_defaults ?? [])
85
+ existingRules.add(r);
83
86
  let added = 0;
84
87
  const now = new Date().toISOString();
85
88
  for (const def of bundled) {
@@ -97,7 +100,12 @@ function mergeDefaults(store) {
97
100
  existingRules.add(def.rule.toLowerCase().trim());
98
101
  added++;
99
102
  }
100
- return added > 0;
103
+ return added;
104
+ }
105
+ /** Is this rule text one of the bundled defaults? */
106
+ function isBundledDefault(rule) {
107
+ const key = rule.toLowerCase().trim();
108
+ return loadBundledDefaults().some((d) => d.rule.toLowerCase().trim() === key);
101
109
  }
102
110
  // [LOCKED] [STORE-NEVER-STARTS-FRESH-OVER-DATA] 2026-09-05
103
111
  // [NEVER] turn an unreadable learnings.json into an empty store, write the store with a
@@ -127,6 +135,55 @@ let batchDirty = false;
127
135
  function sleepMs(ms) {
128
136
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
129
137
  }
138
+ // [LOCKED] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE] 2026-09-27
139
+ // [NEVER] make a save wait out LOCK_STALE_MS for a holder that is provably gone, or leave a dead
140
+ // writer's full copy of the store on disk.
141
+ // WHY: a saver killed inside the lock made every other save in the next ~20 s wait 10 s and fail
142
+ // with "locked by another process" (6 per run, 3 of 3, E2E_REVIEW_2026-09 B4-2), and left its
143
+ // temp copy of the whole store behind for good. The lock directory names its holder's pid;
144
+ // nothing read it.
145
+ // FIX: a lock whose pid no longer exists, or a lock directory older than a second with no pid in
146
+ // it, is taken over at once; a live pid keeps the 30 s rule. Inside the lock, temp copies
147
+ // written by a pid that no longer exists are removed.
148
+ function storeHolderIsGone(age) {
149
+ let pid = NaN;
150
+ try {
151
+ pid = parseInt(readFileSync(join(STORE_LOCK_DIR, "pid"), "utf-8"), 10);
152
+ }
153
+ catch { /* no pid yet */ }
154
+ if (!Number.isInteger(pid) || pid <= 0)
155
+ return age > 1000;
156
+ if (pid === process.pid)
157
+ return false;
158
+ try {
159
+ process.kill(pid, 0);
160
+ return false;
161
+ }
162
+ catch (e) {
163
+ return e.code === "ESRCH";
164
+ }
165
+ }
166
+ function removeDeadWritersTemps() {
167
+ try {
168
+ const dir = dirname(LEARNINGS_PATH);
169
+ for (const f of readdirSync(dir)) {
170
+ const m = /^learnings\.json\.tmp-(\d+)-\d+$/.exec(f);
171
+ if (!m)
172
+ continue;
173
+ const pid = Number(m[1]);
174
+ if (pid === process.pid)
175
+ continue;
176
+ try {
177
+ process.kill(pid, 0);
178
+ }
179
+ catch (e) {
180
+ if (e.code === "ESRCH")
181
+ unlinkSync(join(dir, f));
182
+ }
183
+ }
184
+ }
185
+ catch { /* cleanup is a courtesy; the store is untouched either way */ }
186
+ }
130
187
  /** Cross-process, re-entrant (within this process) lock around the store file. */
131
188
  export function withStoreLock(fn) {
132
189
  if (lockDepth > 0) {
@@ -160,7 +217,8 @@ export function withStoreLock(fn) {
160
217
  catch {
161
218
  age = 0;
162
219
  }
163
- if (age > LOCK_STALE_MS) {
220
+ // [LOCK] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE]
221
+ if (age > LOCK_STALE_MS || storeHolderIsGone(age)) {
164
222
  // Holder died (or hung) without releasing: take it over.
165
223
  try {
166
224
  rmSync(STORE_LOCK_DIR, { recursive: true, force: true });
@@ -176,6 +234,7 @@ export function withStoreLock(fn) {
176
234
  }
177
235
  lockDepth = 1;
178
236
  try {
237
+ removeDeadWritersTemps();
179
238
  return fn();
180
239
  }
181
240
  finally {
@@ -237,14 +296,42 @@ function readStoreFromDisk() {
237
296
  else {
238
297
  store = { version: 1, count: 0, learnings: [] };
239
298
  }
240
- // Auto-merge bundled defaults on first load or when new defaults are added
241
- if (mergeDefaults(store)) {
299
+ // [LOCKED] [A-READ-NEVER-WRITES-THE-STORE] - 2026-09-27
300
+ // [NEVER] write the store from a read that does not hold the store lock, and never merge back a
301
+ // bundled default the owner deleted.
302
+ // WHY: a plain read (listLearnings, searchLearnings, the index build) merged a missing bundled
303
+ // default and wrote the whole store WITHOUT the lock. With that read held 1.5 s before its
304
+ // write, a concurrent saver's 5 learnings, each saved with success, were gone (3 of 3,
305
+ // E2E_REVIEW_2026-09 B4-1); at natural speed the window is milliseconds and opens after every
306
+ // release that adds a default. And a deleted default came back at the next read, with a new id
307
+ // and no audit record: the delete never stuck. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
308
+ // already said "never let two processes write it without the lock".
309
+ // FIX: holding the lock (a save, a delete, a batch), the merge is written as before. A plain read
310
+ // takes the lock for it (re-reading inside), and if the lock is busy only shows the defaults,
311
+ // writing nothing. A deleted default is remembered in `dismissed_defaults`; every persisted
312
+ // merge is recorded as a learning.import of the bundled defaults.
313
+ if (!hasMissingDefaults(store))
314
+ return store;
315
+ if (lockDepth > 0) {
316
+ const added = mergeDefaults(store);
242
317
  if (batchStore)
243
318
  batchDirty = true;
244
319
  else
245
320
  writeStoreToDisk(store);
321
+ safeAppend("learning.import", { source: "bundled defaults", format: "json", imported: added, updated: 0, skipped: 0 });
322
+ return store;
323
+ }
324
+ try {
325
+ return withStoreLock(() => readStoreFromDisk());
246
326
  }
247
- return store;
327
+ catch {
328
+ mergeDefaults(store); // the lock is busy: show them, persist at the next locked read or write
329
+ return store;
330
+ }
331
+ }
332
+ function hasMissingDefaults(store) {
333
+ const probe = { version: store.version, count: 0, learnings: store.learnings.slice(), dismissed_defaults: store.dismissed_defaults };
334
+ return mergeDefaults(probe) > 0;
248
335
  }
249
336
  function loadStore() {
250
337
  if (batchStore)
@@ -517,12 +604,19 @@ function deleteLearningUnlocked(id) {
517
604
  return false;
518
605
  const removed = store.learnings[index];
519
606
  store.learnings.splice(index, 1);
607
+ // A deleted bundled default stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
608
+ const dismissed = typeof removed.rule === "string" && isBundledDefault(removed.rule);
609
+ if (dismissed) {
610
+ const key = removed.rule.toLowerCase().trim();
611
+ store.dismissed_defaults = [...new Set([...(store.dismissed_defaults ?? []), key])];
612
+ }
520
613
  saveStore(store);
521
614
  safeAppend("learning.delete", {
522
615
  id: removed.id,
523
616
  category: removed.category,
524
617
  project: removed.project,
525
618
  rule_length: typeof removed.rule === "string" ? removed.rule.length : 0,
619
+ ...(dismissed ? { default_dismissed: true } : {}),
526
620
  });
527
621
  return true;
528
622
  }