loki-mode 7.58.1 → 7.60.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
@@ -106,7 +106,7 @@ loki quick "build a landing page with a signup form"
106
106
  | **Bun (recommended)** | `bun install -g loki-mode` | Fastest startup for CLI commands. |
107
107
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` | Auto-installs Bun as a dep |
108
108
  | **Docker (easiest)** | `loki docker start prd.md` | Host wrapper: runs loki in the published image with zero config. Bind-mounts the current folder so `.loki` state, resume, and continuity work exactly like local. Auto-detects auth (`ANTHROPIC_API_KEY`, else your host Claude Code login). Needs loki + Docker on the host. See DOCKER_README.md |
109
- | **Docker (raw)** | `docker pull asklokesh/loki-mode:7.50.0 && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:7.50.0 start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
109
+ | **Docker (raw)** | `docker pull asklokesh/loki-mode:7.58.1 && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:7.58.1 start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
110
110
  | **npm (compat)** | `npm install -g loki-mode` | Works without Bun (bash fallback). Migrate any time with `loki self-update --to bun`. |
111
111
 
112
112
  **Upgrading:**
@@ -166,7 +166,7 @@ The next major release sunsets the Bash runtime entirely. There is no firm calen
166
166
  | Method | Command |
167
167
  |--------|---------|
168
168
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` |
169
- | **Docker** | `docker pull asklokesh/loki-mode:7.50.0` |
169
+ | **Docker** | `docker pull asklokesh/loki-mode:7.58.1` |
170
170
  | **Inside Claude Code** | `claude --dangerously-skip-permissions` then type "Loki Mode" |
171
171
  | **Git clone** | `git clone https://github.com/asklokesh/loki-mode.git` |
172
172
 
@@ -481,8 +481,8 @@ See [benchmarks/](benchmarks/) for methodology.
481
481
 
482
482
  ```bash
483
483
  git clone https://github.com/asklokesh/loki-mode.git && cd loki-mode
484
- npm install && npm test # 683 tests
485
- python3 -m pytest # 631 tests
484
+ npm install && npm test # CLI + Node test suites
485
+ python3 -m pytest # Python test suite
486
486
  ```
487
487
 
488
488
  See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
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.58.1
6
+ # Loki Mode v7.60.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.58.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.60.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.58.1
1
+ 7.60.0
@@ -312,6 +312,26 @@ _app_runner_reconcile_port() {
312
312
  iter=$(( iter + 1 ))
313
313
  done
314
314
 
315
+ # No serving keyword line in the log: the app may sit behind a reverse proxy
316
+ # or bind quietly on a port we did not choose. Probe app-scoped candidate
317
+ # ports for a real listener and surface only a port that ACTUALLY responds
318
+ # (never fabricate a URL for a dead port). Conservative: app-scoped candidates
319
+ # only, no blind well-known-port scan. See _probe_app_url.
320
+ if [ -z "$real_port" ]; then
321
+ local probed
322
+ probed=$(_probe_app_url "$_APP_RUNNER_PORT")
323
+ if [ -n "$probed" ]; then
324
+ local probed_port="${probed##*:}"
325
+ if [ "$probed_port" != "$_APP_RUNNER_PORT" ]; then
326
+ log_info "App Runner: surfaced live port $probed_port via probe (reverse-proxy/quiet-bind); recorded was $_APP_RUNNER_PORT"
327
+ _APP_RUNNER_PORT="$probed_port"
328
+ _APP_RUNNER_URL="$probed"
329
+ _rewrite_detection_port
330
+ fi
331
+ fi
332
+ return 0
333
+ fi
334
+
315
335
  [ -n "$real_port" ] || return 0
316
336
  if [ "$real_port" != "$_APP_RUNNER_PORT" ]; then
317
337
  # Liveness guard: only overwrite the recorded port when the reconciled
@@ -489,6 +509,64 @@ sys.exit(0)
489
509
  ' 2>/dev/null || return 0
490
510
  }
491
511
 
512
+ # Detect a Next.js standalone build (next.config output: 'standalone'). A
513
+ # standalone build emits a self-contained `.next/standalone/server.js` that is
514
+ # launched with `node server.js` (NOT `next start`) and listens on PORT (default
515
+ # 3000). The presence of `.next/standalone/server.js` is a specific, safe signal:
516
+ # a normal `.next/` build does NOT create that path, so this never false-positives
517
+ # on an ordinary Next.js project. Echoes the run method (relative to TARGET_DIR,
518
+ # which the launcher cd's into) on success, nothing otherwise. The run method is
519
+ # `node .next/standalone/server.js`; modern Next standalone resolves its asset
520
+ # paths from the server.js __dirname, not cwd, so a TARGET_DIR-relative launch is
521
+ # correct without an extra chdir. The `output: 'standalone'` next.config grep is
522
+ # a weaker secondary signal (the build may not have run yet); the built artifact
523
+ # path is authoritative, which is why we key on the file existing.
524
+ _detect_nextjs_standalone() {
525
+ local dir="${1:-${TARGET_DIR:-.}}"
526
+ if [ -f "$dir/.next/standalone/server.js" ]; then
527
+ printf 'node .next/standalone/server.js\n'
528
+ return 0
529
+ fi
530
+ return 0
531
+ }
532
+
533
+ # Probe app-scoped candidate ports for a live HTTP listener and echo the first
534
+ # port that actually responds, nothing if none do. This handles the case where
535
+ # the app sits behind a reverse proxy or otherwise binds a port we did not
536
+ # choose: rather than fabricate a URL for a port nothing is listening on, we
537
+ # verify liveness with a real HTTP probe before surfacing anything.
538
+ #
539
+ # Conservatism, in order of importance:
540
+ # - Candidates are APP-SCOPED only (PORT env, the recorded port, and the
541
+ # framework default passed by the caller). We deliberately do NOT blind-scan
542
+ # well-known ports like 80/443/8080, because some unrelated local service
543
+ # answering there is its own form of fabrication.
544
+ # - Liveness uses the same contract as _app_runner_reconcile_port (curl -s
545
+ # -o /dev/null -m 2, no -f): any HTTP response (incl. 404/401/500) proves a
546
+ # server is bound; a connection error (dead/unbound port) is a non-zero exit.
547
+ # - curl-less hosts cannot verify, so we surface NOTHING (we never guess a URL
548
+ # we could not probe). This is the conservative direction for this helper:
549
+ # its whole job is "probe before surfacing", so no curl == no claim.
550
+ # Args: $1 = framework default port (may be empty). Reads $LOKI_APP_PORT and
551
+ # $_APP_RUNNER_PORT from the environment as additional candidates.
552
+ _probe_app_url() {
553
+ local default_port="$1"
554
+ command -v curl >/dev/null 2>&1 || return 0
555
+ local cand seen=" "
556
+ for cand in "${LOKI_APP_PORT:-}" "${_APP_RUNNER_PORT:-}" "$default_port"; do
557
+ [ -n "$cand" ] || continue
558
+ [[ "$cand" =~ ^[0-9]+$ ]] || continue
559
+ [ "$cand" -ge 1 ] 2>/dev/null && [ "$cand" -le 65535 ] 2>/dev/null || continue
560
+ case "$seen" in *" $cand "*) continue ;; esac
561
+ seen="$seen$cand "
562
+ if curl -s -o /dev/null -m 2 "http://localhost:${cand}/" 2>/dev/null; then
563
+ printf 'http://localhost:%s\n' "$cand"
564
+ return 0
565
+ fi
566
+ done
567
+ return 0
568
+ }
569
+
492
570
  # Detect port from project files
493
571
  _detect_port() {
494
572
  local method="$1"
@@ -644,6 +722,20 @@ app_runner_init() {
644
722
  # 3-4. package.json (dev or start)
645
723
  if [ -f "$dir/package.json" ]; then
646
724
  _install_node_deps "$dir"
725
+ # 3a. Next.js standalone build (output: 'standalone'). The built artifact
726
+ # `.next/standalone/server.js` is a stronger signal than the dev/start
727
+ # scripts: when present, the app is launched with `node server.js`
728
+ # (listens on PORT, default 3000) rather than `next dev`/`next start`.
729
+ local njs_method
730
+ njs_method=$(_detect_nextjs_standalone "$dir")
731
+ if [ -n "$njs_method" ]; then
732
+ _APP_RUNNER_METHOD="$njs_method"
733
+ _detect_port "npm"
734
+ _write_detection "nextjs-standalone" "$_APP_RUNNER_METHOD"
735
+ log_info "App Runner: detected Next.js standalone server"
736
+ _APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
737
+ return 0
738
+ fi
647
739
  if grep -q '"dev"' "$dir/package.json" 2>/dev/null; then
648
740
  _APP_RUNNER_METHOD="npm run dev"
649
741
  _detect_port "$_APP_RUNNER_METHOD"
@@ -566,8 +566,14 @@ print(str(rc) + ' ' + json.dumps(new_state))
566
566
  # conservative outcome (REJECT / re-iterate).
567
567
  _council_parse_vote() {
568
568
  local raw="$1"
569
- # markdown/whitespace/quote class allowed around the keyword and colon
570
- local _pat='[*_> [:space:]]*VOTE[*_ [:space:]]*:[*_> [:space:]]*(APPROVE|REJECT|CANNOT_VALIDATE)([^A-Za-z0-9_]|$)'
569
+ # markdown/whitespace/quote class allowed around the keyword and colon.
570
+ # The leading (^|[^A-Za-z0-9_]) anchor is a LEFT word boundary on VOTE so a
571
+ # VOTE-suffixed token ("REVOTE: APPROVE", "PROVOTE: REJECT") is NOT parsed as
572
+ # a canonical vote (it previously matched because the keyword had no left
573
+ # boundary). Mirrors the existing right boundary; "\b" stays avoided for
574
+ # BSD/GNU grep parity. The second grep below isolates the verdict word, so the
575
+ # extra captured boundary char is harmless.
576
+ local _pat='(^|[^A-Za-z0-9_])[*_> [:space:]]*VOTE[*_ [:space:]]*:[*_> [:space:]]*(APPROVE|REJECT|CANNOT_VALIDATE)([^A-Za-z0-9_]|$)'
571
577
  printf '%s' "$raw" \
572
578
  | grep -oE "$_pat" \
573
579
  | grep -oE "APPROVE|REJECT|CANNOT_VALIDATE" \
@@ -739,8 +745,20 @@ print('true' if ratio > budget else 'false')
739
745
  else
740
746
  log_warn "Anti-sycophancy: Devil's advocate did not confirm unanimous approval (verdict: ${contrarian_vote:-unparseable})"
741
747
  log_warn "Overriding to require one more iteration for verification"
742
- approve_count=$((approve_count - 1))
743
- reject_count=$((reject_count + 1))
748
+ # The veto MUST drive the verdict below the completion threshold, not
749
+ # merely decrement by one. A bare `-1` left approve_count at
750
+ # COUNCIL_SIZE-1, which for any council of size >= 3 is still
751
+ # >= effective_threshold (ceil(2/3*SIZE)), so the override returned
752
+ # DONE anyway and the anti-sycophancy check was a silent no-op
753
+ # (it only happened to work for the size-2 council). Force
754
+ # approve_count to threshold-1 so the decision at the bottom of this
755
+ # function returns CONTINUE, and keep approve+reject == COUNCIL_SIZE
756
+ # so the cumulative state.json sums and transcript stay consistent.
757
+ # This is the same forced-CONTINUE outcome the parallel
758
+ # council_evaluate() path already delivers on a DA veto (return 1).
759
+ approve_count=$((effective_threshold - 1))
760
+ [ "$approve_count" -lt 0 ] && approve_count=0
761
+ reject_count=$((COUNCIL_SIZE - approve_count))
744
762
  _da_flipped="true"
745
763
  fi
746
764
  fi
@@ -137,6 +137,35 @@ is_no_test_cmd() {
137
137
  [[ "${1:-}" == "__LOKI_NO_TEST_CMD__" ]]
138
138
  }
139
139
 
140
+ # Resolve the directory used to store pre-edit snapshots for the migration
141
+ # (non-healing) hooks. Prefers LOKI_MIGRATION_DIR; falls back to a per-codebase
142
+ # .loki/migration dir so the snapshot/revert pair works even when no migration
143
+ # dir was exported. Echoes the resolved directory.
144
+ _migration_snapshot_dir() {
145
+ local codebase_path="${LOKI_CODEBASE_PATH:-.}"
146
+ local migration_dir="${LOKI_MIGRATION_DIR:-}"
147
+ if [[ -n "$migration_dir" ]]; then
148
+ printf '%s' "$migration_dir"
149
+ else
150
+ printf '%s' "${codebase_path}/.loki/migration"
151
+ fi
152
+ }
153
+
154
+ # Hook: pre_file_edit - runs BEFORE ANY agent modifies a source file.
155
+ # Captures a pre-edit snapshot so post_file_edit can revert ONLY the edit on
156
+ # test failure (instead of a blanket `git checkout` that nukes unrelated
157
+ # uncommitted changes and silently no-ops for untracked files). Mirrors the
158
+ # pairing contract of hook_pre_healing_modify / hook_post_healing_modify.
159
+ hook_pre_file_edit() {
160
+ local file_path="${1:-}"
161
+ [[ "${HOOK_POST_FILE_EDIT_ENABLED:-true}" != "true" ]] && return 0
162
+ [[ -z "$file_path" ]] && return 0
163
+ local snap_base
164
+ snap_base=$(_migration_snapshot_dir)
165
+ _heal_snapshot_save "$snap_base" "$file_path"
166
+ return 0
167
+ }
168
+
140
169
  # Hook: post_file_edit - runs after ANY agent modifies a source file
141
170
  hook_post_file_edit() {
142
171
  local file_path="${1:-}"
@@ -165,9 +194,15 @@ hook_post_file_edit() {
165
194
 
166
195
  case "${HOOK_POST_FILE_EDIT_ON_FAILURE}" in
167
196
  block_and_rollback)
168
- # Revert the file change
169
- git -C "$codebase_path" checkout -- "$file_path" 2>/dev/null || true
170
- echo "HOOK_BLOCKED: Tests failed after editing ${file_path}. Change reverted."
197
+ # Revert ONLY the edit using the pre-edit snapshot captured by
198
+ # hook_pre_file_edit. Do NOT use `git checkout -- "$file_path"`:
199
+ # that discards ALL uncommitted changes to the file (not just
200
+ # this edit) and silently no-ops for an untracked file while
201
+ # still claiming the change was reverted. Report what happened.
202
+ local snap_base revert_msg
203
+ snap_base=$(_migration_snapshot_dir)
204
+ revert_msg=$(_heal_snapshot_restore "$snap_base" "$file_path") || true
205
+ echo "HOOK_BLOCKED: Tests failed after editing ${file_path}. ${revert_msg}"
171
206
  echo "Test output: ${test_output}"
172
207
  return 1
173
208
  ;;
@@ -433,7 +468,7 @@ _heal_snapshot_restore() {
433
468
  # Pre-edit content snapshot exists: restore exactly that content, which
434
469
  # preserves any unrelated uncommitted changes present before the edit.
435
470
  if cp "$snap" "$file_path" 2>/dev/null; then
436
- echo "Healing edit reverted to pre-edit snapshot."
471
+ echo "Edit reverted to pre-edit snapshot."
437
472
  return 0
438
473
  fi
439
474
  echo "Could not restore pre-edit snapshot for ${file_path}; file left as-is."
@@ -441,17 +476,17 @@ _heal_snapshot_restore() {
441
476
  fi
442
477
 
443
478
  if [[ -f "$snap.absent" ]]; then
444
- # File did not exist pre-edit: the healing edit created it. Remove only
445
- # that file, not unrelated state.
479
+ # File did not exist pre-edit: the edit created it. Remove only that
480
+ # file, not unrelated state.
446
481
  if [[ ! -e "$file_path" ]]; then
447
- echo "Healing-added file ${file_path} no longer present; nothing to remove."
482
+ echo "Added file ${file_path} no longer present; nothing to remove."
448
483
  return 0
449
484
  fi
450
485
  if rm -f "$file_path" 2>/dev/null; then
451
- echo "Healing-added file ${file_path} removed."
486
+ echo "Added file ${file_path} removed."
452
487
  return 0
453
488
  fi
454
- echo "Could not remove healing-added file ${file_path}; file left as-is."
489
+ echo "Could not remove added file ${file_path}; file left as-is."
455
490
  return 1
456
491
  fi
457
492
 
@@ -79,6 +79,33 @@ _PATTERNS = [
79
79
  # Bearer tokens: keep the scheme, redact the credential.
80
80
  _BEARER = re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/=-]{20,}")
81
81
 
82
+ # HTTP auth/cookie HEADER lines: redact the credential VALUE for the whole line.
83
+ # This owns the header-line form of Authorization (any scheme: Basic, Bearer,
84
+ # Digest, raw token) plus Cookie / Set-Cookie, which the keyword-based
85
+ # _ENV_ASSIGN rule does not cover (Authorization is excluded there via the
86
+ # AUTH(?!ORIZATION) lookahead, and Cookie/session are not secret keywords).
87
+ #
88
+ # Anchored with (?im):
89
+ # - re.IGNORECASE so "authorization"/"Authorization"/"COOKIE" all match.
90
+ # - re.MULTILINE so an interior header line inside a multi-line blob (crash
91
+ # reports, stack traces, env dumps) matches, not just offset 0. "." does
92
+ # not cross newlines, so the value run stops at end-of-line and adjacent
93
+ # normal lines are left untouched.
94
+ #
95
+ # Group layout:
96
+ # 1 -> line prefix up through the "header:" separator (leading whitespace +
97
+ # header name + colon + following whitespace), preserved verbatim.
98
+ # 2 -> optional auth scheme word (Basic/Bearer/Digest/Negotiate/NTLM) plus
99
+ # its trailing space, preserved so "Authorization: Basic [REDACTED]"
100
+ # keeps the scheme. Empty for Cookie / raw-token forms.
101
+ # Everything after that (the credential) is replaced with [REDACTED].
102
+ _AUTH_HEADER = re.compile(
103
+ r"(?im)"
104
+ r"^([ \t]*(?:authorization|cookie|set-cookie)[ \t]*:[ \t]*)" # 1: header prefix
105
+ r"((?:Basic|Bearer|Digest|Negotiate|NTLM)[ \t]+)?" # 2: optional scheme
106
+ r"\S.*$" # the credential value
107
+ )
108
+
82
109
  # PEM PRIVATE KEY blocks (any -----BEGIN ... PRIVATE KEY----- ... END block).
83
110
  # DOTALL so the body spanning newlines is matched and dropped whole.
84
111
  _PEM = re.compile(
@@ -131,6 +158,14 @@ _ENV_ASSIGN = re.compile(
131
158
  )
132
159
 
133
160
 
161
+ def _auth_header_sub(m):
162
+ """Redact an HTTP auth/cookie header VALUE, keeping the header name and
163
+ (when present) the auth scheme word. Group 1 is the "header:" prefix, group
164
+ 2 is the optional scheme (with trailing space) or None."""
165
+ prefix, scheme = m.group(1), m.group(2)
166
+ return prefix + (scheme or "") + "[REDACTED]"
167
+
168
+
134
169
  def _env_assign_sub(m):
135
170
  """Redact a secret assignment value, preserving key, separator and quotes.
136
171
 
@@ -231,6 +266,12 @@ def _redact_value(s):
231
266
  )
232
267
  total += n
233
268
 
269
+ # HTTP auth/cookie header lines: redact the whole credential value before
270
+ # token-level rules run, so a token inside the header value is not matched
271
+ # (and double-counted) separately. Keeps the header name + scheme word.
272
+ s, n = _AUTH_HEADER.subn(_auth_header_sub, s)
273
+ total += n
274
+
234
275
  # Token patterns (ordered most-specific-first).
235
276
  for pat, repl in _PATTERNS:
236
277
  s, n = pat.subn(repl, s)
package/autonomy/loki CHANGED
@@ -2889,12 +2889,26 @@ cmd_status() {
2889
2889
  # Check orchestrator state
2890
2890
  if [ -f "$LOKI_DIR/state/orchestrator.json" ]; then
2891
2891
  echo -e "${CYAN}Orchestrator State:${NC}"
2892
- jq -r '.currentPhase // "unknown"' "$LOKI_DIR/state/orchestrator.json" 2>/dev/null || echo "unknown"
2892
+ local orch_phase
2893
+ # An empty orchestrator.json makes jq exit 0 with no output, so the
2894
+ # `|| echo unknown` fallback never fires and the phase line is blank.
2895
+ # Capture and normalize an empty result to "unknown".
2896
+ orch_phase=$(jq -r '.currentPhase // "unknown"' "$LOKI_DIR/state/orchestrator.json" 2>/dev/null || echo "unknown")
2897
+ [ -n "$orch_phase" ] || orch_phase="unknown"
2898
+ echo "$orch_phase"
2893
2899
  fi
2894
2900
 
2895
2901
  # Check pending tasks
2896
2902
  if [ -f "$LOKI_DIR/queue/pending.json" ]; then
2897
- local task_count=$(jq 'if type == "array" then length elif .tasks then .tasks | length else 0 end' "$LOKI_DIR/queue/pending.json" 2>/dev/null || echo "0")
2903
+ local task_count
2904
+ task_count=$(jq 'if type == "array" then length elif .tasks then .tasks | length else 0 end' "$LOKI_DIR/queue/pending.json" 2>/dev/null || echo "0")
2905
+ # An empty or whitespace-only pending.json makes jq exit 0 while
2906
+ # printing nothing, so the `|| echo 0` fallback never fires and the
2907
+ # status line renders a blank count. Normalize any non-numeric result
2908
+ # (empty string, null, parse noise) back to 0.
2909
+ case "$task_count" in
2910
+ ''|*[!0-9]*) task_count=0 ;;
2911
+ esac
2898
2912
  echo -e "${CYAN}Pending Tasks:${NC} $task_count"
2899
2913
  fi
2900
2914
 
@@ -16502,6 +16516,9 @@ with open(p, 'w') as f:
16502
16516
  if [ -f "$LOKI_DIR/queue/failed.json" ]; then
16503
16517
  local count
16504
16518
  count=$(jq 'length' "$LOKI_DIR/queue/failed.json" 2>/dev/null || echo "?")
16519
+ # An empty failed.json makes jq exit 0 with no output, so the
16520
+ # `|| echo "?"` fallback never fires; normalize the blank result.
16521
+ [ -n "$count" ] || count="?"
16505
16522
  rm -f "$LOKI_DIR/queue/failed.json"
16506
16523
  echo "[]" > "$LOKI_DIR/queue/failed.json"
16507
16524
  echo -e "${GREEN}Cleared $count failed tasks${NC}"
@@ -16859,7 +16876,11 @@ PYEOF
16859
16876
  export)
16860
16877
  local output="${2:-learnings-export.json}"
16861
16878
 
16862
- LOKI_LEARNINGS_DIR="$learnings_dir" python3 -c "
16879
+ # BUG-PU-010: pass the output filename via an environment variable
16880
+ # rather than interpolating $output into the python -c source, so a
16881
+ # filename containing quotes/backslashes/newlines cannot break out of
16882
+ # the string literal or inject python.
16883
+ LOKI_LEARNINGS_DIR="$learnings_dir" LOKI_LEARNINGS_OUT="$output" python3 -c "
16863
16884
  import json
16864
16885
  import os
16865
16886
 
@@ -16877,9 +16898,10 @@ for category in ['patterns', 'mistakes', 'successes']:
16877
16898
  result[category].append(e)
16878
16899
  except: pass
16879
16900
 
16880
- with open('$output', 'w') as f:
16901
+ _out = os.environ['LOKI_LEARNINGS_OUT']
16902
+ with open(_out, 'w') as f:
16881
16903
  json.dump(result, f, indent=2)
16882
- print(f'Exported to $output')
16904
+ print(f'Exported to {_out}')
16883
16905
  " 2>/dev/null
16884
16906
  ;;
16885
16907
 
@@ -25227,8 +25249,14 @@ generate_component(
25227
25249
 
25228
25250
  # 4. Register in registry
25229
25251
  log_info "Registering component"
25252
+ # BUG-PU-010: pass free-text description/tags via environment variables and
25253
+ # read them with os.environ in the python body, instead of interpolating raw
25254
+ # user input into the python -c source (a value containing triple-quotes,
25255
+ # backslashes, newlines, or $(...) would crash the script or inject code).
25256
+ # Mirrors the LOKI_MEM_QUERY pattern in the memory-search path.
25257
+ LOKI_MAGIC_DESC="$description" LOKI_MAGIC_TAGS="$tags" \
25230
25258
  PYTHONPATH="$(_magic_pypath)" "$py" -c "
25231
- import sys
25259
+ import os, sys
25232
25260
  try:
25233
25261
  from magic.core.registry import register_component
25234
25262
  except Exception as exc:
@@ -25242,8 +25270,8 @@ register_component(
25242
25270
  react_path='.loki/magic/generated/react/${name}.tsx' if '$target' in ('react','both') else '',
25243
25271
  webcomponent_path='.loki/magic/generated/webcomponent/${name}.js' if '$target' in ('webcomponent','both') else '',
25244
25272
  test_path='.loki/magic/generated/tests/${name}.test.tsx',
25245
- description='''$description'''.strip(),
25246
- tags=[t.strip() for t in '''$tags'''.split(',') if t.strip()],
25273
+ description=os.environ.get('LOKI_MAGIC_DESC', '').strip(),
25274
+ tags=[t.strip() for t in os.environ.get('LOKI_MAGIC_TAGS', '').split(',') if t.strip()],
25247
25275
  placement='${placement}' or None,
25248
25276
  )
25249
25277
  " || {
@@ -25288,13 +25316,22 @@ _magic_update() {
25288
25316
  esac
25289
25317
  done
25290
25318
 
25319
+ # BUG-PU-010: validate --name (defense in depth, mirroring _magic_generate)
25320
+ # so a malicious value cannot reach the python body, AND pass it via an
25321
+ # environment variable instead of interpolating it into the python -c source.
25322
+ if [ -n "$name" ] && ! _magic_valid_name "$name"; then
25323
+ log_error "Invalid component name: '$name' (must start with a letter, contain only letters, digits, _ or -)"
25324
+ return 1
25325
+ fi
25326
+
25291
25327
  _magic_ensure_dirs
25292
25328
  local py
25293
25329
  py=$(_magic_python)
25294
25330
 
25295
25331
  log_info "Updating components from specs (name=${name:-<all>}, force=$force)"
25332
+ LOKI_MAGIC_NAME="$name" \
25296
25333
  PYTHONPATH="$(_magic_pypath)" "$py" -c "
25297
- import sys
25334
+ import os, sys
25298
25335
  try:
25299
25336
  from magic.core.generator import update_components
25300
25337
  except Exception as exc:
@@ -25302,7 +25339,7 @@ except Exception as exc:
25302
25339
  sys.exit(2)
25303
25340
  update_components(
25304
25341
  registry_path='.loki/magic/registry.json',
25305
- name='${name}' or None,
25342
+ name=os.environ.get('LOKI_MAGIC_NAME', '') or None,
25306
25343
  force=$([ "$force" = "true" ] && echo True || echo False),
25307
25344
  )
25308
25345
  " || {
package/autonomy/run.sh CHANGED
@@ -13718,6 +13718,100 @@ _loki_sentrux_iteration_end() {
13718
13718
  return 0
13719
13719
  }
13720
13720
 
13721
+ # show_run_start_estimate <prd_path>
13722
+ #
13723
+ # C4 (v7.x): before any real spend, the user must SEE (a) the budget-guard
13724
+ # state and (b) a cost/time estimate -- honestly, with no fabricated dollar
13725
+ # figures. This is the run.sh-side complement to the loki CLI's auto-plan:
13726
+ #
13727
+ # - Budget guard: the hard cap is enforced by check_budget_limit (which
13728
+ # touches .loki/PAUSE at the cap). This helper only DISPLAYS the state;
13729
+ # it never sets a default BUDGET_LIMIT (doing so would change pause
13730
+ # behavior for every user). If BUDGET_LIMIT is set we show the cap and
13731
+ # the pause-at-cap promise; if not, we state plainly that no cap is set
13732
+ # and how to set one. Always shown -- the guard disclosure is universal.
13733
+ #
13734
+ # - Estimate: `loki start` on a TTY already prints the estimate via
13735
+ # maybe_show_auto_plan -> show_prd_plan. The genuine gap is the non-TTY
13736
+ # route (Docker, dashboard, piped invocation): there the CLI skips the
13737
+ # plan, so we fill it here. We gate on stdout NOT being a TTY because
13738
+ # run.sh has no signal that `loki start` already showed the plan (no env
13739
+ # marker exists and `loki` is out of scope to edit), and the non-TTY test
13740
+ # is the only one that is both reliable and free of duplication.
13741
+ # KNOWN LIMITATION: a direct `./autonomy/run.sh <prd>` run in a terminal
13742
+ # (TTY, not launched via `loki start`) skips the estimate here AND was
13743
+ # never shown one by the CLI. The budget-guard disclosure above is still
13744
+ # always shown; only the cost/time estimate is missing on that one
13745
+ # power-user path. Closing it cleanly needs a "plan already shown" marker
13746
+ # set in the loki CLI, which is owned elsewhere.
13747
+ # The estimate is best-effort: it shells out to the loki binary with a
13748
+ # hard timeout, parses only real numbers, and prints an honest
13749
+ # "estimate unavailable" line on any failure. It NEVER fails the run and
13750
+ # NEVER fabricates a figure.
13751
+ show_run_start_estimate() {
13752
+ local prd_path="$1"
13753
+
13754
+ # --- Budget-guard disclosure (always) ---
13755
+ if [ -n "$BUDGET_LIMIT" ]; then
13756
+ log_info "Budget guard: hard cap \$$BUDGET_LIMIT (run pauses via .loki/PAUSE at the cap; warning at 80%)."
13757
+ else
13758
+ log_info "Budget guard: no cap set (no automatic spend stop). Set LOKI_BUDGET_LIMIT=<usd> to pause at a cap."
13759
+ fi
13760
+
13761
+ # --- Estimate (non-TTY gap only; the loki CLI shows it on a TTY) ---
13762
+ if [ -t 1 ]; then
13763
+ return 0
13764
+ fi
13765
+ # No PRD on disk (codebase-analysis mode) -> nothing to estimate from.
13766
+ [ -n "$prd_path" ] && [ -f "$prd_path" ] || return 0
13767
+
13768
+ local loki_bin="${SCRIPT_DIR}/loki"
13769
+ [ -x "$loki_bin" ] || { command -v loki >/dev/null 2>&1 && loki_bin="loki" || return 0; }
13770
+
13771
+ local plan_json=""
13772
+ plan_json=$(timeout 30 "$loki_bin" plan "$prd_path" --json 2>/dev/null) || plan_json=""
13773
+ [ -n "$plan_json" ] || { log_info "Estimate: unavailable (estimator did not return a result); the run continues."; return 0; }
13774
+
13775
+ # Parse REAL numbers only. argv keeps the JSON out of the script body so
13776
+ # there is no $<digit> heredoc footgun, and a missing field prints nothing.
13777
+ local parsed
13778
+ parsed=$(printf '%s' "$plan_json" | python3 -c '
13779
+ import json, sys
13780
+ try:
13781
+ d = json.load(sys.stdin)
13782
+ except Exception:
13783
+ sys.exit(1)
13784
+ cost = d.get("cost", {}).get("total_usd")
13785
+ time_est = d.get("time", {}).get("estimated")
13786
+ iters = d.get("iterations", {}).get("estimated")
13787
+ tier = d.get("complexity", {}).get("tier", "")
13788
+ if cost is None or time_est is None or iters is None:
13789
+ sys.exit(1)
13790
+ print("{:.2f}".format(float(cost)))
13791
+ print(time_est)
13792
+ print(iters)
13793
+ print(tier)
13794
+ ' 2>/dev/null) || parsed=""
13795
+
13796
+ if [ -z "$parsed" ]; then
13797
+ log_info "Estimate: unavailable (estimator did not return a result); the run continues."
13798
+ return 0
13799
+ fi
13800
+
13801
+ local est_cost est_time est_iters est_tier
13802
+ est_cost=$(printf '%s' "$parsed" | sed -n '1p')
13803
+ est_time=$(printf '%s' "$parsed" | sed -n '2p')
13804
+ est_iters=$(printf '%s' "$parsed" | sed -n '3p')
13805
+ est_tier=$(printf '%s' "$parsed" | sed -n '4p')
13806
+
13807
+ if [ -n "$est_tier" ]; then
13808
+ log_info "Estimate (${est_tier} tier): ~\$${est_cost}, ~${est_time}, ~${est_iters} iterations. Actual usage varies with complexity, review cycles, and test failures."
13809
+ else
13810
+ log_info "Estimate: ~\$${est_cost}, ~${est_time}, ~${est_iters} iterations. Actual usage varies with complexity, review cycles, and test failures."
13811
+ fi
13812
+ return 0
13813
+ }
13814
+
13721
13815
  run_autonomous() {
13722
13816
  local prd_path="$1"
13723
13817
 
@@ -13819,9 +13913,10 @@ except Exception:
13819
13913
  log_info "Base wait: ${BASE_WAIT}s"
13820
13914
  log_info "Max wait: ${MAX_WAIT}s"
13821
13915
  log_info "Autonomy mode: $AUTONOMY_MODE"
13822
- if [ -n "$BUDGET_LIMIT" ]; then
13823
- log_info "Budget limit: \$$BUDGET_LIMIT"
13824
- fi
13916
+ # C4: always surface the budget-guard state (and, on the non-TTY route, a
13917
+ # cost/time estimate) BEFORE any real spend. This subsumes the old bare
13918
+ # "Budget limit" line so there is exactly one honest disclosure.
13919
+ show_run_start_estimate "$prd_path"
13825
13920
  # Only show Claude-specific features for Claude provider
13826
13921
  if [ "${PROVIDER_NAME:-claude}" = "claude" ]; then
13827
13922
  log_info "Prompt repetition (Haiku): $PROMPT_REPETITION"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.58.1"
10
+ __version__ = "7.60.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -251,6 +251,25 @@ def resolve_tenant_context(
251
251
  )
252
252
 
253
253
 
254
+ def _require_global_admin(tenant_ctx: TenantContext) -> None:
255
+ """Gate a tenant-lifecycle operation behind global-admin authority.
256
+
257
+ Creating, updating, or deleting a tenant is a global-admin-only operation:
258
+ it manages the isolation boundaries themselves, so a tenant-scoped caller
259
+ (even one holding the `control` scope, which does NOT imply `admin`) must
260
+ not perform it. A global admin is allowed. When auth is disabled there is
261
+ no caller identity to isolate -- single-user local mode -- so the operation
262
+ is permitted, mirroring TenantContext.enforce so legitimate single-tenant
263
+ and local flows are not broken.
264
+ """
265
+ if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
266
+ return
267
+ raise HTTPException(
268
+ status_code=403,
269
+ detail="Tenant lifecycle operations require global admin",
270
+ )
271
+
272
+
254
273
  async def _enforce_project_tenant(
255
274
  db: AsyncSession, tenant_ctx: TenantContext, project_id: int
256
275
  ) -> None:
@@ -306,8 +325,10 @@ async def create_tenant(
306
325
  db: AsyncSession = Depends(get_db),
307
326
  _auth: None = Depends(auth.require_scope("control")),
308
327
  token_info: Optional[dict] = Depends(auth.get_current_token),
328
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
309
329
  ):
310
- """Create a new tenant."""
330
+ """Create a new tenant (global-admin only)."""
331
+ _require_global_admin(tenant_ctx)
311
332
  tenant = await tenants_mod.create_tenant(
312
333
  db, name=body.name, description=body.description, settings=body.settings,
313
334
  )
@@ -366,8 +387,8 @@ async def update_tenant(
366
387
  token_info: Optional[dict] = Depends(auth.get_current_token),
367
388
  tenant_ctx: TenantContext = Depends(resolve_tenant_context),
368
389
  ):
369
- """Update an existing tenant."""
370
- tenant_ctx.enforce(tenant_id)
390
+ """Update an existing tenant (global-admin only)."""
391
+ _require_global_admin(tenant_ctx)
371
392
  tenant = await tenants_mod.update_tenant(
372
393
  db, tenant_id,
373
394
  name=body.name, description=body.description, settings=body.settings,
@@ -392,8 +413,8 @@ async def delete_tenant(
392
413
  token_info: Optional[dict] = Depends(auth.get_current_token),
393
414
  tenant_ctx: TenantContext = Depends(resolve_tenant_context),
394
415
  ):
395
- """Delete a tenant."""
396
- tenant_ctx.enforce(tenant_id)
416
+ """Delete a tenant (global-admin only)."""
417
+ _require_global_admin(tenant_ctx)
397
418
  deleted = await tenants_mod.delete_tenant(db, tenant_id)
398
419
  if not deleted:
399
420
  raise HTTPException(status_code=404, detail="Tenant not found")
@@ -21,11 +21,41 @@ from datetime import datetime, timezone
21
21
  from pathlib import Path
22
22
  from typing import Optional
23
23
 
24
- from fastapi import FastAPI, HTTPException
24
+ from fastapi import Depends, FastAPI, HTTPException
25
25
  from fastapi.middleware.cors import CORSMiddleware
26
26
  from fastapi.responses import StreamingResponse
27
27
  from pydantic import BaseModel
28
28
 
29
+ # Auth gating for the standalone control app.
30
+ #
31
+ # This module also defines a self-contained FastAPI `app` whose own docstring
32
+ # invites operators to expose it via `uvicorn dashboard.control:app`. When that
33
+ # happens the state-mutating endpoints (start/stop/pause/resume) MUST honor the
34
+ # same scope checks as the primary dashboard (dashboard/server.py), otherwise a
35
+ # user who follows the docstring stands up an unauthenticated control plane that
36
+ # can launch arbitrary builds and kill running sessions even when
37
+ # LOKI_ENTERPRISE_AUTH / OIDC are configured.
38
+ #
39
+ # auth.require_scope is a no-op (allows access) when no auth method is enabled,
40
+ # so this import is safe for the default anonymous-localhost workflow and only
41
+ # enforces when the operator has explicitly turned auth on. The import is
42
+ # defensive: if the package context is unavailable (e.g. the file is run from a
43
+ # path where the relative import fails) we fall back to a gate that always
44
+ # allows, preserving the prior behavior rather than crashing import.
45
+ try:
46
+ from . import auth as _auth
47
+
48
+ def _require_control_scope():
49
+ return Depends(_auth.require_scope("control"))
50
+ except Exception: # pragma: no cover - defensive fallback for non-package runs
51
+ def _require_control_scope():
52
+ async def _noop() -> bool:
53
+ return True
54
+
55
+ return Depends(_noop)
56
+
57
+ _CONTROL_DEP = _require_control_scope()
58
+
29
59
  # Configuration
30
60
  LOKI_DIR = Path(os.environ.get("LOKI_DIR", ".loki"))
31
61
  STATE_DIR = LOKI_DIR / "state"
@@ -363,7 +393,7 @@ async def get_session_status():
363
393
  return get_status()
364
394
 
365
395
 
366
- @app.post("/api/control/start", response_model=ControlResponse)
396
+ @app.post("/api/control/start", response_model=ControlResponse, dependencies=[_CONTROL_DEP])
367
397
  async def start_session(request: StartRequest):
368
398
  """
369
399
  Start a Loki Mode session.
@@ -435,7 +465,7 @@ async def start_session(request: StartRequest):
435
465
  raise HTTPException(status_code=500, detail=str(e))
436
466
 
437
467
 
438
- @app.post("/api/control/stop", response_model=ControlResponse)
468
+ @app.post("/api/control/stop", response_model=ControlResponse, dependencies=[_CONTROL_DEP])
439
469
  async def stop_session():
440
470
  """
441
471
  Stop the current Loki Mode session.
@@ -484,7 +514,7 @@ async def stop_session():
484
514
  )
485
515
 
486
516
 
487
- @app.post("/api/control/pause", response_model=ControlResponse)
517
+ @app.post("/api/control/pause", response_model=ControlResponse, dependencies=[_CONTROL_DEP])
488
518
  async def pause_session():
489
519
  """
490
520
  Pause the current Loki Mode session.
@@ -513,7 +543,7 @@ async def pause_session():
513
543
  )
514
544
 
515
545
 
516
- @app.post("/api/control/resume", response_model=ControlResponse)
546
+ @app.post("/api/control/resume", response_model=ControlResponse, dependencies=[_CONTROL_DEP])
517
547
  async def resume_session():
518
548
  """
519
549
  Resume a paused Loki Mode session.
@@ -6867,6 +6867,23 @@ def _resolve_process_state(pid: Optional[int], last_status: str = "",
6867
6867
  started_dt = started_dt.replace(tzinfo=timezone.utc)
6868
6868
  except (ValueError, AttributeError):
6869
6869
  pass
6870
+
6871
+ # PID-reuse guard. os.kill(pid, 0) only proves *some* process owns this
6872
+ # numeric pid -- not that it is OUR process. After our process exits the OS
6873
+ # can recycle its pid for an unrelated program, and a bare existence probe
6874
+ # would then report that stranger as our live run forever. Cross-check the
6875
+ # live pid's real OS start time against the recorded `started` reference: a
6876
+ # genuine process was launched at or before we recorded it, so a live pid
6877
+ # whose start time is comfortably AFTER the reference must be a recycled pid
6878
+ # belonging to someone else. Only downgrade on positive evidence (start time
6879
+ # readable AND reference parseable); if either is missing we keep the prior
6880
+ # best-effort behavior rather than guess, biasing against false downgrades.
6881
+ if pid_alive and started_dt is not None:
6882
+ pid_start = _pid_start_time(pid)
6883
+ if pid_start is not None:
6884
+ reference_epoch = started_dt.timestamp()
6885
+ if pid_start > reference_epoch + _APP_RUNNER_PID_RECYCLE_MARGIN_SECONDS:
6886
+ pid_alive = False
6870
6887
  if heartbeat:
6871
6888
  try:
6872
6889
  heartbeat_dt = datetime.fromisoformat(heartbeat.replace("Z", "+00:00"))
@@ -7805,6 +7822,30 @@ def _compose_service_labels(svc):
7805
7822
  return {}
7806
7823
 
7807
7824
 
7825
+ def _pick_web_port(ports):
7826
+ """From a service's published host ports, pick the one most likely to be HTTP.
7827
+
7828
+ A single service can publish several host ports (e.g. a Spring Boot app that
7829
+ exposes 8080 for HTTP and 8081 for the actuator/management endpoint, or a
7830
+ stack that maps both a debug and a web port). Blindly taking ports[0] is
7831
+ order-dependent and can surface the management/debug port instead of the
7832
+ reachable web URL. Prefer the first port that appears in
7833
+ _COMPOSE_COMMON_WEB_PORTS precedence order (so 8080 wins over a non-common
7834
+ 8081), and only fall back to ports[0] when none is a recognized web port.
7835
+
7836
+ This is why Spring Boot's 8080-over-8081 case resolves correctly without
7837
+ parsing application.properties / server.port: the runtime published host
7838
+ port (from compose ps Publishers) is matched against the known web-port
7839
+ family. Returns a port string, or None if ports is empty. Never raises.
7840
+ """
7841
+ if not ports:
7842
+ return None
7843
+ for cp in _COMPOSE_COMMON_WEB_PORTS:
7844
+ if cp in ports:
7845
+ return cp
7846
+ return ports[0]
7847
+
7848
+
7808
7849
  def _identify_compose_web_service(config_services, running_by_service):
7809
7850
  """Pick the primary web service and its published host port.
7810
7851
 
@@ -7818,6 +7859,12 @@ def _identify_compose_web_service(config_services, running_by_service):
7818
7859
  only running, published containers can yield a real URL. Returns
7819
7860
  (service_name, port_str) or (None, None). Never raises.
7820
7861
 
7862
+ When a chosen service publishes MULTIPLE host ports, _pick_web_port selects
7863
+ the HTTP one (common web port over a management/debug port) rather than the
7864
+ arbitrary first-listed port -- so a Spring Boot service exposing 8080+8081
7865
+ surfaces 8080, and a stack whose web service is not first in the compose file
7866
+ is still resolved by name (rule 2) or by common-port match (rule 3).
7867
+
7821
7868
  config_services: dict {service_name: service_config_dict} (may be empty).
7822
7869
  running_by_service: dict {service_name: [published_port_str, ...]} for
7823
7870
  currently-running containers with at least one published host port.
@@ -7832,26 +7879,30 @@ def _identify_compose_web_service(config_services, running_by_service):
7832
7879
  labels = _compose_service_labels(svc)
7833
7880
  if str(labels.get("loki.primary", "")).lower() == "true":
7834
7881
  ports = running_by_service.get(name)
7835
- if ports:
7836
- return (name, ports[0])
7882
+ picked = _pick_web_port(ports)
7883
+ if picked:
7884
+ return (name, picked)
7837
7885
 
7838
7886
  # (2) service named web/app
7839
7887
  for cand in ("web", "app"):
7840
7888
  ports = running_by_service.get(cand)
7841
- if ports:
7842
- return (cand, ports[0])
7889
+ picked = _pick_web_port(ports)
7890
+ if picked:
7891
+ return (cand, picked)
7843
7892
 
7844
- # (3) service publishing a common web port
7893
+ # (3) service publishing a common web port. Iterate services in sorted order
7894
+ # so selection is deterministic when more than one service is a candidate.
7845
7895
  for cp in _COMPOSE_COMMON_WEB_PORTS:
7846
- for name, ports in running_by_service.items():
7847
- if cp in ports:
7896
+ for name in sorted(running_by_service.keys()):
7897
+ if cp in running_by_service[name]:
7848
7898
  return (name, cp)
7849
7899
 
7850
- # (4) first running service with any published port. Sort for determinism.
7900
+ # (4) first running service with any published port. Sort for determinism;
7901
+ # pick that service's HTTP-most port (not necessarily its first-listed one).
7851
7902
  for name in sorted(running_by_service.keys()):
7852
- ports = running_by_service[name]
7853
- if ports:
7854
- return (name, ports[0])
7903
+ picked = _pick_web_port(running_by_service[name])
7904
+ if picked:
7905
+ return (name, picked)
7855
7906
 
7856
7907
  return (None, None)
7857
7908
 
@@ -3450,10 +3450,10 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
3450
3450
  </div>
3451
3451
  </div>
3452
3452
  </div>
3453
- `;this.shadowRoot.innerHTML=`
3453
+ `,d=this.shadowRoot.activeElement,p=d&&d.id==="spec-input",h=p?d.selectionStart:null,b=p?d.selectionEnd:null;if(this.shadowRoot.innerHTML=`
3454
3454
  ${o}
3455
3455
  ${e?n:l}
3456
- `,this._attachEventListeners()}_attachEventListeners(){let e=this.shadowRoot.getElementById("pause-btn"),t=this.shadowRoot.getElementById("resume-btn"),i=this.shadowRoot.getElementById("stop-btn"),a=this.shadowRoot.getElementById("start-btn");e&&e.addEventListener("click",()=>this._triggerPause()),t&&t.addEventListener("click",()=>this._triggerResume()),i&&i.addEventListener("click",()=>this._triggerStop()),a&&a.addEventListener("click",()=>this._triggerStart());let s=this.shadowRoot.getElementById("model-select");s&&s.addEventListener("change",o=>this._onModelChange(o.target.value));let r=this.shadowRoot.getElementById("spec-input");r&&r.addEventListener("input",o=>this._onSpecInput(o.target.value))}};customElements.get("loki-session-control")||customElements.define("loki-session-control",J);var qe={info:{color:"var(--loki-blue)",label:"INFO"},success:{color:"var(--loki-green)",label:"SUCCESS"},warning:{color:"var(--loki-yellow)",label:"WARN"},error:{color:"var(--loki-red)",label:"ERROR"},step:{color:"var(--loki-purple)",label:"STEP"},agent:{color:"var(--loki-accent)",label:"AGENT"},debug:{color:"var(--loki-text-muted)",label:"DEBUG"}},G=class extends u{static get observedAttributes(){return["api-url","max-lines","auto-scroll","theme","log-file"]}constructor(){super(),this._logs=[],this._maxLines=500,this._autoScroll=!0,this._filter="",this._levelFilter="all",this._api=null,this._pollInterval=null,this._logMessageHandler=null}connectedCallback(){super.connectedCallback(),this._maxLines=parseInt(this.getAttribute("max-lines"))||500,this._autoScroll=this.hasAttribute("auto-scroll"),this._setupApi(),this._startLogPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopLogPolling(),this._api&&this._logMessageHandler&&this._api.removeEventListener(v.LOG_MESSAGE,this._logMessageHandler)}attributeChangedCallback(e,t,i){if(t!==i)switch(e){case"api-url":this._api&&(this._api.baseUrl=i);break;case"max-lines":this._maxLines=parseInt(i)||500,this._trimLogs(),this.render();break;case"auto-scroll":this._autoScroll=this.hasAttribute("auto-scroll"),this.render();break;case"theme":this._applyTheme();break}}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:e}),this._logMessageHandler=t=>this._addLog(t.detail),this._api.addEventListener(v.LOG_MESSAGE,this._logMessageHandler)}_startLogPolling(){let e=this.getAttribute("log-file");e?this._pollLogFile(e):this._pollApiLogs()}async _pollApiLogs(){let e=0,t=async()=>{try{let i=await this._api.getLogs(200);if(Array.isArray(i)&&i.length>e){let a=i.slice(e);for(let s of a)s.message&&s.message.trim()&&this._addLog({message:s.message,level:s.level||"info",timestamp:s.timestamp||new Date().toLocaleTimeString()});e=i.length}}catch{}};t(),this._apiPollInterval=setInterval(t,2e3)}async _pollLogFile(e){let t=0,i=async()=>{try{let a=await fetch(`${e}?t=${Date.now()}`,{credentials:"include"});if(!a.ok)return;let r=(await a.text()).split(`
3456
+ `,this._attachEventListeners(),p){let m=this.shadowRoot.getElementById("spec-input");if(m&&!m.disabled){m.focus();try{m.setSelectionRange(h,b)}catch{}}}}_attachEventListeners(){let e=this.shadowRoot.getElementById("pause-btn"),t=this.shadowRoot.getElementById("resume-btn"),i=this.shadowRoot.getElementById("stop-btn"),a=this.shadowRoot.getElementById("start-btn");e&&e.addEventListener("click",()=>this._triggerPause()),t&&t.addEventListener("click",()=>this._triggerResume()),i&&i.addEventListener("click",()=>this._triggerStop()),a&&a.addEventListener("click",()=>this._triggerStart());let s=this.shadowRoot.getElementById("model-select");s&&s.addEventListener("change",o=>this._onModelChange(o.target.value));let r=this.shadowRoot.getElementById("spec-input");r&&r.addEventListener("input",o=>this._onSpecInput(o.target.value))}};customElements.get("loki-session-control")||customElements.define("loki-session-control",J);var qe={info:{color:"var(--loki-blue)",label:"INFO"},success:{color:"var(--loki-green)",label:"SUCCESS"},warning:{color:"var(--loki-yellow)",label:"WARN"},error:{color:"var(--loki-red)",label:"ERROR"},step:{color:"var(--loki-purple)",label:"STEP"},agent:{color:"var(--loki-accent)",label:"AGENT"},debug:{color:"var(--loki-text-muted)",label:"DEBUG"}},G=class extends u{static get observedAttributes(){return["api-url","max-lines","auto-scroll","theme","log-file"]}constructor(){super(),this._logs=[],this._maxLines=500,this._autoScroll=!0,this._filter="",this._levelFilter="all",this._api=null,this._pollInterval=null,this._logMessageHandler=null}connectedCallback(){super.connectedCallback(),this._maxLines=parseInt(this.getAttribute("max-lines"))||500,this._autoScroll=this.hasAttribute("auto-scroll"),this._setupApi(),this._startLogPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopLogPolling(),this._api&&this._logMessageHandler&&this._api.removeEventListener(v.LOG_MESSAGE,this._logMessageHandler)}attributeChangedCallback(e,t,i){if(t!==i)switch(e){case"api-url":this._api&&(this._api.baseUrl=i);break;case"max-lines":this._maxLines=parseInt(i)||500,this._trimLogs(),this.render();break;case"auto-scroll":this._autoScroll=this.hasAttribute("auto-scroll"),this.render();break;case"theme":this._applyTheme();break}}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:e}),this._logMessageHandler=t=>this._addLog(t.detail),this._api.addEventListener(v.LOG_MESSAGE,this._logMessageHandler)}_startLogPolling(){let e=this.getAttribute("log-file");e?this._pollLogFile(e):this._pollApiLogs()}async _pollApiLogs(){let e=0,t=async()=>{try{let i=await this._api.getLogs(200);if(Array.isArray(i)&&i.length>e){let a=i.slice(e);for(let s of a)s.message&&s.message.trim()&&this._addLog({message:s.message,level:s.level||"info",timestamp:s.timestamp||new Date().toLocaleTimeString()});e=i.length}}catch{}};t(),this._apiPollInterval=setInterval(t,2e3)}async _pollLogFile(e){let t=0,i=async()=>{try{let a=await fetch(`${e}?t=${Date.now()}`,{credentials:"include"});if(!a.ok)return;let r=(await a.text()).split(`
3457
3457
  `);if(r.length>t){let o=r.slice(t);for(let n of o)n.trim()&&this._addLog(this._parseLine(n));t=r.length}}catch{}};i(),this._pollInterval=setInterval(i,1e3)}_stopLogPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._apiPollInterval&&(clearInterval(this._apiPollInterval),this._apiPollInterval=null)}_parseLine(e){let t=e.match(/^\[([^\]]+)\]\s*\[([^\]]+)\]\s*(.+)$/);if(t)return{timestamp:t[1],level:t[2].toLowerCase(),message:t[3]};let i=e.match(/^(\d{2}:\d{2}:\d{2})\s+(\w+)\s+(.+)$/);return i?{timestamp:i[1],level:i[2].toLowerCase(),message:i[3]}:{timestamp:new Date().toLocaleTimeString(),level:"info",message:e}}_addLog(e){if(!e)return;let t={id:Date.now()+Math.random(),timestamp:e.timestamp||new Date().toLocaleTimeString(),level:(e.level||"info").toLowerCase(),message:e.message||e};this._logs.push(t),this._trimLogs(),this.dispatchEvent(new CustomEvent("log-received",{detail:t})),this._renderLogs(),this._autoScroll&&this._scrollToBottom()}_trimLogs(){this._logs.length>this._maxLines&&(this._logs=this._logs.slice(-this._maxLines))}_clearLogs(){this._logs=[],this.dispatchEvent(new CustomEvent("logs-cleared")),this._renderLogs()}_toggleAutoScroll(){this._autoScroll=!this._autoScroll,this.render(),this._autoScroll&&this._scrollToBottom()}_scrollToBottom(){requestAnimationFrame(()=>{let e=this.shadowRoot.getElementById("log-output");e&&(e.scrollTop=e.scrollHeight)})}_downloadLogs(){let e=this._logs.map(s=>`[${s.timestamp}] [${s.level.toUpperCase()}] ${s.message}`).join(`
3458
3458
  `),t=new Blob([e],{type:"text/plain"}),i=URL.createObjectURL(t),a=document.createElement("a");a.href=i,a.download=`loki-logs-${new Date().toISOString().split("T")[0]}.txt`,a.click(),URL.revokeObjectURL(i)}_setFilter(e){this._filter=e.toLowerCase(),this._renderLogs()}_setLevelFilter(e){this._levelFilter=e,this._renderLogs()}_getFilteredLogs(){return this._logs.filter(e=>!(this._levelFilter!=="all"&&e.level!==this._levelFilter||this._filter&&!e.message.toLowerCase().includes(this._filter)))}_renderLogs(){let e=this.shadowRoot.getElementById("log-output");if(!e)return;let t=this._getFilteredLogs();if(t.length===0){e.innerHTML='<div class="log-empty">No log output yet. Terminal will update when Loki Mode is running.</div>';return}e.innerHTML=t.map(i=>{let a=qe[i.level]||qe.info;return`
3459
3459
  <div class="log-line">
@@ -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.58.1
5
+ **Version:** v7.60.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.58.1 start ./my-spec.md
398
+ asklokesh/loki-mode:7.60.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
package/events/bus.py CHANGED
@@ -79,9 +79,32 @@ class LokiEvent:
79
79
 
80
80
  Handles compound types like 'session_start' by splitting on underscore
81
81
  and using the first token as the event type.
82
+
83
+ Tolerates two on-disk schemas:
84
+ - The pending/archive schema written by bus.py / bus.ts / emit.sh:
85
+ ``{id, type, source, timestamp, payload, version}``.
86
+ - The flat events.jsonl schema written by run.sh's emit_event /
87
+ emit_event_json (and read by the dashboard):
88
+ ``{timestamp, type, data: {...}}`` -- here ``source`` lives inside
89
+ ``data`` (or is absent) and the body is ``data``, not ``payload``.
90
+ Without this fallback, import_from_jsonl() would coerce every
91
+ run.sh-written line to source=cli with an empty payload, silently
92
+ dropping the source attribution and the entire event body.
82
93
  """
83
94
  raw_type = data.get('type', '')
95
+
96
+ # Body: prefer the canonical `payload`; fall back to the flat
97
+ # events.jsonl `data` object so run.sh-written lines keep their fields.
98
+ payload = data.get('payload')
99
+ nested = data.get('data')
100
+ if payload is None:
101
+ payload = nested if isinstance(nested, dict) else {}
102
+
103
+ # Source: prefer top-level `source`; otherwise lift it from the nested
104
+ # `data.source` used by the flat events.jsonl schema.
84
105
  raw_source = data.get('source', '')
106
+ if not raw_source and isinstance(nested, dict):
107
+ raw_source = nested.get('source', '')
85
108
 
86
109
  # Parse event type, handling compound values like "session_start"
87
110
  try:
@@ -105,7 +128,7 @@ class LokiEvent:
105
128
  type=event_type,
106
129
  source=event_source,
107
130
  timestamp=data.get('timestamp', ''),
108
- payload=data.get('payload', {}),
131
+ payload=payload,
109
132
  version=data.get('version', '1.0')
110
133
  )
111
134
 
@@ -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.58.1";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.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}
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=D9E2CF477FD688DE64756E2164756E21
793
+ //# debugId=B963F5BED7BF3C2664756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.58.1'
60
+ __version__ = '7.60.0'
package/mcp/server.py CHANGED
@@ -1715,6 +1715,13 @@ async def loki_code_search(
1715
1715
 
1716
1716
  collection = _get_chroma_collection()
1717
1717
  if collection is None:
1718
+ # Emit 'complete' to balance the 'start' above. Without this the
1719
+ # per-tool start-time stack in _tool_call_start_times leaks one entry
1720
+ # per call (and ChromaDB-unavailable is the common default path), and
1721
+ # a later successful call would pop a stale start time, producing a
1722
+ # wildly wrong execution_time_ms learning signal.
1723
+ _emit_tool_event_async('loki_code_search', 'complete',
1724
+ result_status='error', error='ChromaDB not available')
1718
1725
  return json.dumps({
1719
1726
  "error": "ChromaDB not available. Start it with: docker start loki-chroma",
1720
1727
  "hint": "Re-index with: python3.12 tools/index-codebase.py --reset"
@@ -2188,12 +2195,27 @@ async def loki_get_co_changes(
2188
2195
  with safe_open(co_changes_path, 'r') as f:
2189
2196
  pairs = json.load(f)
2190
2197
 
2191
- # Filter pairs involving the requested file
2198
+ # Filter pairs involving the requested file. The co-changes.json
2199
+ # producer lives outside this repo, so treat its shape as an external
2200
+ # contract: skip malformed entries (not a 2-element pair) instead of
2201
+ # letting one bad row raise and abort the whole tool, and skip
2202
+ # self-pairs (a file is not its own co-change partner) which would
2203
+ # otherwise report file_path against itself.
2192
2204
  results = []
2193
- for pair_files, count in pairs:
2194
- if file_path in pair_files:
2195
- partner = pair_files[0] if pair_files[1] == file_path else pair_files[1]
2196
- results.append({"partner": partner, "co_changes": count})
2205
+ for entry in pairs:
2206
+ try:
2207
+ pair_files, count = entry
2208
+ except (ValueError, TypeError):
2209
+ continue
2210
+ if not isinstance(pair_files, (list, tuple)) or len(pair_files) != 2:
2211
+ continue
2212
+ a, b = pair_files[0], pair_files[1]
2213
+ if a == b:
2214
+ continue
2215
+ if a == file_path:
2216
+ results.append({"partner": b, "co_changes": count})
2217
+ elif b == file_path:
2218
+ results.append({"partner": a, "co_changes": count})
2197
2219
 
2198
2220
  # Sort by co-change count descending
2199
2221
  results.sort(key=lambda x: x["co_changes"], reverse=True)
@@ -257,8 +257,12 @@ def compute_quality_score(
257
257
  non_zero = np.count_nonzero(embedding)
258
258
  density = non_zero / len(embedding) if len(embedding) > 0 else 0
259
259
 
260
- # Variance: measure of embedding diversity
261
- variance = float(np.var(embedding))
260
+ # Variance: measure of embedding diversity. np.var of an empty array is NaN,
261
+ # and NaN would silently propagate through min(variance * 10, 1.0) (which
262
+ # returns NaN) into the final score, where min(1.0, NaN) yields a bogus
263
+ # perfect score of 1.0 for an empty embedding. Treat an empty vector as
264
+ # zero-variance so the score reflects its (lack of) content.
265
+ variance = float(np.var(embedding)) if len(embedding) > 0 else 0.0
262
266
 
263
267
  # Coverage: estimate based on text length vs max tokens
264
268
  # Rough estimate: 4 chars per token
@@ -1192,6 +1196,14 @@ class EmbeddingEngine:
1192
1196
  if corpus_embeddings.size == 0:
1193
1197
  return []
1194
1198
 
1199
+ # A single corpus vector may arrive 1-D (shape (dimension,)) instead of
1200
+ # the documented 2-D (n, dimension). Without this promotion, np.dot of a
1201
+ # 1-D corpus with the 1-D query collapses to a 0-d scalar, and the
1202
+ # subsequent len(similarities) raises an opaque
1203
+ # "object of type 'numpy.float32' has no len()". atleast_2d is a no-op
1204
+ # on an already-2-D corpus.
1205
+ corpus_embeddings = np.atleast_2d(corpus_embeddings)
1206
+
1195
1207
  # Normalize
1196
1208
  query_norm = self._normalize(query_embedding)
1197
1209
  corpus_norm = self._normalize(corpus_embeddings)
package/memory/engine.py CHANGED
@@ -917,9 +917,16 @@ class MemoryEngine:
917
917
 
918
918
  category = pattern.get("category", "general")
919
919
 
920
+ # An index.json that is valid JSON but missing the "topics" key (e.g.
921
+ # written by an older/partial writer, or hand-edited) would crash here
922
+ # on index["topics"] because the `or {...}` default only fires when the
923
+ # whole file is falsy. setdefault matches the defensive pattern used in
924
+ # the sibling _update_index_with_episode.
925
+ topics = index.setdefault("topics", [])
926
+
920
927
  # Find or create topic
921
928
  topic_found = False
922
- for topic in index["topics"]:
929
+ for topic in topics:
923
930
  if topic.get("id") == category:
924
931
  topic["last_accessed"] = datetime.now(timezone.utc).isoformat()
925
932
  topic["relevance_score"] = max(
@@ -930,7 +937,7 @@ class MemoryEngine:
930
937
  break
931
938
 
932
939
  if not topic_found:
933
- index["topics"].append({
940
+ topics.append({
934
941
  "id": category,
935
942
  "summary": f"Patterns for {category}",
936
943
  "relevance_score": pattern.get("confidence", 0.5),
@@ -970,7 +977,17 @@ class MemoryEngine:
970
977
  # Handle ISO format with Z suffix
971
978
  if timestamp_str.endswith("Z"):
972
979
  timestamp_str = timestamp_str[:-1]
973
- timestamp = datetime.fromisoformat(timestamp_str)
980
+ # A single corrupt/non-ISO timestamp on one episode file must not
981
+ # crash the whole scan (get_recent_episodes -> retrieve_relevant is
982
+ # on the RARV hot path). Fall back to now() for the unparseable one.
983
+ try:
984
+ timestamp = datetime.fromisoformat(timestamp_str)
985
+ except ValueError:
986
+ logger.warning(
987
+ "Episode %s has unparseable timestamp %r; using current time",
988
+ data.get("id", "<unknown>"), timestamp_str,
989
+ )
990
+ timestamp = datetime.now(timezone.utc)
974
991
  if timestamp.tzinfo is None:
975
992
  timestamp = timestamp.replace(tzinfo=timezone.utc)
976
993
  elif isinstance(timestamp_str, datetime):
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.58.1",
4
+ "version": "7.60.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.58.1",
5
+ "version": "7.60.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",
@@ -284,7 +284,11 @@ provider_invoke() {
284
284
  local prompt="$1"
285
285
  shift
286
286
  _loki_build_claude_auto_flags "development" "${LOKI_COMPLEXITY:-standard}" ""
287
- claude --dangerously-skip-permissions "${_LOKI_CLAUDE_AUTO_FLAGS[@]}" -p "$prompt" "$@"
287
+ # Guard the auto-flag array expansion: when the builder emits zero flags the
288
+ # array is empty, and a bare "${arr[@]}" under `set -u` aborts with "unbound
289
+ # variable" on bash 3.2 (stock macOS /bin/bash). ${arr[@]+...} expands to
290
+ # nothing when unset/empty and preserves spaced elements otherwise.
291
+ claude --dangerously-skip-permissions "${_LOKI_CLAUDE_AUTO_FLAGS[@]+"${_LOKI_CLAUDE_AUTO_FLAGS[@]}"}" -p "$prompt" "$@"
288
292
  }
289
293
 
290
294
  # Model tier to Task tool model parameter value
@@ -443,5 +447,8 @@ provider_invoke_with_tier() {
443
447
  local model
444
448
  model=$(resolve_model_for_tier "$tier")
445
449
  _loki_build_claude_auto_flags "$tier" "${LOKI_COMPLEXITY:-standard}" "$model"
446
- claude --dangerously-skip-permissions --model "$model" "${_LOKI_CLAUDE_AUTO_FLAGS[@]}" -p "$prompt" "$@"
450
+ # Guard empty auto-flag array under `set -u` on bash 3.2 (stock macOS): a bare
451
+ # "${arr[@]}" on an empty array aborts with "unbound variable". ${arr[@]+...}
452
+ # expands to nothing when empty and preserves spaced elements otherwise.
453
+ claude --dangerously-skip-permissions --model "$model" "${_LOKI_CLAUDE_AUTO_FLAGS[@]+"${_LOKI_CLAUDE_AUTO_FLAGS[@]}"}" -p "$prompt" "$@"
447
454
  }
@@ -111,7 +111,11 @@ provider_invoke() {
111
111
  local model="${LOKI_CLINE_MODEL:-}"
112
112
  local model_args=()
113
113
  [[ -n "$model" ]] && model_args=("-m" "$model")
114
- cline -y "${model_args[@]}" "$prompt" "$@" 2>&1
114
+ # Guard the model_args array expansion: when LOKI_CLINE_MODEL is unset the
115
+ # array is empty, and a bare "${arr[@]}" under `set -u` aborts with "unbound
116
+ # variable" on bash 3.2 (stock macOS /bin/bash). ${arr[@]+...} expands to
117
+ # nothing when empty and preserves spaced elements otherwise.
118
+ cline -y "${model_args[@]+"${model_args[@]}"}" "$prompt" "$@" 2>&1
115
119
  }
116
120
 
117
121
  # Model tier to parameter (Cline uses single model, returns model name)
@@ -232,10 +232,14 @@ provider_invoke_with_tier() {
232
232
 
233
233
  LOKI_CODEX_REASONING_EFFORT="$effort" \
234
234
  CODEX_MODEL_REASONING_EFFORT="$effort" \
235
+ # Guard the extra_flags array expansion: with no web-search / output-last
236
+ # knobs the array is empty, and a bare "${arr[@]}" under `set -u` aborts with
237
+ # "unbound variable" on bash 3.2 (stock macOS /bin/bash). ${arr[@]+...}
238
+ # expands to nothing when empty and preserves spaced elements otherwise.
235
239
  codex exec \
236
240
  --sandbox workspace-write \
237
241
  --skip-git-repo-check \
238
242
  --model "$model" \
239
- "${extra_flags[@]}" \
243
+ "${extra_flags[@]+"${extra_flags[@]}"}" \
240
244
  "$prompt" "$@"
241
245
  }
@@ -24,9 +24,38 @@ var engine;
24
24
  try {
25
25
  engine = new PolicyEngine(projectDir);
26
26
  } catch (e) {
27
- // No policies configured - allow by default
28
- process.stdout.write(JSON.stringify({ allowed: true, decision: 'ALLOW', reason: 'No policies configured' }));
29
- process.exit(0);
27
+ // A security control that cannot instantiate must FAIL CLOSED (deny),
28
+ // never allow by default. An unexpected error here means we cannot make
29
+ // a sound policy decision, so we deny rather than silently disable
30
+ // enforcement.
31
+ process.stdout.write(JSON.stringify({
32
+ allowed: false,
33
+ decision: 'DENY',
34
+ reason: 'Policy engine failed to initialize: ' + e.message,
35
+ requiresApproval: false,
36
+ violations: [],
37
+ }));
38
+ process.stderr.write('Policy engine failed to initialize: ' + e.message + '\n');
39
+ process.exit(1);
40
+ }
41
+
42
+ // Fail-closed on a present-but-unparseable policy file. If a policy file
43
+ // exists on disk but could not be loaded (corrupt JSON / bad YAML), the engine
44
+ // records the error and leaves _policies null. Falling through to evaluate()
45
+ // would return the misleading "No policies configured" ALLOW, silently
46
+ // disabling all policy enforcement. A security control that disables itself on
47
+ // malformed config is fail-open; deny instead.
48
+ if (engine.hasLoadErrors()) {
49
+ var loadErrors = engine.getValidationErrors();
50
+ process.stdout.write(JSON.stringify({
51
+ allowed: false,
52
+ decision: 'DENY',
53
+ reason: 'Policy file present but could not be loaded (fail-closed): ' + loadErrors.join('; '),
54
+ requiresApproval: false,
55
+ violations: [],
56
+ }));
57
+ process.stderr.write('Policy file present but could not be loaded; denying (fail-closed): ' + loadErrors.join('; ') + '\n');
58
+ process.exit(1);
30
59
  }
31
60
 
32
61
  var result = engine.evaluate(enforcementPoint, context);
@@ -590,6 +590,26 @@ class PolicyEngine {
590
590
  return this._validationErrors;
591
591
  }
592
592
 
593
+ /**
594
+ * Whether a policy file is present on disk but could not be parsed/loaded
595
+ * into a usable policy object.
596
+ *
597
+ * This is the fail-closed discriminator: it is true only when a policy file
598
+ * exists (_policyPath set) yet parsing failed (_policies === null). It is
599
+ * deliberately NOT keyed off getValidationErrors() length, because a valid
600
+ * policy file can still carry soft warnings (e.g. an unrecognized rule
601
+ * string) while parsing cleanly into a non-null _policies object. Those
602
+ * warnings must not disable enforcement.
603
+ *
604
+ * When no policy file exists at all, _policyPath is null and this returns
605
+ * false, preserving the legitimate "no policies -> allow" behavior.
606
+ *
607
+ * @returns {boolean}
608
+ */
609
+ hasLoadErrors() {
610
+ return this._policyPath !== null && this._policies === null;
611
+ }
612
+
593
613
  /**
594
614
  * Stop watching the policy file.
595
615
  */