loki-mode 7.41.2 → 7.41.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 11 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.41.2
6
+ # Loki Mode v7.41.4
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -398,4 +398,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
398
398
 
399
399
  ---
400
400
 
401
- **v7.41.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
401
+ **v7.41.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.41.2
1
+ 7.41.4
@@ -200,6 +200,24 @@ _app_runner_reconcile_port() {
200
200
 
201
201
  [ -n "$real_port" ] || return 0
202
202
  if [ "$real_port" != "$_APP_RUNNER_PORT" ]; then
203
+ # Liveness guard: only overwrite the recorded port when the reconciled
204
+ # port ACTUALLY serves HTTP. A log line can name a non-serving port (a
205
+ # metrics endpoint like ":9464" or a DB connection like ":5432") emitted
206
+ # after the real serving URL; committing that would clobber a correct
207
+ # recorded port and point the preview at a dead port. We deliberately do
208
+ # NOT use curl -f: any HTTP response (including 404/401/500) proves a
209
+ # server is bound and serving on that port (Spring Boot whitelabel 404,
210
+ # REST-only roots, and auth-gated "/" all return non-2xx but ARE live).
211
+ # A dead/unbound port produces a connection error, which curl reports as
212
+ # a non-zero exit even without -f. If curl is unavailable we cannot
213
+ # verify, so fall back to the prior behavior and commit the parsed port
214
+ # (no regression on curl-less hosts).
215
+ if command -v curl >/dev/null 2>&1; then
216
+ if ! curl -s -o /dev/null -m 2 "http://localhost:${real_port}/" 2>/dev/null; then
217
+ log_info "App Runner: skipped reconcile to port $real_port (no HTTP response); keeping recorded port $_APP_RUNNER_PORT"
218
+ return 0
219
+ fi
220
+ fi
203
221
  log_info "App Runner: reconciled port $_APP_RUNNER_PORT -> $real_port (from app.log listen line)"
204
222
  _APP_RUNNER_PORT="$real_port"
205
223
  _APP_RUNNER_URL="http://localhost:${real_port}"
@@ -212,6 +230,16 @@ _app_runner_reconcile_port() {
212
230
  # shapes in priority order and returns the LAST (most recent) plausible port,
213
231
  # tolerating ANSI color codes that dev servers emit. Validates 1-65535. Echoes
214
232
  # the port or nothing.
233
+ #
234
+ # Tiers 1 and 2 are restricted to lines that ALSO carry a serving keyword
235
+ # (listen|running|ready|started|serving|server|local) so that non-serving noise
236
+ # such as a DB connection string ("Connecting to database on port 5432") or an
237
+ # outbound URL does not win. Note: a metrics endpoint line like
238
+ # "Prometheus metrics server listening on http://0.0.0.0:9464" DOES carry
239
+ # serving keywords ("server"/"listening") and so can still be returned here; the
240
+ # reconcile caller liveness-verifies the parsed port before committing it, which
241
+ # is the layer that rejects a non-serving metrics/DB port.
242
+ _SERVING_KEYWORDS='listen|running|ready|started|serving|server|local'
215
243
  _parse_listen_port() {
216
244
  local file="$1"
217
245
  [ -f "$file" ] || return 0
@@ -220,16 +248,28 @@ _parse_listen_port() {
220
248
  clean=$(sed -E $'s/\x1b\\[[0-9;]*m//g' "$file" 2>/dev/null) || clean=$(cat "$file" 2>/dev/null)
221
249
  [ -n "$clean" ] || return 0
222
250
 
251
+ # Restrict candidate lines to those carrying a serving keyword. This drops
252
+ # DB-connection and outbound-URL noise before any port extraction.
253
+ local serving
254
+ serving=$(printf '%s\n' "$clean" | grep -iE "$_SERVING_KEYWORDS")
255
+
223
256
  local candidate=""
224
257
  # 1) Explicit URL with a port: http://host:PORT (most reliable).
225
- candidate=$(printf '%s\n' "$clean" \
258
+ candidate=$(printf '%s\n' "$serving" \
226
259
  | grep -oiE 'https?://[a-z0-9.\-]+:[0-9]{1,5}' \
227
260
  | grep -oE ':[0-9]{1,5}' | tr -d ':' | tail -1)
228
- # 2) A number anchored to the literal word "port": "port 8080", "port=3000",
261
+ # 2a) Spring Boot form: "Tomcat started on port(s): 8081". The literal
262
+ # "(s):" breaks the generic port[ =:]+ scan below, so match it first.
263
+ if [ -z "$candidate" ]; then
264
+ candidate=$(printf '%s\n' "$serving" \
265
+ | grep -ioE 'port\(s\):[ ]*[0-9]{1,5}' \
266
+ | grep -oE '[0-9]{1,5}' | tail -1)
267
+ fi
268
+ # 2b) A number anchored to the literal word "port": "port 8080", "port=3000",
229
269
  # "port: 5000". This runs BEFORE the bare host:port scan so a clock-style
230
270
  # timestamp on the same line (e.g. "12:30:45 ... port 8080") cannot win.
231
271
  if [ -z "$candidate" ]; then
232
- candidate=$(printf '%s\n' "$clean" \
272
+ candidate=$(printf '%s\n' "$serving" \
233
273
  | grep -ioE 'port[ =:]+[0-9]{1,5}' \
234
274
  | grep -oE '[0-9]{1,5}' | tail -1)
235
275
  fi
@@ -238,8 +278,7 @@ _parse_listen_port() {
238
278
  # or a dot immediately left of the colon excludes "HH:MM" timestamps,
239
279
  # which have a digit there.
240
280
  if [ -z "$candidate" ]; then
241
- candidate=$(printf '%s\n' "$clean" \
242
- | grep -iE 'listen|running on|ready|started|serving|server' \
281
+ candidate=$(printf '%s\n' "$serving" \
243
282
  | grep -oiE '[a-z.][a-z0-9.\-]*:[0-9]{1,5}' \
244
283
  | grep -oE ':[0-9]{1,5}' | tr -d ':' | tail -1)
245
284
  fi
@@ -549,6 +549,31 @@ print(str(rc) + ' ' + json.dumps(new_state))
549
549
  # Council Voting - 3 independent reviewers check completion
550
550
  #===============================================================================
551
551
 
552
+ # _council_parse_vote: extract a canonical council verdict from a reviewer's
553
+ # raw output. Returns exactly one of APPROVE | REJECT | CANNOT_VALIDATE | ""
554
+ # (empty when no canonical VOTE line is present).
555
+ #
556
+ # Hardening (v7.41.3):
557
+ # - Word-bounded verdict: "VOTE: APPROVED" / "VOTE: APPROVE_WITH_CONCERNS"
558
+ # do NOT match APPROVE (non-canonical tokens are unparseable -> empty ->
559
+ # caller treats as REJECT). A trailing class [^A-Za-z0-9_] (or end of line)
560
+ # enforces the boundary; "\b" is a GNU-grep extension that BSD grep on this
561
+ # machine ignores, so it is deliberately avoided for dual-route parity.
562
+ # - Markdown / quote tolerance both BEFORE the keyword and AFTER the colon, so
563
+ # "**VOTE:** APPROVE", "> VOTE: APPROVE", and "VOTE:APPROVE" all match.
564
+ # - Conservative tie-break: only a clean canonical APPROVE yields APPROVE;
565
+ # anything ambiguous yields the empty string, which every caller maps to the
566
+ # conservative outcome (REJECT / re-iterate).
567
+ _council_parse_vote() {
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_]|$)'
571
+ printf '%s' "$raw" \
572
+ | grep -oE "$_pat" \
573
+ | grep -oE "APPROVE|REJECT|CANNOT_VALIDATE" \
574
+ | head -1
575
+ }
576
+
552
577
  council_vote() {
553
578
  local prd_path="${COUNCIL_PRD_PATH:-}"
554
579
  local loki_dir="${TARGET_DIR:-.}/.loki"
@@ -596,7 +621,7 @@ council_vote() {
596
621
  verdict=$(council_member_review "$member" "$role" "$evidence_file" "$vote_dir")
597
622
 
598
623
  local vote_result
599
- vote_result=$(echo "$verdict" | grep -oE "VOTE:\s*(APPROVE|REJECT|CANNOT_VALIDATE)" | grep -oE "APPROVE|REJECT|CANNOT_VALIDATE" | head -1)
624
+ vote_result=$(_council_parse_vote "$verdict")
600
625
 
601
626
  # v6.0.0: Handle CANNOT_VALIDATE - validator lacks enough context to decide
602
627
  if [ "$vote_result" = "CANNOT_VALIDATE" ]; then
@@ -691,15 +716,21 @@ print('true' if ratio > budget else 'false')
691
716
  local contrarian_verdict
692
717
  contrarian_verdict=$(council_devils_advocate "$evidence_file" "$vote_dir")
693
718
  local contrarian_vote
694
- contrarian_vote=$(echo "$contrarian_verdict" | grep -oE "VOTE:\s*(APPROVE|REJECT|CANNOT_VALIDATE)" | grep -oE "APPROVE|REJECT|CANNOT_VALIDATE" | head -1)
695
-
696
- if [ "$contrarian_vote" = "REJECT" ] || [ "$contrarian_vote" = "CANNOT_VALIDATE" ]; then
697
- log_warn "Anti-sycophancy: Devil's advocate REJECTED unanimous approval"
719
+ contrarian_vote=$(_council_parse_vote "$contrarian_verdict")
720
+
721
+ # Conservative tie-break (v7.41.3): ONLY a clean canonical APPROVE
722
+ # confirms the unanimous approval. Any other outcome -- REJECT,
723
+ # CANNOT_VALIDATE, or an unparseable/hedged verdict (empty) -- overrides
724
+ # to one more verification iteration. Previously the else-branch treated
725
+ # an empty/hedged contrarian verdict as "confirmed approval", letting a
726
+ # hedged "VOTE: APPROVED" ship; this flip closes that on the veto path.
727
+ if [ "$contrarian_vote" = "APPROVE" ]; then
728
+ log_info "Anti-sycophancy: Devil's advocate confirmed approval"
729
+ else
730
+ log_warn "Anti-sycophancy: Devil's advocate did not confirm unanimous approval (verdict: ${contrarian_vote:-unparseable})"
698
731
  log_warn "Overriding to require one more iteration for verification"
699
732
  approve_count=$((approve_count - 1))
700
733
  reject_count=$((reject_count + 1))
701
- else
702
- log_info "Anti-sycophancy: Devil's advocate confirmed approval"
703
734
  fi
704
735
  fi
705
736
 
@@ -860,7 +891,10 @@ if not voters:
860
891
  if mdir.exists():
861
892
  for mf in sorted(mdir.glob('member-*.txt')):
862
893
  content = mf.read_text(errors='replace').strip()
863
- vote_match = re.search(r'VOTE\s*:\s*(APPROVE|REJECT|CANNOT_VALIDATE)', content)
894
+ # v7.41.3: word-bounded + markdown-tolerant. VOTE:APPROVED and
895
+ # VOTE:APPROVE_WITH_CONCERNS must NOT match APPROVE; bold/quoted
896
+ # VOTE: APPROVE must match. Unmatched -> default REJECT (conservative).
897
+ vote_match = re.search(r'[*_> ]*VOTE[*_ ]*:[*_> ]*(APPROVE|REJECT|CANNOT_VALIDATE)(?![A-Za-z0-9_])', content)
864
898
  reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
865
899
  issues = []
866
900
  for im in re.finditer(r'ISSUES\s*:\s*(CRITICAL|HIGH|MEDIUM|LOW)\s*:\s*(.+?)(?:\n|\$)', content):
@@ -1783,27 +1817,33 @@ ISSUES: CRITICAL:description (optional, one per line per issue)"
1783
1817
  # Set inline (not via a helper) so the carve-out holds even when
1784
1818
  # this file is sourced standalone and the helpers are out of scope.
1785
1819
  # Inlined on `claude` only (does not cross the pipe). No-op absent.
1786
- verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_cm_argv[@]}" -p 2>/dev/null | tail -20)
1820
+ # v7.41.3 BUG A: do NOT tail-truncate before parsing. A thorough
1821
+ # reviewer that lists >~18 ISSUES lines after VOTE would push its
1822
+ # own VOTE: line out of a tail-20 window, making the parser find
1823
+ # no VOTE and default a real APPROVE to REJECT. Capture the full
1824
+ # output; the downstream parse already greps VOTE/REASON/ISSUES.
1825
+ # CAVEMAN_DEFAULT_MODE=off suppression is preserved (see above).
1826
+ verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_cm_argv[@]}" -p 2>/dev/null)
1787
1827
  fi
1788
1828
  ;;
1789
1829
  codex)
1790
1830
  if command -v codex &>/dev/null; then
1791
- verdict=$(codex exec --full-auto "$prompt" 2>/dev/null | tail -20)
1831
+ verdict=$(codex exec --full-auto "$prompt" 2>/dev/null)
1792
1832
  fi
1793
1833
  ;;
1794
1834
  gemini)
1795
1835
  if command -v gemini &>/dev/null; then
1796
- verdict=$(echo "$prompt" | gemini 2>/dev/null | tail -20)
1836
+ verdict=$(echo "$prompt" | gemini 2>/dev/null)
1797
1837
  fi
1798
1838
  ;;
1799
1839
  cline)
1800
1840
  if command -v cline &>/dev/null; then
1801
- verdict=$(cline -y "$prompt" 2>/dev/null | tail -20)
1841
+ verdict=$(cline -y "$prompt" 2>/dev/null)
1802
1842
  fi
1803
1843
  ;;
1804
1844
  aider)
1805
1845
  if command -v aider &>/dev/null; then
1806
- verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null | tail -20)
1846
+ verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
1807
1847
  fi
1808
1848
  ;;
1809
1849
  esac
@@ -1882,27 +1922,29 @@ REASON: your reasoning"
1882
1922
  # (contrarian) vote is parsed for "VOTE:". Disable caveman
1883
1923
  # unconditionally so compression cannot flip the contrarian vote.
1884
1924
  # Inlined on `claude` only (does not cross the pipe). No-op absent.
1885
- verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_co_argv[@]}" -p 2>/dev/null | tail -20)
1925
+ # v7.41.3 BUG A: full capture, no tail-truncation (see member
1926
+ # subcall note). CAVEMAN_DEFAULT_MODE=off suppression preserved.
1927
+ verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_co_argv[@]}" -p 2>/dev/null)
1886
1928
  fi
1887
1929
  ;;
1888
1930
  codex)
1889
1931
  if command -v codex &>/dev/null; then
1890
- verdict=$(codex exec --full-auto "$prompt" 2>/dev/null | tail -20)
1932
+ verdict=$(codex exec --full-auto "$prompt" 2>/dev/null)
1891
1933
  fi
1892
1934
  ;;
1893
1935
  gemini)
1894
1936
  if command -v gemini &>/dev/null; then
1895
- verdict=$(echo "$prompt" | gemini 2>/dev/null | tail -20)
1937
+ verdict=$(echo "$prompt" | gemini 2>/dev/null)
1896
1938
  fi
1897
1939
  ;;
1898
1940
  cline)
1899
1941
  if command -v cline &>/dev/null; then
1900
- verdict=$(cline -y "$prompt" 2>/dev/null | tail -20)
1942
+ verdict=$(cline -y "$prompt" 2>/dev/null)
1901
1943
  fi
1902
1944
  ;;
1903
1945
  aider)
1904
1946
  if command -v aider &>/dev/null; then
1905
- verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null | tail -20)
1947
+ verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
1906
1948
  fi
1907
1949
  ;;
1908
1950
  esac
@@ -151,8 +151,19 @@ print('{:.3f}'.format(detect_sycophancy(votes)))
151
151
  fi
152
152
 
153
153
  # Step 6: Calibration tracking
154
+ # Compute threshold using ceiling(2/3) formula, consistent with
155
+ # completion-council.sh (council_should_stop, council_aggregate_votes,
156
+ # council_evaluate). An explicit operator override via LOKI_COUNCIL_THRESHOLD
157
+ # is honored; otherwise scale with council_size instead of a flat default of 2.
158
+ local effective_threshold
159
+ if [ -n "${LOKI_COUNCIL_THRESHOLD:-}" ]; then
160
+ effective_threshold="$LOKI_COUNCIL_THRESHOLD"
161
+ else
162
+ effective_threshold=$(( (council_size * 2 + 2) / 3 ))
163
+ fi
164
+
154
165
  local final_decision
155
- if [ "$approve_count" -ge "${COUNCIL_THRESHOLD:-2}" ]; then
166
+ if [ "$approve_count" -ge "$effective_threshold" ]; then
156
167
  final_decision="approve"
157
168
  else
158
169
  final_decision="reject"
@@ -194,7 +205,7 @@ SUMMARY_EOF
194
205
  "iteration=$iteration" \
195
206
  "approve=$approve_count" \
196
207
  "reject=$reject_count" \
197
- "threshold=${COUNCIL_THRESHOLD:-2}" \
208
+ "threshold=$effective_threshold" \
198
209
  "sycophancy_score=$sycophancy_score" \
199
210
  "result=$(echo "$final_decision" | tr '[:lower:]' '[:upper:]')" 2>/dev/null || true
200
211
 
@@ -110,10 +110,33 @@ detect_test_command() {
110
110
  elif [[ -d "${codebase_path}/tests" ]]; then
111
111
  echo "cd '${codebase_path}' && python -m pytest tests/ -q"
112
112
  else
113
- echo "echo 'No test command detected. Set LOKI_TEST_COMMAND.'"
113
+ # No framework detected and LOKI_TEST_COMMAND unset.
114
+ # In healing mode the gates MUST fail closed: "no tests" can never be
115
+ # treated as "tests passed", or the behavioral-preservation guarantee
116
+ # is silently defeated. Emit a sentinel the healing consumers detect and
117
+ # turn into a hard BLOCK (see is_no_test_cmd). The bare token also fails
118
+ # if eval'd directly (command-not-found, exit 127) so the default is
119
+ # fail-closed even if a string check is ever missed.
120
+ # Outside healing mode, preserve the prior fail-open behavior: the
121
+ # non-healing consumers (post_file_edit, post_step, pre_phase_gate) run
122
+ # this via `eval` and a bare token there would exit 127 -> taken block
123
+ # -> destructive (e.g. reverting a user edit in a repo with no tests).
124
+ if [[ "${LOKI_HEAL_MODE:-false}" == "true" ]]; then
125
+ echo "__LOKI_NO_TEST_CMD__"
126
+ else
127
+ echo "echo 'No test command detected. Set LOKI_TEST_COMMAND.'"
128
+ fi
114
129
  fi
115
130
  }
116
131
 
132
+ # Returns 0 (true) when detect_test_command yielded the no-test-command
133
+ # sentinel, i.e. no framework was detected and LOKI_TEST_COMMAND is unset.
134
+ # Used by the healing gates to distinguish "no tests available" (block) from
135
+ # "tests ran and passed" (allow) and "tests ran and failed" (block).
136
+ is_no_test_cmd() {
137
+ [[ "${1:-}" == "__LOKI_NO_TEST_CMD__" ]]
138
+ }
139
+
117
140
  # Hook: post_file_edit - runs after ANY agent modifies a source file
118
141
  hook_post_file_edit() {
119
142
  local file_path="${1:-}"
@@ -342,6 +365,17 @@ hook_post_healing_modify() {
342
365
  # Run characterization tests
343
366
  local test_cmd
344
367
  test_cmd=$(detect_test_command "$codebase_path")
368
+
369
+ # No test command available in healing mode -> fail closed. "No tests" can
370
+ # never count as "characterization tests passed". Do not git-revert here:
371
+ # there is no test-driven baseline to restore against, and the actionable
372
+ # fix is to provide a test command.
373
+ if is_no_test_cmd "$test_cmd"; then
374
+ echo "HOOK_BLOCKED: no test command available; set LOKI_TEST_COMMAND"
375
+ echo "Characterization tests cannot run for healing modification to ${file_path}; refusing to treat absence of tests as success."
376
+ return 1
377
+ fi
378
+
345
379
  local test_result_file
346
380
  test_result_file=$(mktemp)
347
381
 
@@ -437,6 +471,10 @@ except: print(0)
437
471
 
438
472
  local test_cmd
439
473
  test_cmd=$(detect_test_command "$codebase_path")
474
+ if is_no_test_cmd "$test_cmd"; then
475
+ echo "GATE_BLOCKED: no test command available; set LOKI_TEST_COMMAND"
476
+ return 1
477
+ fi
440
478
  if ! eval "$test_cmd" >/dev/null 2>&1; then
441
479
  echo "GATE_BLOCKED: Characterization tests do not pass"
442
480
  return 1
@@ -445,6 +483,10 @@ except: print(0)
445
483
  stabilize:isolate)
446
484
  local test_cmd
447
485
  test_cmd=$(detect_test_command "$codebase_path")
486
+ if is_no_test_cmd "$test_cmd"; then
487
+ echo "GATE_BLOCKED: no test command available; set LOKI_TEST_COMMAND"
488
+ return 1
489
+ fi
448
490
  if ! eval "$test_cmd" >/dev/null 2>&1; then
449
491
  echo "GATE_BLOCKED: Tests do not pass after stabilization"
450
492
  return 1
@@ -453,6 +495,10 @@ except: print(0)
453
495
  isolate:modernize)
454
496
  local test_cmd
455
497
  test_cmd=$(detect_test_command "$codebase_path")
498
+ if is_no_test_cmd "$test_cmd"; then
499
+ echo "GATE_BLOCKED: no test command available; set LOKI_TEST_COMMAND"
500
+ return 1
501
+ fi
456
502
  if ! eval "$test_cmd" >/dev/null 2>&1; then
457
503
  echo "GATE_BLOCKED: Tests do not pass after isolation"
458
504
  return 1
@@ -461,6 +507,10 @@ except: print(0)
461
507
  modernize:validate)
462
508
  local test_cmd
463
509
  test_cmd=$(detect_test_command "$codebase_path")
510
+ if is_no_test_cmd "$test_cmd"; then
511
+ echo "GATE_BLOCKED: no test command available; set LOKI_TEST_COMMAND"
512
+ return 1
513
+ fi
464
514
  if ! eval "$test_cmd" >/dev/null 2>&1; then
465
515
  echo "GATE_BLOCKED: Tests do not pass after modernization"
466
516
  return 1
package/autonomy/loki CHANGED
@@ -6708,14 +6708,23 @@ cmd_assets() {
6708
6708
  shift 2>/dev/null || true
6709
6709
 
6710
6710
  local helper="$_LOKI_SCRIPT_DIR/lib/assets_bundle.py"
6711
- if [ ! -f "$helper" ]; then
6712
- echo -e "${RED}Error: assets helper not found at $helper${NC}" >&2
6713
- return 1
6714
- fi
6715
- if ! command -v python3 &>/dev/null; then
6716
- echo -e "${RED}Error: python3 is required for 'loki assets'${NC}" >&2
6717
- return 1
6718
- fi
6711
+ # The help path needs neither the python3 helper nor python3 itself, so
6712
+ # guard these preconditions to skip help/empty subcommands. Otherwise
6713
+ # `loki assets --help` would error on a missing helper/python3 instead of
6714
+ # printing help (same class as #574 / the cmd_monitor fix).
6715
+ case "$subcommand" in
6716
+ --help|-h|help|"") ;;
6717
+ *)
6718
+ if [ ! -f "$helper" ]; then
6719
+ echo -e "${RED}Error: assets helper not found at $helper${NC}" >&2
6720
+ return 1
6721
+ fi
6722
+ if ! command -v python3 &>/dev/null; then
6723
+ echo -e "${RED}Error: python3 is required for 'loki assets'${NC}" >&2
6724
+ return 1
6725
+ fi
6726
+ ;;
6727
+ esac
6719
6728
 
6720
6729
  # Project dir holds .loki/ (memory, council, wiki). Default to cwd.
6721
6730
  local project_dir
@@ -9878,16 +9887,11 @@ QPRDEOF
9878
9887
 
9879
9888
  # Docker Compose monitoring with auto-fix (v6.67.0)
9880
9889
  cmd_monitor() {
9881
- # Verify Docker is available
9882
- if ! command -v docker &>/dev/null; then
9883
- echo -e "${RED}Docker is not installed. Install from https://docker.com${NC}"
9884
- return 1
9885
- fi
9886
- if ! docker info &>/dev/null 2>&1; then
9887
- echo -e "${RED}Docker daemon is not running. Start Docker Desktop or the Docker service.${NC}"
9888
- return 1
9889
- fi
9890
-
9890
+ # A --help/-h in any position (e.g. `loki monitor --help`) short-circuits
9891
+ # to the help text BEFORE the Docker preconditions, so `monitor --help`
9892
+ # works even when Docker is down (same class as #574). The arg parser's
9893
+ # existing --help arm below prints the help; we just defer the Docker
9894
+ # checks until after parsing so help is reachable.
9891
9895
  local project_dir="."
9892
9896
  local watch_only=false
9893
9897
  local poll_interval="${LOKI_MONITOR_INTERVAL:-10}"
@@ -9945,6 +9949,17 @@ cmd_monitor() {
9945
9949
  esac
9946
9950
  done
9947
9951
 
9952
+ # Verify Docker is available (deferred past arg parsing so --help works
9953
+ # without a running daemon).
9954
+ if ! command -v docker &>/dev/null; then
9955
+ echo -e "${RED}Docker is not installed. Install from https://docker.com${NC}"
9956
+ return 1
9957
+ fi
9958
+ if ! docker info &>/dev/null 2>&1; then
9959
+ echo -e "${RED}Docker daemon is not running. Start Docker Desktop or the Docker service.${NC}"
9960
+ return 1
9961
+ fi
9962
+
9948
9963
  # Resolve to absolute path
9949
9964
  if [[ ! "$project_dir" = /* ]]; then
9950
9965
  project_dir="$(cd "$project_dir" 2>/dev/null && pwd)" || {
package/autonomy/run.sh CHANGED
@@ -14116,7 +14116,7 @@ if __name__ == "__main__":
14116
14116
  # refuse completion until the review passes.
14117
14117
  local _gate_block_for_completion=""
14118
14118
  case "${gate_failures:-}" in
14119
- *code_review,*|*code_review_ESCALATED*) _gate_block_for_completion="code_review" ;;
14119
+ *code_review,*|*code_review_ESCALATED*|*code_review_PAUSED*) _gate_block_for_completion="code_review" ;;
14120
14120
  esac
14121
14121
  # DROP-FIX (v7.28): check_completion_promise -> check_task_completion_signal
14122
14122
  # CONSUMES the completion signal (rm -f) on the FIRST successful call.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.41.2"
10
+ __version__ = "7.41.4"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -5787,12 +5787,20 @@ async def create_checkpoint(body: CheckpointCreate = None):
5787
5787
  checkpoint_dir = checkpoints_dir / checkpoint_id
5788
5788
  checkpoint_dir.mkdir(parents=True, exist_ok=True)
5789
5789
 
5790
- # Capture git SHA
5790
+ # Capture git SHA. Pass cwd= the active project root so the recorded
5791
+ # git_sha belongs to the project being checkpointed, not whatever directory
5792
+ # the dashboard process happens to run from (correctness bug for
5793
+ # multi-project dashboards). Offload to a thread so the blocking git call
5794
+ # does not stall the single-worker uvicorn event loop.
5791
5795
  git_sha = ""
5796
+ git_cwd = str(loki_dir.parent) if loki_dir.name == ".loki" else None
5792
5797
  try:
5793
- result = subprocess.run(
5794
- ["git", "rev-parse", "HEAD"],
5795
- capture_output=True, text=True, timeout=5,
5798
+ result = await asyncio.to_thread(
5799
+ lambda: subprocess.run(
5800
+ ["git", "rev-parse", "HEAD"],
5801
+ capture_output=True, text=True, timeout=5,
5802
+ cwd=git_cwd,
5803
+ )
5796
5804
  )
5797
5805
  if result.returncode == 0:
5798
5806
  git_sha = result.stdout.strip()
@@ -5812,11 +5820,15 @@ async def create_checkpoint(body: CheckpointCreate = None):
5812
5820
  except Exception:
5813
5821
  pass
5814
5822
 
5815
- # Copy queue directory if present
5823
+ # Copy queue directory if present. Offload to a thread: a large queue tree
5824
+ # would otherwise block the single-worker uvicorn event loop.
5816
5825
  queue_src = loki_dir / "queue"
5817
5826
  if queue_src.exists():
5818
5827
  try:
5819
- shutil.copytree(str(queue_src), str(checkpoint_dir / "queue"), dirs_exist_ok=True)
5828
+ await asyncio.to_thread(
5829
+ shutil.copytree,
5830
+ str(queue_src), str(checkpoint_dir / "queue"), dirs_exist_ok=True,
5831
+ )
5820
5832
  except Exception:
5821
5833
  pass
5822
5834
 
@@ -5898,7 +5910,11 @@ async def rollback_checkpoint(checkpoint_id: str):
5898
5910
  src = loki_dir / dname
5899
5911
  if src.exists() and src.is_dir():
5900
5912
  try:
5901
- shutil.copytree(str(src), str(pre_dir / dname), dirs_exist_ok=True)
5913
+ # Offload the (potentially large) directory copy off the event loop.
5914
+ await asyncio.to_thread(
5915
+ shutil.copytree,
5916
+ str(src), str(pre_dir / dname), dirs_exist_ok=True,
5917
+ )
5902
5918
  except Exception:
5903
5919
  pass
5904
5920
  pre_meta = {
@@ -5928,7 +5944,11 @@ async def rollback_checkpoint(checkpoint_id: str):
5928
5944
  dest = loki_dir / item.name
5929
5945
  try:
5930
5946
  if item.is_dir():
5931
- shutil.copytree(str(item), str(dest), dirs_exist_ok=True)
5947
+ # Offload the (potentially large) directory copy off the event loop.
5948
+ await asyncio.to_thread(
5949
+ shutil.copytree,
5950
+ str(item), str(dest), dirs_exist_ok=True,
5951
+ )
5932
5952
  else:
5933
5953
  dest.parent.mkdir(parents=True, exist_ok=True)
5934
5954
  shutil.copy2(str(item), str(dest))
@@ -6246,10 +6266,14 @@ async def get_github_status(token: Optional[dict] = Depends(auth.get_current_tok
6246
6266
  # Detect repo from git
6247
6267
  try:
6248
6268
  import subprocess
6249
- url = subprocess.run(
6250
- ["git", "remote", "get-url", "origin"],
6251
- capture_output=True, text=True, timeout=5,
6252
- cwd=str(loki_dir.parent) if loki_dir.name == ".loki" else None
6269
+ # Offload the blocking git call so it does not stall the single-worker
6270
+ # uvicorn event loop.
6271
+ url = await asyncio.to_thread(
6272
+ lambda: subprocess.run(
6273
+ ["git", "remote", "get-url", "origin"],
6274
+ capture_output=True, text=True, timeout=5,
6275
+ cwd=str(loki_dir.parent) if loki_dir.name == ".loki" else None,
6276
+ )
6253
6277
  )
6254
6278
  if url.returncode == 0:
6255
6279
  repo = url.stdout.strip()
@@ -8365,11 +8389,19 @@ async def post_wiki_ask(req: WikiAskRequest):
8365
8389
  if not ask_script.is_file():
8366
8390
  raise HTTPException(status_code=503, detail="wiki-ask backend missing")
8367
8391
  try:
8368
- proc = subprocess.run(
8369
- ["python3", str(ask_script), "--root", str(project_root),
8370
- "--question", req.question, "--k", str(req.k), "--json"],
8371
- capture_output=True, text=True, timeout=180,
8372
- cwd=str(project_root),
8392
+ # Offload the blocking subprocess to a thread so the single-worker
8393
+ # uvicorn event loop stays responsive (liveness, status, WS heartbeat)
8394
+ # while wiki-ask runs (up to 180s). A direct subprocess.run here would
8395
+ # freeze the whole server; this read-scoped endpoint is reachable by any
8396
+ # reader. Mirrors the await asyncio.to_thread(...) pattern used by the
8397
+ # stop endpoints.
8398
+ proc = await asyncio.to_thread(
8399
+ lambda: subprocess.run(
8400
+ ["python3", str(ask_script), "--root", str(project_root),
8401
+ "--question", req.question, "--k", str(req.k), "--json"],
8402
+ capture_output=True, text=True, timeout=180,
8403
+ cwd=str(project_root),
8404
+ )
8373
8405
  )
8374
8406
  except (OSError, subprocess.SubprocessError) as e:
8375
8407
  raise HTTPException(status_code=503, detail=f"wiki ask failed: {e}")
@@ -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.41.2
5
+ **Version:** v7.41.4
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.41.2";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,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[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ 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 WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
2
+ var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.41.4";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,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[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ 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 WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
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)
@@ -789,4 +789,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
789
789
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
790
790
  `),process.stderr.write(o6),2}}p1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var ZZ=await QZ(Bun.argv.slice(2));process.exit(ZZ);
791
791
 
792
- //# debugId=054C77BCD2DBFFE964756E2164756E21
792
+ //# debugId=203776A170E08A4F64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.41.2'
60
+ __version__ = '7.41.4'
@@ -766,6 +766,14 @@ class ConsolidationPipeline:
766
766
  usage_count=best_match.usage_count,
767
767
  last_used=best_match.last_used,
768
768
  links=best_match.links.copy(),
769
+ # Preserve retrieval/decay-relevant fields. The constructor previously
770
+ # omitted these, so the merged pattern fell back to schema defaults
771
+ # (importance=0.5, access_count=0, last_accessed=None), resetting a hot,
772
+ # high-importance pattern to the floor on every merge and corrupting
773
+ # apply_decay() + importance-weighted ranking in retrieval.
774
+ importance=max(best_match.importance, new_pattern.importance),
775
+ access_count=best_match.access_count,
776
+ last_accessed=best_match.last_accessed,
769
777
  )
770
778
 
771
779
  return merged
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.41.2",
4
+ "version": "7.41.4",
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 11 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.41.2",
5
+ "version": "7.41.4",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 11 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",