@compr/opscontext-mcp 2.5.6 → 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/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,37 @@ 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 / 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 = {}) {
474
512
  if (!existsSync(filePath)) {
475
- 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}`] };
476
514
  }
477
515
  const content = readFileSync(filePath, "utf-8");
478
516
  const ext = filePath.split(".").pop()?.toLowerCase();
517
+ const permissive = opts.permissive === true || LEARNINGS_FILE_NAME.test(filePath.split("/").pop() || "");
479
518
  // One load, one save for the whole file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
480
519
  const result = withStoreBatch(() => ext === "json"
481
- ? importFromJson(content, defaultProject)
482
- : importFromMarkdown(content, defaultCategory, defaultProject));
520
+ ? importFromJson(content, defaultProject, filePath)
521
+ : importFromMarkdown(content, defaultCategory, defaultProject, { permissive, source: filePath }));
483
522
  // Aggregate event correlating the individual learning.save records emitted
484
523
  // inside the loop. Useful for compliance attribution: "this batch came from
485
524
  // file X".
@@ -490,12 +529,13 @@ export function importLearningsFromFile(filePath, defaultCategory = "other", def
490
529
  imported: result.imported,
491
530
  updated: result.updated,
492
531
  skipped: result.skipped,
532
+ ignored: result.ignored,
493
533
  errors: result.errors.length,
494
534
  });
495
535
  return result;
496
536
  }
497
- function importFromJson(content, defaultProject) {
498
- 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: [] };
499
539
  try {
500
540
  const data = JSON.parse(content);
501
541
  const items = Array.isArray(data)
@@ -517,7 +557,7 @@ function importFromJson(content, defaultProject) {
517
557
  const store = loadStore();
518
558
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === item.rule.toLowerCase().trim());
519
559
  try {
520
- saveLearning(cat, item.rule, item.context || "", item.project || defaultProject);
560
+ saveLearning(cat, item.rule, item.context || "", item.project || defaultProject, source);
521
561
  if (existing) {
522
562
  result.updated++;
523
563
  }
@@ -535,12 +575,26 @@ function importFromJson(content, defaultProject) {
535
575
  }
536
576
  return result;
537
577
  }
538
- function importFromMarkdown(content, defaultCategory, defaultProject) {
539
- 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: [] };
540
580
  const lines = content.split("\n");
541
581
  let currentCategory = defaultCategory;
542
582
  let currentRule = "";
543
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
+ }
544
598
  function flushRule() {
545
599
  if (!currentRule)
546
600
  return;
@@ -556,7 +610,7 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
556
610
  const store = loadStore();
557
611
  const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === currentRule.toLowerCase().trim());
558
612
  try {
559
- saveLearning(cat, currentRule, ctx, defaultProject);
613
+ saveLearning(cat, currentRule, ctx, defaultProject, opts.source);
560
614
  if (existing) {
561
615
  result.updated++;
562
616
  }
@@ -572,24 +626,33 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
572
626
  }
573
627
  for (const line of lines) {
574
628
  const trimmed = line.trim();
575
- // H1 — file title, skip
576
- 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;
577
633
  continue;
634
+ }
578
635
  // H2 — category (e.g., "## deployment" or "## Security & Server Administration")
579
636
  if (trimmed.startsWith("## ")) {
580
637
  flushRule();
581
638
  const heading = trimmed.replace(/^##\s+/, "").toLowerCase().trim();
582
639
  currentCategory = heading;
640
+ h2Scope = LEARNINGS_HEADING.test(heading);
641
+ h3Scope = false;
583
642
  continue;
584
643
  }
585
- // H3 — rule (e.g., "### Never docker build | tee")
644
+ // H3 — rule (e.g., "### Never docker build | tee"), or a subsection that says learnings
586
645
  if (trimmed.startsWith("### ")) {
587
646
  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;
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;
592
652
  }
653
+ // Quality filter: skip short headings ("Fix", "UI", "DB")
654
+ if (text.length >= MIN_RULE_LENGTH)
655
+ candidate(text);
593
656
  continue;
594
657
  }
595
658
  // H4+ — sub-rule, treat as context for current rule
@@ -607,18 +670,19 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
607
670
  currentCategory = cat;
608
671
  // Split on → or — for rule/context separation
609
672
  const sepMatch = rest.match(/^(.+?)(?:\s*[→—]\s*|\s+[-–]\s+)(.+)$/);
673
+ // Marked by its author: imported in every mode. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
610
674
  if (sepMatch) {
611
- const candidate = sepMatch[1].trim();
612
- if (candidate.length >= MIN_RULE_LENGTH) {
613
- currentRule = candidate;
675
+ const text = sepMatch[1].trim();
676
+ if (text.length >= MIN_RULE_LENGTH) {
677
+ currentRule = text;
614
678
  currentContext = [sepMatch[2].trim()];
615
679
  flushRule();
616
680
  }
617
681
  }
618
682
  else {
619
- const candidate = rest.trim();
620
- if (candidate.length >= MIN_RULE_LENGTH) {
621
- currentRule = candidate;
683
+ const text = rest.trim();
684
+ if (text.length >= MIN_RULE_LENGTH) {
685
+ currentRule = text;
622
686
  flushRule();
623
687
  }
624
688
  }
@@ -628,11 +692,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
628
692
  const tableMatch = trimmed.match(/^\|\s*\*\*(.+?)\*\*\s*\|(.+)\|(.+)\|/);
629
693
  if (tableMatch) {
630
694
  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();
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
+ }
636
702
  }
637
703
  continue;
638
704
  }
@@ -642,13 +708,13 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
642
708
  flushRule();
643
709
  const boldMatch = trimmed.match(/^[-*]\s+\*\*(.+?)\*\*\s*(.*)$/);
644
710
  if (boldMatch) {
645
- const candidate = boldMatch[1].trim();
711
+ const text = boldMatch[1].trim();
646
712
  // Quality filter: skip short/single-word headings
647
- if (candidate.length < MIN_RULE_LENGTH) {
713
+ if (text.length < MIN_RULE_LENGTH) {
648
714
  continue;
649
715
  }
650
- currentRule = candidate;
651
- if (boldMatch[2]) {
716
+ candidate(text);
717
+ if (currentRule && boldMatch[2]) {
652
718
  // Strip leading separators
653
719
  currentContext = [boldMatch[2].replace(/^[\s—→:]+/, "").trim()];
654
720
  }
@@ -670,117 +736,195 @@ function importFromMarkdown(content, defaultCategory, defaultProject) {
670
736
  flushRule(); // Flush last rule
671
737
  return result;
672
738
  }
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;
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);
697
891
  }
698
- return "other";
892
+ return scores;
699
893
  }
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
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
+ }
905
+ }
906
+ return best;
907
+ }
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.
704
912
  for (const cat of LEARNING_CATEGORIES) {
705
- if (h === cat || h.startsWith(cat))
913
+ if (h.trim() === cat)
706
914
  return cat;
707
915
  }
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))
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))
781
925
  return cat;
782
926
  }
783
- return "other";
927
+ return inferCategory(heading, "");
784
928
  }
785
929
  /**
786
930
  * Convert learnings to Chunks so they can be included in search_context.
@@ -827,6 +971,7 @@ export function learningsToChunks(projects) {
827
971
  export function autoImportFromSources(sources) {
828
972
  let totalImported = 0;
829
973
  let totalUpdated = 0;
974
+ let totalIgnored = 0;
830
975
  let processed = 0;
831
976
  // One load and one save for the whole sweep (~880 files), instead of one full-file
832
977
  // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
@@ -839,14 +984,16 @@ export function autoImportFromSources(sources) {
839
984
  continue;
840
985
  // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
841
986
  const project = source.name.split(" — ")[0]?.trim() || undefined;
987
+ // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
842
988
  const result = importLearningsFromFile(source.path, "other", project);
843
989
  totalImported += result.imported;
844
990
  totalUpdated += result.updated;
991
+ totalIgnored += result.ignored;
845
992
  if (result.imported > 0 || result.updated > 0)
846
993
  processed++;
847
994
  }
848
995
  });
849
- return { total: processed, imported: totalImported, updated: totalUpdated };
996
+ return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored };
850
997
  }
851
998
  /**
852
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.6",
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",