loki-mode 7.117.0 → 7.117.2

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.117.0
6
+ # Loki Mode v7.117.2
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.117.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.117.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.117.0
1
+ 7.117.2
@@ -1753,7 +1753,7 @@ council_evidence_gate() {
1753
1753
  local test_inconclusive_reason=""
1754
1754
  if [ -f "$tr_file" ]; then
1755
1755
  local test_status
1756
- test_status=$(_TR_FILE="$tr_file" python3 -c "
1756
+ test_status=$(_TR_FILE="$tr_file" python3 <<'PYEOF' 2>/dev/null || echo "INCONCLUSIVE:none:true"
1757
1757
  import json, os, sys
1758
1758
  tr_file = os.environ['_TR_FILE']
1759
1759
  try:
@@ -1781,7 +1781,8 @@ elif status == 'no_tests_run' or passed is not True:
1781
1781
  print('INCONCLUSIVE:%s:true' % runner)
1782
1782
  else:
1783
1783
  print('PASS:%s:true' % runner)
1784
- " 2>/dev/null || echo "INCONCLUSIVE:none:true")
1784
+ PYEOF
1785
+ )
1785
1786
  local _verdict="${test_status%%:*}"
1786
1787
  local _rest="${test_status#*:}"
1787
1788
  test_runner="${_rest%%:*}"
package/autonomy/run.sh CHANGED
@@ -1382,8 +1382,14 @@ _parse_json_field() {
1382
1382
  if command -v python3 >/dev/null 2>&1; then
1383
1383
  python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$file" "$field" 2>/dev/null
1384
1384
  else
1385
- # Shell fallback: extract value for simple flat JSON
1386
- sed 's/.*"'"$field"'":\s*//' "$file" 2>/dev/null | sed 's/[",}].*//' | head -1
1385
+ # Shell fallback (no python3): extract value for simple flat JSON. Handles
1386
+ # BOTH quoted string values ("kind":"wrapper") and bare numeric values
1387
+ # ("ppid":206). The prior impl stripped the leading quote's content to empty
1388
+ # for string fields -- broke "kind" parsing on python3-less hosts, which
1389
+ # would drop wrapper entries into the legacy child path (loki-mode #92).
1390
+ sed 's/.*"'"$field"'":[[:space:]]*//' "$file" 2>/dev/null \
1391
+ | sed 's/^"//' \
1392
+ | sed 's/[",}].*//' | head -1
1387
1393
  fi
1388
1394
  }
1389
1395
 
@@ -1435,6 +1441,93 @@ kill_registered_pid() {
1435
1441
  unregister_pid "$pid"
1436
1442
  }
1437
1443
 
1444
+ # Register the CURRENTLY-RUNNING loki-run wrapper itself in the registry.
1445
+ # The wrapper registers its children but historically never itself, so a wrapper
1446
+ # whose launching session dies is never reaped (loki-mode #92). We record the
1447
+ # LAUNCHER's mortal pid (NOT $$ -- $$ is the wrapper's own pid, which would make
1448
+ # the parent-death check always succeed and the reaper INERT; NOT $PPID either --
1449
+ # in the detached setsid/nohup path bash caches getppid()==1 at exec, and kill -0 1
1450
+ # is always true -> also inert). The launcher exports LOKI_LAUNCHER_PID=$$ at each
1451
+ # backgrounding site; that launcher shell exits immediately after backgrounding, so
1452
+ # the recorded ppid genuinely dies and the parent-death precondition CAN fire. For
1453
+ # the foreground path LOKI_LAUNCHER_PID is unset -> $PPID (the user's shell) is the
1454
+ # correct mortal fallback. Reap-vs-spare is then decided by the LIVENESS predicate,
1455
+ # not by parentage alone. Uses a "kind":"wrapper" tag so cleanup_orphan_pids routes
1456
+ # only wrapper entries through the predicate and keeps child entries byte-identical.
1457
+ register_self_wrapper() {
1458
+ [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry
1459
+ local launcher_ppid="${LOKI_LAUNCHER_PID:-$PPID}"
1460
+ case "$launcher_ppid" in ''|*[!0-9]*) launcher_ppid="$PPID" ;; esac
1461
+ local entry_file="$PID_REGISTRY_DIR/$$.json"
1462
+ cat > "$entry_file" << EOF
1463
+ {"pid":$$,"label":"loki-wrapper","started":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","ppid":$launcher_ppid,"kind":"wrapper","extra":""}
1464
+ EOF
1465
+ }
1466
+
1467
+ # Pure, side-effect-free reap predicate (loki-mode #92). ALL inputs are
1468
+ # pre-computed scalars (0/1 booleans or integer ms); NO stat/kill/pgrep inside so
1469
+ # it is hermetically unit-testable. Prints "1" (reap) or "0" (spare); returns 0.
1470
+ # Reap ONLY when: parent dead AND no live engine child AND idle past the budget.
1471
+ # Mirrors the SaaS BFF sweepStuckBuilds and the #91 worker "no-activity != dead"
1472
+ # symmetry: a genuinely-detached run that keeps writing .loki activity is spared.
1473
+ # parent_alive: 1 if recorded ppid is still alive, else 0
1474
+ # last_activity_ms: epoch-ms of most recent .loki activity (0 if none found)
1475
+ # now_ms: current epoch-ms
1476
+ # idle_budget_ms: generous idle budget (default 900000 = 15 min)
1477
+ # has_live_child: 1 if wrapper has a live ENGINE child (claude/node), else 0
1478
+ shouldReapOrphan() {
1479
+ local parent_alive="$1" last_activity_ms="$2" now_ms="$3" idle_budget_ms="$4" has_live_child="$5"
1480
+ if [ "$parent_alive" = "0" ] && [ "$has_live_child" = "0" ] \
1481
+ && [ $(( now_ms - last_activity_ms )) -ge "$idle_budget_ms" ]; then
1482
+ echo 1
1483
+ else
1484
+ echo 0
1485
+ fi
1486
+ }
1487
+
1488
+ # Most-recent .loki activity as epoch-ms across events.jsonl, signals/*,
1489
+ # autonomy-state.json. Cross-platform stat (macOS -f %m / Linux -c %Y). On a stat
1490
+ # PARSE FAILURE we default to now_ms (SPARE), never 0 -- a wrong flag must not make a
1491
+ # live run look maximally idle and get reaped. "No activity files exist" legitimately
1492
+ # yields 0 (reap-eligible); that is distinct from a stat error.
1493
+ _loki_last_activity_ms() {
1494
+ local loki_dir="${TARGET_DIR:-.}/.loki"
1495
+ local newest=0 f mt stat_ok=0
1496
+ local candidates=()
1497
+ [ -f "$loki_dir/events.jsonl" ] && candidates+=("$loki_dir/events.jsonl")
1498
+ [ -f "$loki_dir/autonomy-state.json" ] && candidates+=("$loki_dir/autonomy-state.json")
1499
+ if [ -d "$loki_dir/signals" ]; then
1500
+ for f in "$loki_dir/signals"/*; do [ -e "$f" ] && candidates+=("$f"); done
1501
+ fi
1502
+ for f in "${candidates[@]:-}"; do
1503
+ [ -e "$f" ] || continue
1504
+ mt=$(stat -f %m "$f" 2>/dev/null) || mt=""
1505
+ [ -z "$mt" ] && { mt=$(stat -c %Y "$f" 2>/dev/null) || mt=""; }
1506
+ case "$mt" in ''|*[!0-9]*) continue ;; esac
1507
+ stat_ok=1
1508
+ [ "$mt" -gt "$newest" ] && newest="$mt"
1509
+ done
1510
+ # candidates present but every stat failed -> parse failure -> spare (now_ms)
1511
+ if [ "${#candidates[@]}" -gt 0 ] && [ "$stat_ok" = "0" ]; then
1512
+ echo "$(( $(date +%s) * 1000 ))"
1513
+ return 0
1514
+ fi
1515
+ echo "$(( newest * 1000 ))"
1516
+ }
1517
+
1518
+ # Count LIVE ENGINE children of a wrapper (claude/node), EXCLUDING sleep and the
1519
+ # status/resource monitors. The observed 3h orphan had only sleep children, so a
1520
+ # naive `pgrep -P` (any child) would falsely spare it; the comm filter is what makes
1521
+ # the reaper actually fire on the real orphan shape. Prints 1 (has engine child) or 0.
1522
+ _loki_has_live_engine_child() {
1523
+ local wrapper_pid="$1" c cc
1524
+ for c in $(pgrep -P "$wrapper_pid" 2>/dev/null); do
1525
+ cc=$(ps -o comm= -p "$c" 2>/dev/null)
1526
+ case "$cc" in *claude*|*node*) echo 1; return 0 ;; esac
1527
+ done
1528
+ echo 0
1529
+ }
1530
+
1438
1531
  # Scan registry for orphaned processes and kill them
1439
1532
  # Called on startup and by `loki cleanup`
1440
1533
  # Returns: number of orphans killed
@@ -1457,6 +1550,9 @@ cleanup_orphan_pids() {
1457
1550
  ''|*[!0-9]*) continue ;;
1458
1551
  esac
1459
1552
 
1553
+ # Never reap the currently-running wrapper itself (loki-mode #92 self-skip).
1554
+ [ "$pid" = "$$" ] && continue
1555
+
1460
1556
  if kill -0 "$pid" 2>/dev/null; then
1461
1557
  # Process is alive -- check if its parent session is dead
1462
1558
  local ppid_val=""
@@ -1464,7 +1560,30 @@ cleanup_orphan_pids() {
1464
1560
 
1465
1561
  # Validate ppid_val is numeric before using with kill
1466
1562
  case "$ppid_val" in ''|*[!0-9]*) ppid_val="" ;; esac
1467
- if [ -n "$ppid_val" ] && [ "$ppid_val" != "$$" ]; then
1563
+
1564
+ # Route WRAPPER entries through the liveness predicate; CHILD entries
1565
+ # (no "kind" field) keep the exact parent-death-only behavior (#92).
1566
+ local kind=""
1567
+ kind=$(_parse_json_field "$entry_file" "kind") || true
1568
+
1569
+ if [ "$kind" = "wrapper" ]; then
1570
+ # Wrapper: reap only if orphaned AND idle-past-budget AND no live
1571
+ # engine child. Compute the impure inputs here (predicate stays pure).
1572
+ if [ -n "$ppid_val" ]; then
1573
+ local parent_alive=0
1574
+ kill -0 "$ppid_val" 2>/dev/null && parent_alive=1
1575
+ local last_activity_ms now_ms has_live_child idle_budget_ms
1576
+ last_activity_ms=$(_loki_last_activity_ms)
1577
+ now_ms=$(( $(date +%s) * 1000 ))
1578
+ has_live_child=$(_loki_has_live_engine_child "$pid")
1579
+ idle_budget_ms="${LOKI_WRAPPER_IDLE_BUDGET_MS:-900000}"
1580
+ if [ "$(shouldReapOrphan "$parent_alive" "$last_activity_ms" "$now_ms" "$idle_budget_ms" "$has_live_child")" = "1" ]; then
1581
+ log_warn "Reaping idle orphaned loki wrapper PID=$pid (parent $ppid_val dead, idle >=$((idle_budget_ms/60000))m, no engine child)" >&2
1582
+ kill_registered_pid "$pid"
1583
+ orphan_count=$((orphan_count + 1))
1584
+ fi
1585
+ fi
1586
+ elif [ -n "$ppid_val" ] && [ "$ppid_val" != "$$" ]; then
1468
1587
  if ! kill -0 "$ppid_val" 2>/dev/null; then
1469
1588
  # Parent is dead -- this is an orphan
1470
1589
  local label=""
@@ -1498,6 +1617,9 @@ kill_all_registered() {
1498
1617
  case "$pid" in
1499
1618
  ''|*[!0-9]*) continue ;;
1500
1619
  esac
1620
+ # Never SIGKILL the running wrapper itself mid-shutdown (loki-mode #92):
1621
+ # it now self-registers, so it would otherwise appear in this sweep.
1622
+ [ "$pid" = "$$" ] && continue
1501
1623
  kill_registered_pid "$pid"
1502
1624
  done
1503
1625
  }
@@ -9023,6 +9145,21 @@ _loki_zero_tests_executed() {
9023
9145
  fi
9024
9146
  return 1
9025
9147
  ;;
9148
+ go-test)
9149
+ # #89: `go test ./...` on a package with NO *_test.go EXITS 0 and prints
9150
+ # `? <pkg> [no test files]` -- so a Go project with source but no
9151
+ # tests would score `pass` (a mini fake-green; unlike vitest/pytest/
9152
+ # mocha which exit NON-zero on zero tests and are already not-pass, so
9153
+ # go-test is the only runner in this class that needs detection).
9154
+ # POSITIVE detection: a `[no test files]` line AND no ran-package result
9155
+ # (`ok `/`FAIL`). A mixed run (some pkgs tested) has `ok`, so it is NOT
9156
+ # downgraded. Verified: real passing prints `ok <pkg> 0.2s`.
9157
+ if printf '%s' "$_zt_out" | grep -qE '\[no test files\]' 2>/dev/null \
9158
+ && ! printf '%s' "$_zt_out" | grep -qE '^(ok|FAIL|--- FAIL)' 2>/dev/null; then
9159
+ return 0
9160
+ fi
9161
+ return 1
9162
+ ;;
9026
9163
  *)
9027
9164
  # Unknown/unparseable runner -> never claim zero-tests (safe default).
9028
9165
  return 1
@@ -9753,12 +9890,41 @@ run_doc_quality_gate() {
9753
9890
  local score=100
9754
9891
  local issues=()
9755
9892
 
9893
+ # F52 (doc-scope, gate half): for a "simple"-tier project the accepted
9894
+ # documentation standard is README.md + USAGE.md, NOT the .loki/docs/
9895
+ # architecture suite. auto_generate_docs_if_needed deliberately skips the
9896
+ # suite for simple tier, so the manifest / API.md checks below MUST NOT
9897
+ # penalize it (else the gate would nag for docs we intentionally did not
9898
+ # generate, forcing extra iterations -- the exact waste F52 removes). Score
9899
+ # simple tier on README + USAGE only. Mirrors DOC_SCOPE_INSTRUCTION_SIMPLE.
9900
+ local doc_tier="${DETECTED_COMPLEXITY:-standard}"
9901
+
9756
9902
  # Check 1: README.md exists
9757
9903
  if [ ! -f "$project_dir/README.md" ] || [ ! -s "$project_dir/README.md" ]; then
9758
9904
  score=$((score - 20))
9759
9905
  issues+=("README.md missing or empty")
9760
9906
  fi
9761
9907
 
9908
+ if [ "$doc_tier" = "simple" ]; then
9909
+ # USAGE.md is the always-on end-user handoff doc (usage_doc_instruction);
9910
+ # for a simple project it plus README is sufficient. Penalize only if the
9911
+ # always-on USAGE.md is absent/empty.
9912
+ if [ ! -f "$project_dir/USAGE.md" ] || [ ! -s "$project_dir/USAGE.md" ]; then
9913
+ score=$((score - 20))
9914
+ issues+=("USAGE.md missing or empty")
9915
+ fi
9916
+ if [ ${#issues[@]} -gt 0 ]; then
9917
+ log_warn "Documentation Gate: Score $score/100 (simple tier: README + USAGE)"
9918
+ for issue in "${issues[@]}"; do
9919
+ log_warn " - $issue"
9920
+ done
9921
+ else
9922
+ log_info "Documentation Gate: PASS (Score $score/100, simple tier: README + USAGE)"
9923
+ fi
9924
+ [ "$score" -ge 70 ]
9925
+ return $?
9926
+ fi
9927
+
9762
9928
  # Check 2: Documentation freshness
9763
9929
  local manifest="$project_dir/.loki/docs/docs-manifest.json"
9764
9930
  if [ -f "$manifest" ]; then
@@ -9819,6 +9985,22 @@ run_doc_quality_gate() {
9819
9985
  auto_generate_docs_if_needed() {
9820
9986
  [ "${LOKI_AUTO_DOCS:-true}" = "true" ] || return 0
9821
9987
 
9988
+ # F52 (doc-scope, generator half): a trivial "simple"-tier project does not
9989
+ # warrant the eight-file architecture suite. 'loki docs generate' runs the
9990
+ # provider agentically (claude -p), which writes ARCHITECTURE/API/COMPONENTS/
9991
+ # DECISIONS/SETUP/TESTING/README/CLAUDE to the project -- ~270s of pure token
9992
+ # + wall-clock burn with no reader for a 5-line CLI. The agent already wrote
9993
+ # README.md + USAGE.md under DOC_SCOPE_INSTRUCTION_SIMPLE, so skip the suite.
9994
+ # Parity: this is the generator-side mirror of DOC_SCOPE_INSTRUCTION_SIMPLE
9995
+ # (build_prompt.ts / run.sh:14421); the gate half (run_doc_quality_gate)
9996
+ # accepts README+USAGE for simple so skipping here does not fail Gate 7.
9997
+ # DETECTED_COMPLEXITY is set once by run_autonomous before the first
9998
+ # build_prompt, so it is populated by the time this runs (post code-review).
9999
+ if [ "${DETECTED_COMPLEXITY:-standard}" = "simple" ]; then
10000
+ log_info "Auto-documentation: simple project -- README + USAGE only, skipping architecture suite (F52 doc-scope)"
10001
+ return 0
10002
+ fi
10003
+
9822
10004
  local project_dir="${TARGET_DIR:-.}"
9823
10005
  local manifest="$project_dir/.loki/docs/docs-manifest.json"
9824
10006
  local needs_gen=false
@@ -16171,6 +16353,47 @@ except Exception:
16171
16353
  if type spec_interrogation_run &>/dev/null; then
16172
16354
  spec_interrogation_run "$prd_path" || true
16173
16355
  fi
16356
+ # #87: no-HITL fast-fail on an unresolved spec-INTERNAL contradiction.
16357
+ # A contradiction (class=contradictory) is NEVER auto-acked (P2-4) and only
16358
+ # a human sets confirmed=true, so in an AUTONOMOUS run it can never clear ->
16359
+ # the completion gate blocks EVERY iteration -> the loop grinds to
16360
+ # max-iterations (~20min, opaque). This run is already doomed; here we fail
16361
+ # FAST + HONEST + NAMED instead. Honesty: this is the no-HITL rule verbatim
16362
+ # ("when unsafe to decide, inconclusive-and-proceed, never fake-green") --
16363
+ # it NEVER declares done/green (inconclusive_spec_contradiction maps to a
16364
+ # terminal-failure exit, tested), it just replaces the opaque grind.
16365
+ # SCOPE (deliberately narrow, no regression): fires ONLY when
16366
+ # (a) non-interactive/no-HITL (a TTY human COULD resolve it -> let them), AND
16367
+ # (b) LOKI_ASSUMPTIONS_REQUIRE_CONFIRM != 1 (that knob means a human WILL
16368
+ # confirm -> do not pre-empt), AND
16369
+ # (c) there is >=1 UNRESOLVED class=contradictory entry (the contradiction-
16370
+ # specific count, NOT the broader high-unresolved which includes
16371
+ # auto-ackable non-contradictions).
16372
+ # Opt-out: LOKI_SPEC_CONTRADICTION_FASTFAIL=0.
16373
+ if [ "${LOKI_SPEC_CONTRADICTION_FASTFAIL:-1}" = "1" ] \
16374
+ && [ ! -t 0 ] \
16375
+ && [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" != "1" ] \
16376
+ && type spec_ledger_contradiction_unresolved_count &>/dev/null; then
16377
+ _sc_out="$(spec_ledger_contradiction_unresolved_count 2>/dev/null)"
16378
+ _sc_n="$(printf '%s' "$_sc_out" | head -1)"
16379
+ case "$_sc_n" in ''|*[!0-9]*) _sc_n=0 ;; esac
16380
+ if [ "$_sc_n" -ge 1 ]; then
16381
+ # Option C: name the contradicting clauses so `loki why` is actionable.
16382
+ local _sc_titles
16383
+ _sc_titles="$(printf '%s' "$_sc_out" | tail -n +2 | sed 's/^/ - /' | head -5)"
16384
+ log_error "Spec has ${_sc_n} unresolved internal contradiction(s); an autonomous run cannot resolve them (only a human can). Failing fast instead of grinding to max-iterations."
16385
+ [ -n "$_sc_titles" ] && printf '%s\n' "$_sc_titles" >&2
16386
+ if type _loki_write_last_error &>/dev/null; then
16387
+ _loki_write_last_error 0 "spec_contradiction" \
16388
+ "Spec is internally inconsistent (${_sc_n} unresolved contradiction(s)); resolve the conflicting requirements, then re-run."
16389
+ fi
16390
+ save_state "$retry" "inconclusive_spec_contradiction" 0
16391
+ if type emit_completion_summary &>/dev/null; then
16392
+ emit_completion_summary inconclusive_spec_contradiction 2>/dev/null || true
16393
+ fi
16394
+ return 20
16395
+ fi
16396
+ fi
16174
16397
  fi
16175
16398
 
16176
16399
  # Auto-derive completion promise from PRD (v6.10.0)
@@ -19039,13 +19262,13 @@ main() {
19039
19262
  # pgid.
19040
19263
  case "$_sess_launcher" in
19041
19264
  setsid)
19042
- LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19265
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19043
19266
  perl-setsid)
19044
- LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup perl -e 'use POSIX qw(setsid); setsid(); exec @ARGV or exit 127;' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19267
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup perl -e 'use POSIX qw(setsid); setsid(); exec @ARGV or exit 127;' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19045
19268
  python-setsid)
19046
- LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19269
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19047
19270
  *)
19048
- LOKI_RUNNING_FROM_TEMP='' nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19271
+ LOKI_RUNNING_FROM_TEMP='' LOKI_LAUNCHER_PID=$$ nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19049
19272
  esac
19050
19273
  local bg_pid=$!
19051
19274
  echo "$bg_pid" > "$pid_file"
@@ -19224,8 +19447,11 @@ main() {
19224
19447
  echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id"
19225
19448
  fi
19226
19449
 
19227
- # Initialize PID registry and clean up orphans from previous sessions
19450
+ # Initialize PID registry, self-register this wrapper (loki-mode #92 -- so a
19451
+ # future session can reap it if this run is orphaned+idle), then clean up
19452
+ # orphans from previous sessions.
19228
19453
  init_pid_registry
19454
+ register_self_wrapper
19229
19455
  local orphan_count
19230
19456
  orphan_count=$(cleanup_orphan_pids)
19231
19457
  if [ "$orphan_count" -gt 0 ]; then
@@ -19552,7 +19778,7 @@ main() {
19552
19778
  case "$_final_status" in
19553
19779
  council_approved|council_force_approved|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
19554
19780
  result=0 ;;
19555
- failed|max_iterations_reached|max_retries_exceeded|policy_blocked)
19781
+ failed|max_iterations_reached|max_retries_exceeded|policy_blocked|inconclusive_spec_contradiction)
19556
19782
  result=20 ;;
19557
19783
  *)
19558
19784
  # Unknown/running/exited terminal: leave $result as-is (nonzero on a
@@ -784,6 +784,44 @@ print(n)
784
784
  ' 2>/dev/null || printf '0'
785
785
  }
786
786
 
787
+ # #87: the count of UNRESOLVED spec-INTERNAL CONTRADICTIONS (class=contradictory,
788
+ # not confirmed, not acknowledged). This is the SUBSET of high-unresolved that can
789
+ # NEVER clear in an autonomous run: contradictions are the only high entries that
790
+ # spec_ledger_acknowledge_all deliberately refuses to auto-ack (P2-4), and only a
791
+ # human sets confirmed=true. So >0 here means the completion gate will block every
792
+ # iteration -> the run is ALREADY doomed to grind to max-iterations. run.sh uses
793
+ # this at DISCOVERY to fail fast+honest (inconclusive_spec_contradiction) instead
794
+ # of the opaque grind. Distinct from spec_ledger_high_unresolved_count (which
795
+ # includes non-contradiction highs that CAN auto-ack and must NOT trigger fast-fail).
796
+ # Also prints the contradicting assumption titles (one per line after the count)
797
+ # so the terminal can NAME the cause (Option C).
798
+ spec_ledger_contradiction_unresolved_count() {
799
+ local dir
800
+ dir="$(_spec_ledger_dir)"
801
+ if [ ! -d "$dir" ]; then printf '0'; return 0; fi
802
+ _SL_DIR="$dir" python3 -c '
803
+ import glob, json, os
804
+ d = os.environ["_SL_DIR"]
805
+ n = 0
806
+ titles = []
807
+ for p in sorted(glob.glob(os.path.join(d, "a-*.json"))):
808
+ try:
809
+ with open(p) as f:
810
+ r = json.load(f)
811
+ except Exception:
812
+ continue
813
+ if (r.get("class") == "contradictory"
814
+ and not r.get("confirmed")
815
+ and not r.get("acknowledged")):
816
+ n += 1
817
+ t = r.get("title") or r.get("assumption") or r.get("finding") or "(unnamed contradiction)"
818
+ titles.append(str(t).replace("\n", " ")[:200])
819
+ print(n)
820
+ for t in titles:
821
+ print(t)
822
+ ' 2>/dev/null || printf '0'
823
+ }
824
+
787
825
  # Total ledger entries + high count, "total high" on one line. For summaries.
788
826
  spec_ledger_counts() {
789
827
  local dir
@@ -334,6 +334,22 @@ _verify_zero_tests_executed() {
334
334
  fi
335
335
  return 1
336
336
  ;;
337
+ go-test)
338
+ # #89: `go test ./...` on a package with NO *_test.go files prints
339
+ # `? <pkg> [no test files]` and EXITS 0 (unlike vitest/pytest/mocha,
340
+ # which exit non-zero on zero tests and are already caught as not-pass).
341
+ # So a Go project with source but no tests would score `pass` -- a mini
342
+ # fake-green. Detect it POSITIVELY: at least one `[no test files]` line
343
+ # AND ZERO package results that actually ran (`ok `/`FAIL`). A mixed
344
+ # run (some pkgs tested -> `ok`, others not -> `[no test files]`) has
345
+ # `ok` present, so it is NOT downgraded (verified: real passing prints
346
+ # `ok <pkg> 0.2s`, a bare pkg prints `[no test files]`).
347
+ if printf '%s' "$_zt_out" | grep -qE '\[no test files\]' 2>/dev/null \
348
+ && ! printf '%s' "$_zt_out" | grep -qE '^(ok|FAIL|--- FAIL)' 2>/dev/null; then
349
+ return 0
350
+ fi
351
+ return 1
352
+ ;;
337
353
  *)
338
354
  return 1
339
355
  ;;
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.117.0"
10
+ __version__ = "7.117.2"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.117.0
5
+ **Version:** v7.117.2
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var q8=Object.defineProperty;var J8=($)=>$;function V8($,Q){this[$]=J8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)q8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:V8.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var m1={};b(m1,{lokiDir:()=>j,homeLokiDir:()=>T$,findRepoRootForVersion:()=>$1,REPO_ROOT:()=>h});import{resolve as t,dirname as e$}from"path";import{fileURLToPath as W8}from"url";import{existsSync as N$}from"fs";import{homedir as H8}from"os";function G8(){let $=v1;for(let Q=0;Q<6;Q++){if(N$(t($,"VERSION"))&&N$(t($,"autonomy/run.sh")))return $;let Z=e$($);if(Z===$)break;$=Z}return t(v1,"..","..","..")}function $1($){let Q=$;for(let Z=0;Z<6;Z++){if(N$(t(Q,"VERSION"))&&N$(t(Q,"autonomy/run.sh")))return Q;let z=e$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function j(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(H8(),".loki")}var v1,h;var C=P(()=>{v1=e$(W8(import.meta.url));h=G8()});import{readFileSync as U8}from"fs";import{resolve as Y8,dirname as B8}from"path";import{fileURLToPath as M8}from"url";function S$(){if(Q$!==null)return Q$;let $="7.117.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=B8(M8(import.meta.url)),Z=$1(Q);Q$=U8(Y8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var Q1=P(()=>{C()});var p1={};b(p1,{runOrThrow:()=>S8,run:()=>R,readStreamCapped:()=>c1,commandVersion:()=>C8,commandExists:()=>u,ShellError:()=>Z1,MAX_STDOUT_BYTES:()=>u1});async function c1($,Q=u1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function R($,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([c1(Z.stdout),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 S8($,Q={}){let Z=await R($,Q);if(Z.exitCode!==0)throw new Z1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function u($){let Q=D8($),Z=await R(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function D8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function C8($,Q="--version"){if(!await u($))return null;let z=await R([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var u1=16777216,Z1;var o=P(()=>{Z1=class Z1 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 r($){return b8?"":$}var b8,M,S,_,tZ,L,k,y,J;var p=P(()=>{b8=(process.env.NO_COLOR??"").length>0;M=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),tZ=r("\x1B[0;34m"),L=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as l8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(l8($))return A$=$,$;let Q=await u("python3.12");if(Q)return A$=Q,Q;let Z=await u("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return R([Z,"-c",$],Q)}var A$;var V$=P(()=>{o()});var V0={};b(V0,{runStatus:()=>U3});import{existsSync as v,readFileSync as H$,readdirSync as $0,statSync as Q0}from"fs";import{resolve as D,basename as z3}from"path";import{homedir as X3}from"os";function Z0($){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 z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*C$/Q);if(X>C$)X=C$;let q=C$-X,K=S;if(z>=80)K=M;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Z0($),H=Z0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${H})`}async function q3(){if(await u("jq"))return!0;return process.stdout.write(`${M}Error: jq is required but not installed.${J}
2
+ var q8=Object.defineProperty;var J8=($)=>$;function V8($,Q){this[$]=J8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)q8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:V8.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var m1={};b(m1,{lokiDir:()=>j,homeLokiDir:()=>T$,findRepoRootForVersion:()=>$1,REPO_ROOT:()=>h});import{resolve as t,dirname as e$}from"path";import{fileURLToPath as W8}from"url";import{existsSync as N$}from"fs";import{homedir as H8}from"os";function G8(){let $=v1;for(let Q=0;Q<6;Q++){if(N$(t($,"VERSION"))&&N$(t($,"autonomy/run.sh")))return $;let Z=e$($);if(Z===$)break;$=Z}return t(v1,"..","..","..")}function $1($){let Q=$;for(let Z=0;Z<6;Z++){if(N$(t(Q,"VERSION"))&&N$(t(Q,"autonomy/run.sh")))return Q;let z=e$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function j(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(H8(),".loki")}var v1,h;var C=P(()=>{v1=e$(W8(import.meta.url));h=G8()});import{readFileSync as U8}from"fs";import{resolve as Y8,dirname as B8}from"path";import{fileURLToPath as M8}from"url";function S$(){if(Q$!==null)return Q$;let $="7.117.2";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=B8(M8(import.meta.url)),Z=$1(Q);Q$=U8(Y8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var Q1=P(()=>{C()});var p1={};b(p1,{runOrThrow:()=>S8,run:()=>R,readStreamCapped:()=>c1,commandVersion:()=>C8,commandExists:()=>u,ShellError:()=>Z1,MAX_STDOUT_BYTES:()=>u1});async function c1($,Q=u1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function R($,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([c1(Z.stdout),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 S8($,Q={}){let Z=await R($,Q);if(Z.exitCode!==0)throw new Z1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function u($){let Q=D8($),Z=await R(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function D8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function C8($,Q="--version"){if(!await u($))return null;let z=await R([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var u1=16777216,Z1;var o=P(()=>{Z1=class Z1 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 r($){return b8?"":$}var b8,M,S,_,tZ,L,k,y,J;var p=P(()=>{b8=(process.env.NO_COLOR??"").length>0;M=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),tZ=r("\x1B[0;34m"),L=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as l8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(l8($))return A$=$,$;let Q=await u("python3.12");if(Q)return A$=Q,Q;let Z=await u("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return R([Z,"-c",$],Q)}var A$;var V$=P(()=>{o()});var V0={};b(V0,{runStatus:()=>U3});import{existsSync as v,readFileSync as H$,readdirSync as $0,statSync as Q0}from"fs";import{resolve as D,basename as z3}from"path";import{homedir as X3}from"os";function Z0($){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 z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*C$/Q);if(X>C$)X=C$;let q=C$-X,K=S;if(z>=80)K=M;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Z0($),H=Z0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${H})`}async function q3(){if(await u("jq"))return!0;return process.stdout.write(`${M}Error: jq is required but not installed.${J}
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)
@@ -814,4 +814,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
814
814
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
815
815
  `),process.stderr.write(K8),2}}e1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var SZ=await NZ(Bun.argv.slice(2));process.exit(SZ);
816
816
 
817
- //# debugId=54117DFE71A29F8B64756E2164756E21
817
+ //# debugId=01CA3D14238C4AE464756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.117.0'
60
+ __version__ = '7.117.2'
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.117.0",
4
+ "version": "7.117.2",
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.117.0",
5
+ "version": "7.117.2",
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",