loki-mode 7.97.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/README.md CHANGED
@@ -197,7 +197,7 @@ loki quick "build a landing page with a signup form"
197
197
  | **Bun (recommended)** | `bun install -g loki-mode` | Fastest startup for CLI commands. |
198
198
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` | Auto-installs Bun as a dep |
199
199
  | **Docker (easiest)** | `loki docker start prd.md` | Host wrapper: runs loki in the published image with zero config. Bind-mounts the current folder so `.loki` state, resume, and continuity work exactly like local. Auto-detects auth (`ANTHROPIC_API_KEY`, else your host Claude Code login). Needs loki + Docker on the host. See DOCKER_README.md |
200
- | **Docker (raw)** | `docker pull asklokesh/loki-mode:7.58.1 && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:7.58.1 start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
200
+ | **Docker (raw)** | `docker pull asklokesh/loki-mode:latest && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:latest start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
201
201
  | **npm (compat)** | `npm install -g loki-mode` | Works without Bun (bash fallback). Migrate any time with `loki self-update --to bun`. |
202
202
 
203
203
  **Upgrading:**
@@ -257,7 +257,7 @@ The next major release sunsets the Bash runtime entirely. There is no firm calen
257
257
  | Method | Command |
258
258
  |--------|---------|
259
259
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` |
260
- | **Docker** | `docker pull asklokesh/loki-mode:7.58.1` |
260
+ | **Docker** | `docker pull asklokesh/loki-mode:latest` |
261
261
  | **Inside Claude Code** | `claude --dangerously-skip-permissions` then type "Loki Mode" |
262
262
  | **Git clone** | `git clone https://github.com/asklokesh/loki-mode.git` |
263
263
 
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.97.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.97.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.97.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"
@@ -44,7 +44,37 @@
44
44
  # Council configuration
45
45
  COUNCIL_ENABLED=${LOKI_COUNCIL_ENABLED:-true}
46
46
  COUNCIL_SIZE=${LOKI_COUNCIL_SIZE:-3}
47
+ # Guard COUNCIL_SIZE: a non-positive/non-numeric size would make the 2/3 floor 0
48
+ # and let an empty council "approve" by default (fake-green). Force a sane floor.
49
+ case "$COUNCIL_SIZE" in
50
+ ''|*[!0-9]*) COUNCIL_SIZE=3 ;;
51
+ esac
52
+ [ "$COUNCIL_SIZE" -lt 1 ] 2>/dev/null && COUNCIL_SIZE=3
47
53
  COUNCIL_THRESHOLD=${LOKI_COUNCIL_THRESHOLD:-2}
54
+
55
+ # Effective completion threshold: the operator's COUNCIL_THRESHOLD may only ever
56
+ # TIGHTEN the gate, never weaken it below the 2/3-majority safety floor. Before
57
+ # this, the operator's COUNCIL_THRESHOLD was silently ignored by the vote (a
58
+ # hardcoded 2/3 formula was used), so an operator who set a stricter threshold
59
+ # got a no-op while the logs claimed it was active. We honor it as a floor-raise
60
+ # only: effective = max(ceil(2/3*size), operator_threshold), clamped to size.
61
+ _council_effective_threshold() {
62
+ # Optional $1 = the size to compute against (e.g. the actual number of voters
63
+ # present in this round); defaults to COUNCIL_SIZE. The 2/3 floor + the
64
+ # operator clamp are both relative to this size, so the helper works for both
65
+ # the configured council size and a per-round member count.
66
+ local _size="${1:-$COUNCIL_SIZE}"
67
+ case "$_size" in ''|*[!0-9]*) _size="$COUNCIL_SIZE" ;; esac
68
+ [ "$_size" -lt 1 ] 2>/dev/null && _size=1
69
+ local _floor=$(( (_size * 2 + 2) / 3 ))
70
+ local _op="${COUNCIL_THRESHOLD:-0}"
71
+ case "$_op" in ''|*[!0-9]*) _op=0 ;; esac
72
+ local _eff="$_floor"
73
+ [ "$_op" -gt "$_eff" ] 2>/dev/null && _eff="$_op"
74
+ [ "$_eff" -gt "$_size" ] 2>/dev/null && _eff="$_size"
75
+ [ "$_eff" -lt 1 ] 2>/dev/null && _eff=1
76
+ printf '%s' "$_eff"
77
+ }
48
78
  COUNCIL_CHECK_INTERVAL=${LOKI_COUNCIL_CHECK_INTERVAL:-5}
49
79
  # Guard against invalid interval (must be positive integer)
50
80
  if ! [[ "$COUNCIL_CHECK_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
@@ -608,8 +638,11 @@ council_vote() {
608
638
  log_header "COMPLETION COUNCIL - Iteration $ITERATION_COUNT"
609
639
  log_info "Convening ${COUNCIL_SIZE}-member council..."
610
640
 
611
- # Compute threshold using ceiling(2/3) formula, consistent with council_aggregate_votes
612
- local effective_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
641
+ # Effective threshold honors the operator's COUNCIL_THRESHOLD as a floor-raise
642
+ # over the 2/3-majority safety floor (tighten-only); see
643
+ # _council_effective_threshold.
644
+ local effective_threshold
645
+ effective_threshold=$(_council_effective_threshold)
613
646
 
614
647
  # Gather evidence for council members
615
648
  local evidence_file="$vote_dir/evidence.md"
@@ -2604,7 +2637,13 @@ print(json.dumps(out))
2604
2637
  " 2>/dev/null || echo "[]")
2605
2638
 
2606
2639
  # Calculate threshold: 2/3 majority
2607
- local threshold=$(( (total_members * 2 + 2) / 3 )) # ceiling of 2/3
2640
+ # Effective threshold honors the operator's COUNCIL_THRESHOLD as a tighten-only
2641
+ # floor-raise over the 2/3 ceiling, computed against the actual member count.
2642
+ # This is the PRIMARY completion-decision path (council_should_stop ->
2643
+ # council_evaluate -> council_aggregate_votes), so it must use the same helper
2644
+ # as council_vote, not a separate hardcoded formula.
2645
+ local threshold
2646
+ threshold=$(_council_effective_threshold "$total_members")
2608
2647
  local verdict="CONTINUE"
2609
2648
  if [ "$complete_count" -ge "$threshold" ]; then
2610
2649
  verdict="COMPLETE"
@@ -2833,7 +2872,8 @@ council_evaluate() {
2833
2872
  fi
2834
2873
 
2835
2874
  # Compute threshold using the same ceiling(2/3) formula as council_vote and council_aggregate_votes
2836
- local _eval_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
2875
+ local _eval_threshold
2876
+ _eval_threshold=$(_council_effective_threshold)
2837
2877
 
2838
2878
  # Step 1: Aggregate votes from all members.
2839
2879
  # Phase C (v7.5.20): try the Claude `--agents <json>` + `--json-schema`
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
  }
@@ -2058,6 +2074,25 @@ validate_api_keys() {
2058
2074
  return 1
2059
2075
  fi
2060
2076
 
2077
+ # Zero-friction auth preflight for LOCAL runs (must run BEFORE the early
2078
+ # return below, which exits for non-Docker/K8s envs). When claude is the
2079
+ # provider, the claude CLI is on PATH, there is no ANTHROPIC_API_KEY, and no
2080
+ # OAuth credentials file exists (the user installed claude but never ran
2081
+ # `claude login`), the build would otherwise enter, make a failing call, and
2082
+ # 401 -- the worst first impression -- forcing the user to run `loki why`.
2083
+ # Fail fast with the one-step fix instead. Opt out with LOKI_SKIP_AUTH_PREFLIGHT=1.
2084
+ if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]] \
2085
+ && command -v claude >/dev/null 2>&1; then
2086
+ local _local_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
2087
+ if [[ ! -s "$_local_creds" ]]; then
2088
+ log_error "Claude Code is installed but not logged in -- the build would stall instead of running."
2089
+ log_error "Log in once, then retry:"
2090
+ log_error " claude login"
2091
+ log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
2092
+ return 1
2093
+ fi
2094
+ fi
2095
+
2061
2096
  # CLI tools (claude, codex, cline, aider) use their own login sessions.
2062
2097
  # Only require API keys inside Docker/K8s where CLI login isn't available.
2063
2098
  if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
@@ -2162,6 +2197,9 @@ except Exception:
2162
2197
  return 1
2163
2198
  fi
2164
2199
  fi
2200
+ # NOTE: the never-logged-in (no credentials file) case is handled earlier,
2201
+ # BEFORE the local early-return, so it covers local runs too (this Docker/
2202
+ # K8s-only block would otherwise be unreachable for that case).
2165
2203
  fi
2166
2204
 
2167
2205
  return 0
@@ -12224,16 +12262,31 @@ check_task_completion_signal() {
12224
12262
  local fb_statement="${fb_content:-All PRD requirements implemented and tests passing}"
12225
12263
  # Build minimal JSON payload
12226
12264
  signal_file="$fallback_file"
12227
- # Write the synthesized payload back into the signal file so the
12228
- # rest of this function can read it uniformly.
12229
- python3 -c "
12265
+ # Synthesize the payload into a TEMP file then atomically move it into
12266
+ # place, so the signal file is never observed truncated/empty/malformed:
12267
+ # a bare `python3 ... > "$fallback_file"` truncates the file BEFORE python
12268
+ # runs, so a python crash (or missing interpreter) mid-write would leave
12269
+ # an empty/partial file that downstream json.loads then silently drops.
12270
+ local _fb_tmp="${fallback_file}.tmp.$$"
12271
+ if python3 -c "
12230
12272
  import json, sys
12231
- print(json.dumps({
12273
+ sys.stdout.write(json.dumps({
12232
12274
  'statement': sys.argv[1][:1000],
12233
12275
  'evidence': 'file-based completion via COMPLETION_REQUESTED fallback',
12234
12276
  'confidence': 'medium',
12235
12277
  'source': 'completion_requested_file_fallback'
12236
- }))" "$fb_statement" > "$fallback_file" 2>/dev/null || echo '{}' > "$fallback_file"
12278
+ }))" "$fb_statement" > "$_fb_tmp" 2>/dev/null && [ -s "$_fb_tmp" ]; then
12279
+ mv -f "$_fb_tmp" "$fallback_file" 2>/dev/null || rm -f "$_fb_tmp" 2>/dev/null
12280
+ else
12281
+ # python unavailable or produced nothing: write a hand-built minimal
12282
+ # valid JSON (statement embedded with a conservative escape) atomically.
12283
+ rm -f "$_fb_tmp" 2>/dev/null
12284
+ local _fb_esc
12285
+ _fb_esc=$(printf '%s' "$fb_statement" | tr -d '"\\\n\r' | cut -c1-1000)
12286
+ printf '{"statement":"%s","evidence":"file-based completion via COMPLETION_REQUESTED fallback","confidence":"medium","source":"completion_requested_file_fallback"}' "$_fb_esc" > "${fallback_file}.tmp2.$$" 2>/dev/null \
12287
+ && mv -f "${fallback_file}.tmp2.$$" "$fallback_file" 2>/dev/null \
12288
+ || { rm -f "${fallback_file}.tmp2.$$" 2>/dev/null; printf '{"statement":"completion requested","evidence":"fallback","confidence":"low","source":"completion_requested_file_fallback"}' > "$fallback_file" 2>/dev/null; }
12289
+ fi
12237
12290
  fi
12238
12291
 
12239
12292
  if [ ! -f "$signal_file" ]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.97.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.97.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.97.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=ABBB64E42A394DE264756E2164756E21
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.97.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.97.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.97.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",