loki-mode 7.80.0 → 7.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -279,7 +279,9 @@ Real-time monitoring, agent status, task queue, WebSocket streaming, and Live Ap
279
279
  <td width="33%" valign="top">
280
280
 
281
281
  ### Enterprise Layer
282
- TLS, OIDC/SSO, RBAC, OTEL tracing, policy engine, audit trails. Activated via env vars.
282
+ TLS, OIDC bearer-token validation (the foundation for SSO; browser SAML login is
283
+ roadmap), scoped RBAC, OTEL tracing, policy engine, audit trails. Activated via
284
+ env vars. See [Enterprise Identity Roadmap](docs/ENTERPRISE-IDENTITY-ROADMAP.md).
283
285
 
284
286
  [Enterprise Guide](docs/enterprise/architecture.md)
285
287
 
@@ -305,7 +307,7 @@ The historical feature set (platform pages, Monaco IDE workspace, AI chat panel)
305
307
  | 5 AI provider failover | Yes | No | No | No |
306
308
  | 8 quality gates | Yes | No | No | No |
307
309
  | Blind code review | Yes | No | No | No |
308
- | Enterprise auth (SSO/RBAC) | Yes | No | Yes | No |
310
+ | Enterprise auth (OIDC token + scoped RBAC) | Yes | No | Yes | No |
309
311
  | Air-gapped deployment | Yes | No | No | No |
310
312
  | Docker + CI/CD generation | Yes | No | Yes | No |
311
313
  | Source-available (BUSL-1.1) | Yes | No | No | No |
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.80.0
6
+ # Loki Mode v7.81.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.80.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.81.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.80.0
1
+ 7.81.0
@@ -11,9 +11,18 @@ Honest scope
11
11
  ------------
12
12
  - This syncs the lightweight checkpoint state (.loki/state/checkpoints/**) to the
13
13
  configured object store, and hydrates it back on a durable resume when the
14
- local volume came up empty. It does NOT sync the git refs/loki/cp/* worktree
15
- snapshots (those live in .git, not .loki, and are out of LokiStore's root);
16
- the checkpoint metadata + .loki snapshot are what this transfers.
14
+ local volume came up empty.
15
+ - It ALSO syncs the git refs/loki/cp/* worktree snapshots (the working-tree
16
+ state captured by `git stash create` and anchored under refs/loki/cp/<id>).
17
+ Those commits live in .git (outside LokiStore's .loki root), so they cannot be
18
+ copied as plain objects; instead, for each checkpoint that carries a
19
+ worktree-snapshot.txt SHA we export a `git bundle` of that commit to the store
20
+ (key runs/<run-id>/worktree-bundles/<checkpoint-id>.bundle) and, on hydrate,
21
+ `git fetch` it back and re-create the ref. This makes a fresh-node resume able
22
+ to restore the full working tree, not just the .loki metadata. Best-effort and
23
+ gated on git being present; a repo without git or without snapshots simply
24
+ skips this part. Honest remaining limit: snapshots capture TRACKED changes only
25
+ (the same limit as create_checkpoint's `git stash create`).
17
26
  - All operations are best-effort. A sync/hydrate error never raises to the
18
27
  caller's control flow (run.sh treats a nonzero exit as "skip, continue").
19
28
  The bash side logs and continues a build on any failure.
@@ -40,7 +49,9 @@ Exit codes: 0 on success (including "nothing to do"); nonzero on any error
40
49
  from __future__ import annotations
41
50
 
42
51
  import os
52
+ import subprocess
43
53
  import sys
54
+ import tempfile
44
55
 
45
56
  # Local-first guard: this shim is only meaningful for a non-local backend. If the
46
57
  # backend is local/unset, do nothing (run.sh should not even call us, but be
@@ -49,6 +60,61 @@ _BACKEND = (os.environ.get("LOKI_STORAGE_BACKEND") or "local").strip().lower()
49
60
 
50
61
  # The subtree of the .loki/ store that holds checkpoint state.
51
62
  _CHECKPOINT_PREFIX = "state/checkpoints/"
63
+ # Object-store subkey holding the git-bundle exports of refs/loki/cp/* snapshots.
64
+ _BUNDLE_SUBKEY = "worktree-bundles/"
65
+
66
+
67
+ def _project_dir() -> str:
68
+ """The project working directory (git repo root containing .loki/)."""
69
+ loki_dir = os.environ.get("LOKI_DIR") or os.path.join(
70
+ os.environ.get("TARGET_DIR", "."), ".loki"
71
+ )
72
+ # .loki lives at <project>/.loki, so the project dir is its parent.
73
+ return os.path.dirname(os.path.abspath(loki_dir))
74
+
75
+
76
+ def _git(args, cwd, capture=True):
77
+ """Run a git command best-effort; return (rc, stdout). Never raises."""
78
+ try:
79
+ proc = subprocess.run(
80
+ ["git", *args],
81
+ cwd=cwd,
82
+ stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ text=True,
85
+ check=False,
86
+ )
87
+ return proc.returncode, (proc.stdout or "").strip()
88
+ except (OSError, ValueError):
89
+ return 1, ""
90
+
91
+
92
+ def _have_git_repo(project_dir) -> bool:
93
+ if not project_dir or not os.path.isdir(project_dir):
94
+ return False
95
+ rc, _ = _git(["rev-parse", "--is-inside-work-tree"], project_dir)
96
+ return rc == 0
97
+
98
+
99
+ def _iter_checkpoint_snapshots(local):
100
+ """Yield (checkpoint_id, snapshot_sha) for every worktree-snapshot.txt found
101
+ in the local checkpoint store. checkpoint_id is the directory name under
102
+ state/checkpoints/ that owns the snapshot."""
103
+ for subkey in local.list(_CHECKPOINT_PREFIX):
104
+ # subkey like state/checkpoints/<cp-id>/worktree-snapshot.txt
105
+ if not subkey.endswith("/worktree-snapshot.txt"):
106
+ continue
107
+ parts = subkey.split("/")
108
+ # .../checkpoints/<cp-id>/worktree-snapshot.txt -> cp-id is parts[-2]
109
+ if len(parts) < 2:
110
+ continue
111
+ cp_id = parts[-2]
112
+ try:
113
+ sha = local.get(subkey).decode("utf-8", "replace").strip()
114
+ except Exception:
115
+ continue
116
+ if sha:
117
+ yield cp_id, sha
52
118
 
53
119
 
54
120
  def _resolve_run_id() -> str:
@@ -122,9 +188,64 @@ def cmd_sync() -> int:
122
188
  f"[checkpoint-sync] pushed {count} checkpoint object(s) to "
123
189
  f"{_BACKEND} under runs/{run_id}/\n"
124
190
  )
191
+
192
+ # Also export the git refs/loki/cp/* worktree snapshots as bundles (V2).
193
+ bundles = _sync_worktree_bundles(local, remote, run_id)
194
+ if bundles:
195
+ sys.stderr.write(
196
+ f"[checkpoint-sync] pushed {bundles} worktree snapshot bundle(s)\n"
197
+ )
125
198
  return 0
126
199
 
127
200
 
201
+ def _sync_worktree_bundles(local, remote, run_id) -> int:
202
+ """For each checkpoint snapshot SHA, `git bundle` the commit and store it.
203
+ Best-effort; returns the count pushed. Skips silently without git/snapshots."""
204
+ project_dir = _project_dir()
205
+ if not _have_git_repo(project_dir):
206
+ return 0
207
+ pushed = 0
208
+ for cp_id, sha in _iter_checkpoint_snapshots(local):
209
+ # Symmetric with hydrate: never bundle/store an unsafe cp_id ref name.
210
+ if not _safe_cp_id(cp_id):
211
+ sys.stderr.write(f"[checkpoint-sync] skipped unsafe snapshot id: {cp_id}\n")
212
+ continue
213
+ # Verify the commit still exists locally before bundling.
214
+ rc, _ = _git(["cat-file", "-e", f"{sha}^{{commit}}"], project_dir)
215
+ if rc != 0:
216
+ continue
217
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".bundle")
218
+ os.close(tmp_fd)
219
+ try:
220
+ # `git bundle create` needs a REF-style argument; a bare SHA (or a
221
+ # single-rev `sha`/`sha~0..sha`) makes git refuse with "empty bundle"
222
+ # because there is no ref to anchor. The snapshot is already anchored
223
+ # at refs/loki/cp/<cp_id> by create_checkpoint, so bundle THAT ref.
224
+ # The bundle is self-contained for the snapshot commit + its history.
225
+ rc, _ = _git(
226
+ ["bundle", "create", tmp_path, f"refs/loki/cp/{cp_id}"],
227
+ project_dir,
228
+ capture=False,
229
+ )
230
+ if rc != 0 or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) == 0:
231
+ continue
232
+ with open(tmp_path, "rb") as f:
233
+ data = f.read()
234
+ remote.put(_run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.bundle"), data)
235
+ # Persist the SHA alongside so hydrate can re-create the exact ref.
236
+ remote.put(
237
+ _run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.sha"),
238
+ (sha + "\n").encode("utf-8"),
239
+ )
240
+ pushed += 1
241
+ finally:
242
+ try:
243
+ os.unlink(tmp_path)
244
+ except OSError:
245
+ pass
246
+ return pushed
247
+
248
+
128
249
  def cmd_hydrate() -> int:
129
250
  """
130
251
  Pull checkpoint state from the object store into the local volume, but ONLY
@@ -169,9 +290,95 @@ def cmd_hydrate() -> int:
169
290
  f"[checkpoint-sync] hydrated {count} checkpoint object(s) from "
170
291
  f"{_BACKEND} for runs/{run_id}/\n"
171
292
  )
293
+
294
+ # Restore git refs/loki/cp/* worktree snapshots from their bundles (V2).
295
+ restored = _hydrate_worktree_bundles(remote, run_id)
296
+ if restored:
297
+ sys.stderr.write(
298
+ f"[checkpoint-sync] restored {restored} worktree snapshot ref(s)\n"
299
+ )
172
300
  return 0
173
301
 
174
302
 
303
+ def _hydrate_worktree_bundles(remote, run_id) -> int:
304
+ """Fetch each stored worktree bundle and re-create refs/loki/cp/<id>.
305
+ Best-effort; returns the count restored. No-op without git or bundles."""
306
+ project_dir = _project_dir()
307
+ if not _have_git_repo(project_dir):
308
+ return 0
309
+ bundle_prefix = _run_key(run_id, _BUNDLE_SUBKEY)
310
+ try:
311
+ remote_keys = remote.list(bundle_prefix)
312
+ except Exception:
313
+ return 0
314
+ restored = 0
315
+ for rk in remote_keys:
316
+ if not rk.endswith(".bundle"):
317
+ continue
318
+ cp_id = rk.rsplit("/", 1)[-1][: -len(".bundle")]
319
+ # Guard cp_id against ref-injection (the bundle key comes from the store).
320
+ if not _safe_cp_id(cp_id):
321
+ sys.stderr.write(f"[checkpoint-sync] skipped unsafe bundle id: {cp_id}\n")
322
+ continue
323
+ # Recover the snapshot SHA (stored sidecar) so we re-create the exact ref.
324
+ sha = ""
325
+ try:
326
+ sha = remote.get(_run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.sha")).decode(
327
+ "utf-8", "replace"
328
+ ).strip()
329
+ except Exception:
330
+ sha = ""
331
+ if not sha or any(c not in "0123456789abcdef" for c in sha.lower()):
332
+ continue
333
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".bundle")
334
+ os.close(tmp_fd)
335
+ try:
336
+ remote.get_to(rk, tmp_path)
337
+ # Fetch the commit objects out of the bundle into this repo.
338
+ rc, _ = _git(["fetch", tmp_path, sha], project_dir, capture=False)
339
+ if rc != 0:
340
+ continue
341
+ # Re-create the anchored ref so `git gc` cannot prune it and a resume
342
+ # can find the snapshot exactly as create_checkpoint left it.
343
+ rc, _ = _git(
344
+ ["update-ref", f"refs/loki/cp/{cp_id}", sha], project_dir, capture=False
345
+ )
346
+ if rc == 0:
347
+ restored += 1
348
+ finally:
349
+ try:
350
+ os.unlink(tmp_path)
351
+ except OSError:
352
+ pass
353
+ return restored
354
+
355
+
356
+ # Safe characters for a checkpoint id used in a git ref path (defense vs
357
+ # ref-injection from a crafted object-store key). Checkpoint ids are of the form
358
+ # cp-<iter>-<epoch>. We do NOT rely on git's own ref-name validation as the only
359
+ # backstop (defense-in-depth): reject the git-special forms too.
360
+ _SAFE_CP_CHARS = set(
361
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
362
+ )
363
+
364
+
365
+ def _safe_cp_id(cp_id: str) -> bool:
366
+ """True iff cp_id is safe to interpolate into refs/loki/cp/<cp_id>.
367
+
368
+ Char-allowlist PLUS explicit rejection of git-special ref forms that the
369
+ allowlist would otherwise permit ('.', '..', a trailing dot, a '.lock'
370
+ suffix, 'HEAD', '@'). Not exploitable today (cp ids are locally minted), but
371
+ this must not depend on git's validation when the id can come from a store key.
372
+ """
373
+ if not cp_id or any(c not in _SAFE_CP_CHARS for c in cp_id):
374
+ return False
375
+ if cp_id in (".", "..", "HEAD", "@"):
376
+ return False
377
+ if cp_id.endswith(".") or cp_id.endswith(".lock"):
378
+ return False
379
+ return True
380
+
381
+
175
382
  def main(argv) -> int:
176
383
  if len(argv) < 2 or argv[1] not in ("sync", "hydrate"):
177
384
  sys.stderr.write("usage: checkpoint_sync.py {sync|hydrate}\n")
package/autonomy/loki CHANGED
@@ -1501,6 +1501,17 @@ cmd_start() {
1501
1501
  --config=*|--vars=*|--env-file=*)
1502
1502
  shift
1503
1503
  ;;
1504
+ --)
1505
+ # End-of-options: everything after is positional. Lets a caller
1506
+ # pass an untrusted spec that might start with '-' safely, e.g.
1507
+ # `loki start -- "$spec"` from the queue consumer, so a crafted
1508
+ # work item can never be parsed as a flag.
1509
+ shift
1510
+ if [ -z "$positional_arg" ] && [ "$#" -ge 1 ]; then
1511
+ positional_arg="$1"
1512
+ fi
1513
+ break
1514
+ ;;
1504
1515
  -*)
1505
1516
  echo -e "${RED}Unknown option: $1${NC}"
1506
1517
  exit 1
@@ -29729,8 +29740,16 @@ PYEOF
29729
29740
  cmd_bench() {
29730
29741
  local bench_sh="$SKILL_DIR/benchmarks/bench/run.sh"
29731
29742
  if [ ! -f "$bench_sh" ]; then
29732
- log_error "benchmark harness not found: $bench_sh"
29733
- return 1
29743
+ # The benchmarks/ harness (~8MB, mostly historical result dumps) is a
29744
+ # dev/research feature deliberately omitted from the published package
29745
+ # (.npmignore excludes benchmarks/). On a packaged install it is absent,
29746
+ # so degrade honestly instead of a bare "not found" that reads like a
29747
+ # broken install. Run it from a loki-mode source checkout.
29748
+ echo "loki bench: the benchmark harness is not available in this install." >&2
29749
+ echo "It is a development/research feature shipped only in the loki-mode source" >&2
29750
+ echo "repository, not the published package. Clone the repo and run:" >&2
29751
+ echo " ./benchmarks/run-benchmarks.sh humaneval --execute --loki" >&2
29752
+ return 0
29734
29753
  fi
29735
29754
  local sub="${1:-}"
29736
29755
  case "$sub" in
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env bash
2
+ # shellcheck disable=SC2155 # Declare and assign separately (acceptable in this codebase)
3
+ #===============================================================================
4
+ # Loki Mode - Reference Queue Consumer
5
+ #
6
+ # A pluggable reference queue-consumer entrypoint for the Helm worker modes:
7
+ #
8
+ # deployment -- long-running consumer. Loops forever, pulling one work item at
9
+ # a time, running `loki start <spec>`, and acking on success.
10
+ # serverless -- one-shot consumer (LOKI_QUEUE_ONESHOT=1). Pulls EXACTLY one
11
+ # item, runs it, exits with that build's exit code. Intended for
12
+ # a KEDA ScaledJob that creates one Job per queued item.
13
+ #
14
+ # A "work item" is a Loki spec ref accepted by `loki start`: a path/brief, a
15
+ # GitHub issue ref (owner/repo#N), or a JSON object {"spec": "..."} (the spec
16
+ # field is extracted; any other JSON keys are ignored by this reference consumer).
17
+ #
18
+ # Backends (LOKI_QUEUE_BACKEND):
19
+ # redis -- REAL. Default when redis-cli is on PATH. BLPOP/LPOP a Redis list
20
+ # (LOKI_QUEUE_KEY). At-most-once: a popped item is removed from the
21
+ # queue before the build runs, so a crashed build does NOT requeue
22
+ # automatically (this is a reference consumer, not a broker with
23
+ # visibility timeouts -- see HONESTY below).
24
+ # file -- REAL. Always-available fallback for testing / airgapped clusters.
25
+ # Pops the OLDEST file from LOKI_QUEUE_DIR/pending atomically (mv to
26
+ # LOKI_QUEUE_DIR/processing), runs it, then moves it to done/ on
27
+ # success or failed/ on terminal failure.
28
+ #
29
+ # HONESTY (do not overstate this):
30
+ # - Only redis and file are shipped. SQS, Pub/Sub, RabbitMQ, Kafka, etc. are
31
+ # BRING-YOUR-OWN: override queue.command in values.yaml with your own
32
+ # consumer. They are documented, not implemented here.
33
+ # - The redis backend is at-most-once (LPOP-then-run). It has no visibility
34
+ # timeout / dead-letter requeue. If a build crashes after the item is popped,
35
+ # that item is lost from the queue. For at-least-once delivery use the file
36
+ # backend (a crashed build leaves the item in processing/ for manual
37
+ # re-drive) or bring a real broker.
38
+ # - The file backend's atomicity relies on `mv` being atomic within a single
39
+ # filesystem (true for a normal PVC). Two consumers racing the same pending
40
+ # dir is safe (mv either wins or fails-and-skips), but is not load-balanced.
41
+ #
42
+ # Robustness:
43
+ # - SIGTERM-graceful: a TERM/INT received mid-build lets the CURRENT item
44
+ # finish and ack, then exits cleanly (no half-acked item).
45
+ # - Bounded empty-poll backoff in loop mode; one-shot returns 0 on empty.
46
+ # - Never silently crash-loops: every exit path logs a reason.
47
+ # - set -u safe throughout.
48
+ #
49
+ # Environment:
50
+ # LOKI_QUEUE_BACKEND redis | file (default: redis if redis-cli present, else file)
51
+ # LOKI_QUEUE_ONESHOT 1 = process one item then exit (serverless); else loop (deployment)
52
+ # LOKI_QUEUE_KEY redis list key (default: loki-builds)
53
+ # LOKI_QUEUE_URL redis-cli -u URL (default: $REDIS_URL or redis://127.0.0.1:6379)
54
+ # LOKI_QUEUE_DIR file backend root (default: .loki/queue)
55
+ # LOKI_QUEUE_POLL_SEC loop-mode empty-poll wait, seconds (default: 5)
56
+ # LOKI_QUEUE_BLOCK_SEC redis BLPOP block timeout, seconds (default: 5)
57
+ # LOKI_TERMINAL_EXIT run.sh terminal-failure exit code (default: 20)
58
+ #===============================================================================
59
+
60
+ set -uo pipefail
61
+
62
+ LOG_PREFIX="[queue-consumer]"
63
+
64
+ log() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; }
65
+
66
+ # --- configuration with safe defaults -----------------------------------------
67
+ QUEUE_KEY="${LOKI_QUEUE_KEY:-loki-builds}"
68
+ QUEUE_DIR="${LOKI_QUEUE_DIR:-.loki/queue}"
69
+ POLL_SEC="${LOKI_QUEUE_POLL_SEC:-5}"
70
+ BLOCK_SEC="${LOKI_QUEUE_BLOCK_SEC:-5}"
71
+ TERMINAL_EXIT="${LOKI_TERMINAL_EXIT:-20}"
72
+ ONESHOT="${LOKI_QUEUE_ONESHOT:-0}"
73
+ REDIS_URL_DEFAULT="${REDIS_URL:-redis://127.0.0.1:6379}"
74
+ QUEUE_URL="${LOKI_QUEUE_URL:-$REDIS_URL_DEFAULT}"
75
+
76
+ # Allow the loki binary to be overridden for tests (PATH stub) or vendored paths.
77
+ LOKI_BIN="${LOKI_BIN:-loki}"
78
+
79
+ # Backend selection: explicit env wins; else redis if redis-cli is present; else file.
80
+ select_backend() {
81
+ if [ -n "${LOKI_QUEUE_BACKEND:-}" ]; then
82
+ printf '%s' "$LOKI_QUEUE_BACKEND"
83
+ return 0
84
+ fi
85
+ if command -v redis-cli >/dev/null 2>&1; then
86
+ printf '%s' "redis"
87
+ else
88
+ printf '%s' "file"
89
+ fi
90
+ }
91
+
92
+ # --- graceful shutdown --------------------------------------------------------
93
+ # A TERM/INT sets a flag. The loop checks it between items. If it arrives during
94
+ # a build, the build is allowed to finish and ack; only then do we exit. This
95
+ # guarantees no item is left half-processed by our own shutdown.
96
+ STOP_REQUESTED=0
97
+ request_stop() {
98
+ STOP_REQUESTED=1
99
+ log "shutdown signal received; will exit after the current item finishes"
100
+ }
101
+ trap request_stop TERM INT
102
+
103
+ # --- spec extraction ----------------------------------------------------------
104
+ # A work item may be a bare spec ref or a JSON object {"spec": "..."}. Extract
105
+ # the spec string. Bare refs pass through unchanged. JSON is parsed with python3
106
+ # (already a runtime dependency); if python3 is unavailable or parsing fails, the
107
+ # raw item is used as-is (a bare ref is the common case).
108
+ extract_spec() {
109
+ local item="$1"
110
+ # Trim leading/trailing whitespace/newlines.
111
+ item="${item#"${item%%[![:space:]]*}"}"
112
+ item="${item%"${item##*[![:space:]]}"}"
113
+ case "$item" in
114
+ '{'*)
115
+ if command -v python3 >/dev/null 2>&1; then
116
+ local parsed
117
+ parsed="$(printf '%s' "$item" | python3 -c '
118
+ import sys, json
119
+ try:
120
+ d = json.load(sys.stdin)
121
+ s = d.get("spec", "") if isinstance(d, dict) else ""
122
+ sys.stdout.write(str(s))
123
+ except Exception:
124
+ pass
125
+ ' 2>/dev/null)"
126
+ if [ -n "$parsed" ]; then
127
+ printf '%s' "$parsed"
128
+ return 0
129
+ fi
130
+ fi
131
+ # Fall through: not parseable, return raw (caller may still reject).
132
+ printf '%s' "$item"
133
+ ;;
134
+ *)
135
+ printf '%s' "$item"
136
+ ;;
137
+ esac
138
+ }
139
+
140
+ # --- run one build ------------------------------------------------------------
141
+ # Runs `loki start <spec>` for the given spec. Returns the build's exit code.
142
+ # An empty spec is treated as a terminal failure (a queue item with no spec is
143
+ # malformed; we do not silently run a no-spec codebase-analysis off the queue).
144
+ run_build() {
145
+ local spec="$1"
146
+ if [ -z "$spec" ]; then
147
+ log "ERROR: empty spec extracted from work item; treating as terminal failure"
148
+ return "$TERMINAL_EXIT"
149
+ fi
150
+ # Flag-injection guard: a queue item is untrusted (it came off the queue). A
151
+ # spec that begins with '-' would be parsed by `loki start` as a FLAG, not a
152
+ # PRD path / issue ref / brief (e.g. an item "--ship" would silently switch
153
+ # the build into auto-merge-PR mode). A leading-dash item is never a valid
154
+ # spec, so reject it as malformed rather than letting it steer the build.
155
+ # (The whole item is already a single argv element -- quoting holds -- so this
156
+ # is the remaining surface: a single known flag token.)
157
+ case "$spec" in
158
+ -*)
159
+ log "ERROR: work-item spec starts with '-' (would be parsed as a loki flag): $spec -- treating as terminal failure"
160
+ return "$TERMINAL_EXIT"
161
+ ;;
162
+ esac
163
+ log "starting build: spec=$spec"
164
+ # End-of-options separator so even a future leading-dash that slips past the
165
+ # guard cannot be read as a flag (cmd_start gained a `--` handler).
166
+ "$LOKI_BIN" start -- "$spec"
167
+ local rc=$?
168
+ log "build finished: spec=$spec exit=$rc"
169
+ return "$rc"
170
+ }
171
+
172
+ # =============================================================================
173
+ # Redis backend
174
+ # =============================================================================
175
+ redis_cli() {
176
+ redis-cli -u "$QUEUE_URL" "$@"
177
+ }
178
+
179
+ # Pop one item from the redis list. In loop mode use BLPOP (blocks up to
180
+ # BLOCK_SEC, then returns empty so we can check the stop flag); in one-shot use
181
+ # LPOP (non-blocking, exits immediately on an empty queue).
182
+ # Prints the popped item to stdout, or nothing if the queue was empty.
183
+ redis_pop() {
184
+ if [ "$ONESHOT" = "1" ]; then
185
+ redis-cli -u "$QUEUE_URL" --no-raw LPOP "$QUEUE_KEY" 2>/dev/null | _redis_unquote
186
+ else
187
+ # BLPOP returns two lines: the key name, then the value. Take the value.
188
+ redis-cli -u "$QUEUE_URL" BLPOP "$QUEUE_KEY" "$BLOCK_SEC" 2>/dev/null | sed -n '2p'
189
+ fi
190
+ }
191
+
192
+ # --no-raw LPOP wraps the value in quotes; strip a single surrounding pair and
193
+ # a literal "(nil)" sentinel. (BLPOP path uses raw output and skips this.)
194
+ _redis_unquote() {
195
+ local line
196
+ IFS= read -r line || true
197
+ [ "$line" = "(nil)" ] && return 0
198
+ # Strip one leading and trailing double quote if present.
199
+ line="${line#\"}"
200
+ line="${line%\"}"
201
+ printf '%s' "$line"
202
+ }
203
+
204
+ redis_consume_one() {
205
+ local item
206
+ item="$(redis_pop)"
207
+ if [ -z "$item" ]; then
208
+ return 100 # sentinel: queue empty
209
+ fi
210
+ local spec
211
+ spec="$(extract_spec "$item")"
212
+ run_build "$spec"
213
+ return $?
214
+ }
215
+
216
+ # =============================================================================
217
+ # File backend
218
+ # =============================================================================
219
+ # Layout under LOKI_QUEUE_DIR:
220
+ # pending/ work items waiting to be processed (one file each)
221
+ # processing/ the item currently being processed (atomically mv'd here)
222
+ # done/ successfully processed items
223
+ # failed/ terminally-failed items (exit == TERMINAL_EXIT or empty spec)
224
+ file_init_dirs() {
225
+ mkdir -p "$QUEUE_DIR/pending" "$QUEUE_DIR/processing" "$QUEUE_DIR/done" "$QUEUE_DIR/failed" 2>/dev/null || {
226
+ log "ERROR: cannot create queue directories under $QUEUE_DIR"
227
+ return 1
228
+ }
229
+ }
230
+
231
+ # Claim the oldest pending file by atomically mv'ing it to processing/. Prints
232
+ # the claimed processing-path on success; prints nothing if pending is empty.
233
+ # The mv is the atomic claim: if a racing consumer grabbed it first, mv fails and
234
+ # we move on to the next candidate.
235
+ file_claim_oldest() {
236
+ local f base dest
237
+ # Oldest by mtime. `ls -tr` lists oldest first; restrict to regular files.
238
+ while IFS= read -r f; do
239
+ [ -z "$f" ] && continue
240
+ [ -f "$f" ] || continue
241
+ base="$(basename "$f")"
242
+ dest="$QUEUE_DIR/processing/$base"
243
+ if mv "$f" "$dest" 2>/dev/null; then
244
+ printf '%s' "$dest"
245
+ return 0
246
+ fi
247
+ # mv failed (another consumer claimed it): try the next candidate.
248
+ done <<EOF
249
+ $(ls -tr "$QUEUE_DIR/pending" 2>/dev/null | while IFS= read -r n; do printf '%s/pending/%s\n' "$QUEUE_DIR" "$n"; done)
250
+ EOF
251
+ return 0 # nothing claimed
252
+ }
253
+
254
+ file_consume_one() {
255
+ file_init_dirs || return 1
256
+ local claimed
257
+ claimed="$(file_claim_oldest)"
258
+ if [ -z "$claimed" ]; then
259
+ return 100 # sentinel: queue empty
260
+ fi
261
+ local base
262
+ base="$(basename "$claimed")"
263
+ local item
264
+ item="$(cat "$claimed" 2>/dev/null)"
265
+ local spec
266
+ spec="$(extract_spec "$item")"
267
+ run_build "$spec"
268
+ local rc=$?
269
+ if [ "$rc" -eq 0 ]; then
270
+ mv "$claimed" "$QUEUE_DIR/done/$base" 2>/dev/null || log "WARN: could not move $base to done/"
271
+ elif [ "$rc" -eq "$TERMINAL_EXIT" ]; then
272
+ mv "$claimed" "$QUEUE_DIR/failed/$base" 2>/dev/null || log "WARN: could not move $base to failed/"
273
+ log "item $base TERMINAL-FAILED (exit $rc); moved to failed/, not acked"
274
+ else
275
+ # Transient crash: leave it in processing/ for manual re-drive. We do NOT
276
+ # auto-requeue (no retry counter in a flat dir); honest at-least-once.
277
+ log "item $base crashed (exit $rc); left in processing/ for re-drive"
278
+ fi
279
+ return "$rc"
280
+ }
281
+
282
+ # =============================================================================
283
+ # Driver
284
+ # =============================================================================
285
+ consume_one() {
286
+ case "$1" in
287
+ redis) redis_consume_one ;;
288
+ file) file_consume_one ;;
289
+ *)
290
+ log "ERROR: unknown LOKI_QUEUE_BACKEND='$1' (supported: redis, file)"
291
+ return 2
292
+ ;;
293
+ esac
294
+ }
295
+
296
+ main() {
297
+ local backend
298
+ backend="$(select_backend)"
299
+
300
+ # Fail fast on a misconfigured redis backend rather than crash-looping.
301
+ if [ "$backend" = "redis" ] && ! command -v redis-cli >/dev/null 2>&1; then
302
+ log "ERROR: LOKI_QUEUE_BACKEND=redis but redis-cli is not on PATH"
303
+ return 2
304
+ fi
305
+ if [ "$backend" != "redis" ] && [ "$backend" != "file" ]; then
306
+ log "ERROR: unknown LOKI_QUEUE_BACKEND='$backend' (supported: redis, file)"
307
+ return 2
308
+ fi
309
+
310
+ if [ "$ONESHOT" = "1" ]; then
311
+ log "mode=oneshot backend=$backend (serverless: process one item then exit)"
312
+ consume_one "$backend"
313
+ local rc=$?
314
+ if [ "$rc" -eq 100 ]; then
315
+ log "queue empty; nothing to process; exiting 0"
316
+ return 0
317
+ fi
318
+ return "$rc"
319
+ fi
320
+
321
+ log "mode=loop backend=$backend (deployment: process items until SIGTERM)"
322
+ while [ "$STOP_REQUESTED" -ne 1 ]; do
323
+ consume_one "$backend"
324
+ local rc=$?
325
+ if [ "$rc" -eq 100 ]; then
326
+ # Empty queue. The redis BLPOP path already blocked; sleep only for
327
+ # the file backend (it polls). A pending stop check happens at loop
328
+ # top, so we never sleep through a shutdown longer than one interval.
329
+ if [ "$backend" = "file" ]; then
330
+ sleep "$POLL_SEC"
331
+ fi
332
+ continue
333
+ fi
334
+ if [ "$rc" -eq 2 ]; then
335
+ # Configuration error: do not hot-loop. Surface and exit non-zero.
336
+ log "fatal configuration error; exiting"
337
+ return 2
338
+ fi
339
+ # rc is a build result (0 success, TERMINAL_EXIT terminal, other crash).
340
+ # Build outcomes are normal operation, not consumer errors: keep looping.
341
+ done
342
+ log "stopped gracefully after current item; exiting 0"
343
+ return 0
344
+ }
345
+
346
+ main "$@"
@@ -1156,6 +1156,33 @@ start_sandbox() {
1156
1156
  log_info " Seccomp: enabled"
1157
1157
  fi
1158
1158
 
1159
+ # A5+: workspace-mount allowlist enforcement (opt-in via LOKI_ALLOWED_PATHS).
1160
+ #
1161
+ # The workspace bind-mount below is where provider-driven agent file writes
1162
+ # actually land (the agent writes inside /workspace, which is $PROJECT_DIR on
1163
+ # the host). Unlike the custom --mount surface, this mount was previously
1164
+ # always made writable regardless of LOKI_ALLOWED_PATHS, so an allowlist that
1165
+ # did not include the project dir was silently ignored for the main write
1166
+ # surface.
1167
+ #
1168
+ # When LOKI_ALLOWED_PATHS is set and the workspace itself is OUTSIDE the
1169
+ # allowlist, fail closed: refuse to start rather than bind-mount it writable.
1170
+ # This is genuinely enforceable because docker mount mode (rw vs ro) and which
1171
+ # host paths get bound are decided HERE, before the container exists -- the
1172
+ # kernel enforces the resulting mount, not a wrapper check. We refuse rather
1173
+ # than auto-downgrade to :ro because a read-only workspace cannot be written
1174
+ # by the agent at all and would silently produce a no-op build; refusing makes
1175
+ # the misconfiguration visible. (Allowlisted extra paths are still mounted via
1176
+ # the custom --mount surface above, which also enforces the allowlist.)
1177
+ #
1178
+ # When LOKI_ALLOWED_PATHS is empty (default), _sandbox_path_within_allowed
1179
+ # returns 0 unconditionally, so this is byte-identical to prior behavior.
1180
+ if ! _sandbox_path_within_allowed "$PROJECT_DIR"; then
1181
+ log_error "Workspace is outside LOKI_ALLOWED_PATHS, refusing to mount it writable: $PROJECT_DIR"
1182
+ log_error " Add the project directory to LOKI_ALLOWED_PATHS, or unset LOKI_ALLOWED_PATHS to disable path enforcement."
1183
+ return 1
1184
+ fi
1185
+
1159
1186
  # Mount project directory
1160
1187
  if [[ "$SANDBOX_READONLY" == "true" ]]; then
1161
1188
  docker_args+=("--volume" "$PROJECT_DIR:/workspace:ro")
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.80.0"
10
+ __version__ = "7.81.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3316,6 +3316,224 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
3316
3316
  }
3317
3317
 
3318
3318
 
3319
+ def _recover_spec_source(path: str) -> Optional[_Path]:
3320
+ """Find a re-launchable spec source inside a registry-stored project path.
3321
+
3322
+ Retry re-runs `loki start` from a project's own working directory, so the
3323
+ spec must already live there. This never accepts a caller-supplied path: the
3324
+ project path comes from the registry and only well-known in-tree locations
3325
+ are probed. Returns the first existing spec as an absolute Path, or None when
3326
+ nothing re-launchable is found (the caller then refuses honestly rather than
3327
+ spawning a run that would no-op).
3328
+
3329
+ Probe order (highest fidelity first):
3330
+ 1. A hand-authored PRD in the project root or docs/ (PRD.md, prd.md,
3331
+ docs/PRD.md, docs/prd.md) -- the same set check_project_health uses.
3332
+ 2. A previously generated PRD (.loki/generated-prd.md / .json) written by
3333
+ a prior no-PRD codebase-analysis run.
3334
+ 3. The most recent dashboard inline spec (.loki/specs/*.md) written by the
3335
+ browser PRD-input flow.
3336
+ """
3337
+ if not path:
3338
+ return None
3339
+ try:
3340
+ base = _Path(path)
3341
+ except (TypeError, ValueError):
3342
+ return None
3343
+ if not base.is_dir():
3344
+ return None
3345
+
3346
+ # 1. Hand-authored PRD.
3347
+ for rel in ("PRD.md", "prd.md", "docs/PRD.md", "docs/prd.md"):
3348
+ candidate = base / rel
3349
+ if candidate.is_file():
3350
+ return candidate.resolve()
3351
+
3352
+ loki_dir = base / ".loki"
3353
+
3354
+ # 2. Previously generated PRD.
3355
+ for rel in ("generated-prd.md", "generated-prd.json"):
3356
+ candidate = loki_dir / rel
3357
+ if candidate.is_file():
3358
+ return candidate.resolve()
3359
+
3360
+ # 3. Most recent dashboard inline spec.
3361
+ specs_dir = loki_dir / "specs"
3362
+ if specs_dir.is_dir():
3363
+ try:
3364
+ specs = sorted(
3365
+ (s for s in specs_dir.glob("*.md") if s.is_file()),
3366
+ key=lambda s: s.stat().st_mtime,
3367
+ reverse=True,
3368
+ )
3369
+ except OSError:
3370
+ specs = []
3371
+ if specs:
3372
+ return specs[0].resolve()
3373
+
3374
+ return None
3375
+
3376
+
3377
+ @app.post(
3378
+ "/api/fleet/runs/{identifier}/retry",
3379
+ dependencies=[Depends(auth.require_scope("control"))],
3380
+ )
3381
+ async def retry_fleet_run(request: Request, identifier: str):
3382
+ """Re-launch ONE finished/failed build in the fleet view.
3383
+
3384
+ Resolves the run via the registry (by id / path / alias), exactly like
3385
+ cancel_fleet_run, so the caller identifier is NEVER treated as a filesystem
3386
+ path. Retry re-runs `loki start` (via run.sh) from the project's own stored
3387
+ working directory against a spec source recovered from that directory.
3388
+
3389
+ Guards:
3390
+ - Refuses with 409 if the project is currently running (live pid probe or a
3391
+ fresh session.json), so a retry never double-launches an active build.
3392
+ - Refuses with 409 if no re-launchable spec source exists in the project
3393
+ directory (an honest refusal: retry needs the original spec or working
3394
+ dir; we do not fabricate a launch that would no-op).
3395
+
3396
+ On success the runner re-registers the project as running with its own pid
3397
+ (loki_register_running_project in run.sh); we also flip the registry status
3398
+ to running immediately so the fleet view reflects the relaunch without
3399
+ waiting for the runner's first registry write.
3400
+ """
3401
+ if not _control_limiter.check("control"):
3402
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3403
+
3404
+ project = registry.get_project(identifier)
3405
+ if not project:
3406
+ raise HTTPException(status_code=404, detail="Run not found in fleet")
3407
+
3408
+ project_id = project.get("id")
3409
+ audit.log_event(
3410
+ action="retry",
3411
+ resource_type="fleet_run",
3412
+ details={"source": "api", "project_id": project_id},
3413
+ ip_address=request.client.host if request.client else None,
3414
+ )
3415
+
3416
+ # Only ever operate on the registry-stored path (never the identifier).
3417
+ path = project.get("path", "")
3418
+ if not path:
3419
+ raise HTTPException(
3420
+ status_code=409,
3421
+ detail="Project has no recorded working directory; retry needs the original working dir",
3422
+ )
3423
+ proj_dir = _Path(path)
3424
+ if not proj_dir.is_dir():
3425
+ raise HTTPException(
3426
+ status_code=409,
3427
+ detail=f"Project directory no longer exists: {path}",
3428
+ )
3429
+ proj_dir = proj_dir.resolve()
3430
+ loki_dir = proj_dir / ".loki"
3431
+
3432
+ # Recover a re-launchable spec from the project's own directory FIRST (before
3433
+ # the claim, so we can refuse honestly without holding the lock). Honest
3434
+ # refusal when nothing usable is present (no fabricated no-op launch).
3435
+ spec_file = await asyncio.to_thread(_recover_spec_source, str(proj_dir))
3436
+ if spec_file is None:
3437
+ raise HTTPException(
3438
+ status_code=409,
3439
+ detail=(
3440
+ "No re-launchable spec found in the project directory. Retry "
3441
+ "needs the original spec (PRD.md, .loki/generated-prd.md, or a "
3442
+ "prior dashboard spec) or the original working dir."
3443
+ ),
3444
+ )
3445
+
3446
+ # Locate run.sh (same resolver start_build uses).
3447
+ skill_dir = find_skill_dir()
3448
+ run_sh = skill_dir / "autonomy" / "run.sh"
3449
+ if not run_sh.exists():
3450
+ raise HTTPException(status_code=500, detail=f"run.sh not found at {run_sh}")
3451
+
3452
+ # Atomic check-and-CLAIM (closes the double-launch TOCTOU). Two concurrent
3453
+ # retry calls on the same stopped project must not both spawn. Under the
3454
+ # registry lock we re-read the live entry, refuse if it is running (pid alive,
3455
+ # the project's own session staleness window, or already "launching" -- a
3456
+ # sibling that just claimed it), else stamp status="launching" + save. The
3457
+ # lock + persisted "launching" marker make the window indivisible: the second
3458
+ # caller sees "launching" and is refused before it can spawn.
3459
+ _live_pid = project.get("pid")
3460
+ if registry._pid_alive(_live_pid):
3461
+ raise HTTPException(
3462
+ status_code=409,
3463
+ detail="Project is currently running; cancel it before retrying",
3464
+ )
3465
+ if loki_dir.is_dir() and _project_run_active(loki_dir) is not None:
3466
+ raise HTTPException(
3467
+ status_code=409,
3468
+ detail="A build is already running in this project",
3469
+ )
3470
+ with registry._registry_lock():
3471
+ reg = registry._load_registry()
3472
+ entry = reg.get("projects", {}).get(project_id)
3473
+ if entry is not None:
3474
+ cur_status = entry.get("status")
3475
+ cur_pid = entry.get("pid")
3476
+ if cur_status == "launching" or registry._pid_alive(cur_pid):
3477
+ raise HTTPException(
3478
+ status_code=409,
3479
+ detail="A retry for this project is already in progress",
3480
+ )
3481
+ entry["status"] = "launching"
3482
+ entry["updated_at"] = datetime.now(timezone.utc).isoformat()
3483
+ registry._save_registry(reg)
3484
+
3485
+ # Re-launch from the project's own CWD, mirroring start_build's spawn.
3486
+ args = [str(run_sh), "--bg", str(spec_file)]
3487
+ try:
3488
+ process = subprocess.Popen(
3489
+ args,
3490
+ stdout=subprocess.DEVNULL,
3491
+ stderr=subprocess.DEVNULL,
3492
+ start_new_session=True,
3493
+ cwd=str(proj_dir),
3494
+ )
3495
+ except (OSError, subprocess.SubprocessError) as e:
3496
+ # Release the "launching" claim so a failed spawn does not wedge the
3497
+ # project (else every future retry would be refused as in-progress).
3498
+ try:
3499
+ with registry._registry_lock():
3500
+ reg = registry._load_registry()
3501
+ entry = reg.get("projects", {}).get(project_id)
3502
+ if entry is not None and entry.get("status") == "launching":
3503
+ entry["status"] = "stopped"
3504
+ entry["updated_at"] = datetime.now(timezone.utc).isoformat()
3505
+ registry._save_registry(reg)
3506
+ except Exception:
3507
+ pass
3508
+ raise HTTPException(status_code=500, detail=f"Failed to retry build: {e}")
3509
+
3510
+ # Flip the registry status to running immediately. The runner also
3511
+ # re-registers with its own pid on startup, but updating here gives the
3512
+ # fleet view an instant, accurate reflection of the relaunch. The
3513
+ # load->mutate->save runs under the registry lock so it does not lost-update
3514
+ # against the runner's concurrent re-registration.
3515
+ try:
3516
+ with registry._registry_lock():
3517
+ reg = registry._load_registry()
3518
+ if project_id in reg.get("projects", {}):
3519
+ reg["projects"][project_id]["status"] = "running"
3520
+ reg["projects"][project_id]["pid"] = process.pid
3521
+ reg["projects"][project_id]["updated_at"] = datetime.now(
3522
+ timezone.utc
3523
+ ).isoformat()
3524
+ registry._save_registry(reg)
3525
+ except Exception:
3526
+ pass
3527
+
3528
+ return {
3529
+ "success": True,
3530
+ "project_id": project_id,
3531
+ "retried": True,
3532
+ "pid": process.pid,
3533
+ "spec": str(spec_file),
3534
+ }
3535
+
3536
+
3319
3537
  @app.post(
3320
3538
  "/api/fleet/runs/{identifier}/cancel",
3321
3539
  dependencies=[Depends(auth.require_scope("control"))],
@@ -3330,9 +3548,10 @@ async def cancel_fleet_run(request: Request, identifier: str):
3330
3548
  recorded orchestrator pid, group-kill + cwd-scoped reap as backstop, then
3331
3549
  mark the registry/session stopped.
3332
3550
 
3333
- Retry is intentionally NOT exposed: there is no clean cross-project
3334
- re-launch primitive in the registry path (the original spec source lives
3335
- only in each project's CWD). Retry is a documented follow-up.
3551
+ Retry (re-launch) is exposed separately at
3552
+ POST /api/fleet/runs/{identifier}/retry, which re-runs `loki start` from
3553
+ the registry-stored project directory against a spec recovered from that
3554
+ directory (refuses honestly when none exists).
3336
3555
  """
3337
3556
  if not _control_limiter.check("control"):
3338
3557
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
@@ -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.80.0
5
+ **Version:** v7.81.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.80.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.81.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 QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.80.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{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,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,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 v1=16777216,$1;var d=L(()=>{$1=class $1 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 s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=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 Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){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 e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
2
+ var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.81.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{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,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,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 v1=16777216,$1;var d=L(()=>{$1=class $1 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 s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=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 Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){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 e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}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)
@@ -793,4 +793,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
793
793
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
794
794
  `),process.stderr.write($Q),2}}s1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
795
795
 
796
- //# debugId=5DBFF3AC2E319A5664756E2164756E21
796
+ //# debugId=C0A7A027BE2CCC6B64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.80.0'
60
+ __version__ = '7.81.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.80.0",
4
+ "version": "7.81.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.80.0",
5
+ "version": "7.81.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",