loki-mode 7.94.0 → 7.96.0

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.94.0
5
+ **Version:** v7.96.0
6
6
 
7
7
  ---
8
8
 
package/events/bus.ts CHANGED
@@ -292,7 +292,13 @@ export class EventBus {
292
292
 
293
293
  if (archive) {
294
294
  try {
295
- const files = fs.readdirSync(this.pendingDir).filter((f) => f.includes(event.id));
295
+ // Match the exact id at the filename boundary. Emitted files are named
296
+ // `${timestamp}_${id}.json`, so a substring match (f.includes(event.id))
297
+ // would also match a DIFFERENT event whose id contains this id as a
298
+ // substring, archiving/marking the wrong event file (event loss). The
299
+ // Python impl already uses the precise `*_${id}.json` glob; mirror it.
300
+ const suffix = `_${event.id}.json`;
301
+ const files = fs.readdirSync(this.pendingDir).filter((f) => f.endsWith(suffix));
296
302
  for (const file of files) {
297
303
  const src = path.join(this.pendingDir, file);
298
304
  const dst = path.join(this.archiveDir, file);
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.94.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([f1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.96.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([f1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
802
802
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
803
803
  `),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
804
804
 
805
- //# debugId=4B46D2108E7A951364756E2164756E21
805
+ //# debugId=E60A742D72C38FD764756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.94.0'
60
+ __version__ = '7.96.0'
@@ -512,6 +512,24 @@ def get_mcp_learning_collector(
512
512
  MCPLearningCollector instance
513
513
  """
514
514
  global _collector
515
+ # Recreate when the requested loki_dir differs from the cached collector's
516
+ # directory. The module-level singleton would otherwise return a collector
517
+ # bound to a PREVIOUS project's .loki dir on cwd change, contaminating one
518
+ # project's learnings into another in a multi-project session. Compare by
519
+ # realpath so symlinked/relative paths to the same dir do not churn.
520
+ if _collector is not None and loki_dir is not None:
521
+ try:
522
+ cached = os.path.realpath(str(getattr(_collector, "loki_dir", "")))
523
+ requested = os.path.realpath(str(loki_dir))
524
+ if cached != requested:
525
+ close = getattr(_collector, "close", None)
526
+ if callable(close):
527
+ close()
528
+ _collector = None
529
+ except Exception:
530
+ # On any path-resolution error, fall through and reuse the cache
531
+ # rather than risk losing the collector.
532
+ pass
515
533
  if _collector is None:
516
534
  _collector = MCPLearningCollector(loki_dir=loki_dir)
517
535
  return _collector
package/mcp/server.py CHANGED
@@ -64,13 +64,28 @@ _learning_collector = None
64
64
 
65
65
 
66
66
  def _get_learning_collector():
67
- """Get or create the LearningCollector instance for MCP server."""
67
+ """Get or create the LearningCollector instance for MCP server.
68
+
69
+ Revalidates the cached collector against the current cwd-derived .loki dir
70
+ (mirrors the StateManager realpath-compare-recreate guard in
71
+ _get_mcp_state_manager). A multi-project MCP session can change cwd between
72
+ calls; without this, the cached collector stays bound to a PREVIOUS
73
+ project's .loki dir and contaminates one project's learnings into another.
74
+ """
68
75
  global _learning_collector
69
76
  if not LEARNING_COLLECTOR_AVAILABLE:
70
77
  return None
78
+ from pathlib import Path
79
+ loki_dir = Path(os.getcwd()) / '.loki'
80
+ if _learning_collector is not None:
81
+ existing_dir = getattr(_learning_collector, 'loki_dir', None)
82
+ if existing_dir and os.path.realpath(str(existing_dir)) != os.path.realpath(str(loki_dir)):
83
+ # Project directory changed, recreate
84
+ close = getattr(_learning_collector, 'close', None)
85
+ if callable(close):
86
+ close()
87
+ _learning_collector = None
71
88
  if _learning_collector is None:
72
- from pathlib import Path
73
- loki_dir = Path(os.getcwd()) / '.loki'
74
89
  _learning_collector = get_mcp_learning_collector(loki_dir=loki_dir)
75
90
  return _learning_collector
76
91
 
@@ -228,28 +228,34 @@ class ConsolidationPipeline:
228
228
  merged = False
229
229
  for idx, existing in enumerate(existing_patterns):
230
230
  if self._patterns_similar(new_pattern, existing):
231
- # Re-read the target pattern fresh immediately before
232
- # merging (BUG-MEM C1, lost-update). The whole-run
233
- # snapshot at step 4 can be stale by now: a concurrent
234
- # storage.increment_pattern_usage() (atomic read-mutate-
235
- # write under one exclusive lock) may have bumped
236
- # usage_count/last_used AFTER the snapshot. merge_with_existing() builds the
237
- # merged record from best_match.usage_count/last_used,
238
- # so merging from the stale snapshot clobbers that bump.
239
- # Re-reading narrows the window to this single write.
240
- merge_base = self._reload_pattern(existing)
241
- merged_pattern = self.merge_with_existing(new_pattern, [merge_base])
242
- self.storage.update_pattern(merged_pattern)
243
- # Refresh the in-memory copy so a later new pattern in
244
- # this same run that also merges into this existing
245
- # pattern builds on the just-merged state. Without this,
246
- # the second merge reads the stale pre-merge base and its
247
- # update_pattern() overwrites storage, silently dropping
248
- # the first merge's conditions/source_episodes/confidence.
249
- existing_patterns[idx] = merged_pattern
250
- result.patterns_merged += 1
251
- merged = True
252
- break
231
+ # Atomic read-merge-write (BUG-MEM C1, lost-update).
232
+ # The whole-run snapshot at step 4 can be stale by now:
233
+ # a concurrent storage.increment_pattern_usage()
234
+ # (atomic read-mutate-write under one exclusive lock)
235
+ # may have bumped usage_count/last_used AFTER the
236
+ # snapshot, and merge_with_existing() builds the merged
237
+ # record from the base's usage_count/last_used. The
238
+ # merge now runs INSIDE update_pattern_with_merge's
239
+ # single lock on patterns.json (the same path
240
+ # increment_pattern_usage locks), so the merge base is
241
+ # the live on-disk record and the bump cannot be lost.
242
+ merged_holder = {}
243
+
244
+ def _merge(current, _np=new_pattern, _holder=merged_holder):
245
+ base = SemanticPattern.from_dict(current)
246
+ m = self.merge_with_existing(_np, [base])
247
+ _holder["pattern"] = m
248
+ return m
249
+
250
+ if self.storage.update_pattern_with_merge(existing.id, _merge):
251
+ merged_pattern = merged_holder["pattern"]
252
+ # Refresh the in-memory copy so a later new pattern
253
+ # in this same run that also merges into this
254
+ # existing pattern builds on the just-merged state.
255
+ existing_patterns[idx] = merged_pattern
256
+ result.patterns_merged += 1
257
+ merged = True
258
+ break
253
259
 
254
260
  if not merged:
255
261
  self.storage.save_pattern(new_pattern)
@@ -277,20 +283,27 @@ class ConsolidationPipeline:
277
283
  for idx, existing in enumerate(existing_patterns):
278
284
  if (existing.incorrect_approach and
279
285
  self._patterns_similar(anti_pattern, existing, threshold=0.6)):
280
- # Re-read fresh before merge (same C1 lost-update guard as the
281
- # cluster merge loop above): merge from current on-disk state,
282
- # not the potentially-stale step-4 snapshot.
283
- merge_base = self._reload_pattern(existing)
284
- merged_pattern = self.merge_with_existing(anti_pattern, [merge_base])
285
- self.storage.update_pattern(merged_pattern)
286
- # Refresh in-memory copy (same data-loss guard as the cluster
287
- # merge loop above): a later anti-pattern merging into this same
288
- # existing pattern must build on the just-merged state, not the
289
- # stale pre-merge base.
290
- existing_patterns[idx] = merged_pattern
291
- result.patterns_merged += 1
292
- merged = True
293
- break
286
+ # Atomic read-merge-write (same C1 lost-update guard as the
287
+ # cluster merge loop above): merge from the live on-disk record
288
+ # inside update_pattern_with_merge's single lock so a concurrent
289
+ # usage bump cannot be clobbered.
290
+ merged_holder = {}
291
+
292
+ def _merge(current, _ap=anti_pattern, _holder=merged_holder):
293
+ base = SemanticPattern.from_dict(current)
294
+ m = self.merge_with_existing(_ap, [base])
295
+ _holder["pattern"] = m
296
+ return m
297
+
298
+ if self.storage.update_pattern_with_merge(existing.id, _merge):
299
+ merged_pattern = merged_holder["pattern"]
300
+ # Refresh in-memory copy: a later anti-pattern merging into
301
+ # this same existing pattern must build on the just-merged
302
+ # state, not the stale pre-merge base.
303
+ existing_patterns[idx] = merged_pattern
304
+ result.patterns_merged += 1
305
+ merged = True
306
+ break
294
307
 
295
308
  if not merged:
296
309
  self.storage.save_pattern(anti_pattern)
@@ -324,34 +337,12 @@ class ConsolidationPipeline:
324
337
  result.duration_seconds = time.time() - start_time
325
338
  return result
326
339
 
327
- def _reload_pattern(self, fallback: SemanticPattern) -> SemanticPattern:
328
- """Re-read a pattern fresh from storage immediately before merging.
329
-
330
- Used by the merge branches to avoid the C1 lost-update: the step-4
331
- snapshot may be stale (a concurrent usage bump can land after it), and
332
- merge_with_existing() copies usage_count/last_used from the base. Reading
333
- the current on-disk record makes the merge build on live state.
334
-
335
- Mirrors the snapshot's dict/object handling. If load_pattern returns
336
- nothing (e.g. the record vanished), fall back to the in-memory copy so the
337
- merge still proceeds rather than crashing.
338
-
339
- Residual limitation (honest): load_pattern and update_pattern are SEPARATE
340
- lock acquisitions, so a bump landing between this re-read and the write is
341
- still lost. This narrows the race window from the whole run to a single
342
- write; it is a mitigation, not cross-process atomicity. A full fix needs a
343
- compare-and-set or merge-callback update in storage, which is out of scope
344
- for this file (storage.py is frozen this batch).
345
- """
346
- try:
347
- fresh = self.storage.load_pattern(fallback.id)
348
- except Exception:
349
- return fallback
350
- if not fresh:
351
- return fallback
352
- if isinstance(fresh, dict):
353
- return SemanticPattern.from_dict(fresh)
354
- return fresh
340
+ # NOTE: The former _reload_pattern() helper (a partial C1 lost-update
341
+ # mitigation that re-read the pattern in a SEPARATE lock from the write) was
342
+ # removed once the merge moved fully inside storage.update_pattern_with_merge,
343
+ # which performs the read, merge, and write under ONE exclusive lock on
344
+ # patterns.json. That closes the lost-update race cross-process; no residual
345
+ # narrow window remains.
355
346
 
356
347
  # -------------------------------------------------------------------------
357
348
  # Clustering Methods
package/memory/storage.py CHANGED
@@ -857,6 +857,75 @@ class MemoryStorage:
857
857
 
858
858
  return True
859
859
 
860
+ def update_pattern_with_merge(self, pattern_id: str, merge_fn) -> bool:
861
+ """Atomically merge into an existing pattern under a single lock.
862
+
863
+ Closes the consolidation lost-update (BUG-MEM C1): the previous flow read
864
+ the pattern (load_pattern) and wrote the merged result (update_pattern) in
865
+ SEPARATE lock acquisitions, so a concurrent increment_pattern_usage() bump
866
+ landing between the read and the write was lost. Here the read of the
867
+ current on-disk record, the caller's merge, and the write all happen
868
+ inside ONE exclusive _file_lock on patterns.json -- the same path
869
+ increment_pattern_usage() and update_pattern() lock -- so they mutually
870
+ exclude and no bump is clobbered.
871
+
872
+ Args:
873
+ pattern_id: Id of the existing pattern to merge into.
874
+ merge_fn: Callable taking the current on-disk pattern dict and
875
+ returning the merged record (dict, or any object exposing
876
+ to_dict()/__dict__). It must preserve the id. The dict it
877
+ receives is a fresh read performed under the lock.
878
+
879
+ Returns:
880
+ True if the pattern was found and the merged record written, False if
881
+ the pattern id was not present (caller should fall back to a create).
882
+ """
883
+ if not pattern_id:
884
+ return False
885
+
886
+ patterns_path = self.base_path / "semantic" / "patterns.json"
887
+
888
+ with self._file_lock(patterns_path, exclusive=True):
889
+ if not patterns_path.exists():
890
+ return False
891
+
892
+ with open(patterns_path, "r", encoding="utf-8") as f:
893
+ try:
894
+ patterns_file = json.load(f)
895
+ except (json.JSONDecodeError, UnicodeDecodeError):
896
+ return False
897
+
898
+ patterns = patterns_file.get("patterns", [])
899
+ target_idx = None
900
+ current = None
901
+ for i, p in enumerate(patterns):
902
+ if isinstance(p, dict) and p.get("id") == pattern_id:
903
+ target_idx = i
904
+ current = p
905
+ break
906
+
907
+ if target_idx is None:
908
+ return False
909
+
910
+ # Caller merges against the fresh, lock-protected current record.
911
+ merged = merge_fn(current)
912
+ if hasattr(merged, "to_dict"):
913
+ merged_data = merged.to_dict()
914
+ elif hasattr(merged, "__dict__"):
915
+ merged_data = merged.__dict__.copy()
916
+ else:
917
+ merged_data = dict(merged)
918
+
919
+ # Never let a merge orphan the record by changing its id.
920
+ merged_data["id"] = pattern_id
921
+ merged_data["updated_at"] = datetime.now(timezone.utc).isoformat()
922
+ patterns_file["patterns"][target_idx] = merged_data
923
+ patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
924
+
925
+ self._atomic_write(patterns_path, patterns_file)
926
+
927
+ return True
928
+
860
929
  # -------------------------------------------------------------------------
861
930
  # Skill Storage
862
931
  # -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.94.0",
4
+ "version": "7.96.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.94.0",
5
+ "version": "7.96.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",