loki-mode 7.115.0 → 7.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.115.0
6
+ # Loki Mode v7.117.0
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.115.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.117.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.115.0
1
+ 7.117.0
@@ -1764,10 +1764,21 @@ 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
1784
  " 2>/dev/null || echo "INCONCLUSIVE:none:true")
@@ -1787,6 +1798,16 @@ else:
1787
1798
  test_inconclusive="true"
1788
1799
  test_inconclusive_reason="no_test_runner"
1789
1800
  fi
1801
+ # #82: a real runner that executed ZERO tests (pass:"inconclusive" +
1802
+ # status:"no_tests_run") is INCONCLUSIVE, not affirmative. Mirror the
1803
+ # runner=="none" pass-through so it routes to the council instead of
1804
+ # reading as green. Honest (not a block): test_fails stays "false".
1805
+ # Not gated by LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE -- a zero-test run is
1806
+ # never affirmative evidence regardless of that opt-out.
1807
+ if [ "$_verdict" = "INCONCLUSIVE" ] && [ "$test_runner" != "none" ]; then
1808
+ test_inconclusive="true"
1809
+ test_inconclusive_reason="no_tests_executed"
1810
+ fi
1790
1811
  else
1791
1812
  # Missing test-results.json: no suite was recorded at all. Like the
1792
1813
  # 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
@@ -1668,7 +1668,7 @@ _loki_trust_run_id() {
1668
1668
  # Args: $1 = the new phase value (REASONING, BUILDING, VERIFYING, COMPLETED, etc.)
1669
1669
  _advance_current_phase() {
1670
1670
  local new_phase="${1:?phase required}"
1671
- local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}}/.loki"
1671
+ local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
1672
1672
  local orch="$loki_dir/state/orchestrator.json"
1673
1673
  [ -f "$orch" ] || return 0
1674
1674
  # Values are passed via argv (not interpolated into the source) so a phase or
@@ -8965,6 +8965,71 @@ except Exception:
8965
8965
  return 0
8966
8966
  }
8967
8967
 
8968
+ # _loki_zero_tests_executed -- shared "no real tests ran" detector (#82).
8969
+ # A runner that exits 0 but executed ZERO actual tests is a mini fake-green: it
8970
+ # records pass while proving nothing. This inspects a runner's raw output and
8971
+ # returns 0 (true, "zero tests executed") ONLY on positive detection; any
8972
+ # unrecognized/unparseable shape returns 1 (false) so a legitimate suite this
8973
+ # helper cannot parse is NEVER false-downgraded (bounded blast radius).
8974
+ #
8975
+ # $1 = runner label (node-test|jest|vitest|...)
8976
+ # $2 = raw runner output
8977
+ # $3.. = (optional) test-file paths that were passed to node --test; their
8978
+ # basenames are node's file-wrapper subtest labels and must be excluded
8979
+ # from the "real test" count.
8980
+ #
8981
+ # node --test: even a *.test.js with NO test() calls emits `# tests 1 # pass 1`
8982
+ # because node counts the FILE ITSELF as one passing pseudo-test, printed as
8983
+ # `ok N - <file-basename>`. So `# tests 0` NEVER fires (verified on Node 22).
8984
+ # The true executed count = ok/not-ok lines whose label is NOT a passed file
8985
+ # basename. Zero such lines + exit 0 = no real tests ran.
8986
+ # jest --passWithNoTests: prints "No tests found" and NO "Tests:" summary line.
8987
+ _loki_zero_tests_executed() {
8988
+ local _zt_runner="$1"; shift
8989
+ local _zt_out="$1"; shift
8990
+ case "$_zt_runner" in
8991
+ node-test)
8992
+ # node's file-wrapper subtest label is the ARGUMENT VERBATIM (the
8993
+ # full path we passed), and separately its basename for older node.
8994
+ # Register BOTH forms so the wrapper line is excluded either way.
8995
+ # Delimited with newlines so a path containing spaces still matches
8996
+ # exactly (space-delimited would split it).
8997
+ local _zt_f _zt_bases=$'\n'
8998
+ for _zt_f in "$@"; do
8999
+ [ -n "$_zt_f" ] || continue
9000
+ _zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
9001
+ done
9002
+ # Count ok/not-ok lines whose label is a REAL test (label not a
9003
+ # file-wrapper). node prints "ok N - <label>" / "not ok N - <label>".
9004
+ local _zt_real=0 _zt_line _zt_label
9005
+ while IFS= read -r _zt_line; do
9006
+ # Strip "ok N - " / "not ok N - " prefix to get the label.
9007
+ _zt_label="${_zt_line#* - }"
9008
+ # A file-wrapper label equals a passed file path or basename -> skip.
9009
+ case "$_zt_bases" in
9010
+ *$'\n'"$_zt_label"$'\n'*) continue ;;
9011
+ esac
9012
+ _zt_real=$((_zt_real + 1))
9013
+ done < <(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null)
9014
+ [ "$_zt_real" -eq 0 ] && return 0
9015
+ return 1
9016
+ ;;
9017
+ jest|monorepo-jest)
9018
+ # jest --passWithNoTests: "No tests found, exiting with code 0" and no
9019
+ # "Tests:" summary. A real run prints "Tests: N passed, ...".
9020
+ if printf '%s' "$_zt_out" | grep -qiE 'no tests found' 2>/dev/null \
9021
+ && ! printf '%s' "$_zt_out" | grep -qE '^Tests:' 2>/dev/null; then
9022
+ return 0
9023
+ fi
9024
+ return 1
9025
+ ;;
9026
+ *)
9027
+ # Unknown/unparseable runner -> never claim zero-tests (safe default).
9028
+ return 1
9029
+ ;;
9030
+ esac
9031
+ }
9032
+
8968
9033
  enforce_test_coverage() {
8969
9034
  local loki_dir="${TARGET_DIR:-.}/.loki"
8970
9035
  local quality_dir="$loki_dir/quality"
@@ -9159,6 +9224,51 @@ sys.stdout.write(t.strip())
9159
9224
  details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')"
9160
9225
  fi
9161
9226
 
9227
+ # node --test (built-in Node test runner) -- config-less fallback (task #79).
9228
+ # Node's built-in runner (stable since Node 18) runs *.test.{js,mjs,cjs}
9229
+ # with ZERO config and NO package.json. A deliverable of slug.js +
9230
+ # slug.test.js (a real, passing suite) previously fell straight through to
9231
+ # runner="none" and recorded verification_gap="source_without_tests" -- a
9232
+ # FALSE-NEGATIVE trust defect (symmetric to fake-green): genuinely-correct,
9233
+ # fully-tested work read as NOT VERIFIED. This branch fires ONLY as a
9234
+ # fallback below every explicit-framework path (vitest/jest/mocha/scripts.test
9235
+ # via package.json, monorepo, pytest, go, cargo all gate on
9236
+ # test_runner=="none"), so package.json-with-jest still selects jest. It runs
9237
+ # only when node is on PATH AND *.test.{js,mjs,cjs} files exist at root or
9238
+ # under test/ or tests/. A failing suite records test_passed=false (never
9239
+ # swallowed); absence of node or test files falls through to the honest
9240
+ # "none"/inconclusive path below (never fabricates a pass).
9241
+ if [ "$test_runner" = "none" ] && command -v node &>/dev/null; then
9242
+ local _nt_files=()
9243
+ local _nt_f
9244
+ # Root-level test files (maxdepth 1) plus test/ and tests/ dirs, skipping
9245
+ # vendored trees so we never walk node_modules.
9246
+ while IFS= read -r _nt_f; do
9247
+ [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
9248
+ done < <(find "${TARGET_DIR:-.}" -maxdepth 1 -type f \
9249
+ \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
9250
+ 2>/dev/null)
9251
+ for _nt_dir in test tests; do
9252
+ [ -d "${TARGET_DIR:-.}/$_nt_dir" ] || continue
9253
+ while IFS= read -r _nt_f; do
9254
+ [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
9255
+ done < <(find "${TARGET_DIR:-.}/$_nt_dir" -maxdepth 3 -type f \
9256
+ \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
9257
+ -not -path '*/node_modules/*' 2>/dev/null)
9258
+ done
9259
+ if [ "${#_nt_files[@]}" -gt 0 ]; then
9260
+ test_runner="node-test"
9261
+ local output
9262
+ # Pass matched files explicitly (node --test globbing is
9263
+ # Node-version-sensitive); quote each so paths with spaces survive.
9264
+ output=$(cd "${TARGET_DIR:-.}" && timeout "${LOKI_GATE_TIMEOUT:-300}" \
9265
+ node --test "${_nt_files[@]}" 2>&1) || test_passed=false
9266
+ # tail -14 so node's TAP summary block (# tests / # pass N / # fail N,
9267
+ # ~10 lines) survives truncation for the best-effort count parse below.
9268
+ details="node --test: $(echo "$output" | tail -14 | tr '\n' ' ')"
9269
+ fi
9270
+ fi
9271
+
9162
9272
  if [ "$test_runner" = "none" ]; then
9163
9273
  log_info "Test coverage: no test runner detected, recording inconclusive (not pass)"
9164
9274
  # v7.41.x fail-open fix: previously this wrote pass:true, so a project
@@ -9254,18 +9364,51 @@ TREOF
9254
9364
  *) _tr_cmd="$test_runner" ;;
9255
9365
  esac
9256
9366
  if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
9367
+
9368
+ # #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
9369
+ # real tests is a mini fake-green -- it records "verified" while proving
9370
+ # nothing. node --test on a *.test.js with no test() calls exits 0 (node
9371
+ # counts the FILE ITSELF as one passing pseudo-test, so `# tests 0` never
9372
+ # fires); jest --passWithNoTests exits 0 on an empty suite. Detect the
9373
+ # zero-real-test case ONLY on a green run (a red run already records fail
9374
+ # and must stay fail), and downgrade the record from affirmative to an
9375
+ # HONEST inconclusive (status=no_tests_run, gap=source_without_runnable_tests,
9376
+ # pass:"inconclusive"). This is NOT a failure: the completion-council
9377
+ # evidence gate treats it as pass-through, exactly like runner=="none".
9378
+ # Positive-detection only -- an unparseable runner is left UNCHANGED
9379
+ # (pass:true) so a legitimate suite is never false-downgraded.
9380
+ local _tr_zero_tests=false
9381
+ if [ "$test_passed" = "true" ] \
9382
+ && _loki_zero_tests_executed "$test_runner" "${output:-}" "${_nt_files[@]:-}"; then
9383
+ _tr_zero_tests=true
9384
+ _tr_status="no_tests_run"
9385
+ log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
9386
+ fi
9387
+
9257
9388
  # Best-effort pass/fail counts from the summary text (null when not found).
9258
9389
  local _tr_passed_n _tr_failed_n
9259
9390
  _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
9260
9391
  _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
9392
+ # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
9393
+ # "N passed"/"N failed" pattern above does not match. Fall back to those.
9394
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
9395
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
9261
9396
  [ -n "$_tr_passed_n" ] || _tr_passed_n=null
9262
9397
  [ -n "$_tr_failed_n" ] || _tr_failed_n=null
9263
9398
 
9264
- # verification_gap is "none" whenever a real runner executed: the suite ran,
9265
- # so there is no docs-without-execution / source-without-tests gap. Keeping
9266
- # the key on BOTH writers gives consumers a single stable schema shape.
9399
+ # verification_gap is "none" whenever a real runner executed AND ran tests:
9400
+ # the suite ran, so there is no docs-without-execution / source-without-tests
9401
+ # gap. The #82 zero-test case is the exception -- a runner ran but executed
9402
+ # NO tests -- so it records pass:"inconclusive" (the string, distinct from the
9403
+ # bool true/false) + gap:source_without_runnable_tests. Keeping the key on
9404
+ # BOTH writers gives consumers a single stable schema shape.
9405
+ local _tr_pass_json="$test_passed" _tr_gap_json="none"
9406
+ if [ "$_tr_zero_tests" = "true" ]; then
9407
+ _tr_pass_json='"inconclusive"'
9408
+ _tr_gap_json="source_without_runnable_tests"
9409
+ fi
9267
9410
  cat > "$quality_dir/test-results.json" << TREOF
9268
- {"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"}
9411
+ {"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"}
9269
9412
  TREOF
9270
9413
  # Finding #598: stamp the per-iteration freshness marker (see above).
9271
9414
  printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
@@ -19316,7 +19459,7 @@ main() {
19316
19459
  # engine's own is_completed() recognise this session as terminal. Write the
19317
19460
  # file-system COMPLETED marker (checked by is_completed() in the main loop).
19318
19461
  _advance_current_phase "COMPLETED"
19319
- local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}}/.loki"
19462
+ local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
19320
19463
  echo "Session completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED" 2>/dev/null || true
19321
19464
 
19322
19465
  # Finish-and-own (v7.88.0): write a plain-English ownership handoff
@@ -292,6 +292,54 @@ 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
+ *)
338
+ return 1
339
+ ;;
340
+ esac
341
+ }
342
+
295
343
  # ---------------------------------------------------------------------------
296
344
  # Gate: tests (faithful port of enforce_test_coverage detection, run.sh:6624).
297
345
  #
@@ -301,7 +349,9 @@ verify_gate_build() {
301
349
  # skipped = no test framework detected at all (not-applicable).
302
350
  # inconclusive = a project is present but no runnable framework
303
351
  # (e.g. package.json with no test runner, or a python project
304
- # with no pytest on PATH). Forces at-least-CONCERNS.
352
+ # with no pytest on PATH), OR a runner that ran but executed
353
+ # ZERO tests (#82: node --test empty file, jest --passWithNoTests).
354
+ # Forces at-least-CONCERNS.
305
355
  # fail = tests ran and failed -> High finding.
306
356
  # pass = tests ran green.
307
357
  # ---------------------------------------------------------------------------
@@ -383,6 +433,41 @@ verify_gate_tests() {
383
433
  fi
384
434
  fi
385
435
 
436
+ # node --test (built-in Node runner) -- config-less fallback (task #79).
437
+ # Node's built-in test runner (stable since Node 18) runs
438
+ # *.test.{js,mjs,cjs} with ZERO config and NO package.json, so a real
439
+ # passing suite (slug.js + slug.test.js, no package.json) previously fell
440
+ # through to runner="none" -> tests "skipped" -- a FALSE-NEGATIVE on the
441
+ # verdict surface (symmetric to fake-green). This branch fires ONLY below
442
+ # the explicit-framework paths (vitest/jest/mocha via package.json all set
443
+ # runner above), so package.json-with-jest still selects jest. Runs only
444
+ # when node is on PATH AND *.test.{js,mjs,cjs} exist at root or under
445
+ # test/ or tests/. A FAILING run records fail (never swallowed); absence of
446
+ # node or test files falls through to the honest "skipped" path below.
447
+ if [ "$runner" = "none" ] && command -v node >/dev/null 2>&1; then
448
+ local _nt_files=()
449
+ local _nt_f _nt_dir
450
+ while IFS= read -r _nt_f; do
451
+ [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
452
+ done < <(find "$tree" -maxdepth 1 -type f \
453
+ \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
454
+ 2>/dev/null)
455
+ for _nt_dir in test tests; do
456
+ [ -d "$tree/$_nt_dir" ] || continue
457
+ while IFS= read -r _nt_f; do
458
+ [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
459
+ done < <(find "$tree/$_nt_dir" -maxdepth 3 -type f \
460
+ \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
461
+ -not -path '*/node_modules/*' 2>/dev/null)
462
+ done
463
+ if [ "${#_nt_files[@]}" -gt 0 ]; then
464
+ runner="node-test"
465
+ # Pass matched files explicitly (node --test globbing is
466
+ # Node-version-sensitive); quote each so paths with spaces survive.
467
+ out="$(cd "$tree" && timeout "$timeout_s" node --test "${_nt_files[@]}" 2>&1)" || rc=$?
468
+ fi
469
+ fi
470
+
386
471
  if [ "$runner" = "none" ]; then
387
472
  _verify_add_gate "tests" "skipped" "" "no test framework detected" "true"
388
473
  return 0
@@ -394,7 +479,15 @@ verify_gate_tests() {
394
479
  fi
395
480
 
396
481
  if [ "$rc" -eq 0 ]; then
397
- _verify_add_gate "tests" "pass" "$runner" "tests passed" "true"
482
+ # #82: a green run that executed ZERO real tests is a mini fake-green.
483
+ # Record it as HONEST inconclusive (forces at-least-CONCERNS), NOT pass
484
+ # and NOT fail. Positive-detection only; an unparseable runner stays pass.
485
+ if _verify_zero_tests_executed "$runner" "$out" "${_nt_files[@]:-}"; then
486
+ _verify_add_gate "tests" "inconclusive" "$runner" \
487
+ "runner ran but executed zero tests (source_without_runnable_tests)" "true"
488
+ else
489
+ _verify_add_gate "tests" "pass" "$runner" "tests passed" "true"
490
+ fi
398
491
  else
399
492
  _verify_add_gate "tests" "fail" "$runner" "tests failed (rc=$rc)" "true"
400
493
  _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.115.0"
10
+ __version__ = "7.117.0"
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.115.0
5
+ **Version:** v7.117.0
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.115.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.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}
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=7679240CF905E39B64756E2164756E21
817
+ //# debugId=54117DFE71A29F8B64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.115.0'
60
+ __version__ = '7.117.0'
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.115.0",
4
+ "version": "7.117.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.115.0",
5
+ "version": "7.117.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",