loki-mode 8.5.2 → 8.6.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/README.md CHANGED
@@ -121,6 +121,22 @@ usually load-bearing.
121
121
  Every coding agent tells you it finished. Loki hands you something you can
122
122
  check yourself.
123
123
 
124
+ **We are not the only tool that checks its own work, and you should be
125
+ suspicious of anyone who claims to be.** Lovable runs a security scan on every
126
+ publish and can block the publish outright. Claude Code's review has a step that
127
+ checks findings against actual code behavior. Replit says its agent tests its
128
+ own work.
129
+
130
+ The difference is what you are left holding. Their output lives in their
131
+ dashboard: a findings count in a dialog, a check run that by design never blocks
132
+ a merge. Ours is a **file**. It is bound to a specific diff by `diff_sha256`, it
133
+ records what was NOT proven as prominently as what was, and someone who has
134
+ never installed Loki can re-verify it from the repository alone. Commit it,
135
+ attach it to the PR, hand it to an auditor.
136
+
137
+ Portable, diff-bound, and honest about its gaps -- that is the claim, and it is
138
+ the one worth checking.
139
+
124
140
  Each run writes a receipt to `.loki/proofs/<run_id>/` that separates
125
141
  **deterministic FACTS** (the git diff with base and head SHAs plus a
126
142
  `diff_sha256`, the test command and its exit code, the build command and its
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 v8.2.0
6
+ # Loki Mode v8.6.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
469
469
 
470
470
  ---
471
471
 
472
- **v8.2.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.6.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.5.2
1
+ 8.6.1
package/autonomy/loki CHANGED
@@ -985,8 +985,44 @@ show_help() {
985
985
  echo " loki cockpit # Live multi-repo cockpit (terminal image)"
986
986
  echo " loki remote # Remote session (phone/browser)"
987
987
  echo ""
988
+ # COMPLETE COMMAND INDEX. The curated sections above stay curated -- they
989
+ # are the onboarding path and stuffing 112 commands into them would make
990
+ # the first screen useless. But a command that appears NOWHERE in help does
991
+ # not exist to a user: measured, 43 of 112 were unreachable from here,
992
+ # including `loki proof` -- the Evidence Receipt, which is the whole trust
993
+ # argument. Discoverability is an adoption lever, not tidiness.
994
+ #
995
+ # tests/test-help-discoverability.sh fails the build when a real command is
996
+ # missing from this list, so a new command cannot ship invisible.
997
+ echo ""
998
+ echo "All commands:"
999
+ echo " agent analyze api assets audit bench checkpoint (cp) ci cleanup"
1000
+ echo " cluster cockpit code compliance completions compound config context (ctx)"
1001
+ echo " cost council crash dashboard demo deploy docker docs doctor dogfood"
1002
+ echo " enterprise explain export failover github grill heal help import init"
1003
+ echo " issue kpis logs magic mcp memory metrics migrate modernize monitor"
1004
+ echo " next notify onboard open optimize otel own (handoff) pause plan preview"
1005
+ echo " projects proof (receipt) provider quick quickstart rc remote report reset"
1006
+ echo " resume review rollback run sandbox secrets secure self-update sentrux"
1007
+ echo " serve setup-skill share ship spec start state stats status steer stop"
1008
+ echo " syslog telemetry template test tour trigger trust trust-metrics"
1009
+ echo " ultracode update verify version voice watch watchdog web welcome why"
1010
+ echo " wiki worktree (wt)"
1011
+ echo ""
1012
+ echo "Any command: loki <command> --help"
1013
+ echo ""
988
1014
  echo "Phase A-J features (v7.5.18 - v7.5.28) are default-on. See CHANGELOG."
989
1015
  echo ""
1016
+ echo "Environment variables an operator would set: docs/environment-variables.md"
1017
+ echo "Cost controls (budget, iteration and wall-clock caps): docs/cost-controls.md"
1018
+ echo "Already have a codebase? Start read-only: docs/brownfield.md"
1019
+ echo "No egress allowed? What runs disconnected: docs/air-gapped.md"
1020
+ echo "Where this is going: docs/STRATEGY-2026-2028.md"
1021
+ echo ""
1022
+ echo "Scripting Loki? Exit codes for every command are documented in"
1023
+ echo "docs/exit-codes.md. Severity rises with the code, and 'loki start'"
1024
+ echo "has a richer contract under LOKI_DURABLE_STATE=1 for k8s and ECS."
1025
+ echo ""
990
1026
  echo "Environment Variables:"
991
1027
  echo " Opt-outs: LOKI_HOOK_EVENTS=off, LOKI_DYNAMIC_PROMPT_SECTIONS=keep,"
992
1028
  echo " LOKI_MEMORY_BASE_PATH (shared memory dir for app graph)"
@@ -1400,8 +1436,6 @@ cmd_start() {
1400
1436
  local positional_arg="" # raw positional arg for mode detection
1401
1437
  local issue_ref_explicit="" # --issue value (when explicit_mode=issue)
1402
1438
  local issue_mode_args=() # captured issue-mode flags (--dry-run, --pr, etc.)
1403
- local issue_dry_run=false
1404
- local issue_no_start=false
1405
1439
  local issue_output_file=""
1406
1440
  local issue_use_worktree=false
1407
1441
  local issue_create_pr=false
@@ -1462,6 +1496,9 @@ cmd_start() {
1462
1496
  echo " --no-plan Skip auto-shown PRD analysis at startup"
1463
1497
  echo " --brief \"TEXT\" Zero-config fast first run from a one-line brief"
1464
1498
  echo " --yes, -y Skip confirmation prompts (auto-confirm)"
1499
+ echo " --quiet Suppress [INFO]/[STEP]; warnings and errors still print"
1500
+ echo " --log-level LEVEL debug | info | warn | error (default: info)"
1501
+ echo " --max-duration DUR Wall-clock cap: seconds, or 90m / 2h (default: none)"
1465
1502
  echo ""
1466
1503
  echo "Issue-mode options (only used when input is an ISSUE-REF):"
1467
1504
  echo " --dry-run Preview generated PRD without starting"
@@ -1472,6 +1509,16 @@ cmd_start() {
1472
1509
  echo " --ship Worktree + PR + auto-merge"
1473
1510
  echo " --detach, -d Run in background"
1474
1511
  echo ""
1512
+ echo "Preview without spending (any input mode):"
1513
+ echo " loki plan <PRD> Analyze a spec WITHOUT executing: complexity,"
1514
+ echo " estimated iterations, tokens, and cost. Add --json"
1515
+ echo " for machine-readable output. This is the dry run"
1516
+ echo " for PRD and brief mode; --dry-run above is"
1517
+ echo " issue-mode only."
1518
+ echo " LOKI_CONFIG_DUMP=1 loki start"
1519
+ echo " Print the resolved LOKI_* configuration and exit"
1520
+ echo " without starting a run or spending anything."
1521
+ echo ""
1475
1522
  echo "Environment Variables:"
1476
1523
  echo " LOKI_PRD_FILE Path to PRD file (alternative to positional arg)"
1477
1524
  echo " LOKI_AUTO_CONFIRM Set to 'true'/'false' to control prompts (takes precedence over CI)"
@@ -1661,6 +1708,52 @@ cmd_start() {
1661
1708
  export LOKI_AUTO_CONFIRM=true
1662
1709
  shift
1663
1710
  ;;
1711
+ # Wall-clock cap. Accepts plain seconds or a suffix (30m, 2h) --
1712
+ # an operator setting a build timeout thinks in minutes and hours,
1713
+ # and forcing them to compute 7200 invites the off-by-3600 error.
1714
+ --max-duration)
1715
+ if [[ -n "${2:-}" ]]; then
1716
+ local _md="$2" _mdn _mdmul=1
1717
+ case "$_md" in
1718
+ *s) _mdn="${_md%s}" ;;
1719
+ *m) _mdn="${_md%m}"; _mdmul=60 ;;
1720
+ *h) _mdn="${_md%h}"; _mdmul=3600 ;;
1721
+ *) _mdn="$_md" ;;
1722
+ esac
1723
+ if ! [[ "$_mdn" =~ ^[0-9]+$ ]] || [ "$_mdn" -eq 0 ]; then
1724
+ echo "Error: --max-duration must be a positive duration (e.g. 5400, 90m, 2h); got '$2'" >&2
1725
+ return 1
1726
+ fi
1727
+ export LOKI_MAX_DURATION=$(( _mdn * _mdmul ))
1728
+ shift 2
1729
+ else
1730
+ echo "Error: --max-duration requires a value (e.g. 90m)" >&2
1731
+ return 1
1732
+ fi
1733
+ ;;
1734
+ # Verbosity. --quiet suppresses [INFO]/[STEP]; warnings and errors
1735
+ # always print, so a quiet run can still say why it failed.
1736
+ --quiet)
1737
+ export LOKI_LOG_LEVEL=warn
1738
+ shift
1739
+ ;;
1740
+ --log-level)
1741
+ if [[ -n "${2:-}" ]]; then
1742
+ case "$2" in
1743
+ debug|info|warn|error)
1744
+ export LOKI_LOG_LEVEL="$2"
1745
+ ;;
1746
+ *)
1747
+ echo "Error: --log-level must be debug, info, warn, or error (got '$2')" >&2
1748
+ return 1
1749
+ ;;
1750
+ esac
1751
+ shift 2
1752
+ else
1753
+ echo "Error: --log-level requires a value (debug|info|warn|error)" >&2
1754
+ return 1
1755
+ fi
1756
+ ;;
1664
1757
  --bmad-project)
1665
1758
  if [[ -n "${2:-}" ]]; then
1666
1759
  bmad_project_path="$2"
@@ -1836,12 +1929,10 @@ cmd_start() {
1836
1929
  ;;
1837
1930
  # v6.84.0: issue-mode flags (only meaningful when input is an issue)
1838
1931
  --dry-run)
1839
- issue_dry_run=true
1840
1932
  issue_mode_args+=("--dry-run")
1841
1933
  shift
1842
1934
  ;;
1843
1935
  --no-start)
1844
- issue_no_start=true
1845
1936
  issue_mode_args+=("--no-start")
1846
1937
  shift
1847
1938
  ;;
@@ -9879,6 +9970,7 @@ cmd_config() {
9879
9970
  echo " issue.provider Default issue provider: github, gitlab, jira, azure_devops"
9880
9971
  echo " blind_validation Blind validation mode: true, false (default: true)"
9881
9972
  echo " adversarial_testing Adversarial testing: true, false (default: true)"
9973
+ echo " log_level Output verbosity: debug, info, warn, error (default: info)"
9882
9974
  echo " spawn_timeout [DEPRECATED] No effect since WAVE9 (no consumer); accepted for back-compat"
9883
9975
  echo " spawn_retries [DEPRECATED] No effect since WAVE9 (no consumer); accepted for back-compat"
9884
9976
  echo " notify.slack Slack webhook URL"
@@ -9954,6 +10046,12 @@ cmd_config_set() {
9954
10046
  *) echo -e "${RED}Invalid $key: $value (expected: true, false)${NC}"; return 1 ;;
9955
10047
  esac
9956
10048
  ;;
10049
+ log_level)
10050
+ case "$value" in
10051
+ debug|info|warn|error) ;;
10052
+ *) echo -e "${RED}Invalid log_level: $value (expected: debug, info, warn, error)${NC}"; return 1 ;;
10053
+ esac
10054
+ ;;
9957
10055
  spawn_timeout|spawn_retries)
9958
10056
  if ! echo "$value" | grep -qE '^[0-9]+$'; then
9959
10057
  echo -e "${RED}Invalid $key: $value (expected: integer)${NC}"; return 1
@@ -9985,7 +10083,7 @@ cmd_config_set() {
9985
10083
  echo -e "${RED}Unknown configuration key: '$key'${NC}"
9986
10084
  echo ""
9987
10085
  echo "Valid keys: maxTier, provider, issue.provider, blind_validation,"
9988
- echo " adversarial_testing, spawn_timeout, spawn_retries, budget,"
10086
+ echo " adversarial_testing, log_level, spawn_timeout, spawn_retries, budget,"
9989
10087
  echo " model.planning, model.development, model.fast,"
9990
10088
  echo " notify.slack, notify.discord"
9991
10089
  echo ""
@@ -11308,6 +11406,25 @@ except Exception:
11308
11406
  echo ""
11309
11407
  echo "Then re-run: loki doctor"
11310
11408
  echo "Meanwhile 'loki tour' works right now -- no provider, no key, no spend."
11409
+ # Adoption signal: which CLASS of dependency stopped this first run.
11410
+ # Mapped to an enum here rather than forwarding the blocker TEXT, so
11411
+ # the payload can never carry a path or a version. Best-effort and
11412
+ # backgrounded: telemetry must never delay or fail a doctor run, and
11413
+ # every existing opt-out still gates it downstream.
11414
+ if declare -f loki_emit_first_run_blocked >/dev/null 2>&1; then
11415
+ local _blk_key="other"
11416
+ case "$_doctor_blockers" in
11417
+ *"No AI provider CLI"*) _blk_key="no_provider" ;;
11418
+ *"Node.js is not installed"*|*"Node.js must be"*) _blk_key="node" ;;
11419
+ *"Python 3 is not installed"*|*"Python 3 must be"*) _blk_key="python3" ;;
11420
+ *"jq is not installed"*) _blk_key="jq" ;;
11421
+ *"git is not installed"*) _blk_key="git" ;;
11422
+ *"curl is not installed"*) _blk_key="curl" ;;
11423
+ *"Free up disk"*) _blk_key="disk" ;;
11424
+ *"broken symlink"*) _blk_key="skill_symlink" ;;
11425
+ esac
11426
+ ( loki_emit_first_run_blocked "$_blk_key" >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
11427
+ fi
11311
11428
  return 1
11312
11429
  elif [ "$warn_count" -gt 0 ]; then
11313
11430
  echo -e "${YELLOW}All required checks passed with some warnings.${NC}"
@@ -11477,6 +11594,35 @@ if disk_status == 'pass': pass_count += 1
11477
11594
  elif disk_status == 'fail': fail_count += 1
11478
11595
  elif disk_status == 'warn': warn_count += 1
11479
11596
 
11597
+ # AGGREGATE PROVIDER CHECK. Each provider CLI is individually optional --
11598
+ # you need Claude OR Codex OR Cline OR Aider, not all four -- so no single
11599
+ # one can be marked required. But having NONE is a blocker, and the text
11600
+ # path reports it as one (the No-AI-provider branch near autonomy/loki:11000).
11601
+ #
11602
+ # --json omitted this entirely, so on a host with no provider the two
11603
+ # outputs gave OPPOSITE verdicts: the text path exited 1 while --json
11604
+ # reported zero failures and ok true. An operator gating a pipeline on the
11605
+ # JSON got a pass on a machine that cannot run a build -- the fake-green
11606
+ # this project exists to refuse.
11607
+ #
11608
+ # CAUTION: this block lives inside python3 -c with a DOUBLE-QUOTED program,
11609
+ # so an apostrophe or a double quote in a comment terminates the shell
11610
+ # string and mangles the program. First attempt did exactly that: doctor
11611
+ # --json printed nothing and exited 0. Keep this comment quote-free.
11612
+ _provider_cmds = ('claude', 'codex', 'cline', 'aider')
11613
+ _any_provider = any(shutil.which(_p) is not None for _p in _provider_cmds)
11614
+ ai_provider = {
11615
+ 'found': _any_provider,
11616
+ 'status': 'pass' if _any_provider else 'fail',
11617
+ 'required': 'required',
11618
+ 'detail': None if _any_provider
11619
+ else 'No AI provider CLI. Fix: npm install -g @anthropic-ai/claude-code',
11620
+ }
11621
+ if _any_provider:
11622
+ pass_count += 1
11623
+ else:
11624
+ fail_count += 1
11625
+
11480
11626
  result = {
11481
11627
  'loki_mode_version': os.environ.get('LOKI_VERSION', 'unknown'),
11482
11628
  'checks': checks,
@@ -11484,6 +11630,7 @@ result = {
11484
11630
  'available_gb': disk_gb,
11485
11631
  'status': disk_status
11486
11632
  },
11633
+ 'ai_provider': ai_provider,
11487
11634
  'sentrux': sentrux,
11488
11635
  'receipt_signing': receipt_signing,
11489
11636
  'memory': memory,
@@ -15589,6 +15736,35 @@ for fn in sorted(os.listdir(root)) if os.path.isdir(root) else []:
15589
15736
  # dependency staleness is NOT computed offline -> reported as 'unknown', never guessed.
15590
15737
  dep_lock_present = len(found_lockfiles) > 0 if found_manifests else None
15591
15738
 
15739
+ # RUNTIME + TOP DEPENDENCIES, read from the manifest itself. Offline-safe: this
15740
+ # reports what the file DECLARES, never what is current upstream (that stays
15741
+ # 'unknown' -- see dependency_staleness above).
15742
+ #
15743
+ # Why it matters: after 'how far behind am I', the next question a legacy
15744
+ # modernization buyer asks is 'what is this even built on'. Answering
15745
+ # 'package.json exists' and nothing more leaves them to open the file
15746
+ # themselves, which is the work they were trying to delegate.
15747
+ runtime_info = {'declared_runtime': None, 'declared_dependencies': None, 'framework_hints': []}
15748
+ try:
15749
+ if 'package.json' in found_manifests:
15750
+ with open(os.path.join(root, 'package.json')) as _f:
15751
+ _pkg = json.load(_f)
15752
+ _eng = (_pkg.get('engines') or {}).get('node')
15753
+ runtime_info['declared_runtime'] = ('node ' + _eng) if _eng else 'node (engine unspecified)'
15754
+ _deps = dict(_pkg.get('dependencies') or {})
15755
+ runtime_info['declared_dependencies'] = len(_deps)
15756
+ # Name only frameworks actually present. A hint is a FACT from the file,
15757
+ # not an inference about what the project "probably" is.
15758
+ for _fw in ('express', 'react', 'next', 'vue', 'angular', 'fastify', 'koa', 'nest'):
15759
+ if _fw in _deps:
15760
+ runtime_info['framework_hints'].append(_fw + ' ' + str(_deps[_fw]))
15761
+ elif 'pyproject.toml' in found_manifests or 'requirements.txt' in found_manifests:
15762
+ runtime_info['declared_runtime'] = 'python (version unspecified in manifest)'
15763
+ except (OSError, ValueError):
15764
+ # A malformed manifest is not a crash and not a guess: leave the fields None
15765
+ # so the report says nothing rather than something wrong.
15766
+ pass
15767
+
15592
15768
  todo_density = round((todo_count / total_loc) * 1000, 2) if total_loc else 0.0
15593
15769
 
15594
15770
  # --- 4-level maturity model (Ad hoc -> Planned -> Systematic -> Optimized) ---
@@ -15683,6 +15859,7 @@ report = {
15683
15859
  'lockfiles': found_lockfiles,
15684
15860
  'dependency_lock_present': dep_lock_present,
15685
15861
  'dependency_staleness': 'unknown', # not computable offline; never guessed
15862
+ 'runtime': runtime_info,
15686
15863
  },
15687
15864
  'maturity': {
15688
15865
  'level': maturity_level,
@@ -29711,6 +29888,7 @@ cmd_ci() {
29711
29888
  echo " --github-comment Post review results as PR comment (needs GITHUB_TOKEN)"
29712
29889
  echo " --fail-on <levels> Set exit code 1 on severity: critical,high,medium,low"
29713
29890
  echo " --format <fmt> Output format: json, markdown, github (default: markdown)"
29891
+ echo " --json Alias for --format json"
29714
29892
  echo " --help, -h Show this help"
29715
29893
  echo ""
29716
29894
  echo "Exit codes:"
@@ -29753,6 +29931,15 @@ cmd_ci() {
29753
29931
  ci_fail_on="$(echo "$ci_fail_on" | tr '[:upper:]' '[:lower:]')"
29754
29932
  shift
29755
29933
  ;;
29934
+ # --json is an alias for --format json. `loki ci` could already
29935
+ # emit JSON, but only under a flag name no other command uses, so a
29936
+ # script author reaching for the repo-wide --json convention found
29937
+ # nothing and concluded the capability was missing. Same output,
29938
+ # one spelling everywhere.
29939
+ --json)
29940
+ ci_format="json"
29941
+ shift
29942
+ ;;
29756
29943
  --format)
29757
29944
  shift
29758
29945
  ci_format="${1:-markdown}"
@@ -32523,7 +32710,8 @@ cmd_proof() {
32523
32710
  echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
32524
32711
  echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
32525
32712
  echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
32526
- echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
32713
+ echo " md <id> Paste-able Markdown for a PR comment or Slack
32714
+ open <id> Open .loki/proofs/<id>/index.html in a browser"
32527
32715
  echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
32528
32716
  echo ""
32529
32717
  echo "Options for 'share':"
@@ -32608,6 +32796,38 @@ PYEOF
32608
32796
  fi
32609
32797
  exit 0
32610
32798
  ;;
32799
+ md)
32800
+ # Paste-able Markdown for a PR comment, a Slack message, or a
32801
+ # ticket. The renderer already existed -- render_evidence_receipt_md
32802
+ # in lib/proof-pr.sh -- but was reachable ONLY when Loki opened a PR
32803
+ # itself. So the one form of the receipt built for humans to forward
32804
+ # could not be produced on demand, which is the built-and-unreachable
32805
+ # pattern this project keeps finding.
32806
+ #
32807
+ # Reuses that renderer rather than adding a second one: two writers
32808
+ # of one artifact drift, and the drift surfaces as a PR body and a
32809
+ # pasted summary disagreeing about the same run.
32810
+ local id="${1:-}"
32811
+ if [ -z "$id" ]; then
32812
+ echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'." >&2
32813
+ exit 2
32814
+ fi
32815
+ local pj="${proofs_dir}/${id}/proof.json"
32816
+ if [ ! -f "$pj" ]; then
32817
+ echo -e "${RED}Proof not found: ${id}${NC}" >&2
32818
+ echo "Use 'loki proof list' to see available proofs." >&2
32819
+ exit 1
32820
+ fi
32821
+ local _pr_lib="$_LOKI_SCRIPT_DIR/lib/proof-pr.sh"
32822
+ if [ ! -f "$_pr_lib" ]; then
32823
+ echo -e "${RED}Renderer not found: ${_pr_lib}${NC}" >&2
32824
+ exit 3
32825
+ fi
32826
+ # shellcheck source=/dev/null
32827
+ source "$_pr_lib"
32828
+ render_evidence_receipt_md "$pj"
32829
+ exit 0
32830
+ ;;
32611
32831
  verify)
32612
32832
  # Deterministic re-check of a receipt against the repo: re-hashes the
32613
32833
  # canonical proof (tamper check) and re-derives the diff from the
package/autonomy/run.sh CHANGED
@@ -1508,11 +1508,39 @@ log_header() {
1508
1508
  echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
1509
1509
  }
1510
1510
 
1511
- log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
1512
- log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
1511
+ # VERBOSITY. LOKI_LOG_LEVEL is one of debug|info|warn|error (default info);
1512
+ # LOKI_QUIET=1 is shorthand for warn.
1513
+ #
1514
+ # Implemented at these five functions rather than at 527 call sites, which is
1515
+ # both the smaller diff and the only version that cannot drift -- a new
1516
+ # log_info added next year is covered without anyone remembering to gate it.
1517
+ #
1518
+ # WHY THIS IS NEEDED DESPITE GOOD TTY DETECTION. The heavy decoration (HUD,
1519
+ # completion card, start headline) is already `[ -t 1 ]`-guarded and vanishes
1520
+ # off a TTY, so CI output was never the wall of banners it might have been.
1521
+ # But 527 log_info/log_step calls are unguarded and print regardless, and a
1522
+ # pipeline that wants only warnings had no way to ask. log_debug already
1523
+ # honored LOKI_DEBUG; the other levels honored nothing.
1524
+ #
1525
+ # ERRORS ARE NEVER SUPPRESSED. `error` is the floor: the quietest setting still
1526
+ # prints failures. A verbosity flag that can hide the reason a build failed is
1527
+ # a footgun, not a feature.
1528
+ _loki_log_threshold() {
1529
+ case "${LOKI_LOG_LEVEL:-$([ "${LOKI_QUIET:-0}" = "1" ] && echo warn || echo info)}" in
1530
+ debug) echo 0 ;;
1531
+ info) echo 1 ;;
1532
+ warn) echo 2 ;;
1533
+ error) echo 3 ;;
1534
+ *) echo 1 ;; # unrecognized value behaves as the default, never silences
1535
+ esac
1536
+ }
1537
+ _loki_log_enabled() { [ "$1" -ge "$(_loki_log_threshold)" ]; }
1538
+
1539
+ log_info() { _loki_log_enabled 1 && echo -e "${GREEN}[INFO]${NC} $*" || true; }
1540
+ log_warn() { _loki_log_enabled 2 && echo -e "${YELLOW}[WARN]${NC} $*" || true; }
1513
1541
  log_warning() { log_warn "$@"; } # Alias for backwards compatibility
1514
1542
  log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
1515
- log_step() { echo -e "${CYAN}[STEP]${NC} $*"; }
1543
+ log_step() { _loki_log_enabled 1 && echo -e "${CYAN}[STEP]${NC} $*" || true; }
1516
1544
  log_debug() { [[ "${LOKI_DEBUG:-}" == "true" ]] && echo -e "${CYAN}[DEBUG]${NC} $*" >&2 || true; }
1517
1545
 
1518
1546
  #===============================================================================
@@ -13293,6 +13321,17 @@ ${dependency_context}"
13293
13321
  "diff_bytes=$_review_diff_bytes" \
13294
13322
  "limit_bytes=$_review_max_bytes" \
13295
13323
  "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
13324
+ # Classify this as INFRASTRUCTURE, not a finding. The discriminator is
13325
+ # otherwise set at :14912, which this early return never reaches, so the
13326
+ # variable stayed "" from the reset at :12959 and the consumer at :22126
13327
+ # read a file-size condition as "Critical/High findings" -- escalating to
13328
+ # PAUSE over a diff that was merely too large to send.
13329
+ #
13330
+ # Still fail-closed: the review did NOT pass, and nothing here converts a
13331
+ # skipped review into a green one. It only records WHY it could not run,
13332
+ # which is the difference between "your code is bad" and "we could not
13333
+ # look at it".
13334
+ _LOKI_REVIEW_FAILURE_KIND="infrastructure_inconclusive"
13296
13335
  return 1
13297
13336
  fi
13298
13337
 
@@ -16470,6 +16509,46 @@ check_max_iterations() {
16470
16509
  return 1
16471
16510
  }
16472
16511
 
16512
+ # WALL-CLOCK CAP. Returns 0 (stop) when the run has exceeded LOKI_MAX_DURATION
16513
+ # seconds. Unset or 0 = no cap, which is the default and exactly today's
16514
+ # behavior.
16515
+ #
16516
+ # WHY A THIRD BOUND. Spend was already capped (LOKI_BUDGET_LIMIT) and so were
16517
+ # iterations (LOKI_MAX_ITERATIONS), but a run that STALLS is bounded by neither:
16518
+ # a hung provider call or a wedged subprocess burns hours while spending almost
16519
+ # nothing and completing no iteration, so neither existing breaker ever trips.
16520
+ # It runs until something external kills it -- and an external kill leaves no
16521
+ # terminal status, so the receipt cannot say what happened.
16522
+ #
16523
+ # Kubernetes operators already have activeDeadlineSeconds in the Job spec, but
16524
+ # that SIGKILLs the pod: no status is written, no receipt, and the platform
16525
+ # sees a crash rather than a deliberate stop. This cap stops the loop cleanly
16526
+ # at the next iteration boundary so the run still explains itself.
16527
+ #
16528
+ # It is checked at the boundary, not mid-iteration: interrupting an agent
16529
+ # mid-write is how you get a half-applied change. So the effective stop time is
16530
+ # the cap plus the remainder of the current iteration, which the log states
16531
+ # rather than pretending to be exact.
16532
+ check_max_duration() {
16533
+ local cap="${LOKI_MAX_DURATION:-0}"
16534
+ case "$cap" in
16535
+ ''|*[!0-9]*) return 1 ;; # unset or non-numeric: no cap, never stop
16536
+ esac
16537
+ [ "$cap" -eq 0 ] && return 1
16538
+
16539
+ local start="${_LOKI_RUN_START_EPOCH:-0}"
16540
+ [ "$start" -eq 0 ] 2>/dev/null && return 1
16541
+
16542
+ local now elapsed
16543
+ now=$(date +%s)
16544
+ elapsed=$((now - start))
16545
+ if [ "$elapsed" -ge "$cap" ]; then
16546
+ log_warn "Wall-clock cap reached (${elapsed}s elapsed, LOKI_MAX_DURATION=${cap}s). Stopping at the iteration boundary."
16547
+ return 0
16548
+ fi
16549
+ return 1
16550
+ }
16551
+
16473
16552
  # Load latest ledger content for context injection
16474
16553
  load_ledger_context() {
16475
16554
  local ledger_content=""
@@ -20577,6 +20656,18 @@ except Exception as exc:
20577
20656
  return 20
20578
20657
  fi
20579
20658
 
20659
+ # Wall-clock cap. Checked alongside the iteration cap and treated the
20660
+ # same way: a distinct terminal status so the receipt and `loki why`
20661
+ # can say the run ran out of TIME rather than iterations or money, and
20662
+ # exit 20 because re-running the same spec under the same cap will hit
20663
+ # the same wall. The operator raises LOKI_MAX_DURATION (or narrows the
20664
+ # spec) and submits again.
20665
+ if check_max_duration; then
20666
+ save_state "$retry" "max_duration_reached" 20
20667
+ emit_completion_summary max_duration
20668
+ return 20
20669
+ fi
20670
+
20580
20671
  # Increment after all pre-attempt stop checks pass.
20581
20672
  ((ITERATION_COUNT++))
20582
20673
 
@@ -24266,11 +24357,12 @@ except Exception:
24266
24357
  #
24267
24358
  # Contract (LOKI_DURABLE_STATE=1 only; local/CI exit codes are unchanged):
24268
24359
  # 0 = success / human-controlled clean stop (council approved, completion
24269
- # promise, force-stop, or paused/interrupted/budget/stopped where a
24270
- # human will resume). Job -> Complete, no retry.
24360
+ # promise, force-stop, or paused/interrupted/stopped where a HUMAN
24361
+ # chose to stop and will resume). Job -> Complete, no retry.
24271
24362
  # 20 = deterministic terminal failure (failed, max_iterations_reached,
24272
- # max_retries_exceeded, exited, policy_blocked). Re-running on the same
24273
- # inputs fails the same way -> Job must NOT retry. The Helm Job pairs
24363
+ # max_retries_exceeded, budget_exceeded, max_duration_reached,
24364
+ # policy_blocked). Re-running on
24365
+ # the same inputs fails the same way -> Job must NOT retry. The Helm Job pairs
24274
24366
  # this with restartPolicy: Never + a podFailurePolicy rule that maps
24275
24367
  # exit 20 to FailJob (no retry), so a deterministic failure does not
24276
24368
  # burn the backoffLimit (the Job records the failure; an operator
@@ -24283,9 +24375,25 @@ except Exception:
24283
24375
  _final_state_file="$(_loki_state_file)"
24284
24376
  _final_status=$(LOKI_STATE_FILE="$_final_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status','unknown'))" 2>/dev/null || echo "unknown")
24285
24377
  case "$_final_status" in
24286
- council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
24378
+ council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|force_stopped|paused|interrupted|stopped)
24287
24379
  result=0 ;;
24288
- failed|max_iterations_reached|max_retries_exceeded|policy_blocked|inconclusive_spec_contradiction)
24380
+ # budget_exceeded belongs HERE, not with the human-controlled stops.
24381
+ # It sat in the result=0 arm on the rationale that "a human will
24382
+ # resume", which is true of `paused` (a human pressed pause) and
24383
+ # false of a cost breaker firing inside a k8s Job or a CI pipeline,
24384
+ # where there is no human. A build killed mid-work then reported
24385
+ # SUCCESS: the Job went Complete, the pipeline went green, and an
24386
+ # incomplete build looked finished. That is a false green produced
24387
+ # by our own gate, which is precisely what the Evidence Receipt
24388
+ # exists to prevent.
24389
+ #
24390
+ # It is deterministic rather than retryable: re-running the same
24391
+ # inputs against the same cap exhausts the same budget and fails
24392
+ # identically, so retrying only burns money to reach the same place.
24393
+ # The operator raises the cap (or narrows the spec) and submits a
24394
+ # NEW Job -- the same remedy as max_iterations_reached, which is why
24395
+ # it shares that code.
24396
+ failed|max_iterations_reached|max_retries_exceeded|budget_exceeded|max_duration_reached|policy_blocked|inconclusive_spec_contradiction)
24289
24397
  result=20 ;;
24290
24398
  *)
24291
24399
  # Unknown/running/exited terminal: leave $result as-is (nonzero on a
@@ -187,6 +187,20 @@ _loki_known_command() {
187
187
  esac
188
188
  }
189
189
 
190
+ # Clamp a doctor blocker to a FIXED ENUM. Same discipline as
191
+ # _loki_known_command: an unrecognized value becomes "other" rather than being
192
+ # forwarded, so a new blocker string added later cannot silently start leaking
193
+ # text. Deliberately coarse -- we need to know WHICH CLASS of dependency stops
194
+ # a first run, never the user's paths, versions, or hostnames.
195
+ _loki_known_blocker() {
196
+ case "${1:-}" in
197
+ no_provider|node|python3|jq|git|curl|disk|skill_symlink)
198
+ printf '%s' "$1" ;;
199
+ *)
200
+ printf 'other' ;;
201
+ esac
202
+ }
203
+
190
204
  loki_telemetry() {
191
205
  _loki_telemetry_enabled || return 0
192
206
  local event="$1"; shift
@@ -297,6 +311,37 @@ loki_emit_funnel_once() {
297
311
  # fields are excluded on purpose: headline is a bounded enum (VERIFIED / VERIFIED
298
312
  # WITH GAPS / NOT VERIFIED), never spec/project text; files_changed is a COUNT,
299
313
  # never paths.
314
+ # loki_emit_first_run_blocked <blocker-key>: fire ONE first_run_blocked event
315
+ # naming the CLASS of dependency that stopped a first run.
316
+ #
317
+ # WHY THIS EXISTS. `first_start_attempted` already fires, so we know a first run
318
+ # was ATTEMPTED and nothing about whether it succeeded. That is the wrong half:
319
+ # an attempt that dies at `doctor` looks identical to one that built something.
320
+ # Without this, the question that decides an adoption strategy -- does a trial
321
+ # fail on capability, discoverability, or trust -- has no data behind it.
322
+ #
323
+ # The measured motivation is concrete: on a host with no provider CLI the Bun
324
+ # route used to print "Some required prerequisites are missing" and stop, while
325
+ # bash pointed at `loki tour`. That was a dead end for a first-time evaluator
326
+ # and nobody could see it happening.
327
+ #
328
+ # WHAT IT SENDS, and deliberately no more: one enum from _loki_known_blocker.
329
+ # Never a path, a version, a hostname, a spec, or a command line. Coarse on
330
+ # purpose -- "node" is actionable, "/Users/x/.nvm/versions/node/v18" is a leak.
331
+ # An adoption tool that exfiltrates a user's environment would cost exactly the
332
+ # trust this product sells.
333
+ #
334
+ # Once per install (the funnel marker), under the strict analytics gate, and
335
+ # every existing opt-out still wins because it routes through the same
336
+ # _loki_analytics_enabled check as everything else.
337
+ loki_emit_first_run_blocked() {
338
+ local _blocker
339
+ _blocker="$(_loki_known_blocker "${1:-}")"
340
+ loki_emit_funnel_once "first-run-blocked" "first_run_blocked" \
341
+ "blocker=${_blocker}" 2>/dev/null || true
342
+ return 0
343
+ }
344
+
300
345
  loki_emit_build_verified() {
301
346
  _loki_analytics_enabled || return 0
302
347
  local _proof="$1"