@compr/opscontext-mcp 2.5.7 → 2.5.9
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 +30 -1
- package/dist/audit.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +26 -1
- package/dist/index.js +26 -2
- package/dist/learnings.d.ts +2 -0
- package/dist/learnings.js +63 -22
- package/dist/server-registry.d.ts +40 -0
- package/dist/server-registry.js +155 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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.5.9] — 2026-09-05 — The servers inventory themselves; growth is a tripwire too
|
|
8
|
+
|
|
9
|
+
The evening 2.5.7 shipped, two MCP servers that had started before the build kept the old
|
|
10
|
+
importer for two hours and re-imported 1,766 records the owner had just had deleted. `ps` found
|
|
11
|
+
them on the second look only. Nine more servers, one per open chat, were each re-embedding the
|
|
12
|
+
corpus after every doc change (load average 230). Nothing in the product could say any of this.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Server registry** (`[SERVERS-ARE-INVENTORIED]`, `src/server-registry.ts`): every server writes
|
|
17
|
+
`~/.contextengine/servers/<pid>.json` at the top of `main()` with pid, parent process, start time,
|
|
18
|
+
version, a sha256 of the script it loaded and its cwd; heartbeats every 60 s; removes the record on
|
|
19
|
+
exit; emits `server.start` to the audit log. `contextengine servers` and the end-session checklist
|
|
20
|
+
(§ 3b) list live servers, drop dead records, flag **STALE BUILD** when the script on disk no longer
|
|
21
|
+
matches the hash a server loaded, and warn above 3 concurrent servers. Exit 1 on any warning.
|
|
22
|
+
- **Growth tripwire** (`[STORE-GROWTH-IS-A-TRIPWIRE-TOO]`): a write that adds more than 200 records
|
|
23
|
+
to the learnings store is refused (`learning.store_growth_refused`), the mirror of the shrink guard.
|
|
24
|
+
The auto-import reports `refused` and the server keeps running; `import_learnings` and the CLI
|
|
25
|
+
print the refusal. `CONTEXTENGINE_ALLOW_BULK=1` for one deliberate bulk import.
|
|
26
|
+
|
|
27
|
+
## [2.5.8] — 2026-09-05 — "rules" is not a learnings heading
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `LEARNINGS_HEADING` no longer matches the word "rules". "## Key rules" and "### Security Rules"
|
|
32
|
+
are ordinary doc sections; on the evening 2.5.7 shipped the word let 231 subsection headings back
|
|
33
|
+
into the store. "## Essential Rules, distilled from Learnings" still qualifies through "learnings".
|
|
34
|
+
Replayed from a foreign cwd with the default patterns over ~/Projects: 99 records from 819 sources.
|
|
35
|
+
|
|
7
36
|
## [2.5.7] — 2026-09-05 — The learnings store was a pile of headings
|
|
8
37
|
|
|
9
38
|
Of 3,005 records, about 2,760 had been produced by the doc importer from ~160 ordinary docs
|
|
@@ -17,7 +46,7 @@ secrets" under mobile because "expose" contains "expo".
|
|
|
17
46
|
|
|
18
47
|
- **Auto-import takes only marked learnings** (`[AUTO-IMPORT-ONLY-MARKED-LEARNINGS]`): inline
|
|
19
48
|
`- [category] rule → context` bullets anywhere; every shape inside a `*LEARNINGS.md` file; every
|
|
20
|
-
shape under a heading that says learnings / lessons / gotchas / pitfalls /
|
|
49
|
+
shape under a heading that says learnings / lessons / gotchas / pitfalls / anti-patterns /
|
|
21
50
|
"never repeat" / "the hard way"; JSON. Bare H3 headings, bold bullets and table rows in ordinary
|
|
22
51
|
docs are reported as `ignored` and left alone; the docs stay searchable as docs. Replayed over the
|
|
23
52
|
818 discovered sources: 129 records instead of 1,879. `import_learnings` gains `permissive: true`
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "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";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.store_growth_refused" | "server.start" | "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;
|
package/dist/cli-commands.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -655,6 +655,7 @@ import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationError
|
|
|
655
655
|
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
|
+
import { listServers, formatServers } from "./server-registry.js";
|
|
658
659
|
import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
|
|
659
660
|
import { fileURLToPath } from "url";
|
|
660
661
|
// ---------------------------------------------------------------------------
|
|
@@ -2208,6 +2209,10 @@ async function cliEndSession() {
|
|
|
2208
2209
|
if (autoImport.imported > 0) {
|
|
2209
2210
|
checks.push(`📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources\n`);
|
|
2210
2211
|
}
|
|
2212
|
+
if (autoImport.refused) {
|
|
2213
|
+
checks.push(`- ⛔ Auto-import write refused: ${autoImport.refused}`);
|
|
2214
|
+
failCount++;
|
|
2215
|
+
}
|
|
2211
2216
|
// --- Check 3: Learnings Store ---
|
|
2212
2217
|
checks.push("## 3. Learnings Store\n");
|
|
2213
2218
|
const stats = learningsStats();
|
|
@@ -2225,6 +2230,13 @@ async function cliEndSession() {
|
|
|
2225
2230
|
passCount++;
|
|
2226
2231
|
checks.push("");
|
|
2227
2232
|
// --- Check 4: Sessions ---
|
|
2233
|
+
// --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
|
|
2234
|
+
checks.push("## 3b. Running servers\n");
|
|
2235
|
+
const fleet = listServers();
|
|
2236
|
+
checks.push("```\n" + formatServers(fleet) + "\n```");
|
|
2237
|
+
if (fleet.warnings.length > 0)
|
|
2238
|
+
failCount += fleet.warnings.length;
|
|
2239
|
+
checks.push("");
|
|
2228
2240
|
checks.push("## 4. Sessions\n");
|
|
2229
2241
|
const sessions = listSessions();
|
|
2230
2242
|
if (sessions.length > 0) {
|
|
@@ -2290,7 +2302,14 @@ async function cliImportLearnings(args) {
|
|
|
2290
2302
|
console.error(" --permissive: every H3 heading, bold bullet and table row too.");
|
|
2291
2303
|
process.exit(1);
|
|
2292
2304
|
}
|
|
2293
|
-
|
|
2305
|
+
let result;
|
|
2306
|
+
try {
|
|
2307
|
+
result = importLearningsFromFile(filePath, category, project, { permissive });
|
|
2308
|
+
}
|
|
2309
|
+
catch (e) {
|
|
2310
|
+
console.error(`⛔ Import refused: ${e?.message || e}`);
|
|
2311
|
+
process.exit(1);
|
|
2312
|
+
}
|
|
2294
2313
|
console.log(`\n📥 Import Results:`);
|
|
2295
2314
|
console.log(` Imported: ${result.imported}`);
|
|
2296
2315
|
console.log(` Updated: ${result.updated}`);
|
|
@@ -2508,6 +2527,7 @@ Usage:
|
|
|
2508
2527
|
Export hash-chained audit log (evidence aligned with
|
|
2509
2528
|
SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
|
|
2510
2529
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
2530
|
+
contextengine servers List running MCP servers, their build vs the file on disk
|
|
2511
2531
|
contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
|
|
2512
2532
|
contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
|
|
2513
2533
|
Move old history into an archive segment. Archives
|
|
@@ -2719,6 +2739,11 @@ else if (command === "audit-redact-ack") {
|
|
|
2719
2739
|
else if (command === "audit-rotate") {
|
|
2720
2740
|
cliAuditRotate(process.argv.slice(3));
|
|
2721
2741
|
}
|
|
2742
|
+
else if (command === "servers") {
|
|
2743
|
+
const fleet = listServers();
|
|
2744
|
+
console.log(formatServers(fleet));
|
|
2745
|
+
process.exit(fleet.warnings.length > 0 ? 1 : 0);
|
|
2746
|
+
}
|
|
2722
2747
|
else if (command === "audit-verify") {
|
|
2723
2748
|
cliAuditVerify().catch((err) => {
|
|
2724
2749
|
console.error("Error:", err);
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,8 @@ 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, autoRotateAuditLog } from "./audit.js";
|
|
13
|
+
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
14
|
+
import { registerServer, listServers, formatServers } from "./server-registry.js";
|
|
14
15
|
import { startEventIngestServer } from "./http-server.js";
|
|
15
16
|
import { detect } from "./detector.js";
|
|
16
17
|
import { buildCostReport } from "./cost-report.js";
|
|
@@ -110,6 +111,9 @@ async function reindex() {
|
|
|
110
111
|
if (autoImport.imported > 0) {
|
|
111
112
|
console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
|
|
112
113
|
}
|
|
114
|
+
if (autoImport.refused) {
|
|
115
|
+
console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
|
|
116
|
+
}
|
|
113
117
|
// Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
|
|
114
118
|
const learningChunks = learningsToChunks(activeProjectNames);
|
|
115
119
|
if (learningChunks.length > 0) {
|
|
@@ -973,7 +977,13 @@ server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON f
|
|
|
973
977
|
.optional()
|
|
974
978
|
.describe("Import every heading, bold bullet and table row as a rule (the pre-2.5.7 behaviour). Default false: only marked learnings."),
|
|
975
979
|
}, async ({ file_path, default_category, project, permissive }) => {
|
|
976
|
-
|
|
980
|
+
let result;
|
|
981
|
+
try {
|
|
982
|
+
result = importLearningsFromFile(file_path, default_category || "other", project, { permissive: permissive === true });
|
|
983
|
+
}
|
|
984
|
+
catch (e) {
|
|
985
|
+
return respond("import_learnings", `⛔ Import refused: ${e?.message || e}`);
|
|
986
|
+
}
|
|
977
987
|
// Re-inject learnings into search index (project-scoped)
|
|
978
988
|
const newChunks = learningsToChunks(activeProjectNames);
|
|
979
989
|
const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
|
|
@@ -1078,6 +1088,17 @@ function registerResources() {
|
|
|
1078
1088
|
// Start
|
|
1079
1089
|
// ---------------------------------------------------------------------------
|
|
1080
1090
|
async function main() {
|
|
1091
|
+
// 0. Inventory this server FIRST, before indexing takes minutes: a server exists the moment it
|
|
1092
|
+
// starts. [LOCK] [SERVERS-ARE-INVENTORIED]
|
|
1093
|
+
try {
|
|
1094
|
+
const { record } = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url) });
|
|
1095
|
+
const fleet = listServers();
|
|
1096
|
+
console.error(`[ContextEngine] 🧭 ${formatServers(fleet)}`);
|
|
1097
|
+
safeAppend("server.start", { pid: record.pid, parent: record.parent, version: record.version, build: record.build, cwd: record.cwd, servers_running: fleet.servers.length, stale_builds: fleet.servers.filter((x) => x.staleBuild).length });
|
|
1098
|
+
}
|
|
1099
|
+
catch (err) {
|
|
1100
|
+
console.error("[ContextEngine] ⚠ Server registry failed:", err);
|
|
1101
|
+
}
|
|
1081
1102
|
// 1. Ingest all sources (fast — keyword search available immediately)
|
|
1082
1103
|
sources = loadSources();
|
|
1083
1104
|
chunks = ingestSources(sources);
|
|
@@ -1126,6 +1147,9 @@ async function main() {
|
|
|
1126
1147
|
if (autoImport.imported > 0) {
|
|
1127
1148
|
console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
|
|
1128
1149
|
}
|
|
1150
|
+
if (autoImport.refused) {
|
|
1151
|
+
console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
|
|
1152
|
+
}
|
|
1129
1153
|
// 1e. Inject learnings into search index (project-scoped)
|
|
1130
1154
|
const learningChunks = learningsToChunks(activeProjectNames);
|
|
1131
1155
|
if (learningChunks.length > 0) {
|
package/dist/learnings.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare function withStoreLock<T>(fn: () => T): T;
|
|
|
26
26
|
* loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
|
|
27
27
|
*/
|
|
28
28
|
export declare function withStoreBatch<T>(fn: () => T): T;
|
|
29
|
+
export declare const MAX_GROWTH_PER_WRITE = 200;
|
|
29
30
|
/** Test seam for the writer's tripwire; not part of the API. */
|
|
30
31
|
export declare function __writeStoreForTests(store: LearningsStore): void;
|
|
31
32
|
/**
|
|
@@ -120,6 +121,7 @@ export declare function autoImportFromSources(sources: Array<{
|
|
|
120
121
|
imported: number;
|
|
121
122
|
updated: number;
|
|
122
123
|
ignored: number;
|
|
124
|
+
refused?: string;
|
|
123
125
|
};
|
|
124
126
|
/**
|
|
125
127
|
* Get the store stats.
|
package/dist/learnings.js
CHANGED
|
@@ -266,6 +266,19 @@ function dailyBackup() {
|
|
|
266
266
|
}
|
|
267
267
|
catch { /* a missing backup must never block a save */ }
|
|
268
268
|
}
|
|
269
|
+
// [LOCKED] [STORE-GROWTH-IS-A-TRIPWIRE-TOO] 2026-09-05
|
|
270
|
+
// [NEVER] let one write add more than MAX_GROWTH_PER_WRITE records to the store without the
|
|
271
|
+
// explicit override, and never raise the limit to make an import "just work".
|
|
272
|
+
// WHY: the shrink guard below caught the wipe of 2026-09-05; the same evening two stale servers
|
|
273
|
+
// wrote 1,766 records in one minute and nothing objected, because only shrinking was
|
|
274
|
+
// guarded. Every legitimate write is small: an agent saves one rule, the strict auto-import
|
|
275
|
+
// of a whole workspace produced 99 (replayed 2026-09-05), the largest real learnings file
|
|
276
|
+
// a few dozen. Thousands in one write is a bug or old code, never a lesson.
|
|
277
|
+
// FIX: a write that grows the store by more than MAX_GROWTH_PER_WRITE over the file on disk
|
|
278
|
+
// is refused with an audit event, unless CONTEXTENGINE_ALLOW_BULK=1 (set knowingly, for
|
|
279
|
+
// one deliberate bulk import). The auto-import catches the refusal and reports it; the
|
|
280
|
+
// server keeps running.
|
|
281
|
+
export const MAX_GROWTH_PER_WRITE = 200;
|
|
269
282
|
function writeStoreToDisk(store) {
|
|
270
283
|
ensureDir();
|
|
271
284
|
store.count = store.learnings.length;
|
|
@@ -284,6 +297,21 @@ function writeStoreToDisk(store) {
|
|
|
284
297
|
throw new Error(`refusing to write ${store.learnings.length} learnings over a store of ${onDisk}: that is the shape of a wipe, not an edit. Set CONTEXTENGINE_ALLOW_SHRINK=1 if this is deliberate.`);
|
|
285
298
|
}
|
|
286
299
|
}
|
|
300
|
+
// Growth tripwire. [LOCK] [STORE-GROWTH-IS-A-TRIPWIRE-TOO]
|
|
301
|
+
if (existsSync(LEARNINGS_PATH) && process.env.CONTEXTENGINE_ALLOW_BULK !== "1") {
|
|
302
|
+
let onDisk = -1;
|
|
303
|
+
try {
|
|
304
|
+
onDisk = (JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8")).learnings || []).length;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
onDisk = -1;
|
|
308
|
+
}
|
|
309
|
+
const growth = store.learnings.length - onDisk;
|
|
310
|
+
if (onDisk >= 0 && growth > MAX_GROWTH_PER_WRITE) {
|
|
311
|
+
safeAppend("learning.store_growth_refused", { on_disk: onDisk, attempted: store.learnings.length, growth });
|
|
312
|
+
throw new Error(`refusing to add ${growth} learnings in one write (store ${onDisk}, limit ${MAX_GROWTH_PER_WRITE}): that is the shape of a runaway import, not a lesson. Set CONTEXTENGINE_ALLOW_BULK=1 for one deliberate bulk import.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
287
315
|
dailyBackup();
|
|
288
316
|
const tmp = `${LEARNINGS_PATH}.tmp-${process.pid}-${Date.now()}`;
|
|
289
317
|
writeFileSync(tmp, JSON.stringify(store, null, 2));
|
|
@@ -501,13 +529,15 @@ function deleteLearningUnlocked(id) {
|
|
|
501
529
|
// FIX: a candidate becomes a learning only when the author marked it as one:
|
|
502
530
|
// (1) an inline-category bullet `- [category] rule → context`, anywhere;
|
|
503
531
|
// (2) any shape inside a file whose name says learnings (AGENT-LEARNINGS.md, LEARNINGS.md);
|
|
504
|
-
// (3) any shape under a heading that says learnings / lessons / gotchas / pitfalls /
|
|
505
|
-
// anti-patterns / "never repeat" / "the hard way" (LEARNINGS_HEADING)
|
|
532
|
+
// (3) any shape under a heading that says learnings / lessons / gotchas / pitfalls /
|
|
533
|
+
// anti-patterns / "never repeat" / "the hard way" / mistakes (LEARNINGS_HEADING).
|
|
534
|
+
// Not "rules": "## Key rules" and "### Security Rules" are ordinary doc sections, and
|
|
535
|
+
// the word let 231 subsection headings back in on the evening this shipped;
|
|
506
536
|
// (4) JSON files, which are explicit by construction.
|
|
507
537
|
// `permissive: true` (MCP `import_learnings`, CLI `--permissive`) restores the old parser for
|
|
508
538
|
// a file the user chose on purpose. Every imported record now carries `source`.
|
|
509
539
|
export const LEARNINGS_FILE_NAME = /learnings?\.md$/i;
|
|
510
|
-
export const LEARNINGS_HEADING = /\b(learnings?|lessons?|gotchas?|pitfalls?|anti-?patterns?|never repeat|do not repeat|don'?t repeat|the hard way|hard way|mistakes
|
|
540
|
+
export const LEARNINGS_HEADING = /\b(learnings?|lessons?|gotchas?|pitfalls?|anti-?patterns?|never repeat|do not repeat|don'?t repeat|the hard way|hard way|mistakes?)\b/i;
|
|
511
541
|
export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject, opts = {}) {
|
|
512
542
|
if (!existsSync(filePath)) {
|
|
513
543
|
return { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [`File not found: ${filePath}`] };
|
|
@@ -973,27 +1003,38 @@ export function autoImportFromSources(sources) {
|
|
|
973
1003
|
let totalUpdated = 0;
|
|
974
1004
|
let totalIgnored = 0;
|
|
975
1005
|
let processed = 0;
|
|
1006
|
+
let refused;
|
|
976
1007
|
// One load and one save for the whole sweep (~880 files), instead of one full-file
|
|
977
1008
|
// rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
withStoreBatch(() => {
|
|
1011
|
+
for (const source of sources) {
|
|
1012
|
+
// Only process markdown files
|
|
1013
|
+
if (!source.path.endsWith(".md"))
|
|
1014
|
+
continue;
|
|
1015
|
+
if (!existsSync(source.path))
|
|
1016
|
+
continue;
|
|
1017
|
+
// Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
|
|
1018
|
+
const project = source.name.split(" — ")[0]?.trim() || undefined;
|
|
1019
|
+
// Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
|
|
1020
|
+
const result = importLearningsFromFile(source.path, "other", project);
|
|
1021
|
+
totalImported += result.imported;
|
|
1022
|
+
totalUpdated += result.updated;
|
|
1023
|
+
totalIgnored += result.ignored;
|
|
1024
|
+
if (result.imported > 0 || result.updated > 0)
|
|
1025
|
+
processed++;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch (e) {
|
|
1030
|
+
// A refused write (growth or shrink tripwire, lock timeout) must not take the server down;
|
|
1031
|
+
// it is reported to the caller and the store is left as it was. [LOCK] [STORE-GROWTH-IS-A-TRIPWIRE-TOO]
|
|
1032
|
+
refused = String(e?.message || e);
|
|
1033
|
+
totalImported = 0;
|
|
1034
|
+
totalUpdated = 0;
|
|
1035
|
+
processed = 0;
|
|
1036
|
+
}
|
|
1037
|
+
return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored, refused };
|
|
997
1038
|
}
|
|
998
1039
|
/**
|
|
999
1040
|
* Get the store stats.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface ServerRecord {
|
|
2
|
+
pid: number;
|
|
3
|
+
ppid: number;
|
|
4
|
+
parent: string;
|
|
5
|
+
started: string;
|
|
6
|
+
heartbeat: string;
|
|
7
|
+
version: string;
|
|
8
|
+
script: string;
|
|
9
|
+
build: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
node: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ServerReport {
|
|
14
|
+
servers: Array<ServerRecord & {
|
|
15
|
+
alive: true;
|
|
16
|
+
currentBuild: string | null;
|
|
17
|
+
staleBuild: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
removed: number;
|
|
20
|
+
warnings: string[];
|
|
21
|
+
}
|
|
22
|
+
/** More concurrent servers than this and every doc change costs that many re-embeds. */
|
|
23
|
+
export declare const SERVER_COUNT_WARN = 3;
|
|
24
|
+
/** Short content hash of the script a server loaded; the build identity. */
|
|
25
|
+
export declare function buildHashOf(scriptPath: string): string | null;
|
|
26
|
+
export declare function isAlive(pid: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
29
|
+
*/
|
|
30
|
+
export declare function registerServer(opts: {
|
|
31
|
+
version: string;
|
|
32
|
+
script: string;
|
|
33
|
+
}): {
|
|
34
|
+
record: ServerRecord;
|
|
35
|
+
stop: () => void;
|
|
36
|
+
};
|
|
37
|
+
/** Read every record, drop the dead ones, compare builds with the files on disk now. */
|
|
38
|
+
export declare function listServers(): ServerReport;
|
|
39
|
+
export declare function formatServers(report: ServerReport, home?: string): string;
|
|
40
|
+
//# sourceMappingURL=server-registry.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// [LOCKED] [SERVERS-ARE-INVENTORIED] 2026-09-05
|
|
2
|
+
// [NEVER] let an MCP server start without writing its registry record, or answer "which
|
|
3
|
+
// servers run and on which build?" from a `ps` grep instead of this registry.
|
|
4
|
+
// WHY: on 2026-09-05 two servers started before a build kept running the old importer for
|
|
5
|
+
// two hours and re-imported 1,766 records the owner had just had deleted. `ps` found them
|
|
6
|
+
// only on the second look: the first grep matched the absolute script path and the two
|
|
7
|
+
// had been started with a relative one. Nine other servers, one per open chat, were each
|
|
8
|
+
// re-embedding the whole corpus after every doc change (load average 230) and nothing
|
|
9
|
+
// said so. `server-meta.json` held one version: the last server to start.
|
|
10
|
+
// FIX: every server writes ~/.contextengine/servers/<pid>.json on start (pid, parent, start
|
|
11
|
+
// time, version, a hash of the script it loaded, cwd) and refreshes a heartbeat; the file
|
|
12
|
+
// goes on exit, and a lister removes records whose pid is dead. `contextengine servers`
|
|
13
|
+
// and the end-session checklist compare each record's build hash with the file on disk
|
|
14
|
+
// now, and warn when more than SERVER_COUNT_WARN servers run at once.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
import { execFileSync } from "child_process";
|
|
20
|
+
/** More concurrent servers than this and every doc change costs that many re-embeds. */
|
|
21
|
+
export const SERVER_COUNT_WARN = 3;
|
|
22
|
+
const HEARTBEAT_MS = 60_000;
|
|
23
|
+
function registryDir() {
|
|
24
|
+
return join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "servers");
|
|
25
|
+
}
|
|
26
|
+
/** Short content hash of the script a server loaded; the build identity. */
|
|
27
|
+
export function buildHashOf(scriptPath) {
|
|
28
|
+
try {
|
|
29
|
+
return createHash("sha256").update(readFileSync(scriptPath)).digest("hex").slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parentName(ppid) {
|
|
36
|
+
try {
|
|
37
|
+
// Hardcoded argv, no shell: the only variable is a number.
|
|
38
|
+
return execFileSync("ps", ["-o", "comm=", "-p", String(ppid)], { encoding: "utf8", timeout: 2000 })
|
|
39
|
+
.trim().split("/").pop() || "?";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "?";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return e?.code === "EPERM"; // exists, not ours
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
56
|
+
*/
|
|
57
|
+
export function registerServer(opts) {
|
|
58
|
+
const dir = registryDir();
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const record = {
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
ppid: process.ppid,
|
|
64
|
+
parent: parentName(process.ppid),
|
|
65
|
+
started: now,
|
|
66
|
+
heartbeat: now,
|
|
67
|
+
version: opts.version,
|
|
68
|
+
script: opts.script,
|
|
69
|
+
build: buildHashOf(opts.script) || "unknown",
|
|
70
|
+
cwd: process.cwd(),
|
|
71
|
+
node: process.version,
|
|
72
|
+
};
|
|
73
|
+
const file = join(dir, `${process.pid}.json`);
|
|
74
|
+
const write = () => { try {
|
|
75
|
+
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
76
|
+
}
|
|
77
|
+
catch { /* registry is diagnostics, never fatal */ } };
|
|
78
|
+
write();
|
|
79
|
+
const timer = setInterval(() => { record.heartbeat = new Date().toISOString(); write(); }, HEARTBEAT_MS);
|
|
80
|
+
timer.unref();
|
|
81
|
+
let stopped = false;
|
|
82
|
+
const stop = () => {
|
|
83
|
+
if (stopped)
|
|
84
|
+
return;
|
|
85
|
+
stopped = true;
|
|
86
|
+
clearInterval(timer);
|
|
87
|
+
try {
|
|
88
|
+
unlinkSync(file);
|
|
89
|
+
}
|
|
90
|
+
catch { /* already gone */ }
|
|
91
|
+
};
|
|
92
|
+
process.on("exit", stop);
|
|
93
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
94
|
+
process.on(sig, () => { stop(); process.exit(0); });
|
|
95
|
+
}
|
|
96
|
+
return { record, stop };
|
|
97
|
+
}
|
|
98
|
+
/** Read every record, drop the dead ones, compare builds with the files on disk now. */
|
|
99
|
+
export function listServers() {
|
|
100
|
+
const dir = registryDir();
|
|
101
|
+
const report = { servers: [], removed: 0, warnings: [] };
|
|
102
|
+
if (!existsSync(dir))
|
|
103
|
+
return report;
|
|
104
|
+
for (const f of readdirSync(dir)) {
|
|
105
|
+
if (!f.endsWith(".json"))
|
|
106
|
+
continue;
|
|
107
|
+
const path = join(dir, f);
|
|
108
|
+
let rec;
|
|
109
|
+
try {
|
|
110
|
+
rec = JSON.parse(readFileSync(path, "utf8"));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(path);
|
|
115
|
+
}
|
|
116
|
+
catch { /* */ }
|
|
117
|
+
report.removed++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!isAlive(rec.pid)) {
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(path);
|
|
123
|
+
}
|
|
124
|
+
catch { /* */ }
|
|
125
|
+
report.removed++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const currentBuild = buildHashOf(rec.script);
|
|
129
|
+
const staleBuild = currentBuild !== null && rec.build !== "unknown" && currentBuild !== rec.build;
|
|
130
|
+
report.servers.push({ ...rec, alive: true, currentBuild, staleBuild });
|
|
131
|
+
}
|
|
132
|
+
report.servers.sort((a, b) => a.started.localeCompare(b.started));
|
|
133
|
+
const stale = report.servers.filter((s) => s.staleBuild);
|
|
134
|
+
if (stale.length > 0) {
|
|
135
|
+
report.warnings.push(`${stale.length} server(s) run a build older than the file on disk (pid ${stale.map((s) => s.pid).join(", ")}): restart them or they keep the old behaviour`);
|
|
136
|
+
}
|
|
137
|
+
if (report.servers.length > SERVER_COUNT_WARN) {
|
|
138
|
+
report.warnings.push(`${report.servers.length} servers run at once; every doc change makes each of them re-index and re-embed the corpus (${SERVER_COUNT_WARN} is the comfortable ceiling)`);
|
|
139
|
+
}
|
|
140
|
+
return report;
|
|
141
|
+
}
|
|
142
|
+
export function formatServers(report, home = homedir()) {
|
|
143
|
+
const short = (p) => p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
144
|
+
const lines = [];
|
|
145
|
+
lines.push(`${report.servers.length} server(s) running${report.removed ? `, ${report.removed} dead record(s) removed` : ""}`);
|
|
146
|
+
for (const s of report.servers) {
|
|
147
|
+
const t = s.started.slice(11, 19) + "Z";
|
|
148
|
+
const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
|
|
149
|
+
lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag} parent ${s.parent} cwd ${short(s.cwd)}`);
|
|
150
|
+
}
|
|
151
|
+
for (const w of report.warnings)
|
|
152
|
+
lines.push(` ⚠ ${w}`);
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=server-registry.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.9",
|
|
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",
|