loki-mode 7.98.0 → 7.99.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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.98.0
6
+ # Loki Mode v7.99.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.98.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.99.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.98.0
1
+ 7.99.0
@@ -97,8 +97,30 @@ def run_check(check: dict, project_dir: str, timeout: int) -> dict:
97
97
  text=True,
98
98
  timeout=timeout,
99
99
  )
100
- result["passed"] = proc.returncode == 0
101
- result["output"] = (proc.stdout + proc.stderr)[:500]
100
+ combined = proc.stdout + proc.stderr
101
+ # Trust gate: a tests_pass check REQUIRES that at least one
102
+ # test was actually discovered and run. jest is invoked with
103
+ # --passWithNoTests, so a zero-match pattern exits 0 ("No
104
+ # tests found ...") -- that would be a fake-green (a required
105
+ # verification passing with nothing run). pytest exits 5 when
106
+ # it collects no tests. Detect either no-test signal and fail
107
+ # the check rather than report success on an empty run.
108
+ no_tests = (
109
+ "No tests found" in combined
110
+ or "no tests ran" in combined.lower()
111
+ or "no tests to run" in combined.lower()
112
+ or proc.returncode == 5 # pytest: no tests collected
113
+ )
114
+ if no_tests:
115
+ result["passed"] = False
116
+ result["output"] = (
117
+ "No tests discovered for required check "
118
+ f"(pattern={pattern!r}); a tests_pass check must run "
119
+ "at least one test. Output: "
120
+ ) + combined[:400]
121
+ else:
122
+ result["passed"] = proc.returncode == 0
123
+ result["output"] = combined[:500]
102
124
  except subprocess.TimeoutExpired:
103
125
  result["passed"] = None # timeout = pending
104
126
  result["output"] = f"Timed out after {timeout}s"
package/autonomy/run.sh CHANGED
@@ -947,8 +947,24 @@ PYCHECK
947
947
  fi
948
948
  # (d) Defense-in-depth: reclaim the dashboard port only in the CLEAR
949
949
  # case, so we never kill a shared dashboard another project owns.
950
+ # BUT never kill a HEALTHY dashboard already serving on the port: that is
951
+ # almost always the user's own live dashboard (open in their browser), and
952
+ # killing it mid-use drops their session (ERR_CONNECTION_REFUSED, WS fail).
953
+ # A healthy server is reusable by every project, so probe /api/status first
954
+ # and only reclaim the port when nothing is answering (a genuinely stale
955
+ # listener). Opt out of the probe with LOKI_DASHBOARD_FORCE_RECLAIM=1.
950
956
  if command -v lsof >/dev/null 2>&1; then
951
- lsof -ti:"${DASHBOARD_PORT:-57374}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
957
+ local _dash_port="${DASHBOARD_PORT:-57374}"
958
+ local _dash_alive=""
959
+ if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
960
+ _dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
961
+ "http://127.0.0.1:${_dash_port}/api/status" 2>/dev/null || true)
962
+ fi
963
+ if [ "$_dash_alive" = "200" ]; then
964
+ log_info "Reusing the healthy dashboard already serving on port ${_dash_port} (not reclaiming)."
965
+ else
966
+ lsof -ti:"${_dash_port}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
967
+ fi
952
968
  fi
953
969
  fi
954
970
  }
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.98.0"
10
+ __version__ = "7.99.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -6240,9 +6240,14 @@ def _compute_cost_snapshot() -> dict:
6240
6240
  budget_used = 0.0
6241
6241
  budget_remaining = None
6242
6242
 
6243
- # Read efficiency files (one JSON file per iteration/task)
6243
+ # Read efficiency files (one JSON file per iteration/task).
6244
+ # Use the iteration-*.json pattern so this reader sees the same
6245
+ # authoritative file set as _compute_budget_snapshot and
6246
+ # _compute_cost_timeline (both glob iteration-*.json, mirroring
6247
+ # check_budget_limit in run.sh). A bare *.json would also pick up
6248
+ # non-iteration JSON in the dir and make the three cost readers disagree.
6244
6249
  if efficiency_dir.exists():
6245
- for eff_file in sorted(efficiency_dir.glob("*.json")):
6250
+ for eff_file in sorted(efficiency_dir.glob("iteration-*.json")):
6246
6251
  try:
6247
6252
  data = json.loads(eff_file.read_text())
6248
6253
  # A corrupt/truncated efficiency file can parse to a non-object
@@ -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.98.0
5
+ **Version:** v7.99.0
6
6
 
7
7
  ---
8
8
 
@@ -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.98.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.99.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=5C332E0B9079B5FC64756E2164756E21
805
+ //# debugId=2654E1AA878C0D9664756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.98.0'
60
+ __version__ = '7.99.0'
@@ -680,8 +680,10 @@ class MemoryRetrieval:
680
680
  for ns in namespaces:
681
681
  ns_retrieval = self.with_namespace(ns)
682
682
 
683
- # Simple keyword search in this namespace
684
- for collection in ["episodic", "semantic", "skills"]:
683
+ # Simple keyword search in this namespace.
684
+ # BUG-MEM-012: 'anti_patterns' was omitted, so anti-pattern
685
+ # memories were silently missed in cross-namespace search.
686
+ for collection in ["episodic", "semantic", "skills", "anti_patterns"]:
685
687
  results = ns_retrieval.retrieve_by_keyword(
686
688
  query.split(),
687
689
  collection,
@@ -823,20 +825,36 @@ class MemoryRetrieval:
823
825
  if collection not in self.vector_indices:
824
826
  return self.retrieve_by_keyword(query.split(), collection)[:top_k]
825
827
 
826
- # Check if indices need rebuilding after consolidation (BUG-MEM-002).
827
- # If patterns.json was modified more recently than we last built
828
- # indices, fall back to keyword search for accuracy.
829
- if collection == "semantic" and self._indices_built_at is not None:
830
- patterns_path = self.base_path / "semantic" / "patterns.json"
831
- if patterns_path.exists():
828
+ # Check if indices need rebuilding after consolidation (BUG-MEM-002,
829
+ # BUG-MEM-007). Consolidation rewrites semantic/patterns.json (and may
830
+ # touch semantic/anti-patterns.json), so any index sourced from those
831
+ # files can go stale. The 'semantic' index reads patterns.json; the
832
+ # 'anti_patterns' index reads BOTH patterns.json and anti-patterns.json
833
+ # (consolidated anti-patterns are bridged from patterns.json). If a
834
+ # source file was modified more recently than we last built indices,
835
+ # fall back to keyword search for accuracy. (episodic/skills read their
836
+ # own per-record files and are not rewritten by consolidation, so they
837
+ # are not checked here.)
838
+ if self._indices_built_at is not None:
839
+ stale_sources: List[str] = []
840
+ if collection == "semantic":
841
+ stale_sources = ["semantic/patterns.json"]
842
+ elif collection == "anti_patterns":
843
+ stale_sources = ["semantic/patterns.json",
844
+ "semantic/anti-patterns.json"]
845
+ if stale_sources:
832
846
  import os
833
- patterns_mtime = os.path.getmtime(patterns_path)
834
- if patterns_mtime > self._indices_built_at:
835
- logger.info(
836
- "Semantic index is stale (patterns modified after index build). "
837
- "Falling back to keyword search for accuracy."
838
- )
839
- return self.retrieve_by_keyword(query.split(), collection)[:top_k]
847
+ for rel in stale_sources:
848
+ source_path = self.base_path / rel
849
+ if source_path.exists() and \
850
+ os.path.getmtime(source_path) > self._indices_built_at:
851
+ logger.info(
852
+ "%s index is stale (%s modified after index build). "
853
+ "Falling back to keyword search for accuracy.",
854
+ collection, rel,
855
+ )
856
+ return self.retrieve_by_keyword(
857
+ query.split(), collection)[:top_k]
840
858
 
841
859
  # Generate query embedding
842
860
  query_embedding = self.embedding_engine.embed(query)
@@ -859,6 +877,34 @@ class MemoryRetrieval:
859
877
 
860
878
  return items
861
879
 
880
+ @staticmethod
881
+ def _anti_pattern_content_key(record: Dict[str, Any]) -> tuple:
882
+ """Normalized content key for an anti-pattern across both schemas.
883
+
884
+ Consolidation writes anti-patterns into semantic/patterns.json as
885
+ SemanticPattern objects (fields incorrect_approach|pattern /
886
+ description / correct_approach), while the legacy
887
+ semantic/anti-patterns.json uses what_fails / why / prevention. The
888
+ same anti-pattern can therefore appear in both stores (no migration
889
+ copies one into the other), which would double-count it on read and in
890
+ the vector index (BUG-MEM-011). Map both schemas onto the same
891
+ lowercased/stripped (what_fails, why, prevention) tuple so the two
892
+ representations of one anti-pattern collide on a single key.
893
+ """
894
+ def _norm(*candidates: Any) -> str:
895
+ for c in candidates:
896
+ if c:
897
+ return str(c).strip().lower()
898
+ return ""
899
+
900
+ what_fails = _norm(record.get("what_fails"),
901
+ record.get("incorrect_approach"),
902
+ record.get("pattern"))
903
+ why = _norm(record.get("why"), record.get("description"))
904
+ prevention = _norm(record.get("prevention"),
905
+ record.get("correct_approach"))
906
+ return (what_fails, why, prevention)
907
+
862
908
  @staticmethod
863
909
  def _parse_episode_timestamp(value: Any) -> Optional[datetime]:
864
910
  """Parse an episode timestamp to a tz-aware datetime, or None.
@@ -1746,6 +1792,25 @@ class MemoryRetrieval:
1746
1792
  """Keyword search in anti-patterns."""
1747
1793
  results: List[Dict[str, Any]] = []
1748
1794
  anti_data = self.storage.read_json("semantic/anti-patterns.json") or {}
1795
+ patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1796
+
1797
+ # BUG-MEM-011: an anti-pattern can live in BOTH the consolidated
1798
+ # patterns.json (category="anti-pattern") and the legacy
1799
+ # anti-patterns.json, with no migration copying one into the other.
1800
+ # Reading both naively double-counts it. Pre-scan the consolidated
1801
+ # store (which we keep) into a seen-set keyed by id AND normalized
1802
+ # content, then skip any legacy record that collides with either.
1803
+ seen_ids: set = set()
1804
+ seen_content: set = set()
1805
+ for pat in patterns_data.get("patterns", []):
1806
+ if not isinstance(pat, dict):
1807
+ continue
1808
+ if pat.get("category") != "anti-pattern":
1809
+ continue
1810
+ pid = pat.get("id")
1811
+ if pid:
1812
+ seen_ids.add(pid)
1813
+ seen_content.add(self._anti_pattern_content_key(pat))
1749
1814
 
1750
1815
  for anti in anti_data.get("anti_patterns", []):
1751
1816
  # Defensive: mirror the sibling loop below. A corrupt or
@@ -1753,6 +1818,11 @@ class MemoryRetrieval:
1753
1818
  # the isinstance guard and (x or "") avoid AttributeError.
1754
1819
  if not isinstance(anti, dict):
1755
1820
  continue
1821
+ # Dedup: skip a legacy record already represented in patterns.json.
1822
+ aid = anti.get("id")
1823
+ if (aid and aid in seen_ids) or \
1824
+ self._anti_pattern_content_key(anti) in seen_content:
1825
+ continue
1756
1826
  what_fails = (anti.get("what_fails") or "").lower()
1757
1827
  why = (anti.get("why") or "").lower()
1758
1828
  prevention = (anti.get("prevention") or "").lower()
@@ -1774,7 +1844,6 @@ class MemoryRetrieval:
1774
1844
  # description / correct_approach. Without this bridge, consolidated
1775
1845
  # anti-patterns were never retrievable. Map them onto the same
1776
1846
  # what_fails / why / prevention scoring shape.
1777
- patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1778
1847
  for pat in patterns_data.get("patterns", []):
1779
1848
  if not isinstance(pat, dict):
1780
1849
  continue
@@ -1888,8 +1957,33 @@ class MemoryRetrieval:
1888
1957
 
1889
1958
  index = self.vector_indices["anti_patterns"]
1890
1959
  anti_data = self.storage.read_json("semantic/anti-patterns.json") or {}
1960
+ patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1961
+
1962
+ # BUG-MEM-011: the same anti-pattern may live in BOTH the consolidated
1963
+ # patterns.json and the legacy anti-patterns.json. Indexing both
1964
+ # double-counts it in the vector index. Pre-scan the consolidated store
1965
+ # (which we keep) into a seen-set keyed by id AND normalized content,
1966
+ # then skip any legacy record that collides with either.
1967
+ seen_ids: set = set()
1968
+ seen_content: set = set()
1969
+ for pat in patterns_data.get("patterns", []):
1970
+ if not isinstance(pat, dict):
1971
+ continue
1972
+ if pat.get("category") != "anti-pattern":
1973
+ continue
1974
+ pid = pat.get("id")
1975
+ if pid:
1976
+ seen_ids.add(pid)
1977
+ seen_content.add(self._anti_pattern_content_key(pat))
1891
1978
 
1892
1979
  for anti in anti_data.get("anti_patterns", []):
1980
+ if not isinstance(anti, dict):
1981
+ continue
1982
+ # Dedup: skip a legacy record already represented in patterns.json.
1983
+ aid = anti.get("id")
1984
+ if (aid and aid in seen_ids) or \
1985
+ self._anti_pattern_content_key(anti) in seen_content:
1986
+ continue
1893
1987
  # Create text for embedding
1894
1988
  text = f"{anti.get('what_fails', '')} {anti.get('why', '')} {anti.get('prevention', '')}"
1895
1989
 
@@ -1904,7 +1998,6 @@ class MemoryRetrieval:
1904
1998
  # category="anti-pattern" entries in semantic/patterns.json, not the
1905
1999
  # legacy anti-patterns.json above. Bridge those into the vector index
1906
2000
  # too so embedding-based retrieval sees consolidated anti-patterns.
1907
- patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1908
2001
  for pat in patterns_data.get("patterns", []):
1909
2002
  if not isinstance(pat, dict):
1910
2003
  continue
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.98.0",
4
+ "version": "7.99.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.98.0",
5
+ "version": "7.99.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",