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