@compr/opscontext-mcp 2.5.4 → 2.5.6

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/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
@@ -646,7 +646,7 @@ import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
646
646
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
647
647
  import { scanCodeDir } from "./code-chunker.js";
648
648
  import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
649
- import { listLearnings, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
649
+ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
650
650
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
651
651
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
652
652
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
@@ -755,12 +755,37 @@ async function cliListProjects() {
755
755
  const text = formatProjectList(projects);
756
756
  console.log(`\n${text}`);
757
757
  }
758
- async function cliListLearnings(category) {
758
+ async function cliListLearnings(args) {
759
+ // list-learnings [category] [--since today|yesterday|ISO]. [LOCK] [LEARNINGS-LIST-SHOWS-CREATED]
760
+ let category;
761
+ let sinceSpec;
762
+ for (let i = 0; i < args.length; i++) {
763
+ if (args[i] === "--since") {
764
+ sinceSpec = args[i + 1];
765
+ if (!sinceSpec) {
766
+ console.error("--since needs a value: today, yesterday, or an ISO date");
767
+ process.exit(1);
768
+ }
769
+ i++;
770
+ }
771
+ else if (!category) {
772
+ category = args[i];
773
+ }
774
+ }
775
+ let since;
776
+ if (sinceSpec) {
777
+ const parsed = parseSince(sinceSpec);
778
+ if (!parsed) {
779
+ console.error(`--since "${sinceSpec}" is not today, yesterday, or an ISO date. Refusing to answer with a zero.`);
780
+ process.exit(1);
781
+ }
782
+ since = parsed;
783
+ }
759
784
  // Project-scoped: only show learnings for workspace projects + universal
760
785
  const projectDirs = loadProjectDirs();
761
786
  const projectNames = projectDirs.map((d) => d.name);
762
787
  const learnings = listLearnings(category, projectNames);
763
- const text = formatLearnings(learnings);
788
+ const text = formatLearnings(learnings, { since, sinceSpec });
764
789
  console.log(`\n${text}`);
765
790
  }
766
791
  async function cliSaveLearning(args) {
@@ -2458,7 +2483,8 @@ Usage:
2458
2483
  contextengine search <query> [-n N] Search indexed knowledge (default: top 5)
2459
2484
  contextengine list-sources Show all indexed sources with chunk counts
2460
2485
  contextengine list-projects Discover and analyze all projects (Pro)
2461
- contextengine list-learnings [cat] List all learnings (optional: filter by category)
2486
+ contextengine list-learnings [cat] [--since today|yesterday|ISO]
2487
+ List learnings with their created instant (UTC + Europe/Zurich)
2462
2488
  contextengine save-learning <text> -c <category> Save a learning
2463
2489
  contextengine delete-learning <id> Delete a learning by ID
2464
2490
  contextengine import-learnings <file> [-c cat] [-p project] Bulk-import learnings
@@ -2579,8 +2605,7 @@ else if (command === "list-projects") {
2579
2605
  });
2580
2606
  }
2581
2607
  else if (command === "list-learnings") {
2582
- const category = process.argv[3];
2583
- cliListLearnings(category).catch((err) => {
2608
+ cliListLearnings(process.argv.slice(3)).catch((err) => {
2584
2609
  console.error("Error:", err);
2585
2610
  process.exit(1);
2586
2611
  });
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog } from "./
14
14
  import { startEventIngestServer } from "./http-server.js";
15
15
  import { detect } from "./detector.js";
16
16
  import { buildCostReport } from "./cost-report.js";
17
- import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
17
+ import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, parseSince } from "./learnings.js";
18
18
  import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
19
19
  import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
20
20
  import { basename, join, dirname } from "path";
@@ -919,10 +919,21 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
919
919
  .string()
920
920
  .optional()
921
921
  .describe("Filter by category (deployment, api, database, etc.). Omit to show all."),
922
- }, async ({ category }) => {
922
+ since: z
923
+ .string()
924
+ .optional()
925
+ .describe("Only learnings created at or after this boundary: 'today', 'yesterday' (Europe/Zurich calendar days) or an ISO date/instant. Every entry shows its created instant, UTC plus Europe/Zurich."),
926
+ }, async ({ category, since }) => {
923
927
  // Project-scoped: only show learnings for active workspace projects + universal (no project)
928
+ let sinceDate;
929
+ if (since) {
930
+ const parsed = parseSince(since);
931
+ if (!parsed)
932
+ return respond("list_learnings", `❌ since="${since}" is not today, yesterday, or an ISO date. No list rendered, so this is not a zero.`);
933
+ sinceDate = parsed;
934
+ }
924
935
  const learnings = listLearnings(category, activeProjectNames);
925
- const text = formatLearnings(learnings);
936
+ const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
926
937
  return respond("list_learnings", text);
927
938
  });
928
939
  // ---------------------------------------------------------------------------
@@ -17,11 +17,21 @@ export interface LearningsStore {
17
17
  /** Valid categories for learnings */
18
18
  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
19
  export type LearningCategory = (typeof LEARNING_CATEGORIES)[number];
20
+ /** Cross-process, re-entrant (within this process) lock around the store file. */
21
+ export declare function withStoreLock<T>(fn: () => T): T;
22
+ /**
23
+ * Run `fn` with ONE load and at most ONE save of the store, under the lock. Inside, every
24
+ * loadStore() returns the same in-memory store and every saveStore() only marks it dirty.
25
+ */
26
+ export declare function withStoreBatch<T>(fn: () => T): T;
27
+ /** Test seam for the writer's tripwire; not part of the API. */
28
+ export declare function __writeStoreForTests(store: LearningsStore): void;
20
29
  /**
21
30
  * Save a new learning. Returns the created learning with ID.
22
31
  * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
23
32
  */
24
- export declare function saveLearning(category: string, rule: string, context: string, project?: string): Learning;
33
+ export declare function saveLearning(...args: Parameters<typeof saveLearningUnlocked>): Learning;
34
+ declare function saveLearningUnlocked(category: string, rule: string, context: string, project?: string): Learning;
25
35
  /**
26
36
  * Search learnings by keyword. Returns matches sorted by relevance.
27
37
  */
@@ -104,5 +114,19 @@ export declare function learningsStats(): {
104
114
  /**
105
115
  * Format learnings for display.
106
116
  */
107
- export declare function formatLearnings(learnings: Learning[]): string;
117
+ export declare const LEARNINGS_LOCAL_TZ = "Europe/Zurich";
118
+ /** `2026-09-05 10:30Z (12:30 CEST)`; `undated` when the record carries no usable instant. */
119
+ export declare function formatLearnedAt(iso: string | undefined, tz?: string): string;
120
+ /** `today` | `yesterday` (calendar days in `tz`) | any ISO date or instant. `null` when unparseable. */
121
+ export declare function parseSince(spec: string, now?: Date, tz?: string): Date | null;
122
+ /** Records created at or after `since`, oldest first. Undated records are excluded and counted by the caller. */
123
+ export declare function filterSince(learnings: Learning[], since: Date): Learning[];
124
+ export interface FormatLearningsOptions {
125
+ /** Already-parsed boundary; the caller resolves the spec so an invalid one errors before rendering. */
126
+ since?: Date;
127
+ /** The spec as typed, echoed in the header so the reader sees which boundary applied. */
128
+ sinceSpec?: string;
129
+ }
130
+ export declare function formatLearnings(learnings: Learning[], opts?: FormatLearningsOptions): string;
131
+ export {};
108
132
  //# 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";
@@ -98,32 +98,203 @@ function mergeDefaults(store) {
98
98
  }
99
99
  return added > 0;
100
100
  }
101
- function loadStore() {
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(readFileSync(LEARNINGS_PATH, "utf-8"));
106
- // Filter out corrupted entries missing required 'rule' field
107
- store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
215
+ store = JSON.parse(raw);
216
+ if (!store || !Array.isArray(store.learnings))
217
+ throw new Error("no learnings array");
108
218
  }
109
- catch {
110
- // Corrupted file — start fresh
111
- store = { version: 1, count: 0, learnings: [] };
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.`);
112
227
  }
228
+ // Filter out corrupted entries missing required 'rule' field
229
+ store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
113
230
  }
114
231
  else {
115
232
  store = { version: 1, count: 0, learnings: [] };
116
233
  }
117
234
  // Auto-merge bundled defaults on first load or when new defaults are added
118
235
  if (mergeDefaults(store)) {
119
- saveStore(store);
236
+ if (batchStore)
237
+ batchDirty = true;
238
+ else
239
+ writeStoreToDisk(store);
120
240
  }
121
241
  return store;
122
242
  }
123
- function saveStore(store) {
243
+ function loadStore() {
244
+ if (batchStore)
245
+ return batchStore;
246
+ return readStoreFromDisk();
247
+ }
248
+ function dailyBackup() {
249
+ if (!existsSync(LEARNINGS_PATH))
250
+ return;
251
+ const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
252
+ const bak = `${LEARNINGS_PATH}.bak-${day}`;
253
+ if (existsSync(bak))
254
+ return;
255
+ try {
256
+ copyFileSync(LEARNINGS_PATH, bak);
257
+ const dir = dirname(LEARNINGS_PATH);
258
+ const daily = readdirSync(dir).filter((f) => /^learnings\.json\.bak-\d{8}$/.test(f)).sort();
259
+ for (const f of daily.slice(0, Math.max(0, daily.length - 7)))
260
+ unlinkSync(join(dir, f));
261
+ }
262
+ catch { /* a missing backup must never block a save */ }
263
+ }
264
+ function writeStoreToDisk(store) {
124
265
  ensureDir();
125
266
  store.count = store.learnings.length;
126
- writeFileSync(LEARNINGS_PATH, JSON.stringify(store, null, 2));
267
+ // Shrink tripwire: the exact shape of the 2026-09-05 loss was a near-empty store
268
+ // written over a full one.
269
+ if (existsSync(LEARNINGS_PATH) && process.env.CONTEXTENGINE_ALLOW_SHRINK !== "1") {
270
+ let onDisk = -1;
271
+ try {
272
+ onDisk = (JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8")).learnings || []).length;
273
+ }
274
+ catch {
275
+ onDisk = -1;
276
+ }
277
+ if (onDisk >= 100 && store.learnings.length < onDisk / 2) {
278
+ safeAppend("learning.store_shrink_refused", { on_disk: onDisk, attempted: store.learnings.length });
279
+ 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.`);
280
+ }
281
+ }
282
+ dailyBackup();
283
+ const tmp = `${LEARNINGS_PATH}.tmp-${process.pid}-${Date.now()}`;
284
+ writeFileSync(tmp, JSON.stringify(store, null, 2));
285
+ renameSync(tmp, LEARNINGS_PATH);
286
+ }
287
+ function saveStore(store) {
288
+ if (batchStore) {
289
+ batchStore = store;
290
+ batchDirty = true;
291
+ return;
292
+ }
293
+ writeStoreToDisk(store);
294
+ }
295
+ /** Test seam for the writer's tripwire; not part of the API. */
296
+ export function __writeStoreForTests(store) {
297
+ withStoreLock(() => writeStoreToDisk(store));
127
298
  }
128
299
  /** Generate a short unique ID */
129
300
  function generateId() {
@@ -156,7 +327,10 @@ const MIN_RULE_LENGTH = 15;
156
327
  * Save a new learning. Returns the created learning with ID.
157
328
  * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
158
329
  */
159
- export function saveLearning(category, rule, context, project) {
330
+ export function saveLearning(...args) {
331
+ return withStoreLock(() => saveLearningUnlocked(...args));
332
+ }
333
+ function saveLearningUnlocked(category, rule, context, project) {
160
334
  const trimmedRule = rule.trim();
161
335
  // Quality gate: reject junk rules
162
336
  if (trimmedRule.length < MIN_RULE_LENGTH) {
@@ -278,6 +452,9 @@ export function listLearnings(category, projects) {
278
452
  * Delete a learning by ID.
279
453
  */
280
454
  export function deleteLearning(id) {
455
+ return withStoreLock(() => deleteLearningUnlocked(id));
456
+ }
457
+ function deleteLearningUnlocked(id) {
281
458
  const store = loadStore();
282
459
  const index = store.learnings.findIndex((l) => l.id === id);
283
460
  if (index === -1)
@@ -299,9 +476,10 @@ export function importLearningsFromFile(filePath, defaultCategory = "other", def
299
476
  }
300
477
  const content = readFileSync(filePath, "utf-8");
301
478
  const ext = filePath.split(".").pop()?.toLowerCase();
302
- const result = ext === "json"
479
+ // One load, one save for the whole file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
480
+ const result = withStoreBatch(() => ext === "json"
303
481
  ? importFromJson(content, defaultProject)
304
- : importFromMarkdown(content, defaultCategory, defaultProject);
482
+ : importFromMarkdown(content, defaultCategory, defaultProject));
305
483
  // Aggregate event correlating the individual learning.save records emitted
306
484
  // inside the loop. Useful for compliance attribution: "this batch came from
307
485
  // file X".
@@ -650,20 +828,24 @@ export function autoImportFromSources(sources) {
650
828
  let totalImported = 0;
651
829
  let totalUpdated = 0;
652
830
  let processed = 0;
653
- for (const source of sources) {
654
- // Only process markdown files
655
- if (!source.path.endsWith(".md"))
656
- continue;
657
- if (!existsSync(source.path))
658
- continue;
659
- // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
660
- const project = source.name.split(" — ")[0]?.trim() || undefined;
661
- const result = importLearningsFromFile(source.path, "other", project);
662
- totalImported += result.imported;
663
- totalUpdated += result.updated;
664
- if (result.imported > 0 || result.updated > 0)
665
- processed++;
666
- }
831
+ // One load and one save for the whole sweep (~880 files), instead of one full-file
832
+ // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
833
+ withStoreBatch(() => {
834
+ for (const source of sources) {
835
+ // Only process markdown files
836
+ if (!source.path.endsWith(".md"))
837
+ continue;
838
+ if (!existsSync(source.path))
839
+ continue;
840
+ // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
841
+ const project = source.name.split(" — ")[0]?.trim() || undefined;
842
+ const result = importLearningsFromFile(source.path, "other", project);
843
+ totalImported += result.imported;
844
+ totalUpdated += result.updated;
845
+ if (result.imported > 0 || result.updated > 0)
846
+ processed++;
847
+ }
848
+ });
667
849
  return { total: processed, imported: totalImported, updated: totalUpdated };
668
850
  }
669
851
  /**
@@ -680,12 +862,76 @@ export function learningsStats() {
680
862
  /**
681
863
  * Format learnings for display.
682
864
  */
683
- export function formatLearnings(learnings) {
865
+ // [LOCKED] [LEARNINGS-LIST-SHOWS-CREATED] 2026-09-05
866
+ // [NEVER] print a learning without its `created` instant, and [NEVER] answer "what was
867
+ // saved since X" by probing learnings.json with a hand-written key.
868
+ // WHY: on 2026-09-05 an agent read a date field the records do not have (`createdAt`),
869
+ // got an empty string for every record, and answered "0 saved today" with full
870
+ // confidence; a second agent made the same mistake the same morning. A probe on a
871
+ // missing key returns a confident zero, never an error. The store had 22 records
872
+ // from that day, under `created`. Until then the listing showed the date only,
873
+ // so "today" was also ambiguous around midnight between UTC and Yan's clock.
874
+ // FIX: one renderer shows `created` as the UTC instant plus the Europe/Zurich wall
875
+ // time, and `--since today|yesterday|ISO` is a first-class filter whose empty
876
+ // result names the boundary it applied. An unparseable spec is an error, not zero.
877
+ export const LEARNINGS_LOCAL_TZ = "Europe/Zurich";
878
+ /** `2026-09-05 10:30Z (12:30 CEST)`; `undated` when the record carries no usable instant. */
879
+ export function formatLearnedAt(iso, tz = LEARNINGS_LOCAL_TZ) {
880
+ if (!iso)
881
+ return "undated";
882
+ const d = new Date(iso);
883
+ if (Number.isNaN(d.getTime()))
884
+ return "undated";
885
+ const utc = d.toISOString().slice(0, 16).replace("T", " ") + "Z";
886
+ const local = new Intl.DateTimeFormat("en-GB", {
887
+ timeZone: tz, hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short",
888
+ }).format(d);
889
+ return `${utc} (${local})`;
890
+ }
891
+ /** Midnight of the given calendar day in `tz`, as a UTC instant. Offset read from Intl, never guessed. */
892
+ function localMidnightUtc(y, m, d, tz) {
893
+ const guess = new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
894
+ const off = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" })
895
+ .formatToParts(guess).find((p) => p.type === "timeZoneName")?.value ?? "GMT";
896
+ const mm = /GMT([+-])(\d{2}):(\d{2})/.exec(off);
897
+ const minutes = mm ? (mm[1] === "-" ? -1 : 1) * (parseInt(mm[2], 10) * 60 + parseInt(mm[3], 10)) : 0;
898
+ return new Date(guess.getTime() - minutes * 60_000);
899
+ }
900
+ /** `today` | `yesterday` (calendar days in `tz`) | any ISO date or instant. `null` when unparseable. */
901
+ export function parseSince(spec, now = new Date(), tz = LEARNINGS_LOCAL_TZ) {
902
+ const s = spec.trim().toLowerCase();
903
+ if (s === "today" || s === "yesterday") {
904
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" })
905
+ .formatToParts(now);
906
+ const get = (t) => parseInt(parts.find((p) => p.type === t)?.value ?? "0", 10);
907
+ const midnight = localMidnightUtc(get("year"), get("month"), get("day"), tz);
908
+ return s === "today" ? midnight : new Date(midnight.getTime() - 86_400_000);
909
+ }
910
+ if (!/^\d{4}-\d{2}-\d{2}/.test(spec.trim()))
911
+ return null;
912
+ const d = new Date(spec.trim());
913
+ return Number.isNaN(d.getTime()) ? null : d;
914
+ }
915
+ /** Records created at or after `since`, oldest first. Undated records are excluded and counted by the caller. */
916
+ export function filterSince(learnings, since) {
917
+ return learnings
918
+ .filter((l) => l.created && !Number.isNaN(new Date(l.created).getTime()) && new Date(l.created).getTime() >= since.getTime())
919
+ .sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
920
+ }
921
+ export function formatLearnings(learnings, opts = {}) {
922
+ let sinceNote = "";
923
+ if (opts.since) {
924
+ learnings = filterSince(learnings, opts.since);
925
+ sinceNote = ` since ${opts.sinceSpec ?? opts.since.toISOString()} = ${formatLearnedAt(opts.since.toISOString())}`;
926
+ }
684
927
  if (learnings.length === 0) {
928
+ if (opts.since) {
929
+ return `0 learnings${sinceNote}. The boundary above is the one that was applied; if that looks wrong, the store is at ~/.contextengine/learnings.json and its date field is \`created\`.`;
930
+ }
685
931
  return "No learnings stored yet. Use `save_learning` to add operational rules.";
686
932
  }
687
933
  const lines = [];
688
- lines.push(`# 💡 Learnings Store (${learnings.length} rules)\n`);
934
+ lines.push(`# 💡 Learnings Store (${learnings.length} rules${sinceNote})\n`);
689
935
  // Group by category
690
936
  const byCategory = new Map();
691
937
  for (const l of learnings) {
@@ -704,8 +950,7 @@ export function formatLearnings(learnings) {
704
950
  lines.push(`- **Context:** ${l.context}`);
705
951
  if (l.tags?.length)
706
952
  lines.push(`- **Tags:** ${l.tags.join(", ")}`);
707
- if (l.created)
708
- lines.push(`- **Learned:** ${l.created.split("T")[0]}`);
953
+ lines.push(`- **Learned:** ${formatLearnedAt(l.created)}`);
709
954
  lines.push("");
710
955
  }
711
956
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
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",