@compr/opscontext-mcp 2.5.6 → 2.5.8

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,47 @@ 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.8] — 2026-09-05 — "rules" is not a learnings heading
8
+
9
+ ### Fixed
10
+
11
+ - `LEARNINGS_HEADING` no longer matches the word "rules". "## Key rules" and "### Security Rules"
12
+ are ordinary doc sections; on the evening 2.5.7 shipped the word let 231 subsection headings back
13
+ into the store. "## Essential Rules, distilled from Learnings" still qualifies through "learnings".
14
+ Replayed from a foreign cwd with the default patterns over ~/Projects: 99 records from 819 sources.
15
+
16
+ ## [2.5.7] — 2026-09-05 — The learnings store was a pile of headings
17
+
18
+ Of 3,005 records, about 2,760 had been produced by the doc importer from ~160 ordinary docs
19
+ (copilot-instructions, session docs, Claude memory files) and about 240 by an agent calling
20
+ `save_learning`. In a spread sample of 70 imported records, 32 were not rules at all ("Design
21
+ Language:", "Session 24 TODO", "Files created (Phase 1 foundation)"), and the category of the rest
22
+ came from a section title or a substring guess: "Flow A" under mobile, "Scoring internals are trade
23
+ secrets" under mobile because "expose" contains "expo".
24
+
25
+ ### Changed
26
+
27
+ - **Auto-import takes only marked learnings** (`[AUTO-IMPORT-ONLY-MARKED-LEARNINGS]`): inline
28
+ `- [category] rule → context` bullets anywhere; every shape inside a `*LEARNINGS.md` file; every
29
+ shape under a heading that says learnings / lessons / gotchas / pitfalls / anti-patterns /
30
+ "never repeat" / "the hard way"; JSON. Bare H3 headings, bold bullets and table rows in ordinary
31
+ docs are reported as `ignored` and left alone; the docs stay searchable as docs. Replayed over the
32
+ 818 discovered sources: 129 records instead of 1,879. `import_learnings` gains `permissive: true`
33
+ and the CLI `--permissive` for a file the user chose. Imported records carry `source`.
34
+ - **Category inference scores whole words** (`[CATEGORY-BY-WHOLE-WORD-SCORE]`): every whole-word or
35
+ whole-phrase match counts, rule text weighs double the context, highest total wins, ties go to the
36
+ more specific category, no match is `other`. Measured: 11% → 27.5% agreement on 189 agent-labelled
37
+ records, 25% → 50% on 84 hand-labelled rules (`scripts/measure-categories.mjs`; the sample stays
38
+ outside the repo). `normalizeCategory()` no longer maps "Apple App Store" to api on a prefix.
39
+ - New default source patterns: `AGENT-LEARNINGS.md`, `docs/AGENT-LEARNINGS.md`, `LEARNINGS.md`,
40
+ `docs/LEARNINGS.md`.
41
+
42
+ ### Added
43
+
44
+ - `scripts/learnings-prune.mjs <plan.json> [--apply]`: dry run by default, backup before any
45
+ delete, one batched write under the store lock. The 2026-09-05 plan lists 2,635 import-derived
46
+ records to remove (370 kept), pending the owner's GO.
47
+
7
48
  ## [2.4.3] — 2026-08-17 — The audit verifier called concurrency "tampering" and condemned 316k records
8
49
 
9
50
  `audit-verify` reported `❌ Audit chain BROKEN at index 2826 (of 319438)` and told the user the
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;
@@ -31,7 +33,7 @@ export declare function __writeStoreForTests(store: LearningsStore): void;
31
33
  * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
32
34
  */
33
35
  export declare function saveLearning(...args: Parameters<typeof saveLearningUnlocked>): Learning;
34
- declare function saveLearningUnlocked(category: string, rule: string, context: string, project?: string): Learning;
36
+ declare function saveLearningUnlocked(category: string, rule: string, context: string, project?: string, source?: string): Learning;
35
37
  /**
36
38
  * Search learnings by keyword. Returns matches sorted by relevance.
37
39
  */
@@ -74,9 +76,23 @@ export interface ImportResult {
74
76
  imported: number;
75
77
  updated: number;
76
78
  skipped: number;
79
+ /** Candidates the import rule left alone: headings, bold bullets and table rows outside a learnings scope. */
80
+ ignored: number;
77
81
  errors: string[];
78
82
  }
79
- 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;
80
96
  /**
81
97
  * Convert learnings to Chunks so they can be included in search_context.
82
98
  * This is the key integration — learnings auto-surface in hybrid search.
@@ -103,6 +119,7 @@ export declare function autoImportFromSources(sources: Array<{
103
119
  total: number;
104
120
  imported: number;
105
121
  updated: number;
122
+ ignored: number;
106
123
  };
107
124
  /**
108
125
  * Get the store stats.
package/dist/learnings.js CHANGED
@@ -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",
@@ -225,8 +225,13 @@ function readStoreFromDisk() {
225
225
  safeAppend("learning.store_unreadable", { path: LEARNINGS_PATH, bytes: raw.length, kept: keep, error: String(e?.message || e) });
226
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
227
  }
228
- // Filter out corrupted entries missing required 'rule' field
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).
229
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";
234
+ }
230
235
  }
231
236
  else {
232
237
  store = { version: 1, count: 0, learnings: [] };
@@ -330,7 +335,7 @@ const MIN_RULE_LENGTH = 15;
330
335
  export function saveLearning(...args) {
331
336
  return withStoreLock(() => saveLearningUnlocked(...args));
332
337
  }
333
- function saveLearningUnlocked(category, rule, context, project) {
338
+ function saveLearningUnlocked(category, rule, context, project, source) {
334
339
  const trimmedRule = rule.trim();
335
340
  // Quality gate: reject junk rules
336
341
  if (trimmedRule.length < MIN_RULE_LENGTH) {
@@ -352,12 +357,23 @@ function saveLearningUnlocked(category, rule, context, project) {
352
357
  const existing = store.learnings.find((l) => l.category === category &&
353
358
  typeof l.rule === "string" && l.rule.toLowerCase().trim() === ruleLower);
354
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
+ }
355
369
  // Update existing learning with new context
356
370
  existing.context = context;
357
371
  existing.updated = now;
358
372
  if (project)
359
373
  existing.project = project;
360
- existing.tags = extractTags(existing.rule, context, category);
374
+ if (source)
375
+ existing.source = source;
376
+ existing.tags = newTags;
361
377
  saveStore(store);
362
378
  safeAppend("learning.save", {
363
379
  id: existing.id,
@@ -378,6 +394,8 @@ function saveLearningUnlocked(category, rule, context, project) {
378
394
  created: now,
379
395
  updated: now,
380
396
  };
397
+ if (source)
398
+ learning.source = source;
381
399
  store.learnings.push(learning);
382
400
  saveStore(store);
383
401
  safeAppend("learning.save", {
@@ -444,7 +462,7 @@ export function listLearnings(category, projects) {
444
462
  result = result.filter((l) => !l.project || lowerProjects.includes(l.project.toLowerCase()));
445
463
  }
446
464
  if (category) {
447
- result = result.filter((l) => l.category.toLowerCase() === category.toLowerCase());
465
+ result = result.filter((l) => String(l.category || "other").toLowerCase() === category.toLowerCase());
448
466
  }
449
467
  return result;
450
468
  }
@@ -470,16 +488,39 @@ function deleteLearningUnlocked(id) {
470
488
  });
471
489
  return true;
472
490
  }
473
- 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 /
505
+ // anti-patterns / "never repeat" / "the hard way" / mistakes (LEARNINGS_HEADING).
506
+ // Not "rules": "## Key rules" and "### Security Rules" are ordinary doc sections, and
507
+ // the word let 231 subsection headings back in on the evening this shipped;
508
+ // (4) JSON files, which are explicit by construction.
509
+ // `permissive: true` (MCP `import_learnings`, CLI `--permissive`) restores the old parser for
510
+ // a file the user chose on purpose. Every imported record now carries `source`.
511
+ export const LEARNINGS_FILE_NAME = /learnings?\.md$/i;
512
+ 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;
513
+ export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject, opts = {}) {
474
514
  if (!existsSync(filePath)) {
475
- return { imported: 0, updated: 0, skipped: 0, errors: [`File not found: ${filePath}`] };
515
+ return { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [`File not found: ${filePath}`] };
476
516
  }
477
517
  const content = readFileSync(filePath, "utf-8");
478
518
  const ext = filePath.split(".").pop()?.toLowerCase();
519
+ const permissive = opts.permissive === true || LEARNINGS_FILE_NAME.test(filePath.split("/").pop() || "");
479
520
  // One load, one save for the whole file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
480
521
  const result = withStoreBatch(() => ext === "json"
481
- ? importFromJson(content, defaultProject)
482
- : importFromMarkdown(content, defaultCategory, defaultProject));
522
+ ? importFromJson(content, defaultProject, filePath)
523
+ : importFromMarkdown(content, defaultCategory, defaultProject, { permissive, source: filePath }));
483
524
  // Aggregate event correlating the individual learning.save records emitted
484
525
  // inside the loop. Useful for compliance attribution: "this batch came from
485
526
  // file X".
@@ -490,12 +531,13 @@ export function importLearningsFromFile(filePath, defaultCategory = "other", def
490
531
  imported: result.imported,
491
532
  updated: result.updated,
492
533
  skipped: result.skipped,
534
+ ignored: result.ignored,
493
535
  errors: result.errors.length,
494
536
  });
495
537
  return result;
496
538
  }
497
- function importFromJson(content, defaultProject) {
498
- const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
539
+ function importFromJson(content, defaultProject, source) {
540
+ const result = { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [] };
499
541
  try {
500
542
  const data = JSON.parse(content);
501
543
  const items = Array.isArray(data)
@@ -517,7 +559,7 @@ function importFromJson(content, defaultProject) {
517
559
  const store = loadStore();
518
560
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === item.rule.toLowerCase().trim());
519
561
  try {
520
- saveLearning(cat, item.rule, item.context || "", item.project || defaultProject);
562
+ saveLearning(cat, item.rule, item.context || "", item.project || defaultProject, source);
521
563
  if (existing) {
522
564
  result.updated++;
523
565
  }
@@ -535,12 +577,26 @@ function importFromJson(content, defaultProject) {
535
577
  }
536
578
  return result;
537
579
  }
538
- function importFromMarkdown(content, defaultCategory, defaultProject) {
539
- const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
580
+ function importFromMarkdown(content, defaultCategory, defaultProject, opts = { permissive: false }) {
581
+ const result = { imported: 0, updated: 0, skipped: 0, ignored: 0, errors: [] };
540
582
  const lines = content.split("\n");
541
583
  let currentCategory = defaultCategory;
542
584
  let currentRule = "";
543
585
  let currentContext = [];
586
+ // Learnings scope, [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]: unmarked shapes (H3, bold bullet,
587
+ // table row) count as rules only inside it. Three nested levels: the whole file (permissive,
588
+ // learnings file name, or an H1 that says so), an H2 section, an H3 subsection.
589
+ let fileScope = opts.permissive;
590
+ let h2Scope = false;
591
+ let h3Scope = false;
592
+ const inScope = () => fileScope || h2Scope || h3Scope;
593
+ // A candidate that arrives outside the scope is counted and dropped, never queued.
594
+ function candidate(text) {
595
+ if (inScope())
596
+ currentRule = text;
597
+ else
598
+ result.ignored++;
599
+ }
544
600
  function flushRule() {
545
601
  if (!currentRule)
546
602
  return;
@@ -556,7 +612,7 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
556
612
  const store = loadStore();
557
613
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === currentRule.toLowerCase().trim());
558
614
  try {
559
- saveLearning(cat, currentRule, ctx, defaultProject);
615
+ saveLearning(cat, currentRule, ctx, defaultProject, opts.source);
560
616
  if (existing) {
561
617
  result.updated++;
562
618
  }
@@ -572,24 +628,33 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
572
628
  }
573
629
  for (const line of lines) {
574
630
  const trimmed = line.trim();
575
- // H1 — file title, skip
576
- if (trimmed.startsWith("# ") && !trimmed.startsWith("## "))
631
+ // H1 — file title, skip; a title that says learnings puts the whole file in scope
632
+ if (trimmed.startsWith("# ") && !trimmed.startsWith("## ")) {
633
+ if (LEARNINGS_HEADING.test(trimmed.slice(2)))
634
+ fileScope = true;
577
635
  continue;
636
+ }
578
637
  // H2 — category (e.g., "## deployment" or "## Security & Server Administration")
579
638
  if (trimmed.startsWith("## ")) {
580
639
  flushRule();
581
640
  const heading = trimmed.replace(/^##\s+/, "").toLowerCase().trim();
582
641
  currentCategory = heading;
642
+ h2Scope = LEARNINGS_HEADING.test(heading);
643
+ h3Scope = false;
583
644
  continue;
584
645
  }
585
- // H3 — rule (e.g., "### Never docker build | tee")
646
+ // H3 — rule (e.g., "### Never docker build | tee"), or a subsection that says learnings
586
647
  if (trimmed.startsWith("### ")) {
587
648
  flushRule();
588
- const candidate = trimmed.replace(/^###\s+/, "").trim();
589
- // Quality filter: skip short headings ("Fix", "UI", "DB")
590
- if (candidate.length >= MIN_RULE_LENGTH) {
591
- currentRule = candidate;
649
+ h3Scope = false; // an H3 subsection ends at the next H3
650
+ const text = trimmed.replace(/^###\s+/, "").trim();
651
+ if (!inScope() && LEARNINGS_HEADING.test(text)) {
652
+ h3Scope = true; // "### Lessons learned" opens a scope; the heading itself is not a rule
653
+ continue;
592
654
  }
655
+ // Quality filter: skip short headings ("Fix", "UI", "DB")
656
+ if (text.length >= MIN_RULE_LENGTH)
657
+ candidate(text);
593
658
  continue;
594
659
  }
595
660
  // H4+ — sub-rule, treat as context for current rule
@@ -607,18 +672,19 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
607
672
  currentCategory = cat;
608
673
  // Split on → or — for rule/context separation
609
674
  const sepMatch = rest.match(/^(.+?)(?:\s*[→—]\s*|\s+[-–]\s+)(.+)$/);
675
+ // Marked by its author: imported in every mode. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
610
676
  if (sepMatch) {
611
- const candidate = sepMatch[1].trim();
612
- if (candidate.length >= MIN_RULE_LENGTH) {
613
- currentRule = candidate;
677
+ const text = sepMatch[1].trim();
678
+ if (text.length >= MIN_RULE_LENGTH) {
679
+ currentRule = text;
614
680
  currentContext = [sepMatch[2].trim()];
615
681
  flushRule();
616
682
  }
617
683
  }
618
684
  else {
619
- const candidate = rest.trim();
620
- if (candidate.length >= MIN_RULE_LENGTH) {
621
- currentRule = candidate;
685
+ const text = rest.trim();
686
+ if (text.length >= MIN_RULE_LENGTH) {
687
+ currentRule = text;
622
688
  flushRule();
623
689
  }
624
690
  }
@@ -628,11 +694,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
628
694
  const tableMatch = trimmed.match(/^\|\s*\*\*(.+?)\*\*\s*\|(.+)\|(.+)\|/);
629
695
  if (tableMatch) {
630
696
  flushRule();
631
- const candidate = tableMatch[1].trim();
632
- if (candidate.length >= MIN_RULE_LENGTH) {
633
- currentRule = candidate;
634
- currentContext = [tableMatch[2].trim() + " — " + tableMatch[3].trim()];
635
- flushRule();
697
+ const text = tableMatch[1].trim();
698
+ if (text.length >= MIN_RULE_LENGTH) {
699
+ candidate(text);
700
+ if (currentRule) {
701
+ currentContext = [tableMatch[2].trim() + " — " + tableMatch[3].trim()];
702
+ flushRule();
703
+ }
636
704
  }
637
705
  continue;
638
706
  }
@@ -642,13 +710,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
642
710
  flushRule();
643
711
  const boldMatch = trimmed.match(/^[-*]\s+\*\*(.+?)\*\*\s*(.*)$/);
644
712
  if (boldMatch) {
645
- const candidate = boldMatch[1].trim();
713
+ const text = boldMatch[1].trim();
646
714
  // Quality filter: skip short/single-word headings
647
- if (candidate.length < MIN_RULE_LENGTH) {
715
+ if (text.length < MIN_RULE_LENGTH) {
648
716
  continue;
649
717
  }
650
- currentRule = candidate;
651
- if (boldMatch[2]) {
718
+ candidate(text);
719
+ if (currentRule && boldMatch[2]) {
652
720
  // Strip leading separators
653
721
  currentContext = [boldMatch[2].replace(/^[\s—→:]+/, "").trim()];
654
722
  }
@@ -670,117 +738,195 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
670
738
  flushRule(); // Flush last rule
671
739
  return result;
672
740
  }
673
- /** Infer a category from rule text + context when "other" is provided */
674
- function inferCategory(rule, context) {
675
- const text = `${rule} ${context}`.toLowerCase();
676
- const keywords = {
677
- "deploy": "deployment", "rsync": "deployment", "publish": "deployment", "release": "deployment",
678
- "ci/cd": "devops", "ci cd": "devops", "pipeline": "devops", "github actions": "devops", "docker": "devops",
679
- "nginx": "infrastructure", "ssl": "infrastructure", "server": "infrastructure", "pm2": "infrastructure", "vps": "infrastructure",
680
- "api": "api", "endpoint": "api", "rest": "api", "graphql": "api", "webhook": "api",
681
- "sql": "database", "sqlite": "database", "mysql": "database", "postgres": "database", "query": "database", "migration": "database",
682
- "react": "frontend", "vue": "frontend", "css": "frontend", "html": "frontend", "dom": "frontend", "component": "frontend", "ui": "frontend",
683
- "express": "backend", "node": "backend", "flask": "backend", "middleware": "backend",
684
- "auth": "security", "cors": "security", "xss": "security", "csrf": "security", "helmet": "security", "encrypt": "security", "password": "security",
685
- "test": "testing", "vitest": "testing", "jest": "testing", "spec": "testing", "assert": "testing",
686
- "debug": "debugging", "error": "debugging", "stack trace": "debugging", "breakpoint": "debugging", "log": "debugging",
687
- "npm": "dependencies", "package": "dependencies", "yarn": "dependencies", "pnpm": "dependencies", "version": "dependencies",
688
- "git": "git", "commit": "git", "branch": "git", "merge": "git", "rebase": "git",
689
- "perf": "performance", "latency": "performance", "cache": "performance", "optimize": "performance",
690
- "eslint": "tooling", "lint": "tooling", "prettier": "tooling", "vscode": "tooling", "editor": "tooling",
691
- "pattern": "architecture", "refactor": "architecture", "module": "architecture", "design": "architecture",
692
- "ios": "mobile", "android": "mobile", "expo": "mobile", "react native": "mobile",
693
- };
694
- for (const [keyword, cat] of Object.entries(keywords)) {
695
- if (text.includes(keyword))
696
- return cat;
741
+ // [LOCKED] [CATEGORY-BY-WHOLE-WORD-SCORE] 2026-09-05
742
+ // [NEVER] go back to a first-hit `text.includes(keyword)` over an unanchored substring list,
743
+ // in inferCategory() or in normalizeCategory().
744
+ // WHY: measured 2026-09-05 (Session 25) on 189 store records whose category an agent had
745
+ // chosen by hand: 21 correct, 11%. "expose" matched "expo" (mobile), "access" matched
746
+ // "css" (frontend), "restart" matched "rest" (api), "build" matched "ui", "login" matched
747
+ // "log" (debugging), and the FIRST hit won whatever the rest of the text said, so
748
+ // "Scoring internals are trade secrets, don't expose point values" was filed under mobile.
749
+ // FIX: whole-word and whole-phrase matches only; every match counts; a match in the rule text
750
+ // weighs double a match in the context; the highest total wins; ties go to the more
751
+ // specific category (CATEGORY_TIE_ORDER); no match at all is "other", never a guess.
752
+ // Regression floors in src/learnings-category.test.ts against tests/fixtures/category-labels.json.
753
+ /** Terms per category. Single words match as whole tokens, phrases as whole phrases. */
754
+ const CATEGORY_TERMS = {
755
+ deployment: ["deploy", "deploys", "deployed", "deploying", "deployment", "deployments", "rsync",
756
+ "scp", "publish", "published", "publishing", "release", "releases", "released", "rollout",
757
+ "rollback", "ship", "shipped", "shipping", "go live", "go-live", "cutover", "staging",
758
+ "production", "prod", "tarball", "npm publish", "verify-release", "preflight", "hotfix",
759
+ "live-verify"],
760
+ devops: ["ci", "ci/cd", "cicd", "pipeline", "pipelines", "github actions", "workflow",
761
+ "workflows", "docker", "dockerfile", "container", "containers", "compose", "kubernetes", "k8s",
762
+ "cron", "crontab", "launchd", "scheduler", "scheduled", "automation", "automated",
763
+ "orchestration"],
764
+ infrastructure: ["nginx", "apache", "ssl", "tls", "certificate", "certificates", "letsencrypt",
765
+ "server", "servers", "vps", "pm2", "ssh", "dns", "domain", "domains", "firewall", "ufw",
766
+ "fail2ban", "systemd", "backup", "backups", "restore", "disk", "ovh", "gandi", "hosting",
767
+ "smtp", "cloudflare", "proxy", "reverse proxy", "load balancer", "uptime", "monitoring", "ram",
768
+ "cpu", "swap", "reboot", "restart", "restarted", "daemon",
769
+ "box", "machine", "process", "processes", "host", "hosts"],
770
+ api: ["api", "apis", "endpoint", "endpoints", "rest", "graphql", "webhook", "webhooks", "route",
771
+ "routes", "router", "request", "requests", "response", "responses", "http", "https",
772
+ "status code", "payload", "rate limit", "rate-limit", "throttle", "throttling", "header",
773
+ "headers", "url", "urls", "fetch", "axios", "curl", "openapi", "swagger"],
774
+ database: ["sql", "sqlite", "mysql", "postgres", "postgresql", "mongodb", "mongo", "mongoose",
775
+ "query", "queries", "migration", "migrations", "schema", "table", "tables", "column", "columns",
776
+ "collection", "collections", "aggregate", "redis", "orm", "sqlalchemy", "prisma", "eloquent",
777
+ "transaction", "transactions", "row", "rows", "db", "database", "databases", "pg_dump",
778
+ "setval", "primary key", "foreign key", "upsert", "insert",
779
+ "index", "indexes", "join", "select", "sequence", "dump"],
780
+ frontend: ["react", "vue", "svelte", "css", "html", "dom", "component", "components", "ui", "ux",
781
+ "jsx", "tsx", "tailwind", "vite", "webpack", "render", "renders", "rendering", "rendered",
782
+ "page", "pages", "button", "buttons", "modal", "chip", "chips", "localstorage", "browser",
783
+ "usestate", "useeffect", "spinner", "layout", "responsive", "widget", "widgets", "form",
784
+ "forms", "click", "scroll", "font", "fonts", "color", "colors", "colour", "colours", "contrast",
785
+ "display", "screen", "screens", "frontend", "front-end", "pwa", "service worker", "bundle",
786
+ "hydration"],
787
+ backend: ["express", "node", "nodejs", "flask", "fastapi", "django", "laravel", "php", "python",
788
+ "middleware", "uvicorn", "gunicorn", "worker", "workers", "queue", "queues", "controller",
789
+ "controllers", "service", "services", "artisan", "i18n", "server-side", "backend", "back-end",
790
+ "handler", "handlers", "model", "models", "trait", "setdefault", "asyncio", "celery",
791
+ "cache_key"],
792
+ security: ["auth", "authentication", "authorization", "oauth", "jwt", "token", "tokens", "cors",
793
+ "xss", "csrf", "helmet", "encrypt", "encrypted", "encryption", "password", "passwords",
794
+ "passkey", "passkeys", "webauthn", "credential", "credentials", "secret", "secrets", "vault",
795
+ "permission", "permissions", "tenant", "isolation", "rbac", "hash", "hashed", "injection",
796
+ "sanitize", "sanitise", "vulnerability", "vulnerabilities", "cve", "exposed", "expose",
797
+ "cookie", "cookies", "login", "logout", "signin", "sign-in", "2fa", "mfa", "otp", "magic code",
798
+ "allowlist", "whitelist", "trade secret", "trade secrets", "lockout",
799
+ "origin", "leak", "leaks", "leaked"],
800
+ performance: ["perf", "performance", "latency", "cache", "cached", "caching", "optimize",
801
+ "optimise", "optimization", "optimisation", "slow", "slower", "bottleneck", "bottlenecks",
802
+ "throughput", "memory leak", "n+1", "benchmark", "loop invariant", "nested loop", "timeout",
803
+ "timeouts", "concurrency", "batch size",
804
+ "parallel", "expensive"],
805
+ testing: ["test", "tests", "testing", "tested", "vitest", "jest", "pytest", "spec", "specs",
806
+ "assert", "assertion", "assertions", "mock", "mocks", "mocked", "fixture", "fixtures", "e2e",
807
+ "end-to-end", "headless", "playwright", "cypress", "test suite", "regression", "tdd", "green",
808
+ "red", "smoke", "smoke test", "collect", "collected", "harness", "canary"],
809
+ debugging: ["debug", "debugging", "error", "errors", "stack trace", "traceback", "breakpoint",
810
+ "log", "logs", "logging", "diagnose", "diagnosis", "diagnostic", "diagnostics", "symptom",
811
+ "symptoms", "crash", "crashes", "crashed", "hang", "hangs", "freeze", "frozen", "root cause",
812
+ "reproduce", "repro", "bug", "bugs", "silent", "silently", "off-by-one", "stale",
813
+ "wrong", "invisible"],
814
+ tooling: ["eslint", "lint", "linter", "prettier", "vscode", "vs code", "editor", "cli", "script",
815
+ "scripts", "shell", "bash", "zsh", "terminal", "claude code", "agent", "agents", "subagent",
816
+ "subagents", "mcp", "extension", "plugin", "plugins", "tsc", "compiler", "formatter",
817
+ "makefile", "pipefail", "set -e", "grep", "sed", "regex", "quoting", "command", "commands",
818
+ "flag", "flags", "dry run", "dry-run", "--check", "prompt", "prompts", "transcript",
819
+ "transcripts", "copilot"],
820
+ git: ["git", "commit", "commits", "committed", "branch", "branches", "merge", "merged", "rebase",
821
+ "push", "pushed", "pull", "pull request", "pr", "prs", "checkout", "stash", "cherry-pick",
822
+ "no-verify", "--no-verify", "pre-commit", "post-commit", "pre-push", "post-push", "gitignore",
823
+ ".gitignore", "git push", "git pull", "bare repo", "worktree", "revert", "squash",
824
+ "history", "remote", "remotes", "tag", "tags", "conflict", "conflicts", "hook", "hooks"],
825
+ dependencies: ["npm", "package", "packages", "yarn", "pnpm", "pip", "composer", "dependency",
826
+ "dependencies", "upgrade", "upgraded", "semver", "lockfile", "package.json", "node_modules",
827
+ "requirements.txt", "sdk", "pubspec", "peer dependency", "bump", "bumped", "outdated", "npx",
828
+ "version", "versions", "install", "installed", "pin", "pinned", "pinning"],
829
+ architecture: ["pattern", "patterns", "refactor", "refactoring", "module", "modules", "design",
830
+ "architecture", "single source of truth", "coupling", "boundary", "boundaries", "abstraction",
831
+ "interface", "interfaces", "layer", "layers", "event bus", "invariant", "invariants", "guard",
832
+ "guards", "contract", "contracts", "decision", "decisions", "encode", "encoded", "absence",
833
+ "unknown", "responsibility", "coupled", "decoupled",
834
+ "trace", "structure", "structural"],
835
+ data: ["csv", "dataset", "datasets", "data", "categoriser", "categorizer", "categorisation",
836
+ "categorization", "taxonomy", "parse", "parser", "parsed", "encoding", "unicode", "nfc", "nfd",
837
+ "dedup", "deduplicate", "normalization", "normalisation", "etl", "classifier", "verdict",
838
+ "verdicts", "denominator", "nutri-score", "catalog", "catalogue", "spreadsheet", "excel",
839
+ "count", "counts", "figure", "figures", "json", "product", "products", "field", "fields", "label", "labels", "labelled", "coverage", "metric", "metrics", "import", "imports", "export", "exports", "record", "records"],
840
+ mobile: ["ios", "android", "expo", "react native", "flutter", "dart", "swift", "kotlin", "xcode",
841
+ "app store", "play store", "google play", "testflight", "apk", "aab", "ipa", "riverpod",
842
+ "app store connect", "simulator", "emulator", "mobile", "gradle", "cocoapods", "pod", "pods",
843
+ "mainactivity", "flutterfragmentactivity", "flutteractivity", "revenuecat", "subscription",
844
+ "subscriptions", "guideline", "review team", "samsung", "iphone", "device", "devices",
845
+ "widget tree"],
846
+ };
847
+ /** Unambiguous technology names: one occurrence outweighs two generic words. */
848
+ const STRONG_TERMS = new Set([
849
+ "rsync", "docker", "dockerfile", "kubernetes", "nginx", "fail2ban", "ufw", "pm2", "letsencrypt",
850
+ "graphql", "webhook", "webhooks", "endpoint", "endpoints", "sqlite", "mysql", "postgres", "postgresql",
851
+ "mongodb", "mongoose", "sqlalchemy", "prisma", "eloquent", "pg_dump", "react", "vue", "svelte",
852
+ "tailwind", "usestate", "useeffect", "localstorage", "express", "flask", "fastapi", "django", "laravel",
853
+ "uvicorn", "gunicorn", "artisan", "jwt", "csrf", "xss", "webauthn", "passkey", "passkeys", "oauth",
854
+ "vitest", "jest", "pytest", "playwright", "cypress", "stack trace", "traceback", "eslint", "prettier",
855
+ "vscode", "vs code", "rebase", "cherry-pick", "no-verify", "--no-verify", "pre-commit", "gitignore",
856
+ "npm", "yarn", "pnpm", "pip", "composer", "semver", "package.json", "node_modules", "csv", "unicode",
857
+ "flutter", "dart", "swift", "kotlin", "xcode", "testflight", "apk", "aab", "ipa", "riverpod", "expo",
858
+ "react native", "app store", "play store", "google play", "app store connect", "pubspec",
859
+ "single source of truth", "n+1", "memory leak", "loop invariant", "github actions", "trade secret",
860
+ "trade secrets", "git push", "git pull", "pull request", "mongo", "redis", "migration", "migrations",
861
+ ]);
862
+ /** When two categories tie, the earlier one wins: the more specific before the more generic. */
863
+ const CATEGORY_TIE_ORDER = [
864
+ "mobile", "database", "security", "git", "testing", "deployment", "api", "devops", "infrastructure",
865
+ "frontend", "backend", "performance", "dependencies", "data", "tooling", "debugging", "architecture",
866
+ ];
867
+ function normalizeForMatch(text) {
868
+ // Lowercase; every run of characters outside [a-z0-9+#./_-] becomes one space, so a term like
869
+ // "ci/cd", "n+1", "--no-verify" or "package.json" survives as a phrase, and word boundaries
870
+ // become spaces. Padded with spaces so a term can be looked up as " term ".
871
+ return " " + text.toLowerCase().replace(/[^a-z0-9+#./_-]+/g, " ").trim() + " ";
872
+ }
873
+ /** Whole-word / whole-phrase occurrence check on a normalised string. */
874
+ function hasTerm(normalized, term) {
875
+ return normalized.includes(` ${term} `);
876
+ }
877
+ /** Score every category over rule (x2) and context (x1); the caller picks the winner. */
878
+ export function scoreCategories(rule, context) {
879
+ const r = normalizeForMatch(rule);
880
+ const c = normalizeForMatch(context || "");
881
+ const scores = new Map();
882
+ for (const [cat, terms] of Object.entries(CATEGORY_TERMS)) {
883
+ let s = 0;
884
+ for (const term of terms) {
885
+ const w = STRONG_TERMS.has(term) ? 2 : 1;
886
+ if (hasTerm(r, term))
887
+ s += 2 * w;
888
+ else if (hasTerm(c, term))
889
+ s += w;
890
+ }
891
+ if (s > 0)
892
+ scores.set(cat, s);
697
893
  }
698
- return "other";
894
+ return scores;
699
895
  }
700
- /** Map free-form heading text to closest LEARNING_CATEGORIES value */
701
- function normalizeCategory(heading) {
702
- const h = heading.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
703
- // Direct match
896
+ /** Infer a category from rule text + context. "other" only when nothing matches at all. */
897
+ export function inferCategory(rule, context) {
898
+ const scores = scoreCategories(rule, context);
899
+ let best = "other";
900
+ let bestScore = 0;
901
+ for (const cat of CATEGORY_TIE_ORDER) {
902
+ const s = scores.get(cat) || 0;
903
+ if (s > bestScore) {
904
+ best = cat;
905
+ bestScore = s;
906
+ }
907
+ }
908
+ return best;
909
+ }
910
+ /** Map free-form heading text to the closest LEARNING_CATEGORIES value. */
911
+ export function normalizeCategory(heading) {
912
+ const h = normalizeForMatch(heading);
913
+ // A heading that IS a category name ("## deployment", "## Testing") maps directly.
704
914
  for (const cat of LEARNING_CATEGORIES) {
705
- if (h === cat || h.startsWith(cat))
915
+ if (h.trim() === cat)
706
916
  return cat;
707
917
  }
708
- // Keyword mapping
709
- const map = {
710
- "deploy": "deployment",
711
- "ci/cd": "devops",
712
- "ci cd": "devops",
713
- "pipeline": "devops",
714
- "docker": "devops",
715
- "nginx": "infrastructure",
716
- "server": "infrastructure",
717
- "hosting": "infrastructure",
718
- "ssl": "security",
719
- "cors": "security",
720
- "auth": "security",
721
- "malware": "security",
722
- "hack": "security",
723
- "hardening": "security",
724
- "terminal": "tooling",
725
- "command": "tooling",
726
- "monitoring": "tooling",
727
- "vs code": "tooling",
728
- "test": "testing",
729
- "jest": "testing",
730
- "spec": "testing",
731
- "debug": "debugging",
732
- "bug": "debugging",
733
- "fix": "debugging",
734
- "react": "frontend",
735
- "vue": "frontend",
736
- "css": "frontend",
737
- "ui": "frontend",
738
- "laravel": "backend",
739
- "django": "backend",
740
- "flask": "backend",
741
- "express": "backend",
742
- "mysql": "database",
743
- "postgres": "database",
744
- "sql": "database",
745
- "migration": "database",
746
- "npm": "dependencies",
747
- "composer": "dependencies",
748
- "pip": "dependencies",
749
- "package": "dependencies",
750
- "git": "git",
751
- "commit": "git",
752
- "branch": "git",
753
- "hook": "git",
754
- "perf": "performance",
755
- "speed": "performance",
756
- "cache": "performance",
757
- "mobile": "mobile",
758
- "expo": "mobile",
759
- "flutter": "mobile",
760
- "react native": "mobile",
761
- "swift": "mobile",
762
- "pattern": "architecture",
763
- "design": "architecture",
764
- "struct": "architecture",
765
- "data type": "data",
766
- "csv": "data",
767
- "import": "data",
768
- "export": "data",
769
- "api": "api",
770
- "endpoint": "api",
771
- "rest": "api",
772
- "smtp": "infrastructure",
773
- "email": "infrastructure",
774
- "queue": "infrastructure",
775
- "audit": "security",
776
- "version": "dependencies",
777
- "upgrade": "dependencies",
778
- };
779
- for (const [keyword, cat] of Object.entries(map)) {
780
- if (h.includes(keyword))
918
+ // A few heading words that the term lists do not carry as rule vocabulary.
919
+ const headingWords = [
920
+ ["lessons", "other"], ["learnings", "other"], ["gotchas", "other"],
921
+ ["hardening", "security"], ["malware", "security"], ["audit", "security"],
922
+ ["terminal", "tooling"], ["commands", "tooling"], ["monitoring", "infrastructure"],
923
+ ["bugs", "debugging"], ["fixes", "debugging"], ["speed", "performance"],
924
+ ];
925
+ for (const [word, cat] of headingWords) {
926
+ if (hasTerm(h, word))
781
927
  return cat;
782
928
  }
783
- return "other";
929
+ return inferCategory(heading, "");
784
930
  }
785
931
  /**
786
932
  * Convert learnings to Chunks so they can be included in search_context.
@@ -827,6 +973,7 @@ export function learningsToChunks(projects) {
827
973
  export function autoImportFromSources(sources) {
828
974
  let totalImported = 0;
829
975
  let totalUpdated = 0;
976
+ let totalIgnored = 0;
830
977
  let processed = 0;
831
978
  // One load and one save for the whole sweep (~880 files), instead of one full-file
832
979
  // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
@@ -839,14 +986,16 @@ export function autoImportFromSources(sources) {
839
986
  continue;
840
987
  // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
841
988
  const project = source.name.split(" — ")[0]?.trim() || undefined;
989
+ // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
842
990
  const result = importLearningsFromFile(source.path, "other", project);
843
991
  totalImported += result.imported;
844
992
  totalUpdated += result.updated;
993
+ totalIgnored += result.ignored;
845
994
  if (result.imported > 0 || result.updated > 0)
846
995
  processed++;
847
996
  }
848
997
  });
849
- return { total: processed, imported: totalImported, updated: totalUpdated };
998
+ return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored };
850
999
  }
851
1000
  /**
852
1001
  * Get the store stats.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.6",
3
+ "version": "2.5.8",
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",