@compr/opscontext-mcp 2.5.5 → 2.5.6
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/audit.d.ts +1 -1
- package/dist/learnings.d.ts +12 -1
- package/dist/learnings.js +210 -28
- package/package.json +3 -2
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact";
|
|
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";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
package/dist/learnings.d.ts
CHANGED
|
@@ -17,11 +17,21 @@ export interface LearningsStore {
|
|
|
17
17
|
/** Valid categories for learnings */
|
|
18
18
|
export declare const LEARNING_CATEGORIES: readonly ["deployment", "api", "database", "frontend", "backend", "devops", "security", "performance", "testing", "debugging", "tooling", "git", "dependencies", "architecture", "data", "infrastructure", "mobile", "other"];
|
|
19
19
|
export type LearningCategory = (typeof LEARNING_CATEGORIES)[number];
|
|
20
|
+
/** Cross-process, re-entrant (within this process) lock around the store file. */
|
|
21
|
+
export declare function withStoreLock<T>(fn: () => T): T;
|
|
22
|
+
/**
|
|
23
|
+
* Run `fn` with ONE load and at most ONE save of the store, under the lock. Inside, every
|
|
24
|
+
* loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
|
|
25
|
+
*/
|
|
26
|
+
export declare function withStoreBatch<T>(fn: () => T): T;
|
|
27
|
+
/** Test seam for the writer's tripwire; not part of the API. */
|
|
28
|
+
export declare function __writeStoreForTests(store: LearningsStore): void;
|
|
20
29
|
/**
|
|
21
30
|
* Save a new learning. Returns the created learning with ID.
|
|
22
31
|
* Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
|
|
23
32
|
*/
|
|
24
|
-
export declare function saveLearning(
|
|
33
|
+
export declare function saveLearning(...args: Parameters<typeof saveLearningUnlocked>): Learning;
|
|
34
|
+
declare function saveLearningUnlocked(category: string, rule: string, context: string, project?: string): Learning;
|
|
25
35
|
/**
|
|
26
36
|
* Search learnings by keyword. Returns matches sorted by relevance.
|
|
27
37
|
*/
|
|
@@ -118,4 +128,5 @@ export interface FormatLearningsOptions {
|
|
|
118
128
|
sinceSpec?: string;
|
|
119
129
|
}
|
|
120
130
|
export declare function formatLearnings(learnings: Learning[], opts?: FormatLearningsOptions): string;
|
|
131
|
+
export {};
|
|
121
132
|
//# sourceMappingURL=learnings.d.ts.map
|
package/dist/learnings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// LOCKED — verified March 3 2026 — learning store: quality gates, auto-categorize, dedup, project-scoped filtering
|
|
2
2
|
// DO NOT RE-AUDIT — min 15 chars, inferCategory(), autoImportFromSources() all verified v1.19.1
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync, rmSync, statSync, readdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { join, dirname } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
@@ -98,32 +98,203 @@ function mergeDefaults(store) {
|
|
|
98
98
|
}
|
|
99
99
|
return added > 0;
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
// [LOCKED] [STORE-NEVER-STARTS-FRESH-OVER-DATA] 2026-09-05
|
|
102
|
+
// [NEVER] turn an unreadable learnings.json into an empty store, write the store with a
|
|
103
|
+
// bare writeFileSync, or let two processes write it without the lock below.
|
|
104
|
+
// WHY: on 2026-09-05 (16:34Z) the whole store was rebuilt from scratch: every id
|
|
105
|
+
// replaced, every `created` reset, the save_learning-only records gone. Cause, read
|
|
106
|
+
// from the code and the audit log: every MCP server (launchd, VS Code, Claude Code)
|
|
107
|
+
// watches ~880 doc files and re-imports all of them on any change, one full-file
|
|
108
|
+
// rewrite PER RULE; with two or three servers doing that at once, one read a
|
|
109
|
+
// half-written file, `catch { start fresh }` turned it into an empty store, and the
|
|
110
|
+
// next save overwrote 2,808 records with the rebuilt set. The audit log shows 54
|
|
111
|
+
// such bursts since 2026-06-23 and 143,352 ids created for a store of ~2,800: the
|
|
112
|
+
// same race, repeatedly, and the likeliest source of the 66 audit-chain forks.
|
|
113
|
+
// FIX: (1) atomic writes, temp file + rename, so a reader never sees a torn file;
|
|
114
|
+
// (2) an unreadable existing file is copied to learnings.json.corrupt-<ts> and the
|
|
115
|
+
// load THROWS, it never becomes an empty store; (3) a saved store that is less
|
|
116
|
+
// than half the on-disk one (and the disk one has >= 100 records) is refused
|
|
117
|
+
// unless CONTEXTENGINE_ALLOW_SHRINK=1; (4) a cross-process lock directory
|
|
118
|
+
// around every load-modify-save; (5) imports run as ONE batch: one load, one
|
|
119
|
+
// save, instead of one rewrite per rule; (6) a daily learnings.json.bak-YYYYMMDD
|
|
120
|
+
// before the first write of the day, last 7 kept.
|
|
121
|
+
const STORE_LOCK_DIR = LEARNINGS_PATH + ".lock";
|
|
122
|
+
const LOCK_STALE_MS = 30_000;
|
|
123
|
+
let lockDepth = 0;
|
|
124
|
+
let batchStore = null;
|
|
125
|
+
let batchDirty = false;
|
|
126
|
+
function sleepMs(ms) {
|
|
127
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
128
|
+
}
|
|
129
|
+
/** Cross-process, re-entrant (within this process) lock around the store file. */
|
|
130
|
+
export function withStoreLock(fn) {
|
|
131
|
+
if (lockDepth > 0) {
|
|
132
|
+
lockDepth++;
|
|
133
|
+
try {
|
|
134
|
+
return fn();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
lockDepth--;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
ensureDir();
|
|
141
|
+
const timeoutMs = parseInt(process.env.CONTEXTENGINE_LOCK_TIMEOUT_MS || "10000", 10);
|
|
142
|
+
const deadline = Date.now() + timeoutMs;
|
|
143
|
+
for (;;) {
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(STORE_LOCK_DIR);
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(join(STORE_LOCK_DIR, "pid"), String(process.pid));
|
|
148
|
+
}
|
|
149
|
+
catch { /* diagnostics only */ }
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
if (e?.code !== "EEXIST")
|
|
154
|
+
throw e;
|
|
155
|
+
let age = 0;
|
|
156
|
+
try {
|
|
157
|
+
age = Date.now() - statSync(STORE_LOCK_DIR).mtimeMs;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
age = 0;
|
|
161
|
+
}
|
|
162
|
+
if (age > LOCK_STALE_MS) {
|
|
163
|
+
// Holder died (or hung) without releasing: take it over.
|
|
164
|
+
try {
|
|
165
|
+
rmSync(STORE_LOCK_DIR, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
catch { /* retry below */ }
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (Date.now() > deadline) {
|
|
171
|
+
throw new Error(`learnings store is locked by another process (${STORE_LOCK_DIR}, ${Math.round(age / 1000)}s old); refusing to write over it`);
|
|
172
|
+
}
|
|
173
|
+
sleepMs(25);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
lockDepth = 1;
|
|
177
|
+
try {
|
|
178
|
+
return fn();
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
lockDepth = 0;
|
|
182
|
+
try {
|
|
183
|
+
rmSync(STORE_LOCK_DIR, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
catch { /* best effort */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Run `fn` with ONE load and at most ONE save of the store, under the lock. Inside, every
|
|
190
|
+
* loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
|
|
191
|
+
*/
|
|
192
|
+
export function withStoreBatch(fn) {
|
|
193
|
+
if (batchStore)
|
|
194
|
+
return fn(); // already batching (re-entrant)
|
|
195
|
+
return withStoreLock(() => {
|
|
196
|
+
batchStore = readStoreFromDisk();
|
|
197
|
+
batchDirty = false;
|
|
198
|
+
try {
|
|
199
|
+
const out = fn();
|
|
200
|
+
if (batchDirty)
|
|
201
|
+
writeStoreToDisk(batchStore);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
batchStore = null;
|
|
206
|
+
batchDirty = false;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function readStoreFromDisk() {
|
|
102
211
|
let store;
|
|
103
212
|
if (existsSync(LEARNINGS_PATH)) {
|
|
213
|
+
const raw = readFileSync(LEARNINGS_PATH, "utf-8");
|
|
104
214
|
try {
|
|
105
|
-
store = JSON.parse(
|
|
106
|
-
|
|
107
|
-
|
|
215
|
+
store = JSON.parse(raw);
|
|
216
|
+
if (!store || !Array.isArray(store.learnings))
|
|
217
|
+
throw new Error("no learnings array");
|
|
108
218
|
}
|
|
109
|
-
catch {
|
|
110
|
-
|
|
111
|
-
|
|
219
|
+
catch (e) {
|
|
220
|
+
const keep = `${LEARNINGS_PATH}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
221
|
+
try {
|
|
222
|
+
copyFileSync(LEARNINGS_PATH, keep);
|
|
223
|
+
}
|
|
224
|
+
catch { /* the original stays in place regardless */ }
|
|
225
|
+
safeAppend("learning.store_unreadable", { path: LEARNINGS_PATH, bytes: raw.length, kept: keep, error: String(e?.message || e) });
|
|
226
|
+
throw new Error(`${LEARNINGS_PATH} exists but is unreadable (${e?.message || e}); refusing to start fresh over it. Copy kept at ${keep}. Another process may be mid-write: retry in a moment.`);
|
|
112
227
|
}
|
|
228
|
+
// Filter out corrupted entries missing required 'rule' field
|
|
229
|
+
store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
|
|
113
230
|
}
|
|
114
231
|
else {
|
|
115
232
|
store = { version: 1, count: 0, learnings: [] };
|
|
116
233
|
}
|
|
117
234
|
// Auto-merge bundled defaults on first load or when new defaults are added
|
|
118
235
|
if (mergeDefaults(store)) {
|
|
119
|
-
|
|
236
|
+
if (batchStore)
|
|
237
|
+
batchDirty = true;
|
|
238
|
+
else
|
|
239
|
+
writeStoreToDisk(store);
|
|
120
240
|
}
|
|
121
241
|
return store;
|
|
122
242
|
}
|
|
123
|
-
function
|
|
243
|
+
function loadStore() {
|
|
244
|
+
if (batchStore)
|
|
245
|
+
return batchStore;
|
|
246
|
+
return readStoreFromDisk();
|
|
247
|
+
}
|
|
248
|
+
function dailyBackup() {
|
|
249
|
+
if (!existsSync(LEARNINGS_PATH))
|
|
250
|
+
return;
|
|
251
|
+
const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
252
|
+
const bak = `${LEARNINGS_PATH}.bak-${day}`;
|
|
253
|
+
if (existsSync(bak))
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
copyFileSync(LEARNINGS_PATH, bak);
|
|
257
|
+
const dir = dirname(LEARNINGS_PATH);
|
|
258
|
+
const daily = readdirSync(dir).filter((f) => /^learnings\.json\.bak-\d{8}$/.test(f)).sort();
|
|
259
|
+
for (const f of daily.slice(0, Math.max(0, daily.length - 7)))
|
|
260
|
+
unlinkSync(join(dir, f));
|
|
261
|
+
}
|
|
262
|
+
catch { /* a missing backup must never block a save */ }
|
|
263
|
+
}
|
|
264
|
+
function writeStoreToDisk(store) {
|
|
124
265
|
ensureDir();
|
|
125
266
|
store.count = store.learnings.length;
|
|
126
|
-
|
|
267
|
+
// Shrink tripwire: the exact shape of the 2026-09-05 loss was a near-empty store
|
|
268
|
+
// written over a full one.
|
|
269
|
+
if (existsSync(LEARNINGS_PATH) && process.env.CONTEXTENGINE_ALLOW_SHRINK !== "1") {
|
|
270
|
+
let onDisk = -1;
|
|
271
|
+
try {
|
|
272
|
+
onDisk = (JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8")).learnings || []).length;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
onDisk = -1;
|
|
276
|
+
}
|
|
277
|
+
if (onDisk >= 100 && store.learnings.length < onDisk / 2) {
|
|
278
|
+
safeAppend("learning.store_shrink_refused", { on_disk: onDisk, attempted: store.learnings.length });
|
|
279
|
+
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.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
dailyBackup();
|
|
283
|
+
const tmp = `${LEARNINGS_PATH}.tmp-${process.pid}-${Date.now()}`;
|
|
284
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2));
|
|
285
|
+
renameSync(tmp, LEARNINGS_PATH);
|
|
286
|
+
}
|
|
287
|
+
function saveStore(store) {
|
|
288
|
+
if (batchStore) {
|
|
289
|
+
batchStore = store;
|
|
290
|
+
batchDirty = true;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
writeStoreToDisk(store);
|
|
294
|
+
}
|
|
295
|
+
/** Test seam for the writer's tripwire; not part of the API. */
|
|
296
|
+
export function __writeStoreForTests(store) {
|
|
297
|
+
withStoreLock(() => writeStoreToDisk(store));
|
|
127
298
|
}
|
|
128
299
|
/** Generate a short unique ID */
|
|
129
300
|
function generateId() {
|
|
@@ -156,7 +327,10 @@ const MIN_RULE_LENGTH = 15;
|
|
|
156
327
|
* Save a new learning. Returns the created learning with ID.
|
|
157
328
|
* Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
|
|
158
329
|
*/
|
|
159
|
-
export function saveLearning(
|
|
330
|
+
export function saveLearning(...args) {
|
|
331
|
+
return withStoreLock(() => saveLearningUnlocked(...args));
|
|
332
|
+
}
|
|
333
|
+
function saveLearningUnlocked(category, rule, context, project) {
|
|
160
334
|
const trimmedRule = rule.trim();
|
|
161
335
|
// Quality gate: reject junk rules
|
|
162
336
|
if (trimmedRule.length < MIN_RULE_LENGTH) {
|
|
@@ -278,6 +452,9 @@ export function listLearnings(category, projects) {
|
|
|
278
452
|
* Delete a learning by ID.
|
|
279
453
|
*/
|
|
280
454
|
export function deleteLearning(id) {
|
|
455
|
+
return withStoreLock(() => deleteLearningUnlocked(id));
|
|
456
|
+
}
|
|
457
|
+
function deleteLearningUnlocked(id) {
|
|
281
458
|
const store = loadStore();
|
|
282
459
|
const index = store.learnings.findIndex((l) => l.id === id);
|
|
283
460
|
if (index === -1)
|
|
@@ -299,9 +476,10 @@ export function importLearningsFromFile(filePath, defaultCategory = "other", def
|
|
|
299
476
|
}
|
|
300
477
|
const content = readFileSync(filePath, "utf-8");
|
|
301
478
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
302
|
-
|
|
479
|
+
// One load, one save for the whole file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
|
|
480
|
+
const result = withStoreBatch(() => ext === "json"
|
|
303
481
|
? importFromJson(content, defaultProject)
|
|
304
|
-
: importFromMarkdown(content, defaultCategory, defaultProject);
|
|
482
|
+
: importFromMarkdown(content, defaultCategory, defaultProject));
|
|
305
483
|
// Aggregate event correlating the individual learning.save records emitted
|
|
306
484
|
// inside the loop. Useful for compliance attribution: "this batch came from
|
|
307
485
|
// file X".
|
|
@@ -650,20 +828,24 @@ export function autoImportFromSources(sources) {
|
|
|
650
828
|
let totalImported = 0;
|
|
651
829
|
let totalUpdated = 0;
|
|
652
830
|
let processed = 0;
|
|
653
|
-
for (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
831
|
+
// One load and one save for the whole sweep (~880 files), instead of one full-file
|
|
832
|
+
// rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
|
|
833
|
+
withStoreBatch(() => {
|
|
834
|
+
for (const source of sources) {
|
|
835
|
+
// Only process markdown files
|
|
836
|
+
if (!source.path.endsWith(".md"))
|
|
837
|
+
continue;
|
|
838
|
+
if (!existsSync(source.path))
|
|
839
|
+
continue;
|
|
840
|
+
// Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
|
|
841
|
+
const project = source.name.split(" — ")[0]?.trim() || undefined;
|
|
842
|
+
const result = importLearningsFromFile(source.path, "other", project);
|
|
843
|
+
totalImported += result.imported;
|
|
844
|
+
totalUpdated += result.updated;
|
|
845
|
+
if (result.imported > 0 || result.updated > 0)
|
|
846
|
+
processed++;
|
|
847
|
+
}
|
|
848
|
+
});
|
|
667
849
|
return { total: processed, imported: totalImported, updated: totalUpdated };
|
|
668
850
|
}
|
|
669
851
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.6",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"test:watch": "vitest",
|
|
19
19
|
"lint": "eslint src/",
|
|
20
20
|
"prepublishOnly": "node scripts/check-npm-token-expiry.mjs && npm run build && node scripts/obfuscate-rubric.mjs",
|
|
21
|
-
"check-token": "node scripts/check-npm-token-expiry.mjs"
|
|
21
|
+
"check-token": "node scripts/check-npm-token-expiry.mjs",
|
|
22
|
+
"verify-release": "bash scripts/verify-release.sh"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"mcp",
|