loki-mode 7.60.0 → 7.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.60.0
6
+ # Loki Mode v7.62.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.60.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.62.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.60.0
1
+ 7.62.0
@@ -1374,20 +1374,59 @@ app_runner_health_check() {
1374
1374
  return 1
1375
1375
  fi
1376
1376
 
1377
- # For HTTP apps, try an HTTP health check
1377
+ # For HTTP apps, try an HTTP health check.
1378
1378
  if [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
1379
- if curl -sf -o /dev/null -m 5 "http://localhost:${_APP_RUNNER_PORT}/" 2>/dev/null; then
1379
+ # The health signal is "is the server answering HTTP at all", NOT "does /
1380
+ # return 2xx". Loki generates plenty of apps that legitimately serve a
1381
+ # non-2xx on the root path (an API-only FastAPI/Express backend 404s on
1382
+ # `/`, anything behind auth 401s). Those are serving correctly, so a
1383
+ # status-strict probe (curl -f, which fails on >=400) would mark a healthy
1384
+ # backend unhealthy and trigger a needless restart -> a restart storm /
1385
+ # false crash. What genuinely means "no longer serving" -- a hung event
1386
+ # loop, a deadlock, a wedged dev server -- is a connection that times out
1387
+ # or is refused, i.e. NO HTTP response at all. So we read the HTTP status
1388
+ # code: any code returned (2xx/3xx/4xx/5xx) means the server answered and
1389
+ # is alive; "000" is curl's sentinel for connect-failure/timeout/reset
1390
+ # and is the only thing we treat as a crash.
1391
+ # If curl is unavailable we cannot probe HTTP at all; fall back to the
1392
+ # old, more tolerant signal (PID alive == healthy) rather than declaring
1393
+ # every HTTP app wedged and triggering a restart storm. curl is the only
1394
+ # HTTP client this function uses.
1395
+ if ! command -v curl >/dev/null 2>&1; then
1380
1396
  _write_health "true"
1381
1397
  _write_app_state "running"
1382
1398
  return 0
1383
- else
1384
- # HTTP failed but process alive -- may be a non-HTTP app or still starting
1399
+ fi
1400
+ # On connect-failure/timeout curl already prints "000" via %{http_code}
1401
+ # and exits non-zero; do NOT append our own "000" (a `|| echo 000` would
1402
+ # concatenate to "000000"). The trailing `|| true` swallows the non-zero
1403
+ # exit (matching this file's guarded command-substitution convention, e.g.
1404
+ # the _GIT_DIFF_HASH / port reads) so the watchdog never aborts under a
1405
+ # future `set -e`; the empty fallback then maps to "000".
1406
+ local _http_code
1407
+ _http_code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' \
1408
+ "http://localhost:${_APP_RUNNER_PORT}/" 2>/dev/null || true)
1409
+ _http_code="${_http_code:-000}"
1410
+ if [ "$_http_code" != "000" ]; then
1385
1411
  _write_health "true"
1412
+ _write_app_state "running"
1386
1413
  return 0
1414
+ else
1415
+ # No HTTP response: the process is alive (kill -0 passed above) but is
1416
+ # not serving on its declared port -- a wedged/hung/deadlocked server.
1417
+ # Previously this branch wrote ok:true unconditionally, so the HTTP
1418
+ # signal could never report a failure and a wedged server stayed
1419
+ # "healthy" forever. Report the failure honestly so the watchdog can
1420
+ # act on it. We deliberately do NOT flip state.json to "crashed" here
1421
+ # (mirroring the dead-PID precedent above at the kill -0 check); the
1422
+ # watchdog owns the crashed transition after its circuit breaker, so a
1423
+ # single transient blip does not prematurely mark the app crashed.
1424
+ _write_health "false"
1425
+ return 1
1387
1426
  fi
1388
1427
  fi
1389
1428
 
1390
- # Non-HTTP: PID alive is sufficient
1429
+ # Non-HTTP: PID alive is sufficient (no URL/port to probe)
1391
1430
  _write_health "true"
1392
1431
  return 0
1393
1432
  }
@@ -1477,17 +1516,35 @@ app_runner_watchdog() {
1477
1516
  return 0
1478
1517
  fi
1479
1518
 
1480
- # Process alive, nothing to do
1519
+ # Process alive: kill -0 only proves the PID exists, not that the app is
1520
+ # actually serving. A hung event loop, a deadlock, or a wedged dev server
1521
+ # all pass kill -0 forever while never answering a request, so the old
1522
+ # "alive == healthy" shortcut let a wedged HTTP app run un-restarted and
1523
+ # left health.json stale. Mirror the compose branch: defer to
1524
+ # app_runner_health_check (HTTP-aware for apps that declared a port), and
1525
+ # treat an unhealthy-but-alive process as a crash so the same circuit
1526
+ # breaker + backoff + restart path handles it.
1481
1527
  if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1482
- # BUG 3 fix: a confirmed-alive observation clears the accumulated crash
1483
- # count so the breaker fires only on 5 CONSECUTIVE deaths, not on 5
1484
- # cumulative crashes that were each successfully recovered over a long
1485
- # session (which would trip the breaker on a HEALTHY app).
1486
- _APP_RUNNER_CRASH_COUNT=0
1487
- return 0
1528
+ if app_runner_health_check; then
1529
+ # BUG 3 fix: a confirmed-healthy observation clears the accumulated
1530
+ # crash count so the breaker fires only on 5 CONSECUTIVE failures,
1531
+ # not on 5 cumulative crashes that were each successfully recovered
1532
+ # over a long session (which would trip the breaker on a HEALTHY app).
1533
+ _APP_RUNNER_CRASH_COUNT=0
1534
+ return 0
1535
+ fi
1536
+ # Alive but not healthy (e.g. HTTP probe failed for an app that declared
1537
+ # a port). Fall through to the crash path below, but first terminate the
1538
+ # wedged process: it is still bound to the port, so app_runner_start's
1539
+ # port-conflict guard would otherwise refuse to start and the breaker
1540
+ # would trip while the orphan keeps serving hung responses (a restart
1541
+ # storm). app_runner_stop performs a full process-tree teardown and
1542
+ # clears _APP_RUNNER_PID / app.pid, leaving a clean slate for restart.
1543
+ log_warn "App Runner: process alive but unhealthy (not serving) -- treating as crash"
1544
+ app_runner_stop
1488
1545
  fi
1489
1546
 
1490
- # Process is dead
1547
+ # Process is dead (or was just torn down because it was alive-but-wedged)
1491
1548
  _APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
1492
1549
  log_warn "App Runner: process died (crash #$_APP_RUNNER_CRASH_COUNT)"
1493
1550
 
package/autonomy/run.sh CHANGED
@@ -9809,9 +9809,16 @@ CPEOF
9809
9809
  old_cp="${checkpoint_dir}/${old_cp}"
9810
9810
  rm -rf "$old_cp" 2>/dev/null || true
9811
9811
  done
9812
- # Rebuild index atomically from remaining checkpoints (sorted by epoch)
9812
+ # Rebuild index atomically from remaining checkpoints (sorted by epoch).
9813
+ # BUG-ST-012: sort on the checkpoint dir BASENAME, not the full path.
9814
+ # Checkpoint ids are cp-<iter>-<epoch> so basename field 3 is the epoch,
9815
+ # but a full path like .../loki-mode/.loki/.../cp-N-EPOCH/metadata.json has
9816
+ # extra hyphens (e.g. the loki-mode cwd) that shift the epoch out of field 3.
9817
+ # Prefix each path with a basename-derived key, sort on it, then strip it.
9813
9818
  local tmp_index="${index_file}.tmp.$$"
9814
- for remaining in $(find "$checkpoint_dir" -maxdepth 2 -name "metadata.json" -path "*/cp-*/*" 2>/dev/null | sort -t'-' -k3 -n); do
9819
+ for remaining in $(find "$checkpoint_dir" -maxdepth 2 -name "metadata.json" -path "*/cp-*/*" 2>/dev/null \
9820
+ | while read -r mp; do printf '%s\t%s\n' "$(basename "$(dirname "$mp")")" "$mp"; done \
9821
+ | sort -t'-' -k3 -n | cut -f2-); do
9815
9822
  [ -f "$remaining" ] || continue
9816
9823
  _CP_META="$remaining" python3 -c "
9817
9824
  import json,os
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.60.0"
10
+ __version__ = "7.62.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -6589,7 +6589,7 @@ async def resume_agent(agent_id: str):
6589
6589
 
6590
6590
 
6591
6591
  @app.get("/api/logs")
6592
- async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_current_token)):
6592
+ async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optional[dict] = Depends(auth.get_current_token)):
6593
6593
  """Get recent log entries from session log files."""
6594
6594
  log_dir = _get_loki_dir() / "logs"
6595
6595
  entries = []
@@ -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.60.0
5
+ **Version:** v7.62.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.60.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.62.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
package/events/emit.sh CHANGED
@@ -164,5 +164,23 @@ if [ -f "$EVENTS_LOG" ]; then
164
164
  fi
165
165
  fi
166
166
 
167
+ # Append a flat-schema record to .loki/events.jsonl for dashboard consumption.
168
+ #
169
+ # The dashboard reads .loki/events.jsonl directly (dashboard/server.py
170
+ # _read_events) and run.sh's emit_event/emit_event_json write the FLAT schema
171
+ # {"timestamp","type","data"} -- NOT the nested pending schema written to the
172
+ # per-event file above. Without this append, events emitted via emit.sh land
173
+ # only in the pending dir and stay INVISIBLE to the dashboard.
174
+ #
175
+ # Mapping: data = the existing PAYLOAD object (mirrors emit_event_json, where
176
+ # `data` is a JSON object). `source` is intentionally dropped from the flat
177
+ # record (not part of the dashboard schema); the pending file above preserves
178
+ # it for other consumers. PAYLOAD is already newline-free (built on lines
179
+ # 127-135), so the record is a single compact line. The helper appends its own
180
+ # trailing newline. `|| true` keeps observability from ever aborting the emit
181
+ # under `set -e` (matches autonomy/run.sh:9896).
182
+ FLAT_EVENT="{\"timestamp\":\"$TIMESTAMP\",\"type\":\"$TYPE_ESC\",\"data\":$PAYLOAD}"
183
+ safe_append_event_jsonl "$EVENTS_LOG" "$FLAT_EVENT" 2>/dev/null || true
184
+
167
185
  # Output event ID
168
186
  echo "$EVENT_ID"
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.60.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ 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 a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-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)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.62.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ 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 a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-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)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -790,4 +790,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
790
790
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
791
791
  `),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
792
792
 
793
- //# debugId=B963F5BED7BF3C2664756E2164756E21
793
+ //# debugId=77DAD44E0F11F25F64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.60.0'
60
+ __version__ = '7.62.0'
@@ -45,7 +45,7 @@ class CrossProjectIndex:
45
45
  'path': str(child),
46
46
  'name': child.name,
47
47
  'memory_dir': str(memory_dir),
48
- 'discovered_at': datetime.now(timezone.utc).isoformat() + 'Z',
48
+ 'discovered_at': datetime.now(timezone.utc).isoformat(),
49
49
  })
50
50
  return projects
51
51
 
@@ -58,7 +58,7 @@ class CrossProjectIndex:
58
58
  projects = self.discover_projects()
59
59
  index = {
60
60
  'projects': [],
61
- 'built_at': datetime.now(timezone.utc).isoformat() + 'Z',
61
+ 'built_at': datetime.now(timezone.utc).isoformat(),
62
62
  'total_episodes': 0,
63
63
  'total_patterns': 0,
64
64
  'total_skills': 0,
@@ -46,7 +46,7 @@ class OrganizationKnowledgeGraph:
46
46
  with open(pattern_file) as f:
47
47
  pattern = json.load(f)
48
48
  pattern['_source_project'] = str(project_dir)
49
- pattern['_extracted_at'] = datetime.now(timezone.utc).isoformat() + 'Z'
49
+ pattern['_extracted_at'] = datetime.now(timezone.utc).isoformat()
50
50
  all_patterns.append(pattern)
51
51
  except (json.JSONDecodeError, IOError):
52
52
  continue
@@ -112,7 +112,7 @@ class OrganizationKnowledgeGraph:
112
112
  graph = {
113
113
  'nodes': [],
114
114
  'edges': [],
115
- 'built_at': datetime.now(timezone.utc).isoformat() + 'Z',
115
+ 'built_at': datetime.now(timezone.utc).isoformat(),
116
116
  }
117
117
 
118
118
  for project_dir in project_dirs:
@@ -26,15 +26,26 @@ class Topic:
26
26
  Attributes:
27
27
  id: Unique identifier for the topic
28
28
  summary: Brief summary of the topic content
29
- relevance_score: How relevant this topic is (0.0 to 1.0)
29
+ relevance_score: How relevant this topic is (0.0 to 1.0). This is the
30
+ STORED value and is what to_dict() persists.
30
31
  token_count: Estimated tokens in the full memory
31
32
  last_accessed: When this topic was last accessed
33
+ match_score: Transient, per-query ranking score (stored relevance
34
+ plus a keyword-match boost). None when no query boost applies.
35
+ Never persisted by to_dict(); used only for ranking/threshold
36
+ decisions within a single retrieval call.
32
37
  """
33
38
  id: str
34
39
  summary: str
35
40
  relevance_score: float = 0.5
36
41
  token_count: int = 0
37
42
  last_accessed: Optional[str] = None
43
+ match_score: Optional[float] = None
44
+
45
+ @property
46
+ def effective_score(self) -> float:
47
+ """Ranking score for this query: match_score when set, else stored relevance."""
48
+ return self.match_score if self.match_score is not None else self.relevance_score
38
49
 
39
50
  def to_dict(self) -> Dict[str, Any]:
40
51
  """Convert to dictionary for JSON serialization."""
@@ -80,12 +91,17 @@ class IndexLayer:
80
91
  """
81
92
  self.base_path = Path(base_path)
82
93
  self.index_path = self.base_path / "index.json"
83
- self._cache: Optional[Dict[str, Any]] = None
84
94
 
85
95
  def load(self) -> Dict[str, Any]:
86
96
  """
87
97
  Load index.json from disk.
88
98
 
99
+ Always re-reads from disk: these files are tiny (~100 token target)
100
+ and are written by separate processes (the dashboard reads
101
+ index.json via server.py while the orchestrator writes it), so an
102
+ in-memory cache cannot be invalidated correctly across processes.
103
+ An honest fresh read beats a stale cache for retrieval accuracy.
104
+
89
105
  Returns:
90
106
  Index dictionary with version, topics, and metadata
91
107
  """
@@ -94,8 +110,7 @@ class IndexLayer:
94
110
 
95
111
  try:
96
112
  with open(self.index_path, "r") as f:
97
- self._cache = json.load(f)
98
- return self._cache
113
+ return json.load(f)
99
114
  except (json.JSONDecodeError, IOError):
100
115
  return self._create_empty_index()
101
116
 
@@ -133,8 +148,6 @@ class IndexLayer:
133
148
  pass
134
149
  raise
135
150
 
136
- self._cache = index
137
-
138
151
  def update(self, memories: List[Dict[str, Any]]) -> None:
139
152
  """
140
153
  Rebuild index from a list of memories.
@@ -216,19 +229,22 @@ class IndexLayer:
216
229
  summary_lower = topic.summary.lower()
217
230
  summary_words = set(summary_lower.split())
218
231
 
219
- # Calculate match score based on word overlap
232
+ # Calculate match score based on word overlap.
233
+ # The boost is applied to a SEPARATE transient match_score, never
234
+ # to the stored relevance_score, so callers still see the stored
235
+ # value while ranking and the Layer-3 gate use the boosted score.
220
236
  common_words = query_words & summary_words
221
- if common_words:
222
- # Boost relevance based on word matches
237
+ if common_words and query_words:
223
238
  match_boost = len(common_words) / len(query_words) * 0.3
224
- topic.relevance_score = min(1.0, topic.relevance_score + match_boost)
239
+ topic.match_score = min(1.0, topic.relevance_score + match_boost)
225
240
  relevant.append(topic)
226
241
  elif topic.relevance_score >= 0.8:
227
- # Include high-relevance topics even without exact match
242
+ # Include high-relevance topics even without exact match.
243
+ # No keyword boost: ranking falls back to stored relevance.
228
244
  relevant.append(topic)
229
245
 
230
- # Sort by relevance score, descending
231
- relevant.sort(key=lambda t: t.relevance_score, reverse=True)
246
+ # Sort by effective (match-or-stored) score, descending
247
+ relevant.sort(key=lambda t: t.effective_score, reverse=True)
232
248
 
233
249
  return relevant
234
250
 
@@ -140,33 +140,42 @@ class ProgressiveLoader:
140
140
  self._metrics.calculate_savings(index.get("total_tokens_available", 0))
141
141
  return memories, self._metrics
142
142
 
143
- # Layer 2: Load timeline for relevant topics
143
+ # Layer 2: Load timeline for relevant topics.
144
+ # Affordability gate: the timeline must fit the remaining budget. If the
145
+ # full timeline costs more than we can afford, loading and appending all
146
+ # of it would drive remaining_tokens negative and violate max_tokens
147
+ # (Layer 3 already has this guard; Layer 2 did not). When it does not
148
+ # fit, skip the timeline-as-sufficient-context shortcut and fall through
149
+ # to the budget-aware Layer 3 path instead of overspending.
144
150
  timeline = self.timeline_layer.load()
145
151
  layer2_tokens = self.timeline_layer.get_token_count()
146
- self._metrics.layer2_tokens = layer2_tokens
147
- remaining_tokens -= layer2_tokens
148
-
149
- # Collect timeline context for each relevant topic
150
- topic_ids = {t.id for t in relevant_topics}
151
- timeline_context: Dict[str, List[Dict[str, Any]]] = {}
152
-
153
- for topic in relevant_topics:
154
- topic_entries = self.timeline_layer.get_recent_for_topic(topic.id)
155
- if topic_entries:
156
- timeline_context[topic.id] = topic_entries
157
-
158
- # Check if timeline provides sufficient context
159
- if self.sufficient_context(timeline_context, query):
160
- # Add timeline entries as context
161
- for topic_id, entries in timeline_context.items():
162
- for entry in entries:
163
- memories.append({
164
- "id": topic_id,
165
- "type": "timeline",
166
- "content": entry,
167
- })
168
- self._metrics.calculate_savings(index.get("total_tokens_available", 0))
169
- return memories, self._metrics
152
+ timeline_affordable = layer2_tokens <= remaining_tokens
153
+
154
+ if timeline_affordable:
155
+ self._metrics.layer2_tokens = layer2_tokens
156
+ remaining_tokens -= layer2_tokens
157
+
158
+ # Collect timeline context for each relevant topic
159
+ topic_ids = {t.id for t in relevant_topics}
160
+ timeline_context: Dict[str, List[Dict[str, Any]]] = {}
161
+
162
+ for topic in relevant_topics:
163
+ topic_entries = self.timeline_layer.get_recent_for_topic(topic.id)
164
+ if topic_entries:
165
+ timeline_context[topic.id] = topic_entries
166
+
167
+ # Check if timeline provides sufficient context
168
+ if self.sufficient_context(timeline_context, query):
169
+ # Add timeline entries as context
170
+ for topic_id, entries in timeline_context.items():
171
+ for entry in entries:
172
+ memories.append({
173
+ "id": topic_id,
174
+ "type": "timeline",
175
+ "content": entry,
176
+ })
177
+ self._metrics.calculate_savings(index.get("total_tokens_available", 0))
178
+ return memories, self._metrics
170
179
 
171
180
  # Layer 3: Load full memories for high-relevance topics
172
181
  if remaining_tokens > 0:
@@ -37,12 +37,17 @@ class TimelineLayer:
37
37
  """
38
38
  self.base_path = Path(base_path)
39
39
  self.timeline_path = self.base_path / "timeline.json"
40
- self._cache: Optional[Dict[str, Any]] = None
41
40
 
42
41
  def load(self) -> Dict[str, Any]:
43
42
  """
44
43
  Load timeline.json from disk.
45
44
 
45
+ Always re-reads from disk: these files are tiny (~500 token target)
46
+ and are written by separate processes (the dashboard reads
47
+ timeline.json via server.py while the orchestrator writes it), so an
48
+ in-memory cache cannot be invalidated correctly across processes.
49
+ An honest fresh read beats a stale cache for retrieval accuracy.
50
+
46
51
  Returns:
47
52
  Timeline dictionary with actions, decisions, and context
48
53
  """
@@ -51,8 +56,7 @@ class TimelineLayer:
51
56
 
52
57
  try:
53
58
  with open(self.timeline_path, "r") as f:
54
- self._cache = json.load(f)
55
- return self._cache
59
+ return json.load(f)
56
60
  except (json.JSONDecodeError, IOError):
57
61
  return self._create_empty_timeline()
58
62
 
@@ -94,8 +98,6 @@ class TimelineLayer:
94
98
  pass
95
99
  raise
96
100
 
97
- self._cache = timeline
98
-
99
101
  def add_action(
100
102
  self,
101
103
  action: str,
@@ -372,9 +372,9 @@ class MemoryRetrieval:
372
372
  Returns:
373
373
  One of: exploration, implementation, debugging, review, refactoring
374
374
  """
375
- goal = context.get("goal", "").lower()
376
- action = context.get("action_type", "").lower()
377
- phase = context.get("phase", "").lower()
375
+ goal = (context.get("goal") or "").lower()
376
+ action = (context.get("action_type") or "").lower()
377
+ phase = (context.get("phase") or "").lower()
378
378
 
379
379
  scores: Dict[str, int] = {}
380
380
 
@@ -1523,7 +1523,9 @@ class MemoryRetrieval:
1523
1523
 
1524
1524
  name = data.get("name", "").lower()
1525
1525
  description = data.get("description", "").lower()
1526
- steps_text = " ".join(data.get("steps", [])).lower()
1526
+ steps_text = " ".join(
1527
+ s for s in (data.get("steps") or []) if isinstance(s, str)
1528
+ ).lower()
1527
1529
 
1528
1530
  score = sum(2 for kw in keywords if kw in name)
1529
1531
  score += sum(1 for kw in keywords if kw in description)
@@ -1664,7 +1666,9 @@ class MemoryRetrieval:
1664
1666
  continue
1665
1667
 
1666
1668
  # Create text for embedding
1667
- steps = " ".join(data.get("steps", []))
1669
+ steps = " ".join(
1670
+ s for s in (data.get("steps") or []) if isinstance(s, str)
1671
+ )
1668
1672
  text = f"{data.get('name', '')} {data.get('description', '')} {steps}"
1669
1673
 
1670
1674
  # Generate embedding
@@ -223,8 +223,16 @@ def optimize_context(
223
223
  except (ValueError, TypeError):
224
224
  pass
225
225
 
226
- # Get relevance score (already computed by retrieval)
227
- relevance = memory.get("_score", 0.5)
226
+ # Get relevance score (already computed by retrieval).
227
+ # Prefer the task-aware _weighted_score (task-strategy weight x
228
+ # importance x confidence x recency) when retrieval has computed it,
229
+ # so that token-budget trimming preserves the task-aware ranking
230
+ # instead of re-ranking from scratch on the raw _score. Fall back to
231
+ # _score only when _weighted_score is absent.
232
+ if "_weighted_score" in memory:
233
+ relevance = memory.get("_weighted_score", 0.5)
234
+ else:
235
+ relevance = memory.get("_score", 0.5)
228
236
  if relevance > 1.0:
229
237
  # Normalize high scores
230
238
  relevance = min(1.0, relevance / 10.0)
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.60.0",
4
+ "version": "7.62.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.60.0",
5
+ "version": "7.62.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",
@@ -508,10 +508,48 @@ function linkManifest(opts) {
508
508
  };
509
509
  }
510
510
 
511
+ /**
512
+ * Read the witnessed agent-chain high-water mark.
513
+ *
514
+ * The witness file (witness.jsonl) records, on each append, the agent
515
+ * chain's `agentEntries` count at witness time. Because the file is
516
+ * append-only and the agent chain only grows, the MAX recorded
517
+ * `agentEntries` is a lower bound on how long the agent chain has ever
518
+ * legitimately been. If the live chain is now SHORTER than that, the
519
+ * trailing portion of the chain was truncated -- which a bare
520
+ * verifyChain() (genesis-to-tip linkage with no count anchor) cannot
521
+ * detect, because a truncated prefix re-links cleanly.
522
+ *
523
+ * @returns {object} { present, highWater } -- highWater:0 and
524
+ * present:false when no witness file / no usable counts exist.
525
+ */
526
+ function witnessAgentHighWater(opts) {
527
+ opts = opts || {};
528
+ var witnessFile = opts.witnessFile ||
529
+ path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE);
530
+ if (!fs.existsSync(witnessFile)) {
531
+ return { present: false, highWater: 0, witnessFile: witnessFile };
532
+ }
533
+ var content = fs.readFileSync(witnessFile, 'utf8').trim();
534
+ if (!content) return { present: false, highWater: 0, witnessFile: witnessFile };
535
+ var lines = content.split('\n');
536
+ var high = 0;
537
+ var sawCount = false;
538
+ for (var i = 0; i < lines.length; i++) {
539
+ var rec;
540
+ try { rec = JSON.parse(lines[i]); } catch (_) { continue; }
541
+ if (rec && typeof rec.agentEntries === 'number') {
542
+ sawCount = true;
543
+ if (rec.agentEntries > high) high = rec.agentEntries;
544
+ }
545
+ }
546
+ return { present: sawCount, highWater: high, witnessFile: witnessFile };
547
+ }
548
+
511
549
  /**
512
550
  * Verify the run-manifest link against the evidence chain.
513
551
  *
514
- * Composes TWO checks (mirroring verifyUnified rather than a bare disk-vs
552
+ * Composes THREE checks (mirroring verifyUnified rather than a bare disk-vs
515
553
  * -recorded compare):
516
554
  * 1. Agent chain integrity (AuditLog.verifyChain()). This catches an
517
555
  * edit to the ANCHOR entry itself (e.g. someone rewrites the recorded
@@ -519,14 +557,30 @@ function linkManifest(opts) {
519
557
  * 2. Manifest reconciliation: re-hash the on-disk manifest and require
520
558
  * it to equal the hash recorded by the MOST RECENT manifest-link
521
559
  * anchor. A mutated manifest no longer matches -> tamper detected.
560
+ * 3. Trailing-truncation detection via the append-only witness file.
561
+ * verifyChain() validates previousHash linkage from genesis with NO
562
+ * count anchor, so an attacker who edits .loki/loki-run.json AND
563
+ * truncates .loki/audit/audit.jsonl to drop the trailing
564
+ * manifest-link anchor leaves a SHORTER but internally-consistent
565
+ * chain that verifies clean (and reports present:false, which a
566
+ * caller must NOT read as a pass). We cross-check the witness file's
567
+ * recorded agentEntries high-water mark against the live chain
568
+ * length: if the chain is now shorter than a previously-witnessed
569
+ * count, the trail was truncated and we return valid:false with
570
+ * truncationSuspected:true.
522
571
  *
523
- * HONEST empty cases (distinguishable from a real pass via `present`):
524
- * - No anchor recorded yet -> { present:false, valid:true, reason:... }.
572
+ * HONEST empty cases (distinguishable from a real pass via `present` and
573
+ * `truncationSuspected`):
574
+ * - No anchor recorded yet -> { present:false, ... }. valid is true ONLY
575
+ * when no witness exists or the chain still meets the witnessed
576
+ * high-water mark; a witnessed-then-truncated chain reports
577
+ * valid:false + truncationSuspected:true even on this absent-anchor
578
+ * path, so an absent anchor can never be silently read as verified.
525
579
  * - Anchor exists but the manifest file is now gone -> manifest.valid
526
580
  * is false (the pinned manifest is missing/cannot be reconciled).
527
581
  *
528
582
  * @param {object} [opts] projectDir / logDir / manifestPath as linkManifest.
529
- * @returns {object} { valid, present, chain, manifest }
583
+ * @returns {object} { valid, present, truncationSuspected, chain, manifest, witness }
530
584
  */
531
585
  function verifyManifestLink(opts) {
532
586
  opts = opts || {};
@@ -537,14 +591,39 @@ function verifyManifestLink(opts) {
537
591
  var entries = log.readEntries();
538
592
  log.destroy();
539
593
 
594
+ // Trailing-truncation guard: compare the live chain length against the
595
+ // highest agentEntries count any witness ever recorded. A shrink means
596
+ // the chain was truncated below a point it provably once reached.
597
+ var hw = witnessAgentHighWater(opts);
598
+ var chainLen = typeof chain.entries === 'number' ? chain.entries : entries.length;
599
+ var truncationSuspected = hw.present && chainLen < hw.highWater;
600
+ var witnessInfo = {
601
+ present: hw.present,
602
+ witnessedHighWater: hw.highWater,
603
+ currentChainLength: chainLen,
604
+ truncationSuspected: truncationSuspected,
605
+ };
606
+
540
607
  var anchors = entries.filter(function (e) {
541
608
  return e.what === MANIFEST_LINK_ACTION;
542
609
  });
543
610
 
544
611
  if (anchors.length === 0) {
545
612
  return {
546
- valid: !!chain.valid, present: false, chain: chain,
547
- manifest: { present: false, valid: true, reason: 'no manifest-link anchor recorded' },
613
+ valid: !!chain.valid && !truncationSuspected,
614
+ present: false,
615
+ truncationSuspected: truncationSuspected,
616
+ chain: chain,
617
+ witness: witnessInfo,
618
+ manifest: {
619
+ present: false,
620
+ valid: !truncationSuspected,
621
+ reason: truncationSuspected
622
+ ? 'audit chain truncated below witnessed length ' + hw.highWater +
623
+ ' (current ' + chainLen + '); manifest-link anchor may have been ' +
624
+ 'dropped by trailing-truncation -- absent anchor is NOT a pass'
625
+ : 'no manifest-link anchor recorded',
626
+ },
548
627
  };
549
628
  }
550
629
 
@@ -574,9 +653,11 @@ function verifyManifestLink(opts) {
574
653
  }
575
654
 
576
655
  return {
577
- valid: !!chain.valid && manifest.valid,
656
+ valid: !!chain.valid && manifest.valid && !truncationSuspected,
578
657
  present: true,
658
+ truncationSuspected: truncationSuspected,
579
659
  chain: chain,
660
+ witness: witnessInfo,
580
661
  manifest: manifest,
581
662
  };
582
663
  }
@@ -592,6 +673,7 @@ module.exports = {
592
673
  defaultDashboardAuditDir: defaultDashboardAuditDir,
593
674
  linkManifest: linkManifest,
594
675
  verifyManifestLink: verifyManifestLink,
676
+ witnessAgentHighWater: witnessAgentHighWater,
595
677
  hashManifest: hashManifest,
596
678
  defaultManifestPath: defaultManifestPath,
597
679
  CROSSLINK_ACTION: CROSSLINK_ACTION,
@@ -18,39 +18,60 @@ loki init my-project --template saas-starter
18
18
 
19
19
  ## Templates
20
20
 
21
+ The tier below is the complexity that Loki Mode's `detect_complexity` routine
22
+ (`autonomy/run.sh`) actually assigns to each PRD. Complexity is auto-detected
23
+ from the PRD's structure (its section count and length), not from the size of
24
+ the finished product. A short, lightly-sectioned spec like `simple-todo-app`
25
+ detects as Simple; a richly-sectioned spec (more than 10 h2/h3 sections, or
26
+ more than 1000 words) detects as Complex, even when the app it describes is a
27
+ single static page. So a few visually small projects (for example
28
+ `static-landing-page`) land under Complex purely because their PRD is deeply
29
+ sectioned. The Est. Time column reflects build effort, which does not always
30
+ track the detected tier.
31
+
21
32
  ### Simple
22
33
 
34
+ PRD detects as Simple: fewer than 3 sections, fewer than 5 features, and under
35
+ 200 words.
36
+
23
37
  | Template | Description | Tech Stack | Est. Time |
24
38
  |----------|-------------|------------|-----------|
25
- | [simple-todo-app.md](simple-todo-app.md) | Minimal todo app for testing Loki Mode basics | React, Express, SQLite | 15-20 min |
26
- | [static-landing-page.md](static-landing-page.md) | SaaS landing page with hero, features, pricing, FAQ | HTML, CSS, vanilla JS | 10-15 min |
27
- | [api-only.md](api-only.md) | REST API for notes with full CRUD and tests | Express, in-memory, Vitest | 15-20 min |
39
+ | [simple-todo-app.md](simple-todo-app.md) | Minimal todo app for testing Loki Mode basics | HTML, CSS, vanilla JS (localStorage) | 15-20 min |
28
40
 
29
41
  ### Standard
30
42
 
43
+ PRD detects as Standard: between the Simple and Complex thresholds (roughly
44
+ 3 to 10 sections and under 1000 words).
45
+
31
46
  | Template | Description | Tech Stack | Est. Time |
32
47
  |----------|-------------|------------|-----------|
33
- | [rest-api.md](rest-api.md) | REST API with CRUD, pagination, filtering, Swagger docs (no auth) | Express, TypeScript, Prisma, SQLite | 25-35 min |
34
- | [rest-api-auth.md](rest-api-auth.md) | REST API with JWT auth, registration, login, refresh, rate limiting | Express/FastAPI, PostgreSQL, JWT, bcrypt | 30-45 min |
35
- | [cli-tool.md](cli-tool.md) | File organizer CLI with subcommands, config, watch mode, undo | Node.js, Commander.js, chalk, chokidar | 30-45 min |
36
- | [discord-bot.md](discord-bot.md) | Moderation bot with slash commands, auto-mod, reaction roles | discord.js, SQLite, node-cron | 45-60 min |
37
- | [chrome-extension.md](chrome-extension.md) | Tab manager extension with groups, sessions, search, memory monitor | Manifest V3, vanilla JS, Chrome APIs | 30-45 min |
38
- | [blog-platform.md](blog-platform.md) | Blog with markdown CMS, categories, RSS feed, SEO | Next.js, CodeMirror, SQLite, TailwindCSS | 45-60 min |
39
- | [full-stack-demo.md](full-stack-demo.md) | Bookmark manager with tags, search, and filtering | React, Express, SQLite, TailwindCSS | 30-60 min |
40
- | [web-scraper.md](web-scraper.md) | Configurable scraper with pagination, robots.txt, multi-format export | Python, httpx, BeautifulSoup4, SQLite | 30-45 min |
41
- | [data-pipeline.md](data-pipeline.md) | ETL pipeline with multi-source ingestion, transforms, monitoring | Python, Pydantic, SQLAlchemy, Click | 30-45 min |
42
48
  | [dashboard.md](dashboard.md) | Real-time analytics dashboard with charts, tables, drag-and-drop layout | React, Recharts, TanStack Table, WebSocket | 45-60 min |
49
+ | [data-pipeline.md](data-pipeline.md) | ETL pipeline with multi-source ingestion, transforms, monitoring | Python, Pydantic, SQLAlchemy, Click | 30-45 min |
43
50
  | [game.md](game.md) | Browser-based 2D game with enemy AI, scoring, levels, high scores | HTML5 Canvas, TypeScript, Web Audio API | 30-45 min |
44
- | [slack-bot.md](slack-bot.md) | Slack bot with slash commands, events, interactive messages, scheduling | Node.js, Bolt SDK, SQLite | 30-45 min |
45
- | [npm-library.md](npm-library.md) | npm package with TypeScript, dual ESM/CJS, tree shaking, auto docs | TypeScript, tsup, Vitest, typedoc | 30-45 min |
46
51
  | [microservice.md](microservice.md) | Containerized service with health checks, logging, Prometheus metrics | Express, TypeScript, Docker, Prisma, pino | 30-45 min |
52
+ | [npm-library.md](npm-library.md) | npm package with TypeScript, dual ESM/CJS, tree shaking, auto docs | TypeScript, tsup, Vitest, typedoc | 30-45 min |
53
+ | [web-scraper.md](web-scraper.md) | Configurable scraper with pagination, robots.txt, multi-format export | Python, httpx, BeautifulSoup4, SQLite | 30-45 min |
47
54
 
48
55
  ### Complex
49
56
 
57
+ PRD detects as Complex: more than 10 sections, OR more than 15 features, OR
58
+ more than 1000 words. Most templates land here because their PRDs are deeply
59
+ sectioned, regardless of the finished app's size.
60
+
50
61
  | Template | Description | Tech Stack | Est. Time |
51
62
  |----------|-------------|------------|-----------|
63
+ | [api-only.md](api-only.md) | REST API for notes with full CRUD and tests | Express, in-memory, Vitest | 15-20 min |
64
+ | [static-landing-page.md](static-landing-page.md) | SaaS landing page with hero, features, pricing, FAQ | HTML, CSS, vanilla JS | 10-15 min |
65
+ | [slack-bot.md](slack-bot.md) | Slack bot with slash commands, events, interactive messages, scheduling | Node.js, Bolt SDK, SQLite | 30-45 min |
66
+ | [full-stack-demo.md](full-stack-demo.md) | Bookmark manager with tags, search, and filtering | React, Express, SQLite, TailwindCSS | 30-60 min |
67
+ | [cli-tool.md](cli-tool.md) | File organizer CLI with subcommands, config, watch mode, undo | Node.js, Commander.js, chalk, chokidar | 30-45 min |
68
+ | [discord-bot.md](discord-bot.md) | Moderation bot with slash commands, auto-mod, reaction roles | discord.js, SQLite, node-cron | 45-60 min |
69
+ | [chrome-extension.md](chrome-extension.md) | Tab manager extension with groups, sessions, search, memory monitor | Manifest V3, vanilla JS, Chrome APIs | 30-45 min |
52
70
  | [mobile-app.md](mobile-app.md) | Habit tracker with streaks, reminders, calendar, charts | React Native (Expo), Zustand, AsyncStorage | 45-60 min |
53
71
  | [saas-starter.md](saas-starter.md) | SaaS app with auth, OAuth, Stripe billing, admin dashboard | Next.js, Prisma, PostgreSQL, Stripe, NextAuth | 60-90 min |
72
+ | [blog-platform.md](blog-platform.md) | Blog with markdown CMS, categories, RSS feed, SEO | Next.js, CodeMirror, SQLite, TailwindCSS | 45-60 min |
73
+ | [rest-api.md](rest-api.md) | REST API with CRUD, pagination, filtering, Swagger docs (no auth) | Express, TypeScript, Prisma, SQLite | 25-35 min |
74
+ | [rest-api-auth.md](rest-api-auth.md) | REST API with JWT auth, registration, login, refresh, rate limiting | Express/FastAPI, PostgreSQL, JWT, bcrypt | 30-45 min |
54
75
  | [e-commerce.md](e-commerce.md) | Storefront with catalog, cart, Stripe checkout, order management | Next.js, Prisma, PostgreSQL, Stripe | 60-90 min |
55
76
  | [ai-chatbot.md](ai-chatbot.md) | RAG chatbot with document upload, vector search, streaming responses | Next.js, OpenAI API, ChromaDB, Vercel AI SDK | 60-90 min |
56
77
 
@@ -74,7 +95,7 @@ Every template follows a consistent structure:
74
95
 
75
96
  ## Choosing a Template
76
97
 
77
- **First time using Loki Mode?** Start with `simple-todo-app.md` or `api-only.md`. These complete quickly and validate your setup.
98
+ **First time using Loki Mode?** Start with `simple-todo-app.md` (the one Simple-tier template) or `api-only.md`. Both complete quickly and validate your setup. Note that `api-only.md` detects as Complex despite finishing fast, because its PRD is heavily sectioned.
78
99
 
79
100
  **Testing full capabilities?** Use `full-stack-demo.md`. It exercises frontend, backend, database, and code review agents without taking too long.
80
101