loki-mode 7.74.0 → 7.76.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.74.0
5
+ **Version:** v7.76.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.74.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.76.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var i6=($)=>$;function e6($,Q){this[$]=i6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:e6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>P,homeLokiDir:()=>t$,findRepoRootForVersion:()=>s$,REPO_ROOT:()=>g});import{resolve as a,dirname as a$}from"path";import{fileURLToPath as $Q}from"url";import{existsSync as F$}from"fs";import{homedir as QQ}from"os";function ZQ(){let $=N1;for(let Q=0;Q<6;Q++){if(F$(a($,"VERSION"))&&F$(a($,"autonomy/run.sh")))return $;let Z=a$($);if(Z===$)break;$=Z}return a(N1,"..","..","..")}function s$($){let Q=$;for(let Z=0;Z<6;Z++){if(F$(a(Q,"VERSION"))&&F$(a(Q,"autonomy/run.sh")))return Q;let z=a$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function t$(){return a(QQ(),".loki")}var N1,g;var C=L(()=>{N1=a$($Q(import.meta.url));g=ZQ()});import{readFileSync as zQ}from"fs";import{resolve as XQ,dirname as KQ}from"path";import{fileURLToPath as qQ}from"url";function R$(){if(Q$!==null)return Q$;let $="7.74.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=KQ(qQ(import.meta.url)),Z=s$(Q);Q$=zQ(XQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var r$=L(()=>{C()});var b1={};b(b1,{runOrThrow:()=>VQ,run:()=>F,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>i$});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([new Response(Z.stdout).text(),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 VQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new i$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=JQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function JQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,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 i$;var d=L(()=>{i$=class i$ 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 s($){return UQ?"":$}var UQ,O,N,_,_Z,I,R,h,V;var c=L(()=>{UQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),N=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as _Q}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(_Q($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=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 Y$;var V$=L(()=>{d()});var e1={};b(e1,{runStatus:()=>cQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,basename as CQ}from"path";import{homedir as bQ}from"os";function n1($){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 a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*x$/Q);if(X>x$)X=x$;let q=x$-X,K=N;if(z>=80)K=O;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function yQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var i6=($)=>$;function e6($,Q){this[$]=i6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:e6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>P,homeLokiDir:()=>t$,findRepoRootForVersion:()=>s$,REPO_ROOT:()=>g});import{resolve as a,dirname as a$}from"path";import{fileURLToPath as $Q}from"url";import{existsSync as F$}from"fs";import{homedir as QQ}from"os";function ZQ(){let $=N1;for(let Q=0;Q<6;Q++){if(F$(a($,"VERSION"))&&F$(a($,"autonomy/run.sh")))return $;let Z=a$($);if(Z===$)break;$=Z}return a(N1,"..","..","..")}function s$($){let Q=$;for(let Z=0;Z<6;Z++){if(F$(a(Q,"VERSION"))&&F$(a(Q,"autonomy/run.sh")))return Q;let z=a$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function t$(){return a(QQ(),".loki")}var N1,g;var C=L(()=>{N1=a$($Q(import.meta.url));g=ZQ()});import{readFileSync as zQ}from"fs";import{resolve as XQ,dirname as KQ}from"path";import{fileURLToPath as qQ}from"url";function R$(){if(Q$!==null)return Q$;let $="7.76.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=KQ(qQ(import.meta.url)),Z=s$(Q);Q$=zQ(XQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var r$=L(()=>{C()});var b1={};b(b1,{runOrThrow:()=>VQ,run:()=>F,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>i$});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([new Response(Z.stdout).text(),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 VQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new i$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=JQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function JQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,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 i$;var d=L(()=>{i$=class i$ 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 s($){return UQ?"":$}var UQ,O,N,_,_Z,I,R,h,V;var c=L(()=>{UQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),N=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as _Q}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(_Q($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=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 Y$;var V$=L(()=>{d()});var e1={};b(e1,{runStatus:()=>cQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,basename as CQ}from"path";import{homedir as bQ}from"os";function n1($){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 a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*x$/Q);if(X>x$)X=x$;let q=x$-X,K=N;if(z>=80)K=O;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function yQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
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)
@@ -793,4 +793,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
793
793
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
794
794
  `),process.stderr.write(t6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
795
795
 
796
- //# debugId=034E8130DD935D9E64756E2164756E21
796
+ //# debugId=D4E8DE98E1AB29DC64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.74.0'
60
+ __version__ = '7.76.0'
@@ -226,9 +226,9 @@ class ConsolidationPipeline:
226
226
  # Re-read the target pattern fresh immediately before
227
227
  # merging (BUG-MEM C1, lost-update). The whole-run
228
228
  # snapshot at step 4 can be stale by now: a concurrent
229
- # engine.increment_pattern_usage() (load_pattern then
230
- # save_pattern) may have bumped usage_count/last_used
231
- # AFTER the snapshot. merge_with_existing() builds the
229
+ # storage.increment_pattern_usage() (atomic read-mutate-
230
+ # write under one exclusive lock) may have bumped
231
+ # usage_count/last_used AFTER the snapshot. merge_with_existing() builds the
232
232
  # merged record from best_match.usage_count/last_used,
233
233
  # so merging from the stale snapshot clobbers that bump.
234
234
  # Re-reading narrows the window to this single write.
package/memory/engine.py CHANGED
@@ -580,21 +580,13 @@ class MemoryEngine:
580
580
  Args:
581
581
  pattern_id: Pattern identifier
582
582
  """
583
- # Load pattern via storage (which acquires read lock)
584
- pattern_data = self.storage.load_pattern(pattern_id)
585
- if pattern_data is None:
586
- return
587
-
588
- # Update fields. `or 0` guards against an explicit null usage_count
589
- # (corrupt/hand-edited record) crashing the increment with a TypeError;
590
- # a null and 0 are equivalent here so `or` is safe.
591
- pattern_data["usage_count"] = (pattern_data.get("usage_count") or 0) + 1
592
- pattern_data["last_used"] = datetime.now(timezone.utc).isoformat()
593
-
594
- # Write back via save_pattern which holds an exclusive lock during
595
- # the full read-modify-write (upsert) cycle
596
- pattern_obj = self._dict_to_pattern(pattern_data)
597
- self.storage.save_pattern(pattern_obj)
583
+ # Delegate the entire read-modify-write to storage, which performs it
584
+ # under a single exclusive lock. Doing the read here (load_pattern's
585
+ # shared lock is released immediately) and writing a detached snapshot
586
+ # back via save_pattern (wholesale entry replacement) loses updates
587
+ # when two agents increment concurrently. A no-op if the pattern is
588
+ # missing.
589
+ self.storage.increment_pattern_usage(pattern_id)
598
590
 
599
591
  # -------------------------------------------------------------------------
600
592
  # Skill Operations
package/memory/ingest.py CHANGED
@@ -26,6 +26,7 @@ Safety:
26
26
  """
27
27
  from __future__ import annotations
28
28
 
29
+ import hashlib
29
30
  import json
30
31
  import os
31
32
  import re
@@ -165,6 +166,52 @@ def _log_to_errors(memory_base: str, function_name: str, exc: BaseException) ->
165
166
  return
166
167
 
167
168
 
169
+ def _deterministic_episode_id(stable_key: str) -> str:
170
+ """Derive a stable, idempotent episode id from a stable key.
171
+
172
+ Re-ingesting the same session/summary must NOT create a new episode.
173
+ EpisodeTrace.create() mints a random uuid every call, so without a
174
+ deterministic id the index dedup (which keys on episode_id) counts
175
+ the same session twice, inflating episode_count / total_cost_usd /
176
+ total_tokens. Deriving the id from the stable key (e.g. the session
177
+ or task id) makes re-ingest land on the same filename + the same
178
+ index dedup bucket. Date is intentionally omitted so the id does not
179
+ drift across day boundaries for the same source.
180
+ """
181
+ digest = hashlib.sha1(stable_key.encode("utf-8")).hexdigest()[:12]
182
+ return f"ep-{digest}"
183
+
184
+
185
+ def _existing_episode_path(
186
+ storage: Any, memory_base: str, episode_id: str
187
+ ) -> Optional[str]:
188
+ """Return the on-disk path for an already-stored episode id, or None.
189
+
190
+ Used for the idempotency existence check before writing so a repeat
191
+ ingest of the same source is a no-op rather than an append-new.
192
+ """
193
+ try:
194
+ existing = storage.load_episode(episode_id)
195
+ except Exception:
196
+ return None
197
+ if not existing:
198
+ return None
199
+ # Mirror storage.save_episode's path layout; prefer the stored
200
+ # timestamp date, fall back to a directory scan if absent.
201
+ ts = existing.get("timestamp")
202
+ if isinstance(ts, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", ts[:10] or ""):
203
+ candidate = Path(memory_base) / "episodic" / ts[:10] / f"task-{episode_id}.json"
204
+ if candidate.is_file():
205
+ return str(candidate)
206
+ episodic_dir = Path(memory_base) / "episodic"
207
+ if episodic_dir.is_dir():
208
+ for date_dir in episodic_dir.iterdir():
209
+ candidate = date_dir / f"task-{episode_id}.json"
210
+ if candidate.is_file():
211
+ return str(candidate)
212
+ return None
213
+
214
+
168
215
  def _parse_transcript_line(line: str) -> Optional[Dict[str, Any]]:
169
216
  """Parse one JSONL line; return None on parse error."""
170
217
  try:
@@ -385,12 +432,21 @@ def ingest_from_claude_transcript(
385
432
  engine = MemoryEngine(storage=storage, base_path=memory_base)
386
433
  engine.initialize()
387
434
 
435
+ # The transcript task_id is always stable (sessionId or filename
436
+ # stem), so derive a deterministic episode id from it and skip the
437
+ # write if this session was already ingested (idempotent re-run).
438
+ episode_id = _deterministic_episode_id(task_id)
439
+ already = _existing_episode_path(storage, memory_base, episode_id)
440
+ if already is not None:
441
+ return already
442
+
388
443
  trace = EpisodeTrace.create(
389
444
  task_id=task_id,
390
445
  agent=agent,
391
446
  phase=phase,
392
447
  goal=goal,
393
448
  )
449
+ trace.id = episode_id
394
450
  trace.outcome = outcome
395
451
  trace.duration_seconds = duration
396
452
  # v7.7.18 council fix: apply BOTH scrubbers to file paths --
@@ -440,16 +496,30 @@ def ingest_from_summary(
440
496
  engine = MemoryEngine(storage=storage, base_path=memory_base)
441
497
  engine.initialize()
442
498
 
499
+ # Only a caller-supplied task_id is a stable key. The auto-generated
500
+ # timestamp fallback is unique per call by design, so it must NOT
501
+ # drive a deterministic id (that would alias unrelated captures).
502
+ caller_supplied_key = task_id is not None
443
503
  if task_id is None:
444
504
  ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
445
505
  task_id = f"mcp-capture-{ts}"
446
506
 
507
+ if caller_supplied_key:
508
+ episode_id = _deterministic_episode_id(task_id)
509
+ already = _existing_episode_path(storage, memory_base, episode_id)
510
+ if already is not None:
511
+ return already
512
+ else:
513
+ episode_id = None
514
+
447
515
  trace = EpisodeTrace.create(
448
516
  task_id=task_id,
449
517
  agent=agent,
450
518
  phase=phase,
451
519
  goal=_scrub(goal)[:500],
452
520
  )
521
+ if episode_id is not None:
522
+ trace.id = episode_id
453
523
  trace.outcome = outcome if outcome in ("success", "failure", "partial") else "success"
454
524
  trace.duration_seconds = max(0, int(duration_seconds))
455
525
  trace.files_read = [_scrub_path(_scrub(p)) for p in (files_read or [])]
@@ -9,10 +9,20 @@ Storage: ~/.loki/knowledge/
9
9
  """
10
10
 
11
11
  import json
12
+ import logging
12
13
  import os
13
14
  from pathlib import Path
14
15
  from datetime import datetime, timezone
15
16
 
17
+ try:
18
+ import fcntl # POSIX-only; absent on Windows.
19
+ _HAS_FCNTL = True
20
+ except ImportError: # pragma: no cover - non-POSIX fallback
21
+ fcntl = None
22
+ _HAS_FCNTL = False
23
+
24
+ logger = logging.getLogger(__name__)
25
+
16
26
 
17
27
  class OrganizationKnowledgeGraph:
18
28
  """Aggregates patterns and knowledge across multiple projects."""
@@ -79,11 +89,30 @@ class OrganizationKnowledgeGraph:
79
89
  return unique
80
90
 
81
91
  def save_patterns(self, patterns):
82
- """Save patterns to the knowledge store (appends to JSONL)."""
92
+ """Save patterns to the knowledge store (appends to JSONL).
93
+
94
+ The append is guarded by an exclusive flock and a single flushed
95
+ write so concurrent writers cannot interleave partial lines.
96
+ stdio buffering breaks O_APPEND atomicity and a record longer
97
+ than PIPE_BUF can split mid-line under concurrency, so we both
98
+ hold the lock and emit one contiguous buffer before unlocking.
99
+ """
83
100
  self.ensure_dir()
101
+ # Serialize first so a serialization error cannot leave a partial
102
+ # line on disk while the lock is held.
103
+ buffer = "".join(json.dumps(p) + "\n" for p in patterns)
104
+ if not buffer:
105
+ return
84
106
  with open(self.patterns_file, 'a') as f:
85
- for p in patterns:
86
- f.write(json.dumps(p) + '\n')
107
+ if _HAS_FCNTL:
108
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
109
+ try:
110
+ f.write(buffer)
111
+ f.flush()
112
+ os.fsync(f.fileno())
113
+ finally:
114
+ if _HAS_FCNTL:
115
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
87
116
 
88
117
  def load_patterns(self, limit=100):
89
118
  """Load patterns from the knowledge store."""
@@ -98,6 +127,12 @@ class OrganizationKnowledgeGraph:
98
127
  try:
99
128
  patterns.append(json.loads(line))
100
129
  except json.JSONDecodeError:
130
+ # Resilient: skip the bad row but surface it so silent
131
+ # data loss (e.g. a torn legacy line) is observable.
132
+ logger.warning(
133
+ "knowledge_graph: dropped unparseable patterns.jsonl line: %.120r",
134
+ line,
135
+ )
101
136
  continue
102
137
  if len(patterns) >= limit:
103
138
  break
package/memory/storage.py CHANGED
@@ -1519,6 +1519,53 @@ class MemoryStorage:
1519
1519
 
1520
1520
  return False
1521
1521
 
1522
+ def increment_pattern_usage(self, pattern_id: str) -> bool:
1523
+ """Atomically increment a semantic pattern's usage_count, keyed by id.
1524
+
1525
+ The entire read-mutate-write happens inside a single exclusive
1526
+ _file_lock so concurrent increments cannot lose updates. Mirrors the
1527
+ lock-spanning idiom of _persist_boost_semantic / save_pattern: fresh
1528
+ read of patterns.json under the lock -> bump the matching entry ->
1529
+ atomic write (which reuses the same reentrant lock).
1530
+
1531
+ Args:
1532
+ pattern_id: Pattern identifier to increment.
1533
+
1534
+ Returns:
1535
+ True if the pattern was found and incremented, False otherwise.
1536
+ """
1537
+ patterns_path = self.base_path / "semantic" / "patterns.json"
1538
+ if not patterns_path.exists():
1539
+ return False
1540
+
1541
+ with self._file_lock(patterns_path, exclusive=True):
1542
+ if not patterns_path.exists():
1543
+ return False
1544
+ try:
1545
+ with open(patterns_path, "r", encoding="utf-8") as f:
1546
+ patterns_file = json.load(f)
1547
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1548
+ return False
1549
+ if not patterns_file:
1550
+ return False
1551
+
1552
+ patterns = patterns_file.get("patterns", [])
1553
+ for pattern in patterns:
1554
+ if not isinstance(pattern, dict):
1555
+ continue
1556
+ if pattern.get("id") == pattern_id:
1557
+ # `or 0` guards an explicit null usage_count (corrupt or
1558
+ # hand-edited record); null and 0 are equivalent here.
1559
+ pattern["usage_count"] = (pattern.get("usage_count") or 0) + 1
1560
+ pattern["last_used"] = datetime.now(timezone.utc).isoformat()
1561
+ patterns_file["last_updated"] = datetime.now(
1562
+ timezone.utc
1563
+ ).isoformat()
1564
+ self._atomic_write(patterns_path, patterns_file)
1565
+ return True
1566
+
1567
+ return False
1568
+
1522
1569
  def batch_apply_decay(
1523
1570
  self,
1524
1571
  collection: str = "all",
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.74.0",
4
+ "version": "7.76.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.74.0",
5
+ "version": "7.76.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",