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