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 +4 -4
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +92 -0
- package/autonomy/completion-council.sh +22 -4
- package/autonomy/hooks/migration-hooks.sh +44 -9
- package/autonomy/lib/proof_redact.py +41 -0
- package/autonomy/loki +47 -10
- package/autonomy/run.sh +98 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_v2.py +26 -5
- package/dashboard/control.py +35 -5
- package/dashboard/server.py +62 -11
- package/dashboard/static/index.html +2 -2
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +24 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +27 -5
- package/memory/embeddings.py +14 -2
- package/memory/engine.py +20 -3
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +9 -2
- package/providers/cline.sh +5 -1
- package/providers/codex.sh +5 -1
- package/src/policies/check.js +32 -3
- package/src/policies/engine.js +20 -0
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.
|
|
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.
|
|
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 #
|
|
485
|
-
python3 -m pytest #
|
|
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.
|
|
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.
|
|
409
|
+
**v7.60.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.60.0
|
package/autonomy/app-runner.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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 "
|
|
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
|
|
445
|
-
#
|
|
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 "
|
|
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 "
|
|
486
|
+
echo "Added file ${file_path} removed."
|
|
452
487
|
return 0
|
|
453
488
|
fi
|
|
454
|
-
echo "Could not remove
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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='''
|
|
25246
|
-
tags=[t.strip() for t in '''
|
|
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='
|
|
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
|
-
|
|
13823
|
-
|
|
13824
|
-
|
|
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"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/api_v2.py
CHANGED
|
@@ -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
|
|
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
|
|
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")
|
package/dashboard/control.py
CHANGED
|
@@ -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.
|
package/dashboard/server.py
CHANGED
|
@@ -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
|
-
|
|
7836
|
-
|
|
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
|
-
|
|
7842
|
-
|
|
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
|
|
7847
|
-
if cp in
|
|
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
|
-
|
|
7853
|
-
if
|
|
7854
|
-
return (name,
|
|
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
|
-
|
|
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">
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.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.
|
|
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=
|
|
131
|
+
payload=payload,
|
|
109
132
|
version=data.get('version', '1.0')
|
|
110
133
|
)
|
|
111
134
|
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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=
|
|
793
|
+
//# debugId=B963F5BED7BF3C2664756E2164756E21
|
package/mcp/__init__.py
CHANGED
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
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
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)
|
package/memory/embeddings.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/providers/claude.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/providers/cline.sh
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/providers/codex.sh
CHANGED
|
@@ -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
|
}
|
package/src/policies/check.js
CHANGED
|
@@ -24,9 +24,38 @@ var engine;
|
|
|
24
24
|
try {
|
|
25
25
|
engine = new PolicyEngine(projectDir);
|
|
26
26
|
} catch (e) {
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
package/src/policies/engine.js
CHANGED
|
@@ -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
|
*/
|