loki-mode 7.81.0 → 7.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.81.0
6
+ # Loki Mode v7.82.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.81.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.82.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.81.0
1
+ 7.82.0
@@ -927,7 +927,7 @@ if not voters:
927
927
  # v7.41.3: word-bounded + markdown-tolerant. VOTE:APPROVED and
928
928
  # VOTE:APPROVE_WITH_CONCERNS must NOT match APPROVE; bold/quoted
929
929
  # VOTE: APPROVE must match. Unmatched -> default REJECT (conservative).
930
- vote_match = re.search(r'[*_> ]*VOTE[*_ ]*:[*_> ]*(APPROVE|REJECT|CANNOT_VALIDATE)(?![A-Za-z0-9_])', content)
930
+ vote_match = re.search(r'(?:^|[^A-Za-z0-9_])[*_> ]*VOTE[*_ ]*:[*_> ]*(APPROVE|REJECT|CANNOT_VALIDATE)(?![A-Za-z0-9_])', content)
931
931
  reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
932
932
  issues = []
933
933
  for im in re.finditer(r'ISSUES\s*:\s*(CRITICAL|HIGH|MEDIUM|LOW)\s*:\s*(.+?)(?:\n|\$)', content):
@@ -2537,8 +2537,17 @@ council_aggregate_votes() {
2537
2537
  local complete_count=0
2538
2538
  local continue_count=0
2539
2539
  local total_members=$COUNCIL_SIZE
2540
- local votes_json="["
2541
- local first=true
2540
+
2541
+ # Per-member fields, collected as newline-delimited records and serialized to
2542
+ # JSON inside a python heredoc below. Building the JSON in bash with a sed
2543
+ # quote-escape (the old approach) produced INVALID JSON whenever a reason
2544
+ # contained a backslash or control character: the round-file write then
2545
+ # failed and the caller silently forced CONTINUE. json.dumps escapes
2546
+ # everything correctly, so the round file always parses.
2547
+ local _members=""
2548
+ local _roles=""
2549
+ local _vote_values=""
2550
+ local _reasons=""
2542
2551
 
2543
2552
  local _council_roles=("requirements_verifier" "test_auditor" "devils_advocate")
2544
2553
  local member=1
@@ -2561,20 +2570,38 @@ council_aggregate_votes() {
2561
2570
 
2562
2571
  log_info " Evaluate member $member ($role): $vote_value - $vote_reason"
2563
2572
 
2564
- # Build JSON array entry
2565
- if [ "$first" = "true" ]; then
2566
- first=false
2567
- else
2568
- votes_json="${votes_json},"
2569
- fi
2570
- # Escape double quotes in reason for JSON safety
2571
- local safe_reason
2572
- safe_reason=$(echo "$vote_reason" | sed 's/"/\\"/g')
2573
- votes_json="${votes_json}{\"member\":$member,\"role\":\"$role\",\"vote\":\"$vote_value\",\"reason\":\"$safe_reason\"}"
2573
+ # Accumulate one field per line (reason kept single-line by upstream parse,
2574
+ # but newlines are normalized to spaces here to keep the line mapping 1:1).
2575
+ _members="${_members}${member}"$'\n'
2576
+ _roles="${_roles}${role}"$'\n'
2577
+ _vote_values="${_vote_values}${vote_value}"$'\n'
2578
+ _reasons="${_reasons}$(printf '%s' "$vote_reason" | tr '\n' ' ')"$'\n'
2574
2579
 
2575
2580
  ((member++))
2576
2581
  done
2577
- votes_json="${votes_json}]"
2582
+
2583
+ # Serialize the votes array with json.dumps so backslashes/control chars are
2584
+ # escaped correctly (BUG fix: sed-only escaping produced invalid JSON).
2585
+ local votes_json
2586
+ votes_json=$(_MEMBERS="$_members" _ROLES="$_roles" _VOTEVALS="$_vote_values" _REASONS="$_reasons" python3 -c "
2587
+ import json, os
2588
+ def lines(name):
2589
+ v = os.environ.get(name, '')
2590
+ return v.split('\n')[:-1] if v.endswith('\n') else (v.split('\n') if v else [])
2591
+ members = lines('_MEMBERS')
2592
+ roles = lines('_ROLES')
2593
+ votevals = lines('_VOTEVALS')
2594
+ reasons = lines('_REASONS')
2595
+ out = []
2596
+ for i in range(len(members)):
2597
+ out.append({
2598
+ 'member': int(members[i]) if members[i].isdigit() else members[i],
2599
+ 'role': roles[i] if i < len(roles) else '',
2600
+ 'vote': votevals[i] if i < len(votevals) else '',
2601
+ 'reason': reasons[i] if i < len(reasons) else '',
2602
+ })
2603
+ print(json.dumps(out))
2604
+ " 2>/dev/null || echo "[]")
2578
2605
 
2579
2606
  # Calculate threshold: 2/3 majority
2580
2607
  local threshold=$(( (total_members * 2 + 2) / 3 )) # ceiling of 2/3
@@ -2638,21 +2665,49 @@ council_devils_advocate_review() {
2638
2665
  local issue_details=""
2639
2666
 
2640
2667
  # Skeptical check 1: Are tests actually running and passing?
2641
- local has_test_results=false
2668
+ # Read the SAME structured signal the council already uses
2669
+ # (.loki/quality/test-results.json, written by run.sh:ensure_completion_test
2670
+ # _evidence; parsed the same way as council_evaluate_member ~2414-2438 and the
2671
+ # evidence gate). Parse verdict: runner=="none" => PASS (no real suite to
2672
+ # contradict completion), pass is False => FAIL, else PASS. The legacy log
2673
+ # glob is kept ONLY as an ADDITIONAL red signal -- its absence is NOT an issue
2674
+ # (nothing writes .loki/logs/test-*.log, so an empty glob is the normal case
2675
+ # and must never veto a unanimous COMPLETE on its own).
2676
+ local tr_file="$loki_dir/quality/test-results.json"
2677
+ if [ -f "$tr_file" ]; then
2678
+ local _tr_status
2679
+ _tr_status=$(_TR_FILE="$tr_file" python3 -c "
2680
+ import json, os, sys
2681
+ try:
2682
+ with open(os.environ['_TR_FILE']) as f:
2683
+ d = json.load(f)
2684
+ except (json.JSONDecodeError, IOError, KeyError, ValueError):
2685
+ print('absent')
2686
+ sys.exit(0)
2687
+ runner = d.get('runner', 'none')
2688
+ passed = d.get('pass', True)
2689
+ if runner == 'none':
2690
+ print('pass')
2691
+ elif passed is False:
2692
+ print('fail')
2693
+ else:
2694
+ print('pass')
2695
+ " 2>/dev/null || echo "absent")
2696
+ if [ "$_tr_status" = "fail" ]; then
2697
+ ((issues_found++))
2698
+ issue_details="${issue_details}structured test results red (pass==false); "
2699
+ fi
2700
+ fi
2701
+ # Additional source: any legacy test log that shows no pass indicator is a red
2702
+ # signal. Missing logs are NOT counted (this path is not written by the runner).
2703
+ local f
2642
2704
  for f in "$loki_dir"/logs/test-*.log "$loki_dir"/logs/*test*.log; do
2643
- if [ -f "$f" ]; then
2644
- has_test_results=true
2645
- # Look for test runner output indicating pass
2646
- if ! tail -30 "$f" 2>/dev/null | grep -qiE "(passed|success|ok|all tests)"; then
2647
- ((issues_found++))
2648
- issue_details="${issue_details}test log $(basename "$f") has no clear pass indicator; "
2649
- fi
2705
+ [ -f "$f" ] || continue
2706
+ if ! tail -30 "$f" 2>/dev/null | grep -qiE "(passed|success|ok|all tests)"; then
2707
+ ((issues_found++))
2708
+ issue_details="${issue_details}test log $(basename "$f") has no clear pass indicator; "
2650
2709
  fi
2651
2710
  done
2652
- if [ "$has_test_results" = "false" ]; then
2653
- ((issues_found++))
2654
- issue_details="${issue_details}no test result logs found at all; "
2655
- fi
2656
2711
 
2657
2712
  # Skeptical check 2: Are there still failing tasks in the queue?
2658
2713
  if [ -f "$loki_dir/queue/failed.json" ]; then
@@ -2681,9 +2736,15 @@ council_devils_advocate_review() {
2681
2736
  fi
2682
2737
 
2683
2738
  # Skeptical check 5: Recent error events
2739
+ # events.jsonl uses the flat schema {"timestamp","type","data"} (events/emit.sh
2740
+ # :196; run.sh emit_event). There is no "level" field, so the old
2741
+ # "\"level\":\"error\"" grep never matched (dead check). Match the real shape:
2742
+ # a "type" whose name ends in error/fail/crash (e.g. provider_failover,
2743
+ # dashboard_crash), plus error/failed markers inside the data payload.
2684
2744
  if [ -f "$loki_dir/events.jsonl" ]; then
2685
2745
  local recent_errors
2686
- recent_errors=$(tail -50 "$loki_dir/events.jsonl" 2>/dev/null | grep -ciE "\"level\":\s*\"error\"" 2>/dev/null || echo "0")
2746
+ recent_errors=$(tail -50 "$loki_dir/events.jsonl" 2>/dev/null | grep -ciE '"type"[[:space:]]*:[[:space:]]*"[a-z_]*(error|fail|crash)' 2>/dev/null | tr -d ' \n' || echo "0")
2747
+ [ -n "$recent_errors" ] || recent_errors=0
2687
2748
  if [ "$recent_errors" -gt 0 ]; then
2688
2749
  ((issues_found++))
2689
2750
  issue_details="${issue_details}$recent_errors recent error events; "
@@ -291,6 +291,59 @@ loki_escape_regex() {
291
291
  printf '%s' "$input" | sed 's/[.[\*?+^${}|()\\]/\\&/g'
292
292
  }
293
293
 
294
+ # YAML scalar extractor for the no-yq fallback. Resolves a FULL nested dotted
295
+ # path (e.g. completion.council.enabled) by descending indentation, so keys
296
+ # that share a last segment (dashboard.enabled vs notifications.enabled) never
297
+ # collide. Prints the scalar value (one line) or nothing if the path is absent.
298
+ # Pure awk so it is portable across GNU and BSD (POSIX classes, no \s, no sed
299
+ # quirks). Mirrors what `yq eval ".$path // \"\""` would return for scalars.
300
+ loki_yaml_fallback_extract() {
301
+ local file="$1" dotted_path="$2"
302
+ [ -f "$file" ] || return 0
303
+ awk -v path="$dotted_path" '
304
+ BEGIN { n = split(path, want, "."); depth = 0 }
305
+ {
306
+ line = $0
307
+ if (line ~ /^[[:space:]]*#/) next
308
+ if (line ~ /^[[:space:]]*$/) next
309
+ ind = 0
310
+ while (substr(line, ind + 1, 1) == " ") ind++
311
+ rest = substr(line, ind + 1)
312
+ if (rest !~ /^[^:]+:/) next
313
+ ci = index(rest, ":")
314
+ key = substr(rest, 1, ci - 1)
315
+ val = substr(rest, ci + 1)
316
+ # Pop stack entries that are siblings or shallower than this line.
317
+ while (depth > 0 && stack_ind[depth] >= ind) depth--
318
+ depth++
319
+ stack_ind[depth] = ind
320
+ stack_key[depth] = key
321
+ if (depth == n) {
322
+ ok = 1
323
+ for (i = 1; i <= n; i++) if (stack_key[i] != want[i]) { ok = 0; break }
324
+ if (ok) {
325
+ sub(/^[[:space:]]+/, "", val)
326
+ q = substr(val, 1, 1)
327
+ if (q == "\"" || q == "\047") {
328
+ # Quoted scalar: take chars up to the matching close quote
329
+ # so a "#" or trailing comment inside/after stays correct.
330
+ rest2 = substr(val, 2)
331
+ qi = index(rest2, q)
332
+ if (qi > 0) val = substr(rest2, 1, qi - 1)
333
+ else val = rest2
334
+ } else {
335
+ # Unquoted: drop a trailing comment, then trailing space.
336
+ sub(/[[:space:]]*#.*$/, "", val)
337
+ sub(/[[:space:]]+$/, "", val)
338
+ }
339
+ print val
340
+ exit
341
+ }
342
+ }
343
+ }
344
+ ' "$file"
345
+ }
346
+
294
347
  #===============================================================================
295
348
  # 5a. ${VAR} env-ref expansion (NEVER eval)
296
349
  #===============================================================================
@@ -528,16 +581,11 @@ loki_parse_yaml_file() {
528
581
  if [ "$have_yq" = "1" ]; then
529
582
  value="$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
530
583
  else
531
- # grep/sed fallback: match the LAST path segment as a key.
532
- # `|| true` keeps the no-match case (grep rc=1 under pipefail) from
533
- # tripping set -e in the loki CLI (which runs set -euo pipefail).
534
- local key escaped_key
535
- key="${yaml_path##*.}"
536
- escaped_key="$(loki_escape_regex "$key")"
537
- value="$( { grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 \
538
- | sed -E 's/.*:\s*//' | sed 's/#.*//' \
539
- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\n' \
540
- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
584
+ # No-yq fallback: resolve the FULL nested dotted path by indentation
585
+ # so same-last-segment keys (dashboard.enabled vs notifications.enabled)
586
+ # never collide. `|| true` keeps a no-match from tripping set -e in the
587
+ # loki CLI (which runs set -euo pipefail).
588
+ value="$(loki_yaml_fallback_extract "$file" "$yaml_path" || true)"
541
589
  fi
542
590
  if [ "$value" = "null" ]; then value=""; fi
543
591
  if [ -z "$value" ]; then continue; fi
@@ -831,17 +879,15 @@ loki_config_validate_file() {
831
879
  done < "$f"
832
880
  ;;
833
881
  yaml)
834
- local mapping yaml_path env_var value key escaped_key have_yq=0
882
+ local mapping yaml_path env_var value have_yq=0
835
883
  if command -v yq >/dev/null 2>&1; then have_yq=1; fi
836
884
  for mapping in "${LOKI_CONFIG_MAP[@]}"; do
837
885
  yaml_path="${mapping%%:*}"; env_var="${mapping##*:}"
838
886
  if [ "$have_yq" = 1 ]; then
839
887
  value="$(yq eval ".$yaml_path // \"\"" "$f" 2>/dev/null || true)"
840
888
  else
841
- key="${yaml_path##*.}"; escaped_key="$(loki_escape_regex "$key")"
842
- value="$( { grep -E "^\s*${escaped_key}:" "$f" 2>/dev/null | head -1 \
843
- | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["'\'']//;s/["'\'']$//' \
844
- | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
889
+ # Full nested-path resolution (no same-last-segment collision).
890
+ value="$(loki_yaml_fallback_extract "$f" "$yaml_path" || true)"
845
891
  fi
846
892
  if [ "$value" = "null" ]; then value=""; fi
847
893
  if [ -z "$value" ]; then continue; fi
package/autonomy/loki CHANGED
@@ -1386,6 +1386,13 @@ cmd_start() {
1386
1386
  echo -e "${RED}--budget requires a numeric USD amount (e.g., --budget 5.00)${NC}"
1387
1387
  exit 1
1388
1388
  fi
1389
+ # Reject a non-positive budget: a 0 (or 0.00) cap makes
1390
+ # check_budget_limit pause before any work runs (cost >= 0 is
1391
+ # always true), which looks like a silent hang. Require > 0.
1392
+ if ! awk -v b="$2" 'BEGIN{exit !(b+0 > 0)}'; then
1393
+ echo -e "${RED}--budget must be greater than 0 (a 0 budget pauses before any work)${NC}" >&2
1394
+ exit 1
1395
+ fi
1389
1396
  export LOKI_BUDGET_LIMIT="$2"
1390
1397
  shift 2
1391
1398
  else
@@ -1399,6 +1406,11 @@ cmd_start() {
1399
1406
  echo -e "${RED}--budget requires a numeric USD amount (e.g., --budget=5.00)${NC}"
1400
1407
  exit 1
1401
1408
  fi
1409
+ # Reject a non-positive budget (see --budget arm above).
1410
+ if ! awk -v b="$budget_val" 'BEGIN{exit !(b+0 > 0)}'; then
1411
+ echo -e "${RED}--budget must be greater than 0 (a 0 budget pauses before any work)${NC}" >&2
1412
+ exit 1
1413
+ fi
1402
1414
  export LOKI_BUDGET_LIMIT="$budget_val"
1403
1415
  shift
1404
1416
  ;;
@@ -1613,6 +1625,10 @@ cmd_start() {
1613
1625
  version=$(get_version)
1614
1626
  local _ttfv_max_iter="${LOKI_MAX_ITERATIONS:-3}"
1615
1627
  mkdir -p "$LOKI_DIR" 2>/dev/null || true
1628
+ # Reap stale per-PID temp PRDs from prior runs (the process exec-replaces
1629
+ # into run.sh, so these are never cleaned on exit and accumulate).
1630
+ find "$LOKI_DIR" -maxdepth 1 -name 'brief-prd-*.md' -mtime +1 -delete 2>/dev/null || true
1631
+ find "$LOKI_DIR" -maxdepth 1 -name 'quick-prd-*.md' -mtime +1 -delete 2>/dev/null || true
1616
1632
  local brief_prd="$LOKI_DIR/brief-prd-$$.md"
1617
1633
  synthesize_brief_prd "$brief_prd" "$brief_text"
1618
1634
  prd_file="$brief_prd"
@@ -2951,7 +2967,7 @@ cmd_why() {
2951
2967
  --json) as_json=1 ;;
2952
2968
  --help|-h) echo "Usage: loki why [--json] -- explain the last build's outcome and what to do next"; return 0 ;;
2953
2969
  "" ) : ;;
2954
- *) echo -e "${RED}Unknown flag: $1${NC}"; echo "Usage: loki why [--json]"; return 1 ;;
2970
+ *) echo -e "${RED}Unknown flag: $1${NC}" >&2; echo "Usage: loki why [--json]" >&2; return 1 ;;
2955
2971
  esac
2956
2972
 
2957
2973
  local loki_dir="${LOKI_DIR:-.loki}"
@@ -2985,7 +3001,10 @@ WHYJSON
2985
3001
  # Human-readable report. The diagnosis maps the terminal status to a plain
2986
3002
  # explanation + a concrete next action; everything is sourced from the files,
2987
3003
  # nothing is invented.
3004
+ local _why_head_sha
3005
+ _why_head_sha="$(git rev-parse HEAD 2>/dev/null || echo "")"
2988
3006
  _LOKI_WHY_STATE="$state_file" _LOKI_WHY_COMPLETION="$completion_file" \
3007
+ _LOKI_WHY_HEAD_SHA="$_why_head_sha" \
2989
3008
  _LOKI_WHY_HANDOFFS="$loki_dir/memory/handoffs" python3 - <<'WHYTXT'
2990
3009
  import json, os, glob
2991
3010
  def load(p):
@@ -2994,7 +3013,22 @@ def load(p):
2994
3013
  except Exception: return {}
2995
3014
  state = load(os.environ.get("_LOKI_WHY_STATE", ""))
2996
3015
  comp = load(os.environ.get("_LOKI_WHY_COMPLETION", ""))
2997
- status = state.get("status") or comp.get("outcome") or "unknown"
3016
+
3017
+ # completion.json uses a terminal-outcome vocabulary (complete / max_iterations /
3018
+ # failed / stopped / force_stopped / intervention) that differs from the GUIDE
3019
+ # keys below. Map it onto the GUIDE keys so the diagnosis is never "no mapping".
3020
+ # The live state-file status (running / exited / paused / ...) is preferred when
3021
+ # present; we only fall back to the (normalized) completion outcome.
3022
+ COMP_OUTCOME_ALIASES = {
3023
+ "complete": "council_approved",
3024
+ "max_iterations": "max_iterations_reached",
3025
+ "intervention": "paused",
3026
+ # failed / stopped / force_stopped already match GUIDE keys verbatim.
3027
+ }
3028
+ status = state.get("status")
3029
+ if not status:
3030
+ raw_outcome = comp.get("outcome") or ""
3031
+ status = COMP_OUTCOME_ALIASES.get(raw_outcome, raw_outcome) or "unknown"
2998
3032
  exit_code = state.get("lastExitCode")
2999
3033
  iters = state.get("iterationCount")
3000
3034
 
@@ -3026,10 +3060,25 @@ GUIDE = {
3026
3060
  "Start a new build, or investigate why a force-stop was needed."),
3027
3061
  "running": ("The recorded state says a build is still running (or crashed mid-run).",
3028
3062
  "If no build is active it likely crashed; in durable mode (LOKI_DURABLE_STATE=1) a restart resumes, else loki start re-runs."),
3063
+ "exited": ("The build process exited mid-iteration (likely a crash, kill, or empty provider output).",
3064
+ "Read .loki/logs/ for the last error; if nothing is running it crashed -- re-run with loki start, or loki resume in durable mode."),
3029
3065
  }
3030
3066
  meaning, action = GUIDE.get(status, ("No diagnosis mapping for this status; see the raw fields below.",
3031
3067
  "Check loki status and .loki/logs/ for detail."))
3032
3068
 
3069
+ # The completion.json branch/changes/PR belong to the LAST COMPLETED run. When
3070
+ # the live state shows a build that is still running or crashed mid-iteration
3071
+ # (running/exited), or when the completion record's head_sha no longer matches
3072
+ # the current git HEAD, those fields describe a PREVIOUS run -- not this one.
3073
+ # Label them honestly so a crashed run is never reported with a stale PR/branch.
3074
+ live_statuses = {"running", "exited"}
3075
+ head_sha = os.environ.get("_LOKI_WHY_HEAD_SHA", "")
3076
+ comp_head = comp.get("head_sha", "")
3077
+ comp_is_stale = bool(state.get("status") in live_statuses) or bool(
3078
+ head_sha and comp_head and comp_head != head_sha
3079
+ )
3080
+ comp_label = " (from previous completed run)" if comp_is_stale else ""
3081
+
3033
3082
  print("Loki: why")
3034
3083
  print("=" * 60)
3035
3084
  print(f" Outcome : {status}")
@@ -3038,11 +3087,11 @@ if exit_code is not None:
3038
3087
  if iters is not None:
3039
3088
  print(f" Iterations : {iters}")
3040
3089
  if comp.get("branch"):
3041
- print(f" Branch : {comp['branch']}")
3090
+ print(f" Branch : {comp['branch']}{comp_label}")
3042
3091
  if comp.get("files_changed") is not None:
3043
- print(f" Changes : {comp.get('files_changed',0)} files (+{comp.get('insertions',0)}/-{comp.get('deletions',0)})")
3092
+ print(f" Changes : {comp.get('files_changed',0)} files (+{comp.get('insertions',0)}/-{comp.get('deletions',0)}){comp_label}")
3044
3093
  if comp.get("pr_url"):
3045
- print(f" PR : {comp['pr_url']}")
3094
+ print(f" PR : {comp['pr_url']}{comp_label}")
3046
3095
  print()
3047
3096
  print(f" What happened: {meaning}")
3048
3097
  print(f" What to do : {action}")
@@ -5767,12 +5816,20 @@ cmd_preview() {
5767
5816
  public=true
5768
5817
  ;;
5769
5818
  --provider)
5770
- provider="${2:-}"
5771
- # Guard the value-consuming shift: if --provider is the LAST arg
5772
- # (no value), an unguarded shift here plus the loop's trailing
5773
- # shift would underflow and abort under set -e. Only consume a
5774
- # value when one is actually present.
5775
- [ $# -ge 2 ] && shift
5819
+ # Guard against a missing OR flag-shaped value: an unguarded
5820
+ # shift when --provider is the last arg underflows under set -e,
5821
+ # and a bare "--provider --public" would otherwise swallow the
5822
+ # next flag as the provider name.
5823
+ case "${2:-}" in
5824
+ -*|"")
5825
+ echo "loki preview --provider requires a value (cloudflared|ngrok)" >&2
5826
+ return 1
5827
+ ;;
5828
+ *)
5829
+ provider="$2"
5830
+ shift
5831
+ ;;
5832
+ esac
5776
5833
  ;;
5777
5834
  --yes)
5778
5835
  assume_yes=true
@@ -6122,11 +6179,14 @@ cmd_deploy() {
6122
6179
  return 0
6123
6180
  ;;
6124
6181
  --dir)
6125
- dir="${2:-.}"
6126
- # Guard the value-consuming shift: if --dir is the LAST arg (no
6127
- # value), an unguarded shift plus the loop's trailing shift would
6128
- # underflow and abort under set -e. Consume a value only if present.
6129
- [ $# -ge 2 ] && shift
6182
+ # Guard against a missing OR flag-shaped value: an unguarded
6183
+ # shift when --dir is the last arg underflows under set -e, and a
6184
+ # bare "--dir --no-clip" would otherwise treat the next flag as
6185
+ # the scan directory.
6186
+ case "${2:-}" in
6187
+ -*|"") echo "loki deploy --dir requires a directory path" >&2; return 1 ;;
6188
+ *) dir="$2"; shift ;;
6189
+ esac
6130
6190
  ;;
6131
6191
  --no-clip)
6132
6192
  do_clip=false
@@ -8013,7 +8073,7 @@ cmd_assets() {
8013
8073
  ;;
8014
8074
  *)
8015
8075
  echo -e "${RED}Unknown subcommand: $subcommand${NC}" >&2
8016
- echo "Run 'loki assets --help' for usage."
8076
+ echo "Run 'loki assets --help' for usage." >&2
8017
8077
  return 1
8018
8078
  ;;
8019
8079
  esac
@@ -11081,6 +11141,10 @@ cmd_quick() {
11081
11141
  # BUG-PU-005: Use unique filename to prevent race conditions when
11082
11142
  # multiple simultaneous `loki quick` commands run in the same project
11083
11143
  mkdir -p "$LOKI_DIR"
11144
+ # Reap stale per-PID temp PRDs from prior runs (the process exec-replaces
11145
+ # into run.sh, so these are never cleaned on exit and accumulate).
11146
+ find "$LOKI_DIR" -maxdepth 1 -name 'quick-prd-*.md' -mtime +1 -delete 2>/dev/null || true
11147
+ find "$LOKI_DIR" -maxdepth 1 -name 'brief-prd-*.md' -mtime +1 -delete 2>/dev/null || true
11084
11148
  local quick_prd="$LOKI_DIR/quick-prd-$$.md"
11085
11149
  cat > "$quick_prd" << QPRDEOF
11086
11150
  # Quick Task
@@ -15482,12 +15546,22 @@ cmd_plan() {
15482
15546
  done
15483
15547
 
15484
15548
  if [ -z "$prd_file" ]; then
15549
+ # Under --json, a machine consumer expects JSON on every path, including
15550
+ # errors -- never ANSI-colored prose. Emit a structured error to stdout.
15551
+ if [ "$show_json" = true ]; then
15552
+ printf '{"error":"missing PRD file argument"}\n'
15553
+ return 2
15554
+ fi
15485
15555
  echo -e "${RED}Usage: loki plan <PRD file>${NC}"
15486
15556
  echo "Run 'loki plan --help' for usage."
15487
15557
  return 2
15488
15558
  fi
15489
15559
 
15490
15560
  if [ ! -f "$prd_file" ]; then
15561
+ if [ "$show_json" = true ]; then
15562
+ printf '{"error":"PRD file not found","prd_file":"%s"}\n' "$prd_file"
15563
+ return 1
15564
+ fi
15491
15565
  echo -e "${RED}PRD file not found: $prd_file${NC}"
15492
15566
  return 1
15493
15567
  fi
@@ -15528,6 +15602,60 @@ maybe_show_auto_plan() {
15528
15602
  show_prd_plan "$abs_prd" "false" "false"
15529
15603
  }
15530
15604
 
15605
+ # Suggest the closest known command for a typo (the did-you-mean hint).
15606
+ # Prints "Did you mean 'X'?" to stderr when an unknown command is within a
15607
+ # small Levenshtein distance of a real command. Silent (returns 1) when
15608
+ # nothing is close enough, so a genuinely-unknown command is not given a
15609
+ # misleading suggestion. Pure bash + awk (no extra deps); the candidate list
15610
+ # is the canonical set of top-level commands accepted by the dispatcher.
15611
+ _suggest_command() {
15612
+ local typo="$1"
15613
+ [ -n "$typo" ] || return 1
15614
+ # Canonical top-level command names (keep in sync with the dispatch case
15615
+ # below). Deprecated aliases are intentionally included so a typo of an
15616
+ # alias still resolves to a helpful pointer.
15617
+ local known="start run quick plan grill spec verify proof trust review \
15618
+ ultracode council demo dogfood heal modernize import issue github init template \
15619
+ status stop pause resume monitor watch watchdog dashboard web open preview share \
15620
+ memory context ctx checkpoint state report kpis cost metrics stats logs otel \
15621
+ syslog telemetry crash config provider doctor setup-skill onboard quickstart \
15622
+ welcome update self-update rollback failover cluster enterprise remote deploy docker \
15623
+ sandbox api mcp magic compound assets export wiki docs explain why audit compliance \
15624
+ secrets analyze optimize bench ci reset cleanup notify trigger voice sentrux \
15625
+ worktree wt projects cp rc trust-metrics serve agent code self_update test help version"
15626
+ local best
15627
+ best=$(printf '%s\n' $known | awk -v t="$typo" '
15628
+ function min3(a, b, c) { if (a < b) { if (a < c) return a; return c } if (b < c) return b; return c }
15629
+ function lev(s1, s2, n, m, i, j, prev, cur, cost, tmp) {
15630
+ n = length(s1); m = length(s2)
15631
+ if (n == 0) return m
15632
+ if (m == 0) return n
15633
+ for (j = 0; j <= m; j++) prev[j] = j
15634
+ for (i = 1; i <= n; i++) {
15635
+ cur[0] = i
15636
+ for (j = 1; j <= m; j++) {
15637
+ cost = (substr(s1, i, 1) == substr(s2, j, 1)) ? 0 : 1
15638
+ cur[j] = min3(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost)
15639
+ }
15640
+ for (j = 0; j <= m; j++) prev[j] = cur[j]
15641
+ }
15642
+ return prev[m]
15643
+ }
15644
+ { d = lev(t, $1); if (d < bestd || NR == 1) { bestd = d; bestc = $1 } }
15645
+ END {
15646
+ # Only suggest when the edit distance is small relative to the typo
15647
+ # length: <=2 for short commands, <=3 for longer ones. This avoids
15648
+ # absurd suggestions (e.g. "xyz" -> "web").
15649
+ thr = (length(t) <= 4) ? 2 : 3
15650
+ if (bestd <= thr) print bestc
15651
+ }')
15652
+ if [ -n "$best" ]; then
15653
+ printf '%s\n' "$best"
15654
+ return 0
15655
+ fi
15656
+ return 1
15657
+ }
15658
+
15531
15659
  # Main command dispatcher
15532
15660
  main() {
15533
15661
  # v7.5.18: early guard -- LOKI_PROVIDER=gemini is no longer supported.
@@ -15952,8 +16080,12 @@ main() {
15952
16080
  fi
15953
16081
  ;;
15954
16082
  *)
15955
- echo -e "${RED}Unknown command: $command${NC}"
15956
- echo "Run 'loki help' for usage."
16083
+ echo -e "${RED}Unknown command: $command${NC}" >&2
16084
+ local _suggestion
16085
+ if _suggestion=$(_suggest_command "$command"); then
16086
+ echo "Did you mean 'loki ${_suggestion}'?" >&2
16087
+ fi
16088
+ echo "Run 'loki help' for usage." >&2
15957
16089
  exit 1
15958
16090
  ;;
15959
16091
  esac
@@ -18329,9 +18461,15 @@ try:
18329
18461
  print('No relevant memories found')
18330
18462
  else:
18331
18463
  for i, r in enumerate(results, 1):
18332
- source = r.get('source', 'unknown')
18333
- summary = r.get('summary', r.get('pattern', 'No summary'))[:80]
18334
- score = r.get('score', 0)
18464
+ # Retrieval results expose _source/_score/_weighted_score and the
18465
+ # raw stored fields (pattern/description/goal/name), not
18466
+ # source/summary/score. Read the fields that actually exist so the
18467
+ # output is not a uniform [unknown] ... (score 0.00).
18468
+ source = r.get('_source', 'unknown')
18469
+ score = r.get('_score', r.get('_weighted_score', 0))
18470
+ summary = (r.get('pattern') or r.get('description') or r.get('goal')
18471
+ or (r.get('context') or {}).get('goal') or r.get('name')
18472
+ or 'No summary')[:80]
18335
18473
  print(f'{i}. [{source}] {summary}... (score: {score:.2f})')
18336
18474
  except ImportError as e:
18337
18475
  print(f'Error: Required module not found - {e}')
@@ -21892,7 +22030,7 @@ cmd_trust_metrics() {
21892
22030
  echo "Run 'loki trust-metrics' inside each project directory."
21893
22031
  exit 2
21894
22032
  ;;
21895
- *) echo -e "${RED}Unknown option: $1${NC}"; echo "Run 'loki trust-metrics --help' for usage."; exit 1 ;;
22033
+ *) echo -e "${RED}Unknown option: $1${NC}" >&2; echo "Run 'loki trust-metrics --help' for usage." >&2; exit 1 ;;
21896
22034
  esac
21897
22035
  done
21898
22036
 
@@ -21947,7 +22085,7 @@ cmd_cost() {
21947
22085
  --json) show_json=true; shift ;;
21948
22086
  --last) last_n="${2:-0}"; shift 2 ;;
21949
22087
  --last=*) last_n="${1#*=}"; shift ;;
21950
- *) echo -e "${RED}Unknown option: $1${NC}"; echo "Run 'loki cost --help' for usage."; exit 1 ;;
22088
+ *) echo -e "${RED}Unknown option: $1${NC}" >&2; echo "Run 'loki cost --help' for usage." >&2; exit 1 ;;
21951
22089
  esac
21952
22090
  done
21953
22091
 
@@ -22262,7 +22400,7 @@ cmd_metrics() {
22262
22400
  echo "$response"
22263
22401
  exit 0
22264
22402
  ;;
22265
- *) echo -e "${RED}Unknown option: $1${NC}"; echo "Run 'loki metrics --help' for usage."; exit 1 ;;
22403
+ *) echo -e "${RED}Unknown option: $1${NC}" >&2; echo "Run 'loki metrics --help' for usage." >&2; exit 1 ;;
22266
22404
  esac
22267
22405
  done
22268
22406
 
@@ -29444,7 +29582,7 @@ cmd_share() {
29444
29582
  --private) visibility=""; shift ;;
29445
29583
  --format) format="${2:-markdown}"; shift 2 ;;
29446
29584
  --format=*) format="${1#*=}"; shift ;;
29447
- *) echo -e "${RED}Unknown option: $1${NC}"; echo "Run 'loki share --help' for usage."; exit 1 ;;
29585
+ *) echo -e "${RED}Unknown option: $1${NC}" >&2; echo "Run 'loki share --help' for usage." >&2; exit 1 ;;
29448
29586
  esac
29449
29587
  done
29450
29588
 
@@ -30143,7 +30281,14 @@ cmd_docker() {
30143
30281
  local -a fwd=()
30144
30282
  while [ $# -gt 0 ]; do
30145
30283
  case "$1" in
30146
- --image) [ $# -ge 2 ] && { shift; export LOKI_DOCKER_IMAGE="$1"; } || { echo "loki docker --image requires a value" >&2; return 1; }; shift ;;
30284
+ --image)
30285
+ # Guard against a missing OR flag-shaped value so "--image
30286
+ # --dry-run" cannot swallow the next flag as the image ref.
30287
+ case "${2:-}" in
30288
+ -*|"") echo "loki docker --image requires a value (e.g., --image asklokesh/loki-mode:latest)" >&2; return 1 ;;
30289
+ *) shift; export LOKI_DOCKER_IMAGE="$1"; shift ;;
30290
+ esac
30291
+ ;;
30147
30292
  --dry-run) dry_run=1; shift ;;
30148
30293
  --api) with_api=1; fwd+=("$1"); shift ;;
30149
30294
  *) fwd+=("$1"); shift ;;