@compr/opscontext-mcp 2.5.5 → 2.5.7

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,38 @@ 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.7] — 2026-09-05 — The learnings store was a pile of headings
8
+
9
+ Of 3,005 records, about 2,760 had been produced by the doc importer from ~160 ordinary docs
10
+ (copilot-instructions, session docs, Claude memory files) and about 240 by an agent calling
11
+ `save_learning`. In a spread sample of 70 imported records, 32 were not rules at all ("Design
12
+ Language:", "Session 24 TODO", "Files created (Phase 1 foundation)"), and the category of the rest
13
+ came from a section title or a substring guess: "Flow A" under mobile, "Scoring internals are trade
14
+ secrets" under mobile because "expose" contains "expo".
15
+
16
+ ### Changed
17
+
18
+ - **Auto-import takes only marked learnings** (`[AUTO-IMPORT-ONLY-MARKED-LEARNINGS]`): inline
19
+ `- [category] rule → context` bullets anywhere; every shape inside a `*LEARNINGS.md` file; every
20
+ shape under a heading that says learnings / lessons / gotchas / pitfalls / rules / anti-patterns /
21
+ "never repeat" / "the hard way"; JSON. Bare H3 headings, bold bullets and table rows in ordinary
22
+ docs are reported as `ignored` and left alone; the docs stay searchable as docs. Replayed over the
23
+ 818 discovered sources: 129 records instead of 1,879. `import_learnings` gains `permissive: true`
24
+ and the CLI `--permissive` for a file the user chose. Imported records carry `source`.
25
+ - **Category inference scores whole words** (`[CATEGORY-BY-WHOLE-WORD-SCORE]`): every whole-word or
26
+ whole-phrase match counts, rule text weighs double the context, highest total wins, ties go to the
27
+ more specific category, no match is `other`. Measured: 11% → 27.5% agreement on 189 agent-labelled
28
+ records, 25% → 50% on 84 hand-labelled rules (`scripts/measure-categories.mjs`; the sample stays
29
+ outside the repo). `normalizeCategory()` no longer maps "Apple App Store" to api on a prefix.
30
+ - New default source patterns: `AGENT-LEARNINGS.md`, `docs/AGENT-LEARNINGS.md`, `LEARNINGS.md`,
31
+ `docs/LEARNINGS.md`.
32
+
33
+ ### Added
34
+
35
+ - `scripts/learnings-prune.mjs <plan.json> [--apply]`: dry run by default, backup before any
36
+ delete, one batched write under the store lock. The 2026-09-05 plan lists 2,635 import-derived
37
+ records to remove (370 kept), pending the owner's GO.
38
+
7
39
  ## [2.4.3] — 2026-08-17 — The audit verifier called concurrency "tampering" and condemned 316k records
8
40
 
9
41
  `audit-verify` reported `❌ Audit chain BROKEN at index 2826 (of 319438)` and told the user the
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/cli.js CHANGED
@@ -2269,6 +2269,7 @@ async function cliImportLearnings(args) {
2269
2269
  let filePath = "";
2270
2270
  let category = "other";
2271
2271
  let project;
2272
+ let permissive = false;
2272
2273
  for (let i = 0; i < args.length; i++) {
2273
2274
  if ((args[i] === "-c" || args[i] === "--category") && args[i + 1]) {
2274
2275
  category = args[++i];
@@ -2276,19 +2277,25 @@ async function cliImportLearnings(args) {
2276
2277
  else if ((args[i] === "-p" || args[i] === "--project") && args[i + 1]) {
2277
2278
  project = args[++i];
2278
2279
  }
2280
+ else if (args[i] === "--permissive") {
2281
+ permissive = true;
2282
+ }
2279
2283
  else if (!filePath) {
2280
2284
  filePath = args[i];
2281
2285
  }
2282
2286
  }
2283
2287
  if (!filePath) {
2284
- console.error("Usage: contextengine import-learnings <file.md|file.json> [-c category] [-p project]");
2288
+ console.error("Usage: contextengine import-learnings <file.md|file.json> [-c category] [-p project] [--permissive]");
2289
+ console.error(" Default: only marked learnings ([category] bullets, *LEARNINGS.md files, learnings/lessons/gotchas/rules sections, JSON).");
2290
+ console.error(" --permissive: every H3 heading, bold bullet and table row too.");
2285
2291
  process.exit(1);
2286
2292
  }
2287
- const result = importLearningsFromFile(filePath, category, project);
2293
+ const result = importLearningsFromFile(filePath, category, project, { permissive });
2288
2294
  console.log(`\n📥 Import Results:`);
2289
2295
  console.log(` Imported: ${result.imported}`);
2290
2296
  console.log(` Updated: ${result.updated}`);
2291
2297
  console.log(` Skipped: ${result.skipped}`);
2298
+ console.log(` Ignored: ${result.ignored} (unmarked headings/bullets/rows; --permissive imports them)`);
2292
2299
  if (result.errors.length > 0) {
2293
2300
  console.log(` Errors:`);
2294
2301
  for (const err of result.errors) {
package/dist/config.js CHANGED
@@ -17,6 +17,12 @@ const DEFAULT_PATTERNS = [
17
17
  "AGENTS.md",
18
18
  // Context engineering
19
19
  "CONTEXT_MAP.md",
20
+ // Learnings files: the only ordinary-looking docs the auto-import reads in full
21
+ // ([LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS] in learnings.ts)
22
+ "AGENT-LEARNINGS.md",
23
+ "docs/AGENT-LEARNINGS.md",
24
+ "LEARNINGS.md",
25
+ "docs/LEARNINGS.md",
20
26
  ];
21
27
  /**
22
28
  * Look for contextengine.json in standard locations.
package/dist/index.js CHANGED
@@ -956,7 +956,7 @@ server.tool("delete_learning", "Delete a learning by its ID. Use list_learnings
956
956
  // ---------------------------------------------------------------------------
957
957
  // Tool: import_learnings (Bulk Import from Files)
958
958
  // ---------------------------------------------------------------------------
959
- server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON file. Parses headings, bullets, and tables to extract operational rules. Supports: (1) Structured Markdown (H2=category, H3=rule, bullets=context), (2) Inline bullets with [category] prefix, (3) JSON arrays of {category, rule, context}. Deduplicates against existing learnings.", {
959
+ server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON file. By default only MARKED learnings are imported: inline bullets with a [category] prefix, anything inside a *LEARNINGS.md file, anything under a heading that says learnings / lessons / gotchas / rules, and JSON arrays of {category, rule, context}. Set permissive=true to also import every H3 heading, bold bullet and table row (H2=category, H3=rule, bullets=context). Deduplicates against existing learnings.", {
960
960
  file_path: z
961
961
  .string()
962
962
  .describe("Absolute path to the Markdown (.md) or JSON (.json) file to import from"),
@@ -968,8 +968,12 @@ server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON f
968
968
  .string()
969
969
  .optional()
970
970
  .describe("Project name to tag all imported learnings with (e.g., 'FC_project')"),
971
- }, async ({ file_path, default_category, project }) => {
972
- const result = importLearningsFromFile(file_path, default_category || "other", project);
971
+ permissive: z
972
+ .boolean()
973
+ .optional()
974
+ .describe("Import every heading, bold bullet and table row as a rule (the pre-2.5.7 behaviour). Default false: only marked learnings."),
975
+ }, async ({ file_path, default_category, project, permissive }) => {
976
+ const result = importLearningsFromFile(file_path, default_category || "other", project, { permissive: permissive === true });
973
977
  // Re-inject learnings into search index (project-scoped)
974
978
  const newChunks = learningsToChunks(activeProjectNames);
975
979
  const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
@@ -981,6 +985,7 @@ server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON f
981
985
  `- **Imported:** ${result.imported} new learnings`,
982
986
  `- **Updated:** ${result.updated} existing learnings (dedup match)`,
983
987
  `- **Skipped:** ${result.skipped} entries (missing data)`,
988
+ `- **Ignored:** ${result.ignored} headings / bold bullets / table rows outside a learnings scope (pass permissive=true to import them)`,
984
989
  ``,
985
990
  `📊 Store total: ${stats.total} learnings across ${Object.keys(stats.categories).length} categories`,
986
991
  ``,
@@ -8,6 +8,8 @@ export interface Learning {
8
8
  tags: string[];
9
9
  created: string;
10
10
  updated: string;
11
+ /** Where an imported record came from (absolute file path). Absent on agent-saved records. */
12
+ source?: string;
11
13
  }
12
14
  export interface LearningsStore {
13
15
  version: number;
@@ -17,11 +19,21 @@ export interface LearningsStore {
17
19
  /** Valid categories for learnings */
18
20
  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
21
  export type LearningCategory = (typeof LEARNING_CATEGORIES)[number];
22
+ /** Cross-process, re-entrant (within this process) lock around the store file. */
23
+ export declare function withStoreLock<T>(fn: () => T): T;
24
+ /**
25
+ * Run `fn` with ONE load and at most ONE save of the store, under the lock. Inside, every
26
+ * loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
27
+ */
28
+ export declare function withStoreBatch<T>(fn: () => T): T;
29
+ /** Test seam for the writer's tripwire; not part of the API. */
30
+ export declare function __writeStoreForTests(store: LearningsStore): void;
20
31
  /**
21
32
  * Save a new learning. Returns the created learning with ID.
22
33
  * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
23
34
  */
24
- export declare function saveLearning(category: string, rule: string, context: string, project?: string): Learning;
35
+ export declare function saveLearning(...args: Parameters<typeof saveLearningUnlocked>): Learning;
36
+ declare function saveLearningUnlocked(category: string, rule: string, context: string, project?: string, source?: string): Learning;
25
37
  /**
26
38
  * Search learnings by keyword. Returns matches sorted by relevance.
27
39
  */
@@ -64,9 +76,23 @@ export interface ImportResult {
64
76
  imported: number;
65
77
  updated: number;
66
78
  skipped: number;
79
+ /** Candidates the import rule left alone: headings, bold bullets and table rows outside a learnings scope. */
80
+ ignored: number;
67
81
  errors: string[];
68
82
  }
69
- export declare function importLearningsFromFile(filePath: string, defaultCategory?: string, defaultProject?: string): ImportResult;
83
+ export interface ImportOptions {
84
+ /** Import every heading, bold bullet and table row as a rule, the pre-2026-09-05 behaviour. */
85
+ permissive?: boolean;
86
+ }
87
+ export declare const LEARNINGS_FILE_NAME: RegExp;
88
+ export declare const LEARNINGS_HEADING: RegExp;
89
+ export declare function importLearningsFromFile(filePath: string, defaultCategory?: string, defaultProject?: string, opts?: ImportOptions): ImportResult;
90
+ /** Score every category over rule (x2) and context (x1); the caller picks the winner. */
91
+ export declare function scoreCategories(rule: string, context: string): Map<LearningCategory, number>;
92
+ /** Infer a category from rule text + context. "other" only when nothing matches at all. */
93
+ export declare function inferCategory(rule: string, context: string): LearningCategory;
94
+ /** Map free-form heading text to the closest LEARNING_CATEGORIES value. */
95
+ export declare function normalizeCategory(heading: string): LearningCategory;
70
96
  /**
71
97
  * Convert learnings to Chunks so they can be included in search_context.
72
98
  * This is the key integration — learnings auto-surface in hybrid search.
@@ -93,6 +119,7 @@ export declare function autoImportFromSources(sources: Array<{
93
119
  total: number;
94
120
  imported: number;
95
121
  updated: number;
122
+ ignored: number;
96
123
  };
97
124
  /**
98
125
  * Get the store stats.
@@ -118,4 +145,5 @@ export interface FormatLearningsOptions {
118
145
  sinceSpec?: string;
119
146
  }
120
147
  export declare function formatLearnings(learnings: Learning[], opts?: FormatLearningsOptions): string;
148
+ export {};
121
149
  //# 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";
@@ -23,7 +23,7 @@ const __dirname = dirname(__filename);
23
23
  * - "macOS sandbox blocks ~/Downloads access from VS Code terminal"
24
24
  * - "Unicode NFC vs NFD causes false mismatches on Google Drive vs APFS"
25
25
  */
26
- const LEARNINGS_PATH = join(homedir(), ".contextengine", "learnings.json");
26
+ const LEARNINGS_PATH = join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "learnings.json");
27
27
  /** Valid categories for learnings */
28
28
  export const LEARNING_CATEGORIES = [
29
29
  "deployment",
@@ -98,17 +98,139 @@ function mergeDefaults(store) {
98
98
  }
99
99
  return added > 0;
100
100
  }
101
- function loadStore() {
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(readFileSync(LEARNINGS_PATH, "utf-8"));
106
- // Filter out corrupted entries missing required 'rule' field
107
- store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
215
+ store = JSON.parse(raw);
216
+ if (!store || !Array.isArray(store.learnings))
217
+ throw new Error("no learnings array");
108
218
  }
109
- catch {
110
- // Corrupted file — start fresh
111
- store = { version: 1, count: 0, learnings: [] };
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.`);
227
+ }
228
+ // Filter out corrupted entries missing required 'rule' field; a missing or unknown
229
+ // category becomes "other" (two June-era records crashed list_learnings on 2026-09-05).
230
+ store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
231
+ for (const l of store.learnings) {
232
+ if (typeof l.category !== "string" || !LEARNING_CATEGORIES.includes(l.category))
233
+ l.category = "other";
112
234
  }
113
235
  }
114
236
  else {
@@ -116,14 +238,68 @@ function loadStore() {
116
238
  }
117
239
  // Auto-merge bundled defaults on first load or when new defaults are added
118
240
  if (mergeDefaults(store)) {
119
- saveStore(store);
241
+ if (batchStore)
242
+ batchDirty = true;
243
+ else
244
+ writeStoreToDisk(store);
120
245
  }
121
246
  return store;
122
247
  }
123
- function saveStore(store) {
248
+ function loadStore() {
249
+ if (batchStore)
250
+ return batchStore;
251
+ return readStoreFromDisk();
252
+ }
253
+ function dailyBackup() {
254
+ if (!existsSync(LEARNINGS_PATH))
255
+ return;
256
+ const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
257
+ const bak = `${LEARNINGS_PATH}.bak-${day}`;
258
+ if (existsSync(bak))
259
+ return;
260
+ try {
261
+ copyFileSync(LEARNINGS_PATH, bak);
262
+ const dir = dirname(LEARNINGS_PATH);
263
+ const daily = readdirSync(dir).filter((f) => /^learnings\.json\.bak-\d{8}$/.test(f)).sort();
264
+ for (const f of daily.slice(0, Math.max(0, daily.length - 7)))
265
+ unlinkSync(join(dir, f));
266
+ }
267
+ catch { /* a missing backup must never block a save */ }
268
+ }
269
+ function writeStoreToDisk(store) {
124
270
  ensureDir();
125
271
  store.count = store.learnings.length;
126
- writeFileSync(LEARNINGS_PATH, JSON.stringify(store, null, 2));
272
+ // Shrink tripwire: the exact shape of the 2026-09-05 loss was a near-empty store
273
+ // written over a full one.
274
+ if (existsSync(LEARNINGS_PATH) && process.env.CONTEXTENGINE_ALLOW_SHRINK !== "1") {
275
+ let onDisk = -1;
276
+ try {
277
+ onDisk = (JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8")).learnings || []).length;
278
+ }
279
+ catch {
280
+ onDisk = -1;
281
+ }
282
+ if (onDisk >= 100 && store.learnings.length < onDisk / 2) {
283
+ safeAppend("learning.store_shrink_refused", { on_disk: onDisk, attempted: store.learnings.length });
284
+ 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
+ }
286
+ }
287
+ dailyBackup();
288
+ const tmp = `${LEARNINGS_PATH}.tmp-${process.pid}-${Date.now()}`;
289
+ writeFileSync(tmp, JSON.stringify(store, null, 2));
290
+ renameSync(tmp, LEARNINGS_PATH);
291
+ }
292
+ function saveStore(store) {
293
+ if (batchStore) {
294
+ batchStore = store;
295
+ batchDirty = true;
296
+ return;
297
+ }
298
+ writeStoreToDisk(store);
299
+ }
300
+ /** Test seam for the writer's tripwire; not part of the API. */
301
+ export function __writeStoreForTests(store) {
302
+ withStoreLock(() => writeStoreToDisk(store));
127
303
  }
128
304
  /** Generate a short unique ID */
129
305
  function generateId() {
@@ -156,7 +332,10 @@ const MIN_RULE_LENGTH = 15;
156
332
  * Save a new learning. Returns the created learning with ID.
157
333
  * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
158
334
  */
159
- export function saveLearning(category, rule, context, project) {
335
+ export function saveLearning(...args) {
336
+ return withStoreLock(() => saveLearningUnlocked(...args));
337
+ }
338
+ function saveLearningUnlocked(category, rule, context, project, source) {
160
339
  const trimmedRule = rule.trim();
161
340
  // Quality gate: reject junk rules
162
341
  if (trimmedRule.length < MIN_RULE_LENGTH) {
@@ -178,12 +357,23 @@ export function saveLearning(category, rule, context, project) {
178
357
  const existing = store.learnings.find((l) => l.category === category &&
179
358
  typeof l.rule === "string" && l.rule.toLowerCase().trim() === ruleLower);
180
359
  if (existing) {
360
+ // A re-import that changes nothing must leave no trace: no write, no `updated` bump,
361
+ // no audit event. Before 2026-09-05 every startup re-import emitted one learning.save
362
+ // per rule (2,000 to 5,000 events per server start) for records that did not change.
363
+ const newTags = extractTags(existing.rule, context, category);
364
+ const sameTags = JSON.stringify(newTags) === JSON.stringify(existing.tags || []);
365
+ const sameSource = !source || existing.source === source;
366
+ if (existing.context === context && (!project || existing.project === project) && sameTags && sameSource) {
367
+ return existing;
368
+ }
181
369
  // Update existing learning with new context
182
370
  existing.context = context;
183
371
  existing.updated = now;
184
372
  if (project)
185
373
  existing.project = project;
186
- existing.tags = extractTags(existing.rule, context, category);
374
+ if (source)
375
+ existing.source = source;
376
+ existing.tags = newTags;
187
377
  saveStore(store);
188
378
  safeAppend("learning.save", {
189
379
  id: existing.id,
@@ -204,6 +394,8 @@ export function saveLearning(category, rule, context, project) {
204
394
  created: now,
205
395
  updated: now,
206
396
  };
397
+ if (source)
398
+ learning.source = source;
207
399
  store.learnings.push(learning);
208
400
  saveStore(store);
209
401
  safeAppend("learning.save", {
@@ -270,7 +462,7 @@ export function listLearnings(category, projects) {
270
462
  result = result.filter((l) => !l.project || lowerProjects.includes(l.project.toLowerCase()));
271
463
  }
272
464
  if (category) {
273
- result = result.filter((l) => l.category.toLowerCase() === category.toLowerCase());
465
+ result = result.filter((l) => String(l.category || "other").toLowerCase() === category.toLowerCase());
274
466
  }
275
467
  return result;
276
468
  }
@@ -278,6 +470,9 @@ export function listLearnings(category, projects) {
278
470
  * Delete a learning by ID.
279
471
  */
280
472
  export function deleteLearning(id) {
473
+ return withStoreLock(() => deleteLearningUnlocked(id));
474
+ }
475
+ function deleteLearningUnlocked(id) {
281
476
  const store = loadStore();
282
477
  const index = store.learnings.findIndex((l) => l.id === id);
283
478
  if (index === -1)
@@ -293,15 +488,37 @@ export function deleteLearning(id) {
293
488
  });
294
489
  return true;
295
490
  }
296
- export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject) {
491
+ // [LOCKED] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS] 2026-09-05
492
+ // [NEVER] let the auto-import (autoImportFromSources, run by every MCP server on every doc change)
493
+ // treat an H3 heading, a bold bullet or a table row in an ordinary doc as a learning again.
494
+ // WHY: measured 2026-09-05 (Session 25): of 3,005 store records, about 2,760 had been produced by
495
+ // this importer from ~160 ordinary docs (copilot-instructions, session docs, Claude memory
496
+ // files) and about 240 by an agent calling save_learning. In a spread sample of 70 imported
497
+ // records, 32 were not rules at all ("Design Language:", "External References:", "Session 24
498
+ // TODO", "Files created (Phase 1 foundation)"), and the category of the rest came from a
499
+ // section title or a substring guess ("Flow A" under mobile). A count of headings is not a
500
+ // knowledge base; every one of those docs is already searchable as a doc.
501
+ // FIX: a candidate becomes a learning only when the author marked it as one:
502
+ // (1) an inline-category bullet `- [category] rule → context`, anywhere;
503
+ // (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 / rules /
505
+ // anti-patterns / "never repeat" / "the hard way" (LEARNINGS_HEADING);
506
+ // (4) JSON files, which are explicit by construction.
507
+ // `permissive: true` (MCP `import_learnings`, CLI `--permissive`) restores the old parser for
508
+ // a file the user chose on purpose. Every imported record now carries `source`.
509
+ 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?|rules?)\b/i;
511
+ export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject, opts = {}) {
297
512
  if (!existsSync(filePath)) {
298
- return { imported: 0, updated: 0, skipped: 0, errors: [`File not found: ${filePath}`] };
513
+ return { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [`File not found: ${filePath}`] };
299
514
  }
300
515
  const content = readFileSync(filePath, "utf-8");
301
516
  const ext = filePath.split(".").pop()?.toLowerCase();
302
- const result = ext === "json"
303
- ? importFromJson(content, defaultProject)
304
- : importFromMarkdown(content, defaultCategory, defaultProject);
517
+ const permissive = opts.permissive === true || LEARNINGS_FILE_NAME.test(filePath.split("/").pop() || "");
518
+ // One load, one save for the whole file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
519
+ const result = withStoreBatch(() => ext === "json"
520
+ ? importFromJson(content, defaultProject, filePath)
521
+ : importFromMarkdown(content, defaultCategory, defaultProject, { permissive, source: filePath }));
305
522
  // Aggregate event correlating the individual learning.save records emitted
306
523
  // inside the loop. Useful for compliance attribution: "this batch came from
307
524
  // file X".
@@ -312,12 +529,13 @@ export function importLearningsFromFile(filePath, defaultCategory = "other", def
312
529
  imported: result.imported,
313
530
  updated: result.updated,
314
531
  skipped: result.skipped,
532
+ ignored: result.ignored,
315
533
  errors: result.errors.length,
316
534
  });
317
535
  return result;
318
536
  }
319
- function importFromJson(content, defaultProject) {
320
- const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
537
+ function importFromJson(content, defaultProject, source) {
538
+ const result = { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [] };
321
539
  try {
322
540
  const data = JSON.parse(content);
323
541
  const items = Array.isArray(data)
@@ -339,7 +557,7 @@ function importFromJson(content, defaultProject) {
339
557
  const store = loadStore();
340
558
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === item.rule.toLowerCase().trim());
341
559
  try {
342
- saveLearning(cat, item.rule, item.context || "", item.project || defaultProject);
560
+ saveLearning(cat, item.rule, item.context || "", item.project || defaultProject, source);
343
561
  if (existing) {
344
562
  result.updated++;
345
563
  }
@@ -357,12 +575,26 @@ function importFromJson(content, defaultProject) {
357
575
  }
358
576
  return result;
359
577
  }
360
- function importFromMarkdown(content, defaultCategory, defaultProject) {
361
- const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
578
+ function importFromMarkdown(content, defaultCategory, defaultProject, opts = { permissive: false }) {
579
+ const result = { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [] };
362
580
  const lines = content.split("\n");
363
581
  let currentCategory = defaultCategory;
364
582
  let currentRule = "";
365
583
  let currentContext = [];
584
+ // Learnings scope, [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]: unmarked shapes (H3, bold bullet,
585
+ // table row) count as rules only inside it. Three nested levels: the whole file (permissive,
586
+ // learnings file name, or an H1 that says so), an H2 section, an H3 subsection.
587
+ let fileScope = opts.permissive;
588
+ let h2Scope = false;
589
+ let h3Scope = false;
590
+ const inScope = () => fileScope || h2Scope || h3Scope;
591
+ // A candidate that arrives outside the scope is counted and dropped, never queued.
592
+ function candidate(text) {
593
+ if (inScope())
594
+ currentRule = text;
595
+ else
596
+ result.ignored++;
597
+ }
366
598
  function flushRule() {
367
599
  if (!currentRule)
368
600
  return;
@@ -378,7 +610,7 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
378
610
  const store = loadStore();
379
611
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === currentRule.toLowerCase().trim());
380
612
  try {
381
- saveLearning(cat, currentRule, ctx, defaultProject);
613
+ saveLearning(cat, currentRule, ctx, defaultProject, opts.source);
382
614
  if (existing) {
383
615
  result.updated++;
384
616
  }
@@ -394,24 +626,33 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
394
626
  }
395
627
  for (const line of lines) {
396
628
  const trimmed = line.trim();
397
- // H1 — file title, skip
398
- if (trimmed.startsWith("# ") && !trimmed.startsWith("## "))
629
+ // H1 — file title, skip; a title that says learnings puts the whole file in scope
630
+ if (trimmed.startsWith("# ") && !trimmed.startsWith("## ")) {
631
+ if (LEARNINGS_HEADING.test(trimmed.slice(2)))
632
+ fileScope = true;
399
633
  continue;
634
+ }
400
635
  // H2 — category (e.g., "## deployment" or "## Security & Server Administration")
401
636
  if (trimmed.startsWith("## ")) {
402
637
  flushRule();
403
638
  const heading = trimmed.replace(/^##\s+/, "").toLowerCase().trim();
404
639
  currentCategory = heading;
640
+ h2Scope = LEARNINGS_HEADING.test(heading);
641
+ h3Scope = false;
405
642
  continue;
406
643
  }
407
- // H3 — rule (e.g., "### Never docker build | tee")
644
+ // H3 — rule (e.g., "### Never docker build | tee"), or a subsection that says learnings
408
645
  if (trimmed.startsWith("### ")) {
409
646
  flushRule();
410
- const candidate = trimmed.replace(/^###\s+/, "").trim();
411
- // Quality filter: skip short headings ("Fix", "UI", "DB")
412
- if (candidate.length >= MIN_RULE_LENGTH) {
413
- currentRule = candidate;
647
+ h3Scope = false; // an H3 subsection ends at the next H3
648
+ const text = trimmed.replace(/^###\s+/, "").trim();
649
+ if (!inScope() && LEARNINGS_HEADING.test(text)) {
650
+ h3Scope = true; // "### Lessons learned" opens a scope; the heading itself is not a rule
651
+ continue;
414
652
  }
653
+ // Quality filter: skip short headings ("Fix", "UI", "DB")
654
+ if (text.length >= MIN_RULE_LENGTH)
655
+ candidate(text);
415
656
  continue;
416
657
  }
417
658
  // H4+ — sub-rule, treat as context for current rule
@@ -429,18 +670,19 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
429
670
  currentCategory = cat;
430
671
  // Split on → or — for rule/context separation
431
672
  const sepMatch = rest.match(/^(.+?)(?:\s*[→—]\s*|\s+[-–]\s+)(.+)$/);
673
+ // Marked by its author: imported in every mode. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
432
674
  if (sepMatch) {
433
- const candidate = sepMatch[1].trim();
434
- if (candidate.length >= MIN_RULE_LENGTH) {
435
- currentRule = candidate;
675
+ const text = sepMatch[1].trim();
676
+ if (text.length >= MIN_RULE_LENGTH) {
677
+ currentRule = text;
436
678
  currentContext = [sepMatch[2].trim()];
437
679
  flushRule();
438
680
  }
439
681
  }
440
682
  else {
441
- const candidate = rest.trim();
442
- if (candidate.length >= MIN_RULE_LENGTH) {
443
- currentRule = candidate;
683
+ const text = rest.trim();
684
+ if (text.length >= MIN_RULE_LENGTH) {
685
+ currentRule = text;
444
686
  flushRule();
445
687
  }
446
688
  }
@@ -450,11 +692,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
450
692
  const tableMatch = trimmed.match(/^\|\s*\*\*(.+?)\*\*\s*\|(.+)\|(.+)\|/);
451
693
  if (tableMatch) {
452
694
  flushRule();
453
- const candidate = tableMatch[1].trim();
454
- if (candidate.length >= MIN_RULE_LENGTH) {
455
- currentRule = candidate;
456
- currentContext = [tableMatch[2].trim() + " — " + tableMatch[3].trim()];
457
- flushRule();
695
+ const text = tableMatch[1].trim();
696
+ if (text.length >= MIN_RULE_LENGTH) {
697
+ candidate(text);
698
+ if (currentRule) {
699
+ currentContext = [tableMatch[2].trim() + " — " + tableMatch[3].trim()];
700
+ flushRule();
701
+ }
458
702
  }
459
703
  continue;
460
704
  }
@@ -464,13 +708,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
464
708
  flushRule();
465
709
  const boldMatch = trimmed.match(/^[-*]\s+\*\*(.+?)\*\*\s*(.*)$/);
466
710
  if (boldMatch) {
467
- const candidate = boldMatch[1].trim();
711
+ const text = boldMatch[1].trim();
468
712
  // Quality filter: skip short/single-word headings
469
- if (candidate.length < MIN_RULE_LENGTH) {
713
+ if (text.length < MIN_RULE_LENGTH) {
470
714
  continue;
471
715
  }
472
- currentRule = candidate;
473
- if (boldMatch[2]) {
716
+ candidate(text);
717
+ if (currentRule && boldMatch[2]) {
474
718
  // Strip leading separators
475
719
  currentContext = [boldMatch[2].replace(/^[\s—→:]+/, "").trim()];
476
720
  }
@@ -492,117 +736,195 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
492
736
  flushRule(); // Flush last rule
493
737
  return result;
494
738
  }
495
- /** Infer a category from rule text + context when "other" is provided */
496
- function inferCategory(rule, context) {
497
- const text = `${rule} ${context}`.toLowerCase();
498
- const keywords = {
499
- "deploy": "deployment", "rsync": "deployment", "publish": "deployment", "release": "deployment",
500
- "ci/cd": "devops", "ci cd": "devops", "pipeline": "devops", "github actions": "devops", "docker": "devops",
501
- "nginx": "infrastructure", "ssl": "infrastructure", "server": "infrastructure", "pm2": "infrastructure", "vps": "infrastructure",
502
- "api": "api", "endpoint": "api", "rest": "api", "graphql": "api", "webhook": "api",
503
- "sql": "database", "sqlite": "database", "mysql": "database", "postgres": "database", "query": "database", "migration": "database",
504
- "react": "frontend", "vue": "frontend", "css": "frontend", "html": "frontend", "dom": "frontend", "component": "frontend", "ui": "frontend",
505
- "express": "backend", "node": "backend", "flask": "backend", "middleware": "backend",
506
- "auth": "security", "cors": "security", "xss": "security", "csrf": "security", "helmet": "security", "encrypt": "security", "password": "security",
507
- "test": "testing", "vitest": "testing", "jest": "testing", "spec": "testing", "assert": "testing",
508
- "debug": "debugging", "error": "debugging", "stack trace": "debugging", "breakpoint": "debugging", "log": "debugging",
509
- "npm": "dependencies", "package": "dependencies", "yarn": "dependencies", "pnpm": "dependencies", "version": "dependencies",
510
- "git": "git", "commit": "git", "branch": "git", "merge": "git", "rebase": "git",
511
- "perf": "performance", "latency": "performance", "cache": "performance", "optimize": "performance",
512
- "eslint": "tooling", "lint": "tooling", "prettier": "tooling", "vscode": "tooling", "editor": "tooling",
513
- "pattern": "architecture", "refactor": "architecture", "module": "architecture", "design": "architecture",
514
- "ios": "mobile", "android": "mobile", "expo": "mobile", "react native": "mobile",
515
- };
516
- for (const [keyword, cat] of Object.entries(keywords)) {
517
- if (text.includes(keyword))
518
- return cat;
739
+ // [LOCKED] [CATEGORY-BY-WHOLE-WORD-SCORE] 2026-09-05
740
+ // [NEVER] go back to a first-hit `text.includes(keyword)` over an unanchored substring list,
741
+ // in inferCategory() or in normalizeCategory().
742
+ // WHY: measured 2026-09-05 (Session 25) on 189 store records whose category an agent had
743
+ // chosen by hand: 21 correct, 11%. "expose" matched "expo" (mobile), "access" matched
744
+ // "css" (frontend), "restart" matched "rest" (api), "build" matched "ui", "login" matched
745
+ // "log" (debugging), and the FIRST hit won whatever the rest of the text said, so
746
+ // "Scoring internals are trade secrets, don't expose point values" was filed under mobile.
747
+ // FIX: whole-word and whole-phrase matches only; every match counts; a match in the rule text
748
+ // weighs double a match in the context; the highest total wins; ties go to the more
749
+ // specific category (CATEGORY_TIE_ORDER); no match at all is "other", never a guess.
750
+ // Regression floors in src/learnings-category.test.ts against tests/fixtures/category-labels.json.
751
+ /** Terms per category. Single words match as whole tokens, phrases as whole phrases. */
752
+ const CATEGORY_TERMS = {
753
+ deployment: ["deploy", "deploys", "deployed", "deploying", "deployment", "deployments", "rsync",
754
+ "scp", "publish", "published", "publishing", "release", "releases", "released", "rollout",
755
+ "rollback", "ship", "shipped", "shipping", "go live", "go-live", "cutover", "staging",
756
+ "production", "prod", "tarball", "npm publish", "verify-release", "preflight", "hotfix",
757
+ "live-verify"],
758
+ devops: ["ci", "ci/cd", "cicd", "pipeline", "pipelines", "github actions", "workflow",
759
+ "workflows", "docker", "dockerfile", "container", "containers", "compose", "kubernetes", "k8s",
760
+ "cron", "crontab", "launchd", "scheduler", "scheduled", "automation", "automated",
761
+ "orchestration"],
762
+ infrastructure: ["nginx", "apache", "ssl", "tls", "certificate", "certificates", "letsencrypt",
763
+ "server", "servers", "vps", "pm2", "ssh", "dns", "domain", "domains", "firewall", "ufw",
764
+ "fail2ban", "systemd", "backup", "backups", "restore", "disk", "ovh", "gandi", "hosting",
765
+ "smtp", "cloudflare", "proxy", "reverse proxy", "load balancer", "uptime", "monitoring", "ram",
766
+ "cpu", "swap", "reboot", "restart", "restarted", "daemon",
767
+ "box", "machine", "process", "processes", "host", "hosts"],
768
+ api: ["api", "apis", "endpoint", "endpoints", "rest", "graphql", "webhook", "webhooks", "route",
769
+ "routes", "router", "request", "requests", "response", "responses", "http", "https",
770
+ "status code", "payload", "rate limit", "rate-limit", "throttle", "throttling", "header",
771
+ "headers", "url", "urls", "fetch", "axios", "curl", "openapi", "swagger"],
772
+ database: ["sql", "sqlite", "mysql", "postgres", "postgresql", "mongodb", "mongo", "mongoose",
773
+ "query", "queries", "migration", "migrations", "schema", "table", "tables", "column", "columns",
774
+ "collection", "collections", "aggregate", "redis", "orm", "sqlalchemy", "prisma", "eloquent",
775
+ "transaction", "transactions", "row", "rows", "db", "database", "databases", "pg_dump",
776
+ "setval", "primary key", "foreign key", "upsert", "insert",
777
+ "index", "indexes", "join", "select", "sequence", "dump"],
778
+ frontend: ["react", "vue", "svelte", "css", "html", "dom", "component", "components", "ui", "ux",
779
+ "jsx", "tsx", "tailwind", "vite", "webpack", "render", "renders", "rendering", "rendered",
780
+ "page", "pages", "button", "buttons", "modal", "chip", "chips", "localstorage", "browser",
781
+ "usestate", "useeffect", "spinner", "layout", "responsive", "widget", "widgets", "form",
782
+ "forms", "click", "scroll", "font", "fonts", "color", "colors", "colour", "colours", "contrast",
783
+ "display", "screen", "screens", "frontend", "front-end", "pwa", "service worker", "bundle",
784
+ "hydration"],
785
+ backend: ["express", "node", "nodejs", "flask", "fastapi", "django", "laravel", "php", "python",
786
+ "middleware", "uvicorn", "gunicorn", "worker", "workers", "queue", "queues", "controller",
787
+ "controllers", "service", "services", "artisan", "i18n", "server-side", "backend", "back-end",
788
+ "handler", "handlers", "model", "models", "trait", "setdefault", "asyncio", "celery",
789
+ "cache_key"],
790
+ security: ["auth", "authentication", "authorization", "oauth", "jwt", "token", "tokens", "cors",
791
+ "xss", "csrf", "helmet", "encrypt", "encrypted", "encryption", "password", "passwords",
792
+ "passkey", "passkeys", "webauthn", "credential", "credentials", "secret", "secrets", "vault",
793
+ "permission", "permissions", "tenant", "isolation", "rbac", "hash", "hashed", "injection",
794
+ "sanitize", "sanitise", "vulnerability", "vulnerabilities", "cve", "exposed", "expose",
795
+ "cookie", "cookies", "login", "logout", "signin", "sign-in", "2fa", "mfa", "otp", "magic code",
796
+ "allowlist", "whitelist", "trade secret", "trade secrets", "lockout",
797
+ "origin", "leak", "leaks", "leaked"],
798
+ performance: ["perf", "performance", "latency", "cache", "cached", "caching", "optimize",
799
+ "optimise", "optimization", "optimisation", "slow", "slower", "bottleneck", "bottlenecks",
800
+ "throughput", "memory leak", "n+1", "benchmark", "loop invariant", "nested loop", "timeout",
801
+ "timeouts", "concurrency", "batch size",
802
+ "parallel", "expensive"],
803
+ testing: ["test", "tests", "testing", "tested", "vitest", "jest", "pytest", "spec", "specs",
804
+ "assert", "assertion", "assertions", "mock", "mocks", "mocked", "fixture", "fixtures", "e2e",
805
+ "end-to-end", "headless", "playwright", "cypress", "test suite", "regression", "tdd", "green",
806
+ "red", "smoke", "smoke test", "collect", "collected", "harness", "canary"],
807
+ debugging: ["debug", "debugging", "error", "errors", "stack trace", "traceback", "breakpoint",
808
+ "log", "logs", "logging", "diagnose", "diagnosis", "diagnostic", "diagnostics", "symptom",
809
+ "symptoms", "crash", "crashes", "crashed", "hang", "hangs", "freeze", "frozen", "root cause",
810
+ "reproduce", "repro", "bug", "bugs", "silent", "silently", "off-by-one", "stale",
811
+ "wrong", "invisible"],
812
+ tooling: ["eslint", "lint", "linter", "prettier", "vscode", "vs code", "editor", "cli", "script",
813
+ "scripts", "shell", "bash", "zsh", "terminal", "claude code", "agent", "agents", "subagent",
814
+ "subagents", "mcp", "extension", "plugin", "plugins", "tsc", "compiler", "formatter",
815
+ "makefile", "pipefail", "set -e", "grep", "sed", "regex", "quoting", "command", "commands",
816
+ "flag", "flags", "dry run", "dry-run", "--check", "prompt", "prompts", "transcript",
817
+ "transcripts", "copilot"],
818
+ git: ["git", "commit", "commits", "committed", "branch", "branches", "merge", "merged", "rebase",
819
+ "push", "pushed", "pull", "pull request", "pr", "prs", "checkout", "stash", "cherry-pick",
820
+ "no-verify", "--no-verify", "pre-commit", "post-commit", "pre-push", "post-push", "gitignore",
821
+ ".gitignore", "git push", "git pull", "bare repo", "worktree", "revert", "squash",
822
+ "history", "remote", "remotes", "tag", "tags", "conflict", "conflicts", "hook", "hooks"],
823
+ dependencies: ["npm", "package", "packages", "yarn", "pnpm", "pip", "composer", "dependency",
824
+ "dependencies", "upgrade", "upgraded", "semver", "lockfile", "package.json", "node_modules",
825
+ "requirements.txt", "sdk", "pubspec", "peer dependency", "bump", "bumped", "outdated", "npx",
826
+ "version", "versions", "install", "installed", "pin", "pinned", "pinning"],
827
+ architecture: ["pattern", "patterns", "refactor", "refactoring", "module", "modules", "design",
828
+ "architecture", "single source of truth", "coupling", "boundary", "boundaries", "abstraction",
829
+ "interface", "interfaces", "layer", "layers", "event bus", "invariant", "invariants", "guard",
830
+ "guards", "contract", "contracts", "decision", "decisions", "encode", "encoded", "absence",
831
+ "unknown", "responsibility", "coupled", "decoupled",
832
+ "trace", "structure", "structural"],
833
+ data: ["csv", "dataset", "datasets", "data", "categoriser", "categorizer", "categorisation",
834
+ "categorization", "taxonomy", "parse", "parser", "parsed", "encoding", "unicode", "nfc", "nfd",
835
+ "dedup", "deduplicate", "normalization", "normalisation", "etl", "classifier", "verdict",
836
+ "verdicts", "denominator", "nutri-score", "catalog", "catalogue", "spreadsheet", "excel",
837
+ "count", "counts", "figure", "figures", "json", "product", "products", "field", "fields", "label", "labels", "labelled", "coverage", "metric", "metrics", "import", "imports", "export", "exports", "record", "records"],
838
+ mobile: ["ios", "android", "expo", "react native", "flutter", "dart", "swift", "kotlin", "xcode",
839
+ "app store", "play store", "google play", "testflight", "apk", "aab", "ipa", "riverpod",
840
+ "app store connect", "simulator", "emulator", "mobile", "gradle", "cocoapods", "pod", "pods",
841
+ "mainactivity", "flutterfragmentactivity", "flutteractivity", "revenuecat", "subscription",
842
+ "subscriptions", "guideline", "review team", "samsung", "iphone", "device", "devices",
843
+ "widget tree"],
844
+ };
845
+ /** Unambiguous technology names: one occurrence outweighs two generic words. */
846
+ const STRONG_TERMS = new Set([
847
+ "rsync", "docker", "dockerfile", "kubernetes", "nginx", "fail2ban", "ufw", "pm2", "letsencrypt",
848
+ "graphql", "webhook", "webhooks", "endpoint", "endpoints", "sqlite", "mysql", "postgres", "postgresql",
849
+ "mongodb", "mongoose", "sqlalchemy", "prisma", "eloquent", "pg_dump", "react", "vue", "svelte",
850
+ "tailwind", "usestate", "useeffect", "localstorage", "express", "flask", "fastapi", "django", "laravel",
851
+ "uvicorn", "gunicorn", "artisan", "jwt", "csrf", "xss", "webauthn", "passkey", "passkeys", "oauth",
852
+ "vitest", "jest", "pytest", "playwright", "cypress", "stack trace", "traceback", "eslint", "prettier",
853
+ "vscode", "vs code", "rebase", "cherry-pick", "no-verify", "--no-verify", "pre-commit", "gitignore",
854
+ "npm", "yarn", "pnpm", "pip", "composer", "semver", "package.json", "node_modules", "csv", "unicode",
855
+ "flutter", "dart", "swift", "kotlin", "xcode", "testflight", "apk", "aab", "ipa", "riverpod", "expo",
856
+ "react native", "app store", "play store", "google play", "app store connect", "pubspec",
857
+ "single source of truth", "n+1", "memory leak", "loop invariant", "github actions", "trade secret",
858
+ "trade secrets", "git push", "git pull", "pull request", "mongo", "redis", "migration", "migrations",
859
+ ]);
860
+ /** When two categories tie, the earlier one wins: the more specific before the more generic. */
861
+ const CATEGORY_TIE_ORDER = [
862
+ "mobile", "database", "security", "git", "testing", "deployment", "api", "devops", "infrastructure",
863
+ "frontend", "backend", "performance", "dependencies", "data", "tooling", "debugging", "architecture",
864
+ ];
865
+ function normalizeForMatch(text) {
866
+ // Lowercase; every run of characters outside [a-z0-9+#./_-] becomes one space, so a term like
867
+ // "ci/cd", "n+1", "--no-verify" or "package.json" survives as a phrase, and word boundaries
868
+ // become spaces. Padded with spaces so a term can be looked up as " term ".
869
+ return " " + text.toLowerCase().replace(/[^a-z0-9+#./_-]+/g, " ").trim() + " ";
870
+ }
871
+ /** Whole-word / whole-phrase occurrence check on a normalised string. */
872
+ function hasTerm(normalized, term) {
873
+ return normalized.includes(` ${term} `);
874
+ }
875
+ /** Score every category over rule (x2) and context (x1); the caller picks the winner. */
876
+ export function scoreCategories(rule, context) {
877
+ const r = normalizeForMatch(rule);
878
+ const c = normalizeForMatch(context || "");
879
+ const scores = new Map();
880
+ for (const [cat, terms] of Object.entries(CATEGORY_TERMS)) {
881
+ let s = 0;
882
+ for (const term of terms) {
883
+ const w = STRONG_TERMS.has(term) ? 2 : 1;
884
+ if (hasTerm(r, term))
885
+ s += 2 * w;
886
+ else if (hasTerm(c, term))
887
+ s += w;
888
+ }
889
+ if (s > 0)
890
+ scores.set(cat, s);
891
+ }
892
+ return scores;
893
+ }
894
+ /** Infer a category from rule text + context. "other" only when nothing matches at all. */
895
+ export function inferCategory(rule, context) {
896
+ const scores = scoreCategories(rule, context);
897
+ let best = "other";
898
+ let bestScore = 0;
899
+ for (const cat of CATEGORY_TIE_ORDER) {
900
+ const s = scores.get(cat) || 0;
901
+ if (s > bestScore) {
902
+ best = cat;
903
+ bestScore = s;
904
+ }
519
905
  }
520
- return "other";
906
+ return best;
521
907
  }
522
- /** Map free-form heading text to closest LEARNING_CATEGORIES value */
523
- function normalizeCategory(heading) {
524
- const h = heading.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
525
- // Direct match
908
+ /** Map free-form heading text to the closest LEARNING_CATEGORIES value. */
909
+ export function normalizeCategory(heading) {
910
+ const h = normalizeForMatch(heading);
911
+ // A heading that IS a category name ("## deployment", "## Testing") maps directly.
526
912
  for (const cat of LEARNING_CATEGORIES) {
527
- if (h === cat || h.startsWith(cat))
913
+ if (h.trim() === cat)
528
914
  return cat;
529
915
  }
530
- // Keyword mapping
531
- const map = {
532
- "deploy": "deployment",
533
- "ci/cd": "devops",
534
- "ci cd": "devops",
535
- "pipeline": "devops",
536
- "docker": "devops",
537
- "nginx": "infrastructure",
538
- "server": "infrastructure",
539
- "hosting": "infrastructure",
540
- "ssl": "security",
541
- "cors": "security",
542
- "auth": "security",
543
- "malware": "security",
544
- "hack": "security",
545
- "hardening": "security",
546
- "terminal": "tooling",
547
- "command": "tooling",
548
- "monitoring": "tooling",
549
- "vs code": "tooling",
550
- "test": "testing",
551
- "jest": "testing",
552
- "spec": "testing",
553
- "debug": "debugging",
554
- "bug": "debugging",
555
- "fix": "debugging",
556
- "react": "frontend",
557
- "vue": "frontend",
558
- "css": "frontend",
559
- "ui": "frontend",
560
- "laravel": "backend",
561
- "django": "backend",
562
- "flask": "backend",
563
- "express": "backend",
564
- "mysql": "database",
565
- "postgres": "database",
566
- "sql": "database",
567
- "migration": "database",
568
- "npm": "dependencies",
569
- "composer": "dependencies",
570
- "pip": "dependencies",
571
- "package": "dependencies",
572
- "git": "git",
573
- "commit": "git",
574
- "branch": "git",
575
- "hook": "git",
576
- "perf": "performance",
577
- "speed": "performance",
578
- "cache": "performance",
579
- "mobile": "mobile",
580
- "expo": "mobile",
581
- "flutter": "mobile",
582
- "react native": "mobile",
583
- "swift": "mobile",
584
- "pattern": "architecture",
585
- "design": "architecture",
586
- "struct": "architecture",
587
- "data type": "data",
588
- "csv": "data",
589
- "import": "data",
590
- "export": "data",
591
- "api": "api",
592
- "endpoint": "api",
593
- "rest": "api",
594
- "smtp": "infrastructure",
595
- "email": "infrastructure",
596
- "queue": "infrastructure",
597
- "audit": "security",
598
- "version": "dependencies",
599
- "upgrade": "dependencies",
600
- };
601
- for (const [keyword, cat] of Object.entries(map)) {
602
- if (h.includes(keyword))
916
+ // A few heading words that the term lists do not carry as rule vocabulary.
917
+ const headingWords = [
918
+ ["lessons", "other"], ["learnings", "other"], ["gotchas", "other"],
919
+ ["hardening", "security"], ["malware", "security"], ["audit", "security"],
920
+ ["terminal", "tooling"], ["commands", "tooling"], ["monitoring", "infrastructure"],
921
+ ["bugs", "debugging"], ["fixes", "debugging"], ["speed", "performance"],
922
+ ];
923
+ for (const [word, cat] of headingWords) {
924
+ if (hasTerm(h, word))
603
925
  return cat;
604
926
  }
605
- return "other";
927
+ return inferCategory(heading, "");
606
928
  }
607
929
  /**
608
930
  * Convert learnings to Chunks so they can be included in search_context.
@@ -649,22 +971,29 @@ export function learningsToChunks(projects) {
649
971
  export function autoImportFromSources(sources) {
650
972
  let totalImported = 0;
651
973
  let totalUpdated = 0;
974
+ let totalIgnored = 0;
652
975
  let processed = 0;
653
- for (const source of sources) {
654
- // Only process markdown files
655
- if (!source.path.endsWith(".md"))
656
- continue;
657
- if (!existsSync(source.path))
658
- continue;
659
- // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
660
- const project = source.name.split(" — ")[0]?.trim() || undefined;
661
- const result = importLearningsFromFile(source.path, "other", project);
662
- totalImported += result.imported;
663
- totalUpdated += result.updated;
664
- if (result.imported > 0 || result.updated > 0)
665
- processed++;
666
- }
667
- return { total: processed, imported: totalImported, updated: totalUpdated };
976
+ // One load and one save for the whole sweep (~880 files), instead of one full-file
977
+ // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
978
+ withStoreBatch(() => {
979
+ for (const source of sources) {
980
+ // Only process markdown files
981
+ if (!source.path.endsWith(".md"))
982
+ continue;
983
+ if (!existsSync(source.path))
984
+ continue;
985
+ // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
986
+ const project = source.name.split(" — ")[0]?.trim() || undefined;
987
+ // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
988
+ const result = importLearningsFromFile(source.path, "other", project);
989
+ totalImported += result.imported;
990
+ totalUpdated += result.updated;
991
+ totalIgnored += result.ignored;
992
+ if (result.imported > 0 || result.updated > 0)
993
+ processed++;
994
+ }
995
+ });
996
+ return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored };
668
997
  }
669
998
  /**
670
999
  * Get the store stats.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
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",