loki-mode 7.90.2 → 7.91.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.90.2
6
+ # Loki Mode v7.91.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.90.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.91.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.90.2
1
+ 7.91.0
package/autonomy/loki CHANGED
@@ -9834,7 +9834,25 @@ cmd_doctor() {
9834
9834
  echo -e " ${GREEN}PASS${NC} ANTHROPIC_API_KEY is set"
9835
9835
  pass_count=$((pass_count + 1))
9836
9836
  elif command -v claude &>/dev/null; then
9837
- echo -e " ${DIM} -- ${NC} ANTHROPIC_API_KEY not set (Claude CLI uses its own login)"
9837
+ # Zero-network check: warn if the Claude OAuth login has expired. An
9838
+ # expired token would otherwise pass and then 401 mid-build (the build
9839
+ # stalls at BOOTSTRAP). Mirror run.sh's fail-fast preflight here so a
9840
+ # user catches it with `loki doctor` BEFORE starting a build.
9841
+ _claude_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
9842
+ if [ -s "$_claude_creds" ] && [ "$(python3 -c "
9843
+ import json, sys, time
9844
+ try:
9845
+ d = json.load(open(sys.argv[1]))
9846
+ exp = d.get('claudeAiOauth', {}).get('expiresAt')
9847
+ print('expired' if isinstance(exp,(int,float)) and exp>0 and (exp/1000.0)<=(time.time()+60) else 'ok')
9848
+ except Exception:
9849
+ print('ok')
9850
+ " "$_claude_creds" 2>/dev/null || echo ok)" = "expired" ]; then
9851
+ echo -e " ${YELLOW}WARN${NC} Claude login has EXPIRED -- run 'claude login' before a build (it would otherwise stall)"
9852
+ warn_count=$((warn_count + 1))
9853
+ else
9854
+ echo -e " ${DIM} -- ${NC} ANTHROPIC_API_KEY not set (Claude CLI uses its own login)"
9855
+ fi
9838
9856
  fi
9839
9857
  if [ -n "${OPENAI_API_KEY:-}" ]; then
9840
9858
  echo -e " ${GREEN}PASS${NC} OPENAI_API_KEY is set"
package/autonomy/run.sh CHANGED
@@ -829,6 +829,12 @@ CONCURRENCY_CRITICAL_THRESHOLD=${LOKI_CONCURRENCY_CRITICAL_THRESHOLD:-95}
829
829
  GATE_CLEAR_LIMIT=${LOKI_GATE_CLEAR_LIMIT:-3}
830
830
  GATE_ESCALATE_LIMIT=${LOKI_GATE_ESCALATE_LIMIT:-5}
831
831
  GATE_PAUSE_LIMIT=${LOKI_GATE_PAUSE_LIMIT:-10}
832
+ # Workspace resolution: the build runs against TARGET_DIR, defaulting to the
833
+ # launch cwd. A caller can pin a per-build workspace by exporting
834
+ # LOKI_TARGET_DIR (and the matching LOKI_DIR=<dir>/.loki); the dashboard
835
+ # /api/control/start `workspace` param (Track-1 S1) does exactly this so a
836
+ # hosted build runs in its own dir instead of the engine's repo. Every
837
+ # $TARGET_DIR/.loki reference below then resolves under that workspace.
832
838
  TARGET_DIR="${LOKI_TARGET_DIR:-$(pwd)}"
833
839
  PARALLEL_BLOG=${LOKI_PARALLEL_BLOG:-false}
834
840
  AUTO_MERGE=${LOKI_AUTO_MERGE:-true}
@@ -1428,6 +1434,30 @@ _loki_trust_run_id() {
1428
1434
  printf '%s' ""
1429
1435
  }
1430
1436
 
1437
+ # _advance_current_phase: atomically advance currentPhase in orchestrator.json.
1438
+ # This is the single source of truth for the build dashboard and the BFF
1439
+ # reconciliation gate (isTerminalPhase). The engine initialises it to "BOOTSTRAP"
1440
+ # at session start; call this to advance through the SDLC lifecycle phases.
1441
+ # Args: $1 = the new phase value (REASONING, BUILDING, VERIFYING, COMPLETED, etc.)
1442
+ _advance_current_phase() {
1443
+ local new_phase="${1:?phase required}"
1444
+ local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}}/.loki"
1445
+ local orch="$loki_dir/state/orchestrator.json"
1446
+ [ -f "$orch" ] || return 0
1447
+ # Values are passed via argv (not interpolated into the source) so a phase or
1448
+ # path containing quotes can never break or inject into the python.
1449
+ python3 -c "
1450
+ import json, sys
1451
+ f, phase = sys.argv[1], sys.argv[2]
1452
+ try:
1453
+ d = json.load(open(f))
1454
+ d['currentPhase'] = phase
1455
+ json.dump(d, open(f, 'w'))
1456
+ except (json.JSONDecodeError, OSError):
1457
+ pass
1458
+ " "$orch" "$new_phase" 2>/dev/null || true
1459
+ }
1460
+
1431
1461
  # Usage: record_trust_event_bash <event_type> [key=value ...]
1432
1462
  # Pass LOKI_TRUST_RUN_ID in the environment to override the resolved id (the
1433
1463
  # run_start site sets it to the freshly minted id so the first event matches).
@@ -1808,6 +1838,37 @@ validate_api_keys() {
1808
1838
  local masked="${key_value:0:8}...${key_value: -4}"
1809
1839
  log_info "API key $key_var: $masked (${#key_value} chars)"
1810
1840
 
1841
+ # Fail-fast auth preflight (v7.91): for Claude OAuth logins, a present-but-
1842
+ # EXPIRED token passes the presence check above but then 401s on the first
1843
+ # provider call, leaving the build stalled at BOOTSTRAP with no clear cause.
1844
+ # Catch that here with a zero-network, zero-token local expiry check so the
1845
+ # user gets an instant, copy-pasteable fix instead of a silent stall.
1846
+ # Opt out with LOKI_SKIP_AUTH_PREFLIGHT=1 (offline/odd setups).
1847
+ if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
1848
+ local _creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
1849
+ if [[ -s "$_creds" ]]; then
1850
+ local _expired
1851
+ _expired=$(python3 -c "
1852
+ import json, sys, time
1853
+ try:
1854
+ d = json.load(open(sys.argv[1]))
1855
+ exp = d.get('claudeAiOauth', {}).get('expiresAt')
1856
+ # expiresAt is epoch milliseconds; compare with a 60s safety margin.
1857
+ if isinstance(exp, (int, float)) and exp > 0 and (exp / 1000.0) <= (time.time() + 60):
1858
+ print('expired')
1859
+ except Exception:
1860
+ pass # unreadable/unknown schema -> do not block (fail open, let the call decide)
1861
+ " "$_creds" 2>/dev/null || true)
1862
+ if [[ "$_expired" == "expired" ]]; then
1863
+ log_error "Your Claude Code login has expired -- the build would stall instead of running."
1864
+ log_error "Fix it in one step, then retry:"
1865
+ log_error " claude login"
1866
+ log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
1867
+ return 1
1868
+ fi
1869
+ fi
1870
+ fi
1871
+
1811
1872
  return 0
1812
1873
  }
1813
1874
 
@@ -17852,7 +17913,10 @@ main() {
17852
17913
  # Cleanup parallel streams
17853
17914
  cleanup_parallel_streams
17854
17915
  else
17855
- # Standard mode: single session
17916
+ # Standard mode: single session. Advance phase from BOOTSTRAP to BUILDING
17917
+ # before the first iteration so the dashboard shows real progress, not
17918
+ # a stuck "Planning" state.
17919
+ _advance_current_phase "BUILDING"
17856
17920
  run_autonomous "$PRD_PATH" || result=$?
17857
17921
  fi
17858
17922
 
@@ -17939,6 +18003,13 @@ main() {
17939
18003
  generate_proof_of_run "$result" || true
17940
18004
  fi
17941
18005
 
18006
+ # Advance currentPhase to COMPLETED so the BFF reconciliation gate and the
18007
+ # engine's own is_completed() recognise this session as terminal. Write the
18008
+ # file-system COMPLETED marker (checked by is_completed() in the main loop).
18009
+ _advance_current_phase "COMPLETED"
18010
+ local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}}/.loki"
18011
+ echo "Session completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED" 2>/dev/null || true
18012
+
17942
18013
  # Finish-and-own (v7.88.0): write a plain-English ownership handoff
17943
18014
  # (HANDOFF.md) for a non-technical owner. Runs AFTER the proof so the
17944
18015
  # "is it working?" verdict reads the receipt's honest headline. Default-on,
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.90.2"
10
+ __version__ = "7.91.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -430,6 +430,15 @@ class StatusResponse(BaseModel):
430
430
  # correlation-only (v7.34); "resume" = LOKI_RESUME_SESSION recovery resume.
431
431
  # Empty when the run predates this field or no claude session was stamped.
432
432
  claude_session_mode: str = ""
433
+ # Track-1 (S1): the trust run_id (the proof key, format
434
+ # run-<ts>-<pid>-<rand>) of the run in THIS engine's resolved .loki dir, read
435
+ # from .loki/state/trust-run-id. Lets a caller correlate the run with its
436
+ # proof/receipt without parsing the pid. Empty when no run has minted one
437
+ # yet, or the run predates the field. NOTE for hosted callers: this reflects
438
+ # the engine's GLOBAL/cwd .loki (_get_loki_dir), so for a build started with a
439
+ # distinct workspace param it does NOT track that workspace's run -- read
440
+ # <workspace>/.loki/state/trust-run-id directly for the per-workspace build.
441
+ current_run_id: str = ""
433
442
  # Concurrent sessions (v6.4.0)
434
443
  sessions: list[SessionInfo] = []
435
444
 
@@ -1158,6 +1167,18 @@ async def get_status() -> StatusResponse:
1158
1167
  except (json.JSONDecodeError, OSError, KeyError, AttributeError):
1159
1168
  pass
1160
1169
 
1170
+ # Track-1 (S1): the trust run_id (proof key) for the run in this resolved
1171
+ # .loki dir. Best-effort read; empty when no run has minted one yet. Lets a
1172
+ # caller correlate the run with its proof without parsing the pid.
1173
+ current_run_id = ""
1174
+ trust_run_id_file = loki_dir / "state" / "trust-run-id"
1175
+ if trust_run_id_file.exists():
1176
+ try:
1177
+ _rid = trust_run_id_file.read_text(encoding="utf-8").strip()
1178
+ current_run_id = _rid if isinstance(_rid, str) else ""
1179
+ except OSError:
1180
+ pass
1181
+
1161
1182
  # Read dashboard state (with retry for concurrent writes)
1162
1183
  _has_dashboard_state = False
1163
1184
  if state_file.exists():
@@ -1412,6 +1433,7 @@ async def get_status() -> StatusResponse:
1412
1433
  current_task=current_task,
1413
1434
  claude_session_id=claude_session_id,
1414
1435
  claude_session_mode=claude_session_mode,
1436
+ current_run_id=current_run_id,
1415
1437
  sessions=active_session_list,
1416
1438
  )
1417
1439
 
@@ -3018,6 +3040,14 @@ class StartBuildRequest(BaseModel):
3018
3040
  prd_path: Optional[str] = None
3019
3041
  provider: str = "claude"
3020
3042
  parallel: bool = False
3043
+ # Track-1 (S1): an OPTIONAL per-build workspace directory. When present and
3044
+ # path-guarded, the build runs against THIS dir (its own cwd + .loki) instead
3045
+ # of the engine's own project dir, so a hosted caller (the SaaS BFF) can run a
3046
+ # build outside the engine repo without polluting it. When OMITTED, behavior
3047
+ # is byte-identical to before this field existed (the global project dir).
3048
+ # The path is path-guarded against ALLOWED_WORKSPACE_ROOTS (see
3049
+ # _validate_workspace) -- it is NOT a free-form filesystem write target.
3050
+ workspace: Optional[str] = None
3021
3051
 
3022
3052
  def validate_provider(self) -> None:
3023
3053
  """Validate provider is from the supported list.
@@ -3071,6 +3101,88 @@ def _validate_prd_path(raw_path: str, project_dir: _Path) -> _Path:
3071
3101
  raise ValueError(f"PRD path is outside allowed directories: {raw_path}")
3072
3102
 
3073
3103
 
3104
+ def _allowed_workspace_roots() -> list[_Path]:
3105
+ """Resolved roots a caller-supplied workspace may live under (Track-1 S1).
3106
+
3107
+ Configured via LOKI_WORKSPACE_ROOTS (os.pathsep-separated absolute dirs).
3108
+ Each entry is expanded and resolved; nonexistent / non-absolute / relative
3109
+ entries are dropped (a misconfigured root must NOT silently widen the guard
3110
+ to the CWD). When the env var is unset or yields no valid root, the list is
3111
+ empty and _validate_workspace rejects every workspace -- the feature is
3112
+ opt-in by configuration, fail-closed by default.
3113
+
3114
+ This is deliberately NARROWER than the PRD-path roots ([project_dir, home]):
3115
+ a build cwd runs an autonomous agent, so its root must be an explicitly
3116
+ operator-allowed location (e.g. the BFF's ENGINE_WORKSPACE_ROOT, default
3117
+ /workspaces), never the engine repo or the whole home directory.
3118
+ """
3119
+ raw = os.environ.get("LOKI_WORKSPACE_ROOTS", "").strip()
3120
+ if not raw:
3121
+ return []
3122
+ roots: list[_Path] = []
3123
+ for entry in raw.split(os.pathsep):
3124
+ entry = entry.strip()
3125
+ if not entry:
3126
+ continue
3127
+ candidate = _Path(entry).expanduser()
3128
+ if not candidate.is_absolute():
3129
+ continue
3130
+ try:
3131
+ resolved = candidate.resolve()
3132
+ except OSError:
3133
+ continue
3134
+ if resolved.is_dir():
3135
+ roots.append(resolved)
3136
+ return roots
3137
+
3138
+
3139
+ def _validate_workspace(raw_path: str) -> _Path:
3140
+ """Path-guard a caller-supplied per-build workspace dir (Track-1 S1).
3141
+
3142
+ Unlike _validate_prd_path, the workspace need NOT exist yet (the BFF passes
3143
+ <ENGINE_WORKSPACE_ROOT>/<buildId>, created on first build); this guard
3144
+ normalizes a possibly-nonexistent path and verifies CONTAINMENT under one of
3145
+ LOKI_WORKSPACE_ROOTS, then the caller mkdirs it. Returns the resolved,
3146
+ verified, absolute workspace path. Raises ValueError on any unsafe path.
3147
+
3148
+ Guard order:
3149
+ 1. Reject literal ".." traversal sequences before any resolution.
3150
+ 2. Require an absolute input (a relative path would resolve against the
3151
+ dashboard CWD -- never an intended workspace).
3152
+ 3. resolve() (follows symlinks on existing parents) then assert the real
3153
+ path is under an allowed root -- catches a symlinked parent that
3154
+ escaped the no-".." check.
3155
+ """
3156
+ if not raw_path or not raw_path.strip():
3157
+ raise ValueError("workspace must be a non-empty path")
3158
+ raw_path = raw_path.strip()
3159
+ if ".." in raw_path:
3160
+ raise ValueError("workspace contains path traversal sequence (..)")
3161
+
3162
+ candidate = _Path(raw_path).expanduser()
3163
+ if not candidate.is_absolute():
3164
+ raise ValueError("workspace must be an absolute path")
3165
+
3166
+ try:
3167
+ ws = candidate.resolve()
3168
+ except OSError as e:
3169
+ raise ValueError(f"workspace path could not be resolved: {e}")
3170
+
3171
+ roots = _allowed_workspace_roots()
3172
+ if not roots:
3173
+ raise ValueError(
3174
+ "workspace param is not enabled: set LOKI_WORKSPACE_ROOTS to one or "
3175
+ "more absolute directories before passing a workspace"
3176
+ )
3177
+ for root in roots:
3178
+ try:
3179
+ ws.relative_to(root)
3180
+ return ws
3181
+ except ValueError:
3182
+ continue
3183
+ raise ValueError(f"workspace is outside allowed roots: {raw_path}")
3184
+
3185
+
3074
3186
  def _write_spec_text(prd_text: str, project_dir: _Path) -> _Path:
3075
3187
  """Persist an inline spec to .loki/specs/ and return its path.
3076
3188
 
@@ -3153,10 +3265,34 @@ async def start_build(request: Request, body: StartBuildRequest):
3153
3265
  detail="Provide exactly one of prd_text or prd_path",
3154
3266
  )
3155
3267
 
3156
- # Resolve the target project directory from the active dashboard project.
3157
- loki_dir = _get_loki_dir()
3158
- project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
3159
- project_dir = project_dir.resolve()
3268
+ # Resolve the target project directory. When the caller supplies a
3269
+ # path-guarded workspace (Track-1 S1), the build runs against THAT dir (its
3270
+ # own cwd + .loki) so a hosted caller can build outside the engine repo.
3271
+ # When omitted, behavior is byte-identical to before: the active dashboard
3272
+ # project (the engine's own _get_loki_dir).
3273
+ workspace_dir: Optional[_Path] = None
3274
+ if body.workspace and body.workspace.strip():
3275
+ try:
3276
+ workspace_dir = _validate_workspace(body.workspace)
3277
+ except ValueError as e:
3278
+ raise HTTPException(status_code=400, detail=str(e))
3279
+
3280
+ if workspace_dir is not None:
3281
+ # The build's project dir IS the workspace; its .loki lives inside it.
3282
+ # Create it now so single-flight + spec-write below operate on the real
3283
+ # build dir (the BFF passes <root>/<buildId>, absent on first build).
3284
+ try:
3285
+ workspace_dir.mkdir(parents=True, exist_ok=True)
3286
+ except OSError as e:
3287
+ raise HTTPException(
3288
+ status_code=500, detail=f"Could not create workspace: {e}"
3289
+ )
3290
+ project_dir = workspace_dir
3291
+ loki_dir = workspace_dir / ".loki"
3292
+ else:
3293
+ loki_dir = _get_loki_dir()
3294
+ project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
3295
+ project_dir = project_dir.resolve()
3160
3296
 
3161
3297
  # Single-flight: refuse if a run is already active in this project.
3162
3298
  active_pid = _project_run_active(loki_dir)
@@ -3191,6 +3327,19 @@ async def start_build(request: Request, body: StartBuildRequest):
3191
3327
  args.append("--bg")
3192
3328
  args.append(str(spec_file))
3193
3329
 
3330
+ # When a workspace is given, pass an explicit env that PINS run.sh to the
3331
+ # workspace. cwd alone is not enough: `loki` exports LOKI_DIR (default
3332
+ # .loki) into the dashboard process, and run.sh resolves its workspace as
3333
+ # ${LOKI_DIR:-${LOKI_TARGET_DIR:-$(pwd)}/.loki} -- an inherited LOKI_DIR
3334
+ # would override the workspace cwd and the build would write into the
3335
+ # engine's own .loki. Setting both LOKI_DIR and LOKI_TARGET_DIR makes the
3336
+ # workspace authoritative. When no workspace is given, env=None (inherit) so
3337
+ # behavior is byte-identical to before this feature.
3338
+ popen_env = None
3339
+ if workspace_dir is not None:
3340
+ popen_env = dict(os.environ)
3341
+ popen_env["LOKI_TARGET_DIR"] = str(workspace_dir)
3342
+ popen_env["LOKI_DIR"] = str(loki_dir)
3194
3343
  try:
3195
3344
  process = subprocess.Popen(
3196
3345
  args,
@@ -3198,6 +3347,7 @@ async def start_build(request: Request, body: StartBuildRequest):
3198
3347
  stderr=subprocess.DEVNULL,
3199
3348
  start_new_session=True,
3200
3349
  cwd=str(project_dir),
3350
+ env=popen_env,
3201
3351
  )
3202
3352
  except (OSError, subprocess.SubprocessError) as e:
3203
3353
  raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
@@ -3242,12 +3392,34 @@ async def start_build(request: Request, body: StartBuildRequest):
3242
3392
  ip_address=request.client.host if request.client else None,
3243
3393
  )
3244
3394
 
3395
+ # Best-effort: surface the trust run_id (the proof key) so the caller can
3396
+ # correlate this build to its Evidence Receipt without parsing the pid.
3397
+ # run.sh mints it into <loki_dir>/state/trust-run-id at run-start; the 0.3s
3398
+ # liveness poll above usually does NOT outlast the mint, so this is often
3399
+ # null at start time. It is RELIABLY derivable later by the caller, which
3400
+ # owns the workspace path: read <workspace>/.loki/state/trust-run-id (or the
3401
+ # newest proof under <workspace>/.loki). Null here is expected, not an error.
3402
+ run_id = ""
3403
+ try:
3404
+ rid_file = loki_dir / "state" / "trust-run-id"
3405
+ if rid_file.is_file():
3406
+ run_id = rid_file.read_text(encoding="utf-8").strip()
3407
+ except OSError:
3408
+ pass
3409
+
3245
3410
  return {
3246
3411
  "success": True,
3247
3412
  "message": f"Build started with provider {body.provider}",
3248
3413
  "pid": process.pid,
3249
3414
  "spec": str(spec_file),
3250
3415
  "provider": body.provider,
3416
+ # The workspace this build runs in (echoed back; empty == engine's own
3417
+ # project dir, today's default). The caller derives the proof path from
3418
+ # this: <workspace>/.loki/state/trust-run-id.
3419
+ "workspace": str(workspace_dir) if workspace_dir is not None else "",
3420
+ # The trust run_id if already minted, else "" (see note above). When "",
3421
+ # derive it from the workspace's .loki/state/trust-run-id.
3422
+ "run_id": run_id,
3251
3423
  }
3252
3424
 
3253
3425
 
@@ -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.90.2
5
+ **Version:** v7.91.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.90.2 start ./my-spec.md
398
+ asklokesh/loki-mode:7.91.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -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.90.2";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.91.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=0A25C50D5896B93C64756E2164756E21
805
+ //# debugId=2A4ACA24DD51A05A64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.90.2'
60
+ __version__ = '7.91.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.90.2",
4
+ "version": "7.91.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.90.2",
5
+ "version": "7.91.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",