loki-mode 7.98.0 → 7.99.1

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.1
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.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.98.0
1
+ 7.99.1
@@ -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
  }
@@ -11230,6 +11246,7 @@ start_dashboard() {
11230
11246
  local original_port=$DASHBOARD_PORT
11231
11247
  local max_attempts=10
11232
11248
  local attempt=0
11249
+ local DASHBOARD_REUSED=0
11233
11250
 
11234
11251
  while lsof -i :$DASHBOARD_PORT &>/dev/null && [ $attempt -lt $max_attempts ]; do
11235
11252
  # Check if it's our own dashboard
@@ -11238,7 +11255,24 @@ start_dashboard() {
11238
11255
  # Only kill if it's a Python/uvicorn dashboard process
11239
11256
  local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true)
11240
11257
  if [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then
11241
- log_step "Killing existing dashboard on port $DASHBOARD_PORT (PID: $existing_pid)..."
11258
+ # Never kill a HEALTHY dashboard already serving here: it is almost
11259
+ # always the user's own live dashboard (open in their browser), and
11260
+ # killing it on `loki start` drops their session mid-use. A healthy
11261
+ # server is reusable by this build too, so probe /api/status and, if
11262
+ # it answers 200, REUSE it (skip starting our own). Only kill a
11263
+ # dashboard process that is NOT serving (a genuinely stuck/dead one).
11264
+ # Opt out with LOKI_DASHBOARD_FORCE_RECLAIM=1.
11265
+ local _dash_alive=""
11266
+ if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
11267
+ _dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
11268
+ "http://127.0.0.1:${DASHBOARD_PORT}/api/status" 2>/dev/null || true)
11269
+ fi
11270
+ if [ "$_dash_alive" = "200" ]; then
11271
+ log_info "Reusing the healthy dashboard already serving on port $DASHBOARD_PORT (not killing it)."
11272
+ DASHBOARD_REUSED=1
11273
+ break
11274
+ fi
11275
+ log_step "Killing stuck dashboard on port $DASHBOARD_PORT (PID: $existing_pid, not serving)..."
11242
11276
  kill "$existing_pid" 2>/dev/null || true
11243
11277
  sleep 1
11244
11278
  break
@@ -11260,6 +11294,15 @@ start_dashboard() {
11260
11294
  return 1
11261
11295
  fi
11262
11296
 
11297
+ # If we found a HEALTHY dashboard already serving on the port, reuse it: do
11298
+ # NOT launch a second server (that would fight for the port / kill the user's
11299
+ # live one). The existing server already serves this project's state.
11300
+ if [ "${DASHBOARD_REUSED:-0}" = "1" ]; then
11301
+ export LOKI_DASHBOARD_PORT="$DASHBOARD_PORT"
11302
+ log_info "Dashboard already live on port $DASHBOARD_PORT; reusing it."
11303
+ return 0
11304
+ fi
11305
+
11263
11306
  # Start FastAPI dashboard server (unified UI + API)
11264
11307
  log_step "Starting unified dashboard server..."
11265
11308
  local log_file=".loki/dashboard/logs/dashboard.log"
@@ -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.1"
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.1
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.1";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=2476B8BD9E98366964756E2164756E21
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.1'
@@ -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,27 +825,55 @@ 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]
840
-
841
- # Generate query embedding
842
- query_embedding = self.embedding_engine.embed(query)
843
-
844
- # Search vector index
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]
858
+
859
+ # Generate query embedding and search the vector index. If the embedding
860
+ # engine has fallen back to a different model/dimension since the index
861
+ # was built, the query vector dimension will not match the stored index
862
+ # and VectorSearchIndex.search raises ValueError. That must NOT crash
863
+ # retrieval or return wrong-dimension neighbors -- degrade to keyword
864
+ # search (the honest, accurate fallback), same as the staleness path.
845
865
  index = self.vector_indices[collection]
846
- results = index.search(query_embedding, top_k)
866
+ try:
867
+ query_embedding = self.embedding_engine.embed(query)
868
+ results = index.search(query_embedding, top_k)
869
+ except ValueError as exc:
870
+ logger.info(
871
+ "%s vector search failed (%s); likely an embedding "
872
+ "dimension change since index build. Falling back to keyword "
873
+ "search for accuracy.",
874
+ collection, exc,
875
+ )
876
+ return self.retrieve_by_keyword(query.split(), collection)[:top_k]
847
877
 
848
878
  # Convert to standard format
849
879
  items: List[Dict[str, Any]] = []
@@ -859,6 +889,34 @@ class MemoryRetrieval:
859
889
 
860
890
  return items
861
891
 
892
+ @staticmethod
893
+ def _anti_pattern_content_key(record: Dict[str, Any]) -> tuple:
894
+ """Normalized content key for an anti-pattern across both schemas.
895
+
896
+ Consolidation writes anti-patterns into semantic/patterns.json as
897
+ SemanticPattern objects (fields incorrect_approach|pattern /
898
+ description / correct_approach), while the legacy
899
+ semantic/anti-patterns.json uses what_fails / why / prevention. The
900
+ same anti-pattern can therefore appear in both stores (no migration
901
+ copies one into the other), which would double-count it on read and in
902
+ the vector index (BUG-MEM-011). Map both schemas onto the same
903
+ lowercased/stripped (what_fails, why, prevention) tuple so the two
904
+ representations of one anti-pattern collide on a single key.
905
+ """
906
+ def _norm(*candidates: Any) -> str:
907
+ for c in candidates:
908
+ if c:
909
+ return str(c).strip().lower()
910
+ return ""
911
+
912
+ what_fails = _norm(record.get("what_fails"),
913
+ record.get("incorrect_approach"),
914
+ record.get("pattern"))
915
+ why = _norm(record.get("why"), record.get("description"))
916
+ prevention = _norm(record.get("prevention"),
917
+ record.get("correct_approach"))
918
+ return (what_fails, why, prevention)
919
+
862
920
  @staticmethod
863
921
  def _parse_episode_timestamp(value: Any) -> Optional[datetime]:
864
922
  """Parse an episode timestamp to a tz-aware datetime, or None.
@@ -1746,6 +1804,25 @@ class MemoryRetrieval:
1746
1804
  """Keyword search in anti-patterns."""
1747
1805
  results: List[Dict[str, Any]] = []
1748
1806
  anti_data = self.storage.read_json("semantic/anti-patterns.json") or {}
1807
+ patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1808
+
1809
+ # BUG-MEM-011: an anti-pattern can live in BOTH the consolidated
1810
+ # patterns.json (category="anti-pattern") and the legacy
1811
+ # anti-patterns.json, with no migration copying one into the other.
1812
+ # Reading both naively double-counts it. Pre-scan the consolidated
1813
+ # store (which we keep) into a seen-set keyed by id AND normalized
1814
+ # content, then skip any legacy record that collides with either.
1815
+ seen_ids: set = set()
1816
+ seen_content: set = set()
1817
+ for pat in patterns_data.get("patterns", []):
1818
+ if not isinstance(pat, dict):
1819
+ continue
1820
+ if pat.get("category") != "anti-pattern":
1821
+ continue
1822
+ pid = pat.get("id")
1823
+ if pid:
1824
+ seen_ids.add(pid)
1825
+ seen_content.add(self._anti_pattern_content_key(pat))
1749
1826
 
1750
1827
  for anti in anti_data.get("anti_patterns", []):
1751
1828
  # Defensive: mirror the sibling loop below. A corrupt or
@@ -1753,6 +1830,11 @@ class MemoryRetrieval:
1753
1830
  # the isinstance guard and (x or "") avoid AttributeError.
1754
1831
  if not isinstance(anti, dict):
1755
1832
  continue
1833
+ # Dedup: skip a legacy record already represented in patterns.json.
1834
+ aid = anti.get("id")
1835
+ if (aid and aid in seen_ids) or \
1836
+ self._anti_pattern_content_key(anti) in seen_content:
1837
+ continue
1756
1838
  what_fails = (anti.get("what_fails") or "").lower()
1757
1839
  why = (anti.get("why") or "").lower()
1758
1840
  prevention = (anti.get("prevention") or "").lower()
@@ -1774,7 +1856,6 @@ class MemoryRetrieval:
1774
1856
  # description / correct_approach. Without this bridge, consolidated
1775
1857
  # anti-patterns were never retrievable. Map them onto the same
1776
1858
  # what_fails / why / prevention scoring shape.
1777
- patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1778
1859
  for pat in patterns_data.get("patterns", []):
1779
1860
  if not isinstance(pat, dict):
1780
1861
  continue
@@ -1888,8 +1969,33 @@ class MemoryRetrieval:
1888
1969
 
1889
1970
  index = self.vector_indices["anti_patterns"]
1890
1971
  anti_data = self.storage.read_json("semantic/anti-patterns.json") or {}
1972
+ patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1973
+
1974
+ # BUG-MEM-011: the same anti-pattern may live in BOTH the consolidated
1975
+ # patterns.json and the legacy anti-patterns.json. Indexing both
1976
+ # double-counts it in the vector index. Pre-scan the consolidated store
1977
+ # (which we keep) into a seen-set keyed by id AND normalized content,
1978
+ # then skip any legacy record that collides with either.
1979
+ seen_ids: set = set()
1980
+ seen_content: set = set()
1981
+ for pat in patterns_data.get("patterns", []):
1982
+ if not isinstance(pat, dict):
1983
+ continue
1984
+ if pat.get("category") != "anti-pattern":
1985
+ continue
1986
+ pid = pat.get("id")
1987
+ if pid:
1988
+ seen_ids.add(pid)
1989
+ seen_content.add(self._anti_pattern_content_key(pat))
1891
1990
 
1892
1991
  for anti in anti_data.get("anti_patterns", []):
1992
+ if not isinstance(anti, dict):
1993
+ continue
1994
+ # Dedup: skip a legacy record already represented in patterns.json.
1995
+ aid = anti.get("id")
1996
+ if (aid and aid in seen_ids) or \
1997
+ self._anti_pattern_content_key(anti) in seen_content:
1998
+ continue
1893
1999
  # Create text for embedding
1894
2000
  text = f"{anti.get('what_fails', '')} {anti.get('why', '')} {anti.get('prevention', '')}"
1895
2001
 
@@ -1904,7 +2010,6 @@ class MemoryRetrieval:
1904
2010
  # category="anti-pattern" entries in semantic/patterns.json, not the
1905
2011
  # legacy anti-patterns.json above. Bridge those into the vector index
1906
2012
  # too so embedding-based retrieval sees consolidated anti-patterns.
1907
- patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1908
2013
  for pat in patterns_data.get("patterns", []):
1909
2014
  if not isinstance(pat, dict):
1910
2015
  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.1",
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.1",
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",