loki-mode 7.116.0 → 7.117.1

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.116.0
6
+ # Loki Mode v7.117.1
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.116.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.117.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.116.0
1
+ 7.117.1
@@ -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:
@@ -1764,13 +1764,25 @@ except (json.JSONDecodeError, IOError, KeyError, ValueError):
1764
1764
  sys.exit(0)
1765
1765
  runner = d.get('runner', 'none')
1766
1766
  passed = d.get('pass', True)
1767
+ status = d.get('status', '')
1768
+ #82 (zero-test-file hardening): a runner that ran but executed ZERO real tests
1769
+ # (node --test on a *.test.js with no test() calls; jest --passWithNoTests with
1770
+ # no suites) records pass:"inconclusive" + status:"no_tests_run" -- a mini
1771
+ # fake-green when read as affirmative. The pass value is then the STRING
1772
+ # "inconclusive" (not the bool True/False), so the old else-branch (PASS) would
1773
+ # have counted it as green. Route it to INCONCLUSIVE (pass-through, never a
1774
+ # block): a real runner ran but proved nothing, exactly like runner=="none".
1775
+ # Only the boolean True passes as affirmative; only the boolean False blocks.
1767
1776
  if runner == 'none':
1768
1777
  print('PASS:none:true')
1769
1778
  elif passed is False:
1770
1779
  print('FAIL:%s:false' % runner)
1780
+ elif status == 'no_tests_run' or passed is not True:
1781
+ print('INCONCLUSIVE:%s:true' % runner)
1771
1782
  else:
1772
1783
  print('PASS:%s:true' % runner)
1773
- " 2>/dev/null || echo "INCONCLUSIVE:none:true")
1784
+ PYEOF
1785
+ )
1774
1786
  local _verdict="${test_status%%:*}"
1775
1787
  local _rest="${test_status#*:}"
1776
1788
  test_runner="${_rest%%:*}"
@@ -1787,6 +1799,16 @@ else:
1787
1799
  test_inconclusive="true"
1788
1800
  test_inconclusive_reason="no_test_runner"
1789
1801
  fi
1802
+ # #82: a real runner that executed ZERO tests (pass:"inconclusive" +
1803
+ # status:"no_tests_run") is INCONCLUSIVE, not affirmative. Mirror the
1804
+ # runner=="none" pass-through so it routes to the council instead of
1805
+ # reading as green. Honest (not a block): test_fails stays "false".
1806
+ # Not gated by LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE -- a zero-test run is
1807
+ # never affirmative evidence regardless of that opt-out.
1808
+ if [ "$_verdict" = "INCONCLUSIVE" ] && [ "$test_runner" != "none" ]; then
1809
+ test_inconclusive="true"
1810
+ test_inconclusive_reason="no_tests_executed"
1811
+ fi
1790
1812
  else
1791
1813
  # Missing test-results.json: no suite was recorded at all. Like the
1792
1814
  # runner=="none" case this is not affirmative evidence, so classify it
package/autonomy/loki CHANGED
@@ -14069,6 +14069,9 @@ cmd_heal_help() {
14069
14069
  echo " validate Verify behavioral equivalence with baseline"
14070
14070
  echo ""
14071
14071
  echo "Options:"
14072
+ echo " --assess Read-only modernization readiness triage (maturity"
14073
+ echo " level + ranked where-to-start targets); no changes"
14074
+ echo " --json With --assess: emit the readiness report as JSON"
14072
14075
  echo " --phase PHASE Start from specific phase (default: archaeology)"
14073
14076
  echo " --resume Resume healing from last checkpoint"
14074
14077
  echo " --status Show healing progress"
@@ -14089,6 +14092,8 @@ cmd_heal_help() {
14089
14092
  echo " LOKI_HEAL_STRICT Block all behavioral changes (default: false)"
14090
14093
  echo ""
14091
14094
  echo "Examples:"
14095
+ echo " loki heal ./legacy-app --assess # Readiness triage (read-only)"
14096
+ echo " loki heal ./legacy-app --assess --json # Same, structured for dashboard"
14092
14097
  echo " loki heal ./legacy-app # Full healing pipeline"
14093
14098
  echo " loki heal ./legacy-app --phase archaeology # Knowledge extraction only"
14094
14099
  echo " loki heal ./legacy-app --archaeology-only # Extract without modifying"
@@ -14136,6 +14141,8 @@ cmd_heal() {
14136
14141
  local do_status="false"
14137
14142
  local do_report="false"
14138
14143
  local do_friction_map="false"
14144
+ local do_assess="false"
14145
+ local assess_json="false"
14139
14146
  local archaeology_only="false"
14140
14147
  local strict="${LOKI_HEAL_STRICT:-false}"
14141
14148
  local compliance=""
@@ -14167,6 +14174,14 @@ cmd_heal() {
14167
14174
  do_friction_map="true"
14168
14175
  shift
14169
14176
  ;;
14177
+ --assess)
14178
+ do_assess="true"
14179
+ shift
14180
+ ;;
14181
+ --json)
14182
+ assess_json="true"
14183
+ shift
14184
+ ;;
14170
14185
  --phase)
14171
14186
  if [[ -z "${2:-}" ]]; then
14172
14187
  echo -e "${RED}Error: --phase requires a value (archaeology|stabilize|isolate|modernize|validate)${NC}"
@@ -14302,6 +14317,311 @@ for f in data.get('frictions', []):
14302
14317
  return 0
14303
14318
  fi
14304
14319
 
14320
+ # Modernization readiness triage (rank 13). READ-ONLY: scans the target with
14321
+ # cheap deterministic heuristics (find/wc/grep counts via python's os.walk and
14322
+ # in-python line counts) and prints a readiness report to STDOUT. It writes
14323
+ # NOTHING into the target (no .loki dir, no state files) so it cannot mutate
14324
+ # the codebase and needs zero flags beyond a path. Emitted before the
14325
+ # path-init/mkdir block below, so no healing session is created.
14326
+ if [ "$do_assess" = "true" ]; then
14327
+ local assess_path="${codebase_path:-.}"
14328
+ if [[ ! -d "$assess_path" ]]; then
14329
+ echo -e "${RED}Error: Directory not found: $assess_path${NC}"
14330
+ return 1
14331
+ fi
14332
+ if ! command -v python3 >/dev/null 2>&1; then
14333
+ echo -e "${RED}Error: python3 is required for readiness assessment.${NC}"
14334
+ return 1
14335
+ fi
14336
+ LOKI_ASSESS_PATH="$assess_path" \
14337
+ LOKI_ASSESS_JSON="$assess_json" \
14338
+ python3 - <<'PYASSESS'
14339
+ import json, os, re, sys
14340
+
14341
+ root = os.environ.get('LOKI_ASSESS_PATH', '.')
14342
+ as_json = os.environ.get('LOKI_ASSESS_JSON', 'false') == 'true'
14343
+
14344
+ # Directories we never descend into (VCS/deps/build noise). Keeps counts honest:
14345
+ # a vendored node_modules would otherwise dominate LOC and target ranking.
14346
+ SKIP_DIRS = {
14347
+ '.git', '.hg', '.svn', 'node_modules', '.loki', 'vendor', 'dist', 'build',
14348
+ '.next', '.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache',
14349
+ 'target', '.idea', '.vscode', 'coverage', '.gradle', 'bin', 'obj',
14350
+ }
14351
+
14352
+ # Extension -> language label. Only source-ish extensions are counted for LOC;
14353
+ # unknown extensions are ignored (reported as 'other' only when they carry text).
14354
+ LANG_BY_EXT = {
14355
+ '.py': 'Python', '.js': 'JavaScript', '.jsx': 'JavaScript', '.mjs': 'JavaScript',
14356
+ '.cjs': 'JavaScript', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.go': 'Go',
14357
+ '.rb': 'Ruby', '.java': 'Java', '.kt': 'Kotlin', '.c': 'C', '.h': 'C',
14358
+ '.cpp': 'C++', '.cc': 'C++', '.hpp': 'C++', '.cs': 'C#', '.php': 'PHP',
14359
+ '.rs': 'Rust', '.swift': 'Swift', '.scala': 'Scala', '.sh': 'Shell',
14360
+ '.bash': 'Shell', '.pl': 'Perl', '.pm': 'Perl', '.r': 'R', '.m': 'Objective-C',
14361
+ '.lua': 'Lua', '.ex': 'Elixir', '.exs': 'Elixir', '.clj': 'Clojure',
14362
+ '.vue': 'Vue', '.dart': 'Dart',
14363
+ }
14364
+
14365
+ TEST_HINT = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__)([/_.-]|s?$|\.)', re.I)
14366
+ TODO_RE = re.compile(r'\b(TODO|FIXME|HACK|XXX)\b')
14367
+
14368
+ # import/require line -> the referenced module basename(s). Deliberately cheap:
14369
+ # we only need a proxy for how many OTHER files reference a given file, to rank
14370
+ # isolated (low blast-radius) modules ABOVE coupled ones (playbook weeks 1-2).
14371
+ IMPORT_PATTERNS = [
14372
+ re.compile(r'^\s*import\s+(?:[\w.]+\s+)?["\']?([\w./-]+)'), # py/js/ts/go
14373
+ re.compile(r'^\s*from\s+([\w./-]+)\s+import'), # python
14374
+ re.compile(r'require\(\s*["\']([\w./-]+)["\']\s*\)'), # node
14375
+ re.compile(r'^\s*#include\s+[<"]([\w./-]+)[>"]'), # c/c++
14376
+ re.compile(r'^\s*use\s+([\w:./-]+)'), # rust
14377
+ ]
14378
+
14379
+ files = [] # per-file records
14380
+ lang_loc = {} # language -> total LOC
14381
+ lang_files = {} # language -> file count
14382
+ total_loc = 0
14383
+ total_files = 0
14384
+ todo_count = 0
14385
+ has_tests = False
14386
+ big_files = [] # (loc, relpath)
14387
+ # module reference tallies: how many files import a given basename (no-ext)
14388
+ ref_counts = {}
14389
+ file_records = {} # relpath -> record
14390
+
14391
+ def stem(name):
14392
+ b = os.path.basename(name)
14393
+ for e in sorted(LANG_BY_EXT, key=len, reverse=True):
14394
+ if b.endswith(e):
14395
+ return b[:-len(e)]
14396
+ return os.path.splitext(b)[0]
14397
+
14398
+ for dirpath, dirnames, filenames in os.walk(root):
14399
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith('.git')]
14400
+ for fn in filenames:
14401
+ ext = os.path.splitext(fn)[1].lower()
14402
+ lang = LANG_BY_EXT.get(ext)
14403
+ if lang is None:
14404
+ continue
14405
+ full = os.path.join(dirpath, fn)
14406
+ try:
14407
+ with open(full, 'r', encoding='utf-8', errors='replace') as fh:
14408
+ lines = fh.readlines()
14409
+ except (OSError, IOError):
14410
+ continue
14411
+ loc = len(lines)
14412
+ rel = os.path.relpath(full, root)
14413
+ total_loc += loc
14414
+ total_files += 1
14415
+ lang_loc[lang] = lang_loc.get(lang, 0) + loc
14416
+ lang_files[lang] = lang_files.get(lang, 0) + 1
14417
+ is_test = bool(TEST_HINT.search(rel))
14418
+ if is_test:
14419
+ has_tests = True
14420
+ ftodo = 0
14421
+ refs = set()
14422
+ for ln in lines:
14423
+ if TODO_RE.search(ln):
14424
+ ftodo += 1
14425
+ for pat in IMPORT_PATTERNS:
14426
+ m = pat.search(ln)
14427
+ if m:
14428
+ refs.add(stem(m.group(1)))
14429
+ todo_count += ftodo
14430
+ rec = {
14431
+ 'path': rel, 'loc': loc, 'language': lang,
14432
+ 'todos': ftodo, 'is_test': is_test,
14433
+ }
14434
+ files.append(rec)
14435
+ file_records[rel] = rec
14436
+ if loc >= 500:
14437
+ big_files.append((loc, rel))
14438
+ # tally inbound references toward the imported basenames
14439
+ for r in refs:
14440
+ ref_counts[r] = ref_counts.get(r, 0) + 1
14441
+
14442
+ # Attach inbound-reference (blast-radius proxy) to each non-test source file.
14443
+ for rec in files:
14444
+ rec['inbound_refs'] = ref_counts.get(stem(rec['path']), 0)
14445
+
14446
+ # --- Technical-debt / maintenance-risk signals (all deterministic) ---
14447
+ big_files.sort(reverse=True)
14448
+ largest_loc = big_files[0][0] if big_files else 0
14449
+ # dependency-manifest presence (a real, cheap signal; NOT a staleness figure).
14450
+ manifest_names = {
14451
+ 'package.json', 'requirements.txt', 'Pipfile', 'pyproject.toml', 'go.mod',
14452
+ 'Cargo.toml', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json',
14453
+ }
14454
+ lockfile_names = {
14455
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'poetry.lock',
14456
+ 'Pipfile.lock', 'go.sum', 'Cargo.lock', 'Gemfile.lock', 'composer.lock',
14457
+ }
14458
+ found_manifests, found_lockfiles = [], []
14459
+ for fn in sorted(os.listdir(root)) if os.path.isdir(root) else []:
14460
+ if fn in manifest_names:
14461
+ found_manifests.append(fn)
14462
+ if fn in lockfile_names:
14463
+ found_lockfiles.append(fn)
14464
+ # dependency staleness is NOT computed offline -> reported as 'unknown', never guessed.
14465
+ dep_lock_present = len(found_lockfiles) > 0 if found_manifests else None
14466
+
14467
+ todo_density = round((todo_count / total_loc) * 1000, 2) if total_loc else 0.0
14468
+
14469
+ # --- 4-level maturity model (Ad hoc -> Planned -> Systematic -> Optimized) ---
14470
+ # Deterministic rubric from cheap signals. Higher = more modern/ready.
14471
+ # Ad hoc: no tests. Planned: tests exist but sparse / large files / high TODO.
14472
+ # Systematic: tests + locked deps + no oversized files + low TODO density.
14473
+ # Optimized: Systematic AND a strong test footprint (>=20% of source files test).
14474
+ test_files = sum(1 for f in files if f['is_test'])
14475
+ test_ratio = round(test_files / total_files, 3) if total_files else 0.0
14476
+ if not has_tests:
14477
+ maturity = 'Ad hoc'
14478
+ maturity_level = 1
14479
+ maturity_reason = 'No test/spec files detected: changes are unguarded.'
14480
+ elif (test_ratio < 0.10) or (largest_loc >= 1000) or (todo_density >= 5.0):
14481
+ maturity = 'Planned'
14482
+ maturity_level = 2
14483
+ maturity_reason = ('Tests exist but coverage footprint is thin or debt signals are high '
14484
+ '(sparse tests / oversized files / dense TODOs).')
14485
+ elif dep_lock_present and test_ratio >= 0.20:
14486
+ maturity = 'Optimized'
14487
+ maturity_level = 4
14488
+ maturity_reason = ('Strong test footprint, pinned dependencies, and no oversized files: '
14489
+ 'ready for continuous background modernization.')
14490
+ else:
14491
+ maturity = 'Systematic'
14492
+ maturity_level = 3
14493
+ maturity_reason = ('Tests present with a healthy footprint and contained file sizes; '
14494
+ 'dependency pinning or test depth can still improve.')
14495
+
14496
+ # --- Ranked candidate targets (playbook weeks 1-2: low blast-radius FIRST) ---
14497
+ # AC#2 requires isolated / low-blast-radius modules to rank ABOVE coupled ones,
14498
+ # so isolation is the PRIMARY key -- it must never be traded away for size. We
14499
+ # bucket by inbound-reference tier (0 refs = isolated, 1-2 = low, 3-5 = moderate,
14500
+ # 6+ = highly coupled); within a tier, higher "visibility" (LOC + TODO weight)
14501
+ # breaks the tie so a bigger isolated file surfaces first. A large-but-coupled
14502
+ # file can therefore never outrank a small isolated one. Tests are excluded.
14503
+ candidates = [f for f in files if not f['is_test']]
14504
+ def blast_tier(refs):
14505
+ if refs == 0:
14506
+ return 0 # isolated
14507
+ if refs <= 2:
14508
+ return 1 # low blast radius
14509
+ if refs <= 5:
14510
+ return 2 # moderate
14511
+ return 3 # highly coupled
14512
+ def visibility(f):
14513
+ # tie-break WITHIN a blast tier only; bigger / more-flagged = more worth it.
14514
+ return f['loc'] + f['todos'] * 50
14515
+ for f in candidates:
14516
+ f['_tier'] = blast_tier(f['inbound_refs'])
14517
+ f['_score'] = visibility(f)
14518
+ # primary: lower tier first (isolation dominates); secondary: higher visibility.
14519
+ candidates.sort(key=lambda f: (f['_tier'], -f['_score'], f['path']))
14520
+ top = candidates[:10]
14521
+
14522
+ def target_rationale(f):
14523
+ bits = []
14524
+ if f['inbound_refs'] == 0:
14525
+ bits.append('isolated (no inbound imports -> low blast radius)')
14526
+ else:
14527
+ bits.append(f"{f['inbound_refs']} inbound reference(s)")
14528
+ bits.append(f"{f['loc']} LOC")
14529
+ if f['todos']:
14530
+ bits.append(f"{f['todos']} TODO/FIXME")
14531
+ return ', '.join(bits)
14532
+
14533
+ lang_mix = sorted(
14534
+ ({'language': k, 'loc': v, 'files': lang_files.get(k, 0),
14535
+ 'pct': round((v / total_loc) * 100, 1) if total_loc else 0.0}
14536
+ for k, v in lang_loc.items()),
14537
+ key=lambda x: -x['loc'],
14538
+ )
14539
+
14540
+ report = {
14541
+ 'codebase': os.path.abspath(root),
14542
+ 'scan': {
14543
+ 'total_files': total_files,
14544
+ 'total_loc': total_loc,
14545
+ 'language_mix': lang_mix,
14546
+ },
14547
+ 'debt_signals': {
14548
+ 'has_tests': has_tests,
14549
+ 'test_files': test_files,
14550
+ 'test_ratio': test_ratio,
14551
+ 'todo_fixme_count': todo_count,
14552
+ 'todo_density_per_kloc': todo_density,
14553
+ 'large_files_ge_500_loc': len(big_files),
14554
+ 'largest_file_loc': largest_loc,
14555
+ },
14556
+ 'maintenance_risk': {
14557
+ 'dependency_manifests': found_manifests,
14558
+ 'lockfiles': found_lockfiles,
14559
+ 'dependency_lock_present': dep_lock_present,
14560
+ 'dependency_staleness': 'unknown', # not computable offline; never guessed
14561
+ },
14562
+ 'maturity': {
14563
+ 'level': maturity_level,
14564
+ 'label': maturity,
14565
+ 'scale': ['Ad hoc', 'Planned', 'Systematic', 'Optimized'],
14566
+ 'rationale': maturity_reason,
14567
+ },
14568
+ 'ranked_targets': [
14569
+ {'path': f['path'], 'loc': f['loc'], 'language': f['language'],
14570
+ 'inbound_refs': f['inbound_refs'], 'todos': f['todos'],
14571
+ 'blast_tier': f['_tier'], # 0 isolated, 1 low, 2 moderate, 3 highly coupled (primary sort key)
14572
+ 'visibility_score': f['_score'], 'rationale': target_rationale(f)}
14573
+ for f in top
14574
+ ],
14575
+ }
14576
+
14577
+ if as_json:
14578
+ print(json.dumps(report, indent=2))
14579
+ sys.exit(0)
14580
+
14581
+ BOLD = '\033[1m'; NC = '\033[0m'; CYAN = '\033[36m'
14582
+ print(f"{BOLD}Modernization Readiness Assessment{NC}")
14583
+ print()
14584
+ print(f" Codebase: {report['codebase']}")
14585
+ print(f" Files: {total_files} source file(s)")
14586
+ print(f" LOC: {total_loc}")
14587
+ if total_files == 0:
14588
+ print()
14589
+ print(" No recognized source files found; nothing to assess.")
14590
+ sys.exit(0)
14591
+ print()
14592
+ print(f"{BOLD}Language mix{NC}")
14593
+ for lm in lang_mix:
14594
+ print(f" {lm['language']:<12} {lm['loc']:>8} LOC ({lm['pct']}%, {lm['files']} files)")
14595
+ print()
14596
+ print(f"{BOLD}Technical-debt signals{NC}")
14597
+ d = report['debt_signals']
14598
+ print(f" Tests present: {'yes' if d['has_tests'] else 'NO'} "
14599
+ f"({d['test_files']} test file(s), {d['test_ratio']:.0%} of files)")
14600
+ print(f" TODO/FIXME/HACK: {d['todo_fixme_count']} "
14601
+ f"({d['todo_density_per_kloc']} per KLOC)")
14602
+ print(f" Large files (>=500): {d['large_files_ge_500_loc']} "
14603
+ f"(largest {d['largest_file_loc']} LOC)")
14604
+ mr = report['maintenance_risk']
14605
+ lock_str = ('yes' if mr['dependency_lock_present'] else 'NO') if mr['dependency_lock_present'] is not None else 'no manifest'
14606
+ print(f" Dependency lockfile: {lock_str}")
14607
+ print(f" Dependency staleness: {mr['dependency_staleness']} (not computed offline)")
14608
+ print()
14609
+ print(f"{BOLD}Maturity{NC} (Ad hoc -> Planned -> Systematic -> Optimized)")
14610
+ print(f" Level {maturity_level}/4: {CYAN}{maturity}{NC}")
14611
+ print(f" {maturity_reason}")
14612
+ print()
14613
+ print(f"{BOLD}Where to start{NC} (ranked low-blast-radius, high-visibility targets)")
14614
+ if not top:
14615
+ print(" No non-test candidate modules found.")
14616
+ else:
14617
+ for i, f in enumerate(top, 1):
14618
+ print(f" {i}. {f['path']}")
14619
+ print(f" {target_rationale(f)}")
14620
+ print()
14621
+ PYASSESS
14622
+ return $?
14623
+ fi
14624
+
14305
14625
  if [ "$do_report" = "true" ]; then
14306
14626
  local heal_dir="${codebase_path:-.}/.loki/healing"
14307
14627
  if [[ ! -d "$heal_dir" ]]; then
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
  }
@@ -8965,6 +9087,86 @@ except Exception:
8965
9087
  return 0
8966
9088
  }
8967
9089
 
9090
+ # _loki_zero_tests_executed -- shared "no real tests ran" detector (#82).
9091
+ # A runner that exits 0 but executed ZERO actual tests is a mini fake-green: it
9092
+ # records pass while proving nothing. This inspects a runner's raw output and
9093
+ # returns 0 (true, "zero tests executed") ONLY on positive detection; any
9094
+ # unrecognized/unparseable shape returns 1 (false) so a legitimate suite this
9095
+ # helper cannot parse is NEVER false-downgraded (bounded blast radius).
9096
+ #
9097
+ # $1 = runner label (node-test|jest|vitest|...)
9098
+ # $2 = raw runner output
9099
+ # $3.. = (optional) test-file paths that were passed to node --test; their
9100
+ # basenames are node's file-wrapper subtest labels and must be excluded
9101
+ # from the "real test" count.
9102
+ #
9103
+ # node --test: even a *.test.js with NO test() calls emits `# tests 1 # pass 1`
9104
+ # because node counts the FILE ITSELF as one passing pseudo-test, printed as
9105
+ # `ok N - <file-basename>`. So `# tests 0` NEVER fires (verified on Node 22).
9106
+ # The true executed count = ok/not-ok lines whose label is NOT a passed file
9107
+ # basename. Zero such lines + exit 0 = no real tests ran.
9108
+ # jest --passWithNoTests: prints "No tests found" and NO "Tests:" summary line.
9109
+ _loki_zero_tests_executed() {
9110
+ local _zt_runner="$1"; shift
9111
+ local _zt_out="$1"; shift
9112
+ case "$_zt_runner" in
9113
+ node-test)
9114
+ # node's file-wrapper subtest label is the ARGUMENT VERBATIM (the
9115
+ # full path we passed), and separately its basename for older node.
9116
+ # Register BOTH forms so the wrapper line is excluded either way.
9117
+ # Delimited with newlines so a path containing spaces still matches
9118
+ # exactly (space-delimited would split it).
9119
+ local _zt_f _zt_bases=$'\n'
9120
+ for _zt_f in "$@"; do
9121
+ [ -n "$_zt_f" ] || continue
9122
+ _zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
9123
+ done
9124
+ # Count ok/not-ok lines whose label is a REAL test (label not a
9125
+ # file-wrapper). node prints "ok N - <label>" / "not ok N - <label>".
9126
+ local _zt_real=0 _zt_line _zt_label
9127
+ while IFS= read -r _zt_line; do
9128
+ # Strip "ok N - " / "not ok N - " prefix to get the label.
9129
+ _zt_label="${_zt_line#* - }"
9130
+ # A file-wrapper label equals a passed file path or basename -> skip.
9131
+ case "$_zt_bases" in
9132
+ *$'\n'"$_zt_label"$'\n'*) continue ;;
9133
+ esac
9134
+ _zt_real=$((_zt_real + 1))
9135
+ done < <(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null)
9136
+ [ "$_zt_real" -eq 0 ] && return 0
9137
+ return 1
9138
+ ;;
9139
+ jest|monorepo-jest)
9140
+ # jest --passWithNoTests: "No tests found, exiting with code 0" and no
9141
+ # "Tests:" summary. A real run prints "Tests: N passed, ...".
9142
+ if printf '%s' "$_zt_out" | grep -qiE 'no tests found' 2>/dev/null \
9143
+ && ! printf '%s' "$_zt_out" | grep -qE '^Tests:' 2>/dev/null; then
9144
+ return 0
9145
+ fi
9146
+ return 1
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
+ ;;
9163
+ *)
9164
+ # Unknown/unparseable runner -> never claim zero-tests (safe default).
9165
+ return 1
9166
+ ;;
9167
+ esac
9168
+ }
9169
+
8968
9170
  enforce_test_coverage() {
8969
9171
  local loki_dir="${TARGET_DIR:-.}/.loki"
8970
9172
  local quality_dir="$loki_dir/quality"
@@ -9299,6 +9501,27 @@ TREOF
9299
9501
  *) _tr_cmd="$test_runner" ;;
9300
9502
  esac
9301
9503
  if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
9504
+
9505
+ # #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
9506
+ # real tests is a mini fake-green -- it records "verified" while proving
9507
+ # nothing. node --test on a *.test.js with no test() calls exits 0 (node
9508
+ # counts the FILE ITSELF as one passing pseudo-test, so `# tests 0` never
9509
+ # fires); jest --passWithNoTests exits 0 on an empty suite. Detect the
9510
+ # zero-real-test case ONLY on a green run (a red run already records fail
9511
+ # and must stay fail), and downgrade the record from affirmative to an
9512
+ # HONEST inconclusive (status=no_tests_run, gap=source_without_runnable_tests,
9513
+ # pass:"inconclusive"). This is NOT a failure: the completion-council
9514
+ # evidence gate treats it as pass-through, exactly like runner=="none".
9515
+ # Positive-detection only -- an unparseable runner is left UNCHANGED
9516
+ # (pass:true) so a legitimate suite is never false-downgraded.
9517
+ local _tr_zero_tests=false
9518
+ if [ "$test_passed" = "true" ] \
9519
+ && _loki_zero_tests_executed "$test_runner" "${output:-}" "${_nt_files[@]:-}"; then
9520
+ _tr_zero_tests=true
9521
+ _tr_status="no_tests_run"
9522
+ log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
9523
+ fi
9524
+
9302
9525
  # Best-effort pass/fail counts from the summary text (null when not found).
9303
9526
  local _tr_passed_n _tr_failed_n
9304
9527
  _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
@@ -9310,11 +9533,19 @@ TREOF
9310
9533
  [ -n "$_tr_passed_n" ] || _tr_passed_n=null
9311
9534
  [ -n "$_tr_failed_n" ] || _tr_failed_n=null
9312
9535
 
9313
- # verification_gap is "none" whenever a real runner executed: the suite ran,
9314
- # so there is no docs-without-execution / source-without-tests gap. Keeping
9315
- # the key on BOTH writers gives consumers a single stable schema shape.
9536
+ # verification_gap is "none" whenever a real runner executed AND ran tests:
9537
+ # the suite ran, so there is no docs-without-execution / source-without-tests
9538
+ # gap. The #82 zero-test case is the exception -- a runner ran but executed
9539
+ # NO tests -- so it records pass:"inconclusive" (the string, distinct from the
9540
+ # bool true/false) + gap:source_without_runnable_tests. Keeping the key on
9541
+ # BOTH writers gives consumers a single stable schema shape.
9542
+ local _tr_pass_json="$test_passed" _tr_gap_json="none"
9543
+ if [ "$_tr_zero_tests" = "true" ]; then
9544
+ _tr_pass_json='"inconclusive"'
9545
+ _tr_gap_json="source_without_runnable_tests"
9546
+ fi
9316
9547
  cat > "$quality_dir/test-results.json" << TREOF
9317
- {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details","command":"$_tr_cmd","exit_code":$_tr_exit,"status":"$_tr_status","passed_count":$_tr_passed_n,"failed_count":$_tr_failed_n,"verification_gap":"none"}
9548
+ {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$_tr_pass_json,"min_coverage":$min_coverage,"summary":"$details","command":"$_tr_cmd","exit_code":$_tr_exit,"status":"$_tr_status","passed_count":$_tr_passed_n,"failed_count":$_tr_failed_n,"verification_gap":"$_tr_gap_json"}
9318
9549
  TREOF
9319
9550
  # Finding #598: stamp the per-iteration freshness marker (see above).
9320
9551
  printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
@@ -16077,6 +16308,47 @@ except Exception:
16077
16308
  if type spec_interrogation_run &>/dev/null; then
16078
16309
  spec_interrogation_run "$prd_path" || true
16079
16310
  fi
16311
+ # #87: no-HITL fast-fail on an unresolved spec-INTERNAL contradiction.
16312
+ # A contradiction (class=contradictory) is NEVER auto-acked (P2-4) and only
16313
+ # a human sets confirmed=true, so in an AUTONOMOUS run it can never clear ->
16314
+ # the completion gate blocks EVERY iteration -> the loop grinds to
16315
+ # max-iterations (~20min, opaque). This run is already doomed; here we fail
16316
+ # FAST + HONEST + NAMED instead. Honesty: this is the no-HITL rule verbatim
16317
+ # ("when unsafe to decide, inconclusive-and-proceed, never fake-green") --
16318
+ # it NEVER declares done/green (inconclusive_spec_contradiction maps to a
16319
+ # terminal-failure exit, tested), it just replaces the opaque grind.
16320
+ # SCOPE (deliberately narrow, no regression): fires ONLY when
16321
+ # (a) non-interactive/no-HITL (a TTY human COULD resolve it -> let them), AND
16322
+ # (b) LOKI_ASSUMPTIONS_REQUIRE_CONFIRM != 1 (that knob means a human WILL
16323
+ # confirm -> do not pre-empt), AND
16324
+ # (c) there is >=1 UNRESOLVED class=contradictory entry (the contradiction-
16325
+ # specific count, NOT the broader high-unresolved which includes
16326
+ # auto-ackable non-contradictions).
16327
+ # Opt-out: LOKI_SPEC_CONTRADICTION_FASTFAIL=0.
16328
+ if [ "${LOKI_SPEC_CONTRADICTION_FASTFAIL:-1}" = "1" ] \
16329
+ && [ ! -t 0 ] \
16330
+ && [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" != "1" ] \
16331
+ && type spec_ledger_contradiction_unresolved_count &>/dev/null; then
16332
+ _sc_out="$(spec_ledger_contradiction_unresolved_count 2>/dev/null)"
16333
+ _sc_n="$(printf '%s' "$_sc_out" | head -1)"
16334
+ case "$_sc_n" in ''|*[!0-9]*) _sc_n=0 ;; esac
16335
+ if [ "$_sc_n" -ge 1 ]; then
16336
+ # Option C: name the contradicting clauses so `loki why` is actionable.
16337
+ local _sc_titles
16338
+ _sc_titles="$(printf '%s' "$_sc_out" | tail -n +2 | sed 's/^/ - /' | head -5)"
16339
+ 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."
16340
+ [ -n "$_sc_titles" ] && printf '%s\n' "$_sc_titles" >&2
16341
+ if type _loki_write_last_error &>/dev/null; then
16342
+ _loki_write_last_error 0 "spec_contradiction" \
16343
+ "Spec is internally inconsistent (${_sc_n} unresolved contradiction(s)); resolve the conflicting requirements, then re-run."
16344
+ fi
16345
+ save_state "$retry" "inconclusive_spec_contradiction" 0
16346
+ if type emit_completion_summary &>/dev/null; then
16347
+ emit_completion_summary inconclusive_spec_contradiction 2>/dev/null || true
16348
+ fi
16349
+ return 20
16350
+ fi
16351
+ fi
16080
16352
  fi
16081
16353
 
16082
16354
  # Auto-derive completion promise from PRD (v6.10.0)
@@ -18945,13 +19217,13 @@ main() {
18945
19217
  # pgid.
18946
19218
  case "$_sess_launcher" in
18947
19219
  setsid)
18948
- LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19220
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
18949
19221
  perl-setsid)
18950
- 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 & ;;
19222
+ 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 & ;;
18951
19223
  python-setsid)
18952
- 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 & ;;
19224
+ 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 & ;;
18953
19225
  *)
18954
- LOKI_RUNNING_FROM_TEMP='' nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
19226
+ LOKI_RUNNING_FROM_TEMP='' LOKI_LAUNCHER_PID=$$ nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
18955
19227
  esac
18956
19228
  local bg_pid=$!
18957
19229
  echo "$bg_pid" > "$pid_file"
@@ -19130,8 +19402,11 @@ main() {
19130
19402
  echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id"
19131
19403
  fi
19132
19404
 
19133
- # Initialize PID registry and clean up orphans from previous sessions
19405
+ # Initialize PID registry, self-register this wrapper (loki-mode #92 -- so a
19406
+ # future session can reap it if this run is orphaned+idle), then clean up
19407
+ # orphans from previous sessions.
19134
19408
  init_pid_registry
19409
+ register_self_wrapper
19135
19410
  local orphan_count
19136
19411
  orphan_count=$(cleanup_orphan_pids)
19137
19412
  if [ "$orphan_count" -gt 0 ]; then
@@ -19458,7 +19733,7 @@ main() {
19458
19733
  case "$_final_status" in
19459
19734
  council_approved|council_force_approved|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
19460
19735
  result=0 ;;
19461
- failed|max_iterations_reached|max_retries_exceeded|policy_blocked)
19736
+ failed|max_iterations_reached|max_retries_exceeded|policy_blocked|inconclusive_spec_contradiction)
19462
19737
  result=20 ;;
19463
19738
  *)
19464
19739
  # 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
@@ -292,6 +292,70 @@ verify_gate_build() {
292
292
  return 0
293
293
  }
294
294
 
295
+ # _verify_zero_tests_executed -- "no real tests ran" detector (#82), mirrors
296
+ # _loki_zero_tests_executed in run.sh. Returns 0 (true) ONLY on positive
297
+ # detection of a runner that exited 0 yet executed ZERO actual tests; any
298
+ # unrecognized shape returns 1 (false) so a legitimate suite is never
299
+ # false-downgraded. $1=runner $2=raw output $3..=node --test file paths.
300
+ #
301
+ # node --test: a *.test.js with no test() calls still emits `# tests 1 # pass 1`
302
+ # (node counts the FILE as one passing pseudo-test: `ok N - <file-basename>`),
303
+ # so the true executed count = ok/not-ok lines whose label is NOT a passed
304
+ # file basename. Zero such = no real tests.
305
+ # jest --passWithNoTests: prints "No tests found" and no "Tests:" summary.
306
+ _verify_zero_tests_executed() {
307
+ local _zt_runner="$1"; shift
308
+ local _zt_out="$1"; shift
309
+ case "$_zt_runner" in
310
+ node-test)
311
+ # node's file-wrapper label is the argument verbatim (full path) and,
312
+ # on older node, its basename. Register BOTH (newline-delimited so a
313
+ # spaced path still matches exactly).
314
+ local _zt_f _zt_bases=$'\n'
315
+ for _zt_f in "$@"; do
316
+ [ -n "$_zt_f" ] || continue
317
+ _zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
318
+ done
319
+ local _zt_real=0 _zt_line _zt_label
320
+ while IFS= read -r _zt_line; do
321
+ _zt_label="${_zt_line#* - }"
322
+ case "$_zt_bases" in
323
+ *$'\n'"$_zt_label"$'\n'*) continue ;;
324
+ esac
325
+ _zt_real=$((_zt_real + 1))
326
+ done < <(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null)
327
+ [ "$_zt_real" -eq 0 ] && return 0
328
+ return 1
329
+ ;;
330
+ jest)
331
+ if printf '%s' "$_zt_out" | grep -qiE 'no tests found' 2>/dev/null \
332
+ && ! printf '%s' "$_zt_out" | grep -qE '^Tests:' 2>/dev/null; then
333
+ return 0
334
+ fi
335
+ return 1
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
+ ;;
353
+ *)
354
+ return 1
355
+ ;;
356
+ esac
357
+ }
358
+
295
359
  # ---------------------------------------------------------------------------
296
360
  # Gate: tests (faithful port of enforce_test_coverage detection, run.sh:6624).
297
361
  #
@@ -301,7 +365,9 @@ verify_gate_build() {
301
365
  # skipped = no test framework detected at all (not-applicable).
302
366
  # inconclusive = a project is present but no runnable framework
303
367
  # (e.g. package.json with no test runner, or a python project
304
- # with no pytest on PATH). Forces at-least-CONCERNS.
368
+ # with no pytest on PATH), OR a runner that ran but executed
369
+ # ZERO tests (#82: node --test empty file, jest --passWithNoTests).
370
+ # Forces at-least-CONCERNS.
305
371
  # fail = tests ran and failed -> High finding.
306
372
  # pass = tests ran green.
307
373
  # ---------------------------------------------------------------------------
@@ -429,7 +495,15 @@ verify_gate_tests() {
429
495
  fi
430
496
 
431
497
  if [ "$rc" -eq 0 ]; then
432
- _verify_add_gate "tests" "pass" "$runner" "tests passed" "true"
498
+ # #82: a green run that executed ZERO real tests is a mini fake-green.
499
+ # Record it as HONEST inconclusive (forces at-least-CONCERNS), NOT pass
500
+ # and NOT fail. Positive-detection only; an unparseable runner stays pass.
501
+ if _verify_zero_tests_executed "$runner" "$out" "${_nt_files[@]:-}"; then
502
+ _verify_add_gate "tests" "inconclusive" "$runner" \
503
+ "runner ran but executed zero tests (source_without_runnable_tests)" "true"
504
+ else
505
+ _verify_add_gate "tests" "pass" "$runner" "tests passed" "true"
506
+ fi
433
507
  else
434
508
  _verify_add_gate "tests" "fail" "$runner" "tests failed (rc=$rc)" "true"
435
509
  _verify_add_finding "High" "tests" "deterministic:$runner" "" "null" \
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.116.0"
10
+ __version__ = "7.117.1"
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.116.0
5
+ **Version:** v7.117.1
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.116.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.1";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=30F27BA7342E3CAE64756E2164756E21
817
+ //# debugId=C0F83BD59DEB4E5764756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.116.0'
60
+ __version__ = '7.117.1'
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.116.0",
4
+ "version": "7.117.1",
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.116.0",
5
+ "version": "7.117.1",
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",