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 +2 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/checklist-verify.py +24 -2
- package/autonomy/completion-council.sh +44 -4
- package/autonomy/run.sh +59 -6
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +7 -2
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +110 -17
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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:
|
|
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:
|
|
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.
|
|
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.
|
|
411
|
+
**v7.99.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
#
|
|
612
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
12228
|
-
#
|
|
12229
|
-
python3
|
|
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
|
-
|
|
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" > "$
|
|
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
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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("
|
|
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
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -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.
|
|
5
|
+
**Version:** v7.99.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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=
|
|
805
|
+
//# debugId=2654E1AA878C0D9664756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/memory/retrieval.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
828
|
-
#
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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.
|
|
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.
|
|
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",
|