@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 +32 -0
- package/dist/cli.js +9 -2
- package/dist/config.js +6 -0
- package/dist/index.js +8 -3
- package/dist/learnings.d.ts +19 -2
- package/dist/learnings.js +289 -142
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
972
|
-
|
|
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
|
``,
|
package/dist/learnings.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
if (
|
|
591
|
-
|
|
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
|
|
612
|
-
if (
|
|
613
|
-
currentRule =
|
|
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
|
|
620
|
-
if (
|
|
621
|
-
currentRule =
|
|
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
|
|
632
|
-
if (
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
711
|
+
const text = boldMatch[1].trim();
|
|
646
712
|
// Quality filter: skip short/single-word headings
|
|
647
|
-
if (
|
|
713
|
+
if (text.length < MIN_RULE_LENGTH) {
|
|
648
714
|
continue;
|
|
649
715
|
}
|
|
650
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
"
|
|
689
|
-
"
|
|
690
|
-
"
|
|
691
|
-
"
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
892
|
+
return scores;
|
|
699
893
|
}
|
|
700
|
-
/**
|
|
701
|
-
function
|
|
702
|
-
const
|
|
703
|
-
|
|
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
|
|
913
|
+
if (h.trim() === cat)
|
|
706
914
|
return cat;
|
|
707
915
|
}
|
|
708
|
-
//
|
|
709
|
-
const
|
|
710
|
-
"
|
|
711
|
-
"
|
|
712
|
-
"
|
|
713
|
-
"
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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 "
|
|
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.
|
|
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",
|