loki-mode 8.5.2 → 8.6.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/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.0
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.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.5.2
1
+ 8.6.0
package/autonomy/loki CHANGED
@@ -985,8 +985,43 @@ 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 ""
1021
+ echo "Scripting Loki? Exit codes for every command are documented in"
1022
+ echo "docs/exit-codes.md. Severity rises with the code, and 'loki start'"
1023
+ echo "has a richer contract under LOKI_DURABLE_STATE=1 for k8s and ECS."
1024
+ echo ""
990
1025
  echo "Environment Variables:"
991
1026
  echo " Opt-outs: LOKI_HOOK_EVENTS=off, LOKI_DYNAMIC_PROMPT_SECTIONS=keep,"
992
1027
  echo " LOKI_MEMORY_BASE_PATH (shared memory dir for app graph)"
@@ -1400,8 +1435,6 @@ cmd_start() {
1400
1435
  local positional_arg="" # raw positional arg for mode detection
1401
1436
  local issue_ref_explicit="" # --issue value (when explicit_mode=issue)
1402
1437
  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
1438
  local issue_output_file=""
1406
1439
  local issue_use_worktree=false
1407
1440
  local issue_create_pr=false
@@ -1462,6 +1495,9 @@ cmd_start() {
1462
1495
  echo " --no-plan Skip auto-shown PRD analysis at startup"
1463
1496
  echo " --brief \"TEXT\" Zero-config fast first run from a one-line brief"
1464
1497
  echo " --yes, -y Skip confirmation prompts (auto-confirm)"
1498
+ echo " --quiet Suppress [INFO]/[STEP]; warnings and errors still print"
1499
+ echo " --log-level LEVEL debug | info | warn | error (default: info)"
1500
+ echo " --max-duration DUR Wall-clock cap: seconds, or 90m / 2h (default: none)"
1465
1501
  echo ""
1466
1502
  echo "Issue-mode options (only used when input is an ISSUE-REF):"
1467
1503
  echo " --dry-run Preview generated PRD without starting"
@@ -1472,6 +1508,16 @@ cmd_start() {
1472
1508
  echo " --ship Worktree + PR + auto-merge"
1473
1509
  echo " --detach, -d Run in background"
1474
1510
  echo ""
1511
+ echo "Preview without spending (any input mode):"
1512
+ echo " loki plan <PRD> Analyze a spec WITHOUT executing: complexity,"
1513
+ echo " estimated iterations, tokens, and cost. Add --json"
1514
+ echo " for machine-readable output. This is the dry run"
1515
+ echo " for PRD and brief mode; --dry-run above is"
1516
+ echo " issue-mode only."
1517
+ echo " LOKI_CONFIG_DUMP=1 loki start"
1518
+ echo " Print the resolved LOKI_* configuration and exit"
1519
+ echo " without starting a run or spending anything."
1520
+ echo ""
1475
1521
  echo "Environment Variables:"
1476
1522
  echo " LOKI_PRD_FILE Path to PRD file (alternative to positional arg)"
1477
1523
  echo " LOKI_AUTO_CONFIRM Set to 'true'/'false' to control prompts (takes precedence over CI)"
@@ -1661,6 +1707,52 @@ cmd_start() {
1661
1707
  export LOKI_AUTO_CONFIRM=true
1662
1708
  shift
1663
1709
  ;;
1710
+ # Wall-clock cap. Accepts plain seconds or a suffix (30m, 2h) --
1711
+ # an operator setting a build timeout thinks in minutes and hours,
1712
+ # and forcing them to compute 7200 invites the off-by-3600 error.
1713
+ --max-duration)
1714
+ if [[ -n "${2:-}" ]]; then
1715
+ local _md="$2" _mdn _mdmul=1
1716
+ case "$_md" in
1717
+ *s) _mdn="${_md%s}" ;;
1718
+ *m) _mdn="${_md%m}"; _mdmul=60 ;;
1719
+ *h) _mdn="${_md%h}"; _mdmul=3600 ;;
1720
+ *) _mdn="$_md" ;;
1721
+ esac
1722
+ if ! [[ "$_mdn" =~ ^[0-9]+$ ]] || [ "$_mdn" -eq 0 ]; then
1723
+ echo "Error: --max-duration must be a positive duration (e.g. 5400, 90m, 2h); got '$2'" >&2
1724
+ return 1
1725
+ fi
1726
+ export LOKI_MAX_DURATION=$(( _mdn * _mdmul ))
1727
+ shift 2
1728
+ else
1729
+ echo "Error: --max-duration requires a value (e.g. 90m)" >&2
1730
+ return 1
1731
+ fi
1732
+ ;;
1733
+ # Verbosity. --quiet suppresses [INFO]/[STEP]; warnings and errors
1734
+ # always print, so a quiet run can still say why it failed.
1735
+ --quiet)
1736
+ export LOKI_LOG_LEVEL=warn
1737
+ shift
1738
+ ;;
1739
+ --log-level)
1740
+ if [[ -n "${2:-}" ]]; then
1741
+ case "$2" in
1742
+ debug|info|warn|error)
1743
+ export LOKI_LOG_LEVEL="$2"
1744
+ ;;
1745
+ *)
1746
+ echo "Error: --log-level must be debug, info, warn, or error (got '$2')" >&2
1747
+ return 1
1748
+ ;;
1749
+ esac
1750
+ shift 2
1751
+ else
1752
+ echo "Error: --log-level requires a value (debug|info|warn|error)" >&2
1753
+ return 1
1754
+ fi
1755
+ ;;
1664
1756
  --bmad-project)
1665
1757
  if [[ -n "${2:-}" ]]; then
1666
1758
  bmad_project_path="$2"
@@ -1836,12 +1928,10 @@ cmd_start() {
1836
1928
  ;;
1837
1929
  # v6.84.0: issue-mode flags (only meaningful when input is an issue)
1838
1930
  --dry-run)
1839
- issue_dry_run=true
1840
1931
  issue_mode_args+=("--dry-run")
1841
1932
  shift
1842
1933
  ;;
1843
1934
  --no-start)
1844
- issue_no_start=true
1845
1935
  issue_mode_args+=("--no-start")
1846
1936
  shift
1847
1937
  ;;
@@ -9879,6 +9969,7 @@ cmd_config() {
9879
9969
  echo " issue.provider Default issue provider: github, gitlab, jira, azure_devops"
9880
9970
  echo " blind_validation Blind validation mode: true, false (default: true)"
9881
9971
  echo " adversarial_testing Adversarial testing: true, false (default: true)"
9972
+ echo " log_level Output verbosity: debug, info, warn, error (default: info)"
9882
9973
  echo " spawn_timeout [DEPRECATED] No effect since WAVE9 (no consumer); accepted for back-compat"
9883
9974
  echo " spawn_retries [DEPRECATED] No effect since WAVE9 (no consumer); accepted for back-compat"
9884
9975
  echo " notify.slack Slack webhook URL"
@@ -9954,6 +10045,12 @@ cmd_config_set() {
9954
10045
  *) echo -e "${RED}Invalid $key: $value (expected: true, false)${NC}"; return 1 ;;
9955
10046
  esac
9956
10047
  ;;
10048
+ log_level)
10049
+ case "$value" in
10050
+ debug|info|warn|error) ;;
10051
+ *) echo -e "${RED}Invalid log_level: $value (expected: debug, info, warn, error)${NC}"; return 1 ;;
10052
+ esac
10053
+ ;;
9957
10054
  spawn_timeout|spawn_retries)
9958
10055
  if ! echo "$value" | grep -qE '^[0-9]+$'; then
9959
10056
  echo -e "${RED}Invalid $key: $value (expected: integer)${NC}"; return 1
@@ -9985,7 +10082,7 @@ cmd_config_set() {
9985
10082
  echo -e "${RED}Unknown configuration key: '$key'${NC}"
9986
10083
  echo ""
9987
10084
  echo "Valid keys: maxTier, provider, issue.provider, blind_validation,"
9988
- echo " adversarial_testing, spawn_timeout, spawn_retries, budget,"
10085
+ echo " adversarial_testing, log_level, spawn_timeout, spawn_retries, budget,"
9989
10086
  echo " model.planning, model.development, model.fast,"
9990
10087
  echo " notify.slack, notify.discord"
9991
10088
  echo ""
@@ -11308,6 +11405,25 @@ except Exception:
11308
11405
  echo ""
11309
11406
  echo "Then re-run: loki doctor"
11310
11407
  echo "Meanwhile 'loki tour' works right now -- no provider, no key, no spend."
11408
+ # Adoption signal: which CLASS of dependency stopped this first run.
11409
+ # Mapped to an enum here rather than forwarding the blocker TEXT, so
11410
+ # the payload can never carry a path or a version. Best-effort and
11411
+ # backgrounded: telemetry must never delay or fail a doctor run, and
11412
+ # every existing opt-out still gates it downstream.
11413
+ if declare -f loki_emit_first_run_blocked >/dev/null 2>&1; then
11414
+ local _blk_key="other"
11415
+ case "$_doctor_blockers" in
11416
+ *"No AI provider CLI"*) _blk_key="no_provider" ;;
11417
+ *"Node.js is not installed"*|*"Node.js must be"*) _blk_key="node" ;;
11418
+ *"Python 3 is not installed"*|*"Python 3 must be"*) _blk_key="python3" ;;
11419
+ *"jq is not installed"*) _blk_key="jq" ;;
11420
+ *"git is not installed"*) _blk_key="git" ;;
11421
+ *"curl is not installed"*) _blk_key="curl" ;;
11422
+ *"Free up disk"*) _blk_key="disk" ;;
11423
+ *"broken symlink"*) _blk_key="skill_symlink" ;;
11424
+ esac
11425
+ ( loki_emit_first_run_blocked "$_blk_key" >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
11426
+ fi
11311
11427
  return 1
11312
11428
  elif [ "$warn_count" -gt 0 ]; then
11313
11429
  echo -e "${YELLOW}All required checks passed with some warnings.${NC}"
@@ -11477,6 +11593,35 @@ if disk_status == 'pass': pass_count += 1
11477
11593
  elif disk_status == 'fail': fail_count += 1
11478
11594
  elif disk_status == 'warn': warn_count += 1
11479
11595
 
11596
+ # AGGREGATE PROVIDER CHECK. Each provider CLI is individually optional --
11597
+ # you need Claude OR Codex OR Cline OR Aider, not all four -- so no single
11598
+ # one can be marked required. But having NONE is a blocker, and the text
11599
+ # path reports it as one (the No-AI-provider branch near autonomy/loki:11000).
11600
+ #
11601
+ # --json omitted this entirely, so on a host with no provider the two
11602
+ # outputs gave OPPOSITE verdicts: the text path exited 1 while --json
11603
+ # reported zero failures and ok true. An operator gating a pipeline on the
11604
+ # JSON got a pass on a machine that cannot run a build -- the fake-green
11605
+ # this project exists to refuse.
11606
+ #
11607
+ # CAUTION: this block lives inside python3 -c with a DOUBLE-QUOTED program,
11608
+ # so an apostrophe or a double quote in a comment terminates the shell
11609
+ # string and mangles the program. First attempt did exactly that: doctor
11610
+ # --json printed nothing and exited 0. Keep this comment quote-free.
11611
+ _provider_cmds = ('claude', 'codex', 'cline', 'aider')
11612
+ _any_provider = any(shutil.which(_p) is not None for _p in _provider_cmds)
11613
+ ai_provider = {
11614
+ 'found': _any_provider,
11615
+ 'status': 'pass' if _any_provider else 'fail',
11616
+ 'required': 'required',
11617
+ 'detail': None if _any_provider
11618
+ else 'No AI provider CLI. Fix: npm install -g @anthropic-ai/claude-code',
11619
+ }
11620
+ if _any_provider:
11621
+ pass_count += 1
11622
+ else:
11623
+ fail_count += 1
11624
+
11480
11625
  result = {
11481
11626
  'loki_mode_version': os.environ.get('LOKI_VERSION', 'unknown'),
11482
11627
  'checks': checks,
@@ -11484,6 +11629,7 @@ result = {
11484
11629
  'available_gb': disk_gb,
11485
11630
  'status': disk_status
11486
11631
  },
11632
+ 'ai_provider': ai_provider,
11487
11633
  'sentrux': sentrux,
11488
11634
  'receipt_signing': receipt_signing,
11489
11635
  'memory': memory,
@@ -15589,6 +15735,35 @@ for fn in sorted(os.listdir(root)) if os.path.isdir(root) else []:
15589
15735
  # dependency staleness is NOT computed offline -> reported as 'unknown', never guessed.
15590
15736
  dep_lock_present = len(found_lockfiles) > 0 if found_manifests else None
15591
15737
 
15738
+ # RUNTIME + TOP DEPENDENCIES, read from the manifest itself. Offline-safe: this
15739
+ # reports what the file DECLARES, never what is current upstream (that stays
15740
+ # 'unknown' -- see dependency_staleness above).
15741
+ #
15742
+ # Why it matters: after 'how far behind am I', the next question a legacy
15743
+ # modernization buyer asks is 'what is this even built on'. Answering
15744
+ # 'package.json exists' and nothing more leaves them to open the file
15745
+ # themselves, which is the work they were trying to delegate.
15746
+ runtime_info = {'declared_runtime': None, 'declared_dependencies': None, 'framework_hints': []}
15747
+ try:
15748
+ if 'package.json' in found_manifests:
15749
+ with open(os.path.join(root, 'package.json')) as _f:
15750
+ _pkg = json.load(_f)
15751
+ _eng = (_pkg.get('engines') or {}).get('node')
15752
+ runtime_info['declared_runtime'] = ('node ' + _eng) if _eng else 'node (engine unspecified)'
15753
+ _deps = dict(_pkg.get('dependencies') or {})
15754
+ runtime_info['declared_dependencies'] = len(_deps)
15755
+ # Name only frameworks actually present. A hint is a FACT from the file,
15756
+ # not an inference about what the project "probably" is.
15757
+ for _fw in ('express', 'react', 'next', 'vue', 'angular', 'fastify', 'koa', 'nest'):
15758
+ if _fw in _deps:
15759
+ runtime_info['framework_hints'].append(_fw + ' ' + str(_deps[_fw]))
15760
+ elif 'pyproject.toml' in found_manifests or 'requirements.txt' in found_manifests:
15761
+ runtime_info['declared_runtime'] = 'python (version unspecified in manifest)'
15762
+ except (OSError, ValueError):
15763
+ # A malformed manifest is not a crash and not a guess: leave the fields None
15764
+ # so the report says nothing rather than something wrong.
15765
+ pass
15766
+
15592
15767
  todo_density = round((todo_count / total_loc) * 1000, 2) if total_loc else 0.0
15593
15768
 
15594
15769
  # --- 4-level maturity model (Ad hoc -> Planned -> Systematic -> Optimized) ---
@@ -15683,6 +15858,7 @@ report = {
15683
15858
  'lockfiles': found_lockfiles,
15684
15859
  'dependency_lock_present': dep_lock_present,
15685
15860
  'dependency_staleness': 'unknown', # not computable offline; never guessed
15861
+ 'runtime': runtime_info,
15686
15862
  },
15687
15863
  'maturity': {
15688
15864
  'level': maturity_level,
@@ -29711,6 +29887,7 @@ cmd_ci() {
29711
29887
  echo " --github-comment Post review results as PR comment (needs GITHUB_TOKEN)"
29712
29888
  echo " --fail-on <levels> Set exit code 1 on severity: critical,high,medium,low"
29713
29889
  echo " --format <fmt> Output format: json, markdown, github (default: markdown)"
29890
+ echo " --json Alias for --format json"
29714
29891
  echo " --help, -h Show this help"
29715
29892
  echo ""
29716
29893
  echo "Exit codes:"
@@ -29753,6 +29930,15 @@ cmd_ci() {
29753
29930
  ci_fail_on="$(echo "$ci_fail_on" | tr '[:upper:]' '[:lower:]')"
29754
29931
  shift
29755
29932
  ;;
29933
+ # --json is an alias for --format json. `loki ci` could already
29934
+ # emit JSON, but only under a flag name no other command uses, so a
29935
+ # script author reaching for the repo-wide --json convention found
29936
+ # nothing and concluded the capability was missing. Same output,
29937
+ # one spelling everywhere.
29938
+ --json)
29939
+ ci_format="json"
29940
+ shift
29941
+ ;;
29756
29942
  --format)
29757
29943
  shift
29758
29944
  ci_format="${1:-markdown}"
@@ -32523,7 +32709,8 @@ cmd_proof() {
32523
32709
  echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
32524
32710
  echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
32525
32711
  echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
32526
- echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
32712
+ echo " md <id> Paste-able Markdown for a PR comment or Slack
32713
+ open <id> Open .loki/proofs/<id>/index.html in a browser"
32527
32714
  echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
32528
32715
  echo ""
32529
32716
  echo "Options for 'share':"
@@ -32608,6 +32795,38 @@ PYEOF
32608
32795
  fi
32609
32796
  exit 0
32610
32797
  ;;
32798
+ md)
32799
+ # Paste-able Markdown for a PR comment, a Slack message, or a
32800
+ # ticket. The renderer already existed -- render_evidence_receipt_md
32801
+ # in lib/proof-pr.sh -- but was reachable ONLY when Loki opened a PR
32802
+ # itself. So the one form of the receipt built for humans to forward
32803
+ # could not be produced on demand, which is the built-and-unreachable
32804
+ # pattern this project keeps finding.
32805
+ #
32806
+ # Reuses that renderer rather than adding a second one: two writers
32807
+ # of one artifact drift, and the drift surfaces as a PR body and a
32808
+ # pasted summary disagreeing about the same run.
32809
+ local id="${1:-}"
32810
+ if [ -z "$id" ]; then
32811
+ echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'." >&2
32812
+ exit 2
32813
+ fi
32814
+ local pj="${proofs_dir}/${id}/proof.json"
32815
+ if [ ! -f "$pj" ]; then
32816
+ echo -e "${RED}Proof not found: ${id}${NC}" >&2
32817
+ echo "Use 'loki proof list' to see available proofs." >&2
32818
+ exit 1
32819
+ fi
32820
+ local _pr_lib="$_LOKI_SCRIPT_DIR/lib/proof-pr.sh"
32821
+ if [ ! -f "$_pr_lib" ]; then
32822
+ echo -e "${RED}Renderer not found: ${_pr_lib}${NC}" >&2
32823
+ exit 3
32824
+ fi
32825
+ # shellcheck source=/dev/null
32826
+ source "$_pr_lib"
32827
+ render_evidence_receipt_md "$pj"
32828
+ exit 0
32829
+ ;;
32611
32830
  verify)
32612
32831
  # Deterministic re-check of a receipt against the repo: re-hashes the
32613
32832
  # 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
  #===============================================================================
@@ -16470,6 +16498,46 @@ check_max_iterations() {
16470
16498
  return 1
16471
16499
  }
16472
16500
 
16501
+ # WALL-CLOCK CAP. Returns 0 (stop) when the run has exceeded LOKI_MAX_DURATION
16502
+ # seconds. Unset or 0 = no cap, which is the default and exactly today's
16503
+ # behavior.
16504
+ #
16505
+ # WHY A THIRD BOUND. Spend was already capped (LOKI_BUDGET_LIMIT) and so were
16506
+ # iterations (LOKI_MAX_ITERATIONS), but a run that STALLS is bounded by neither:
16507
+ # a hung provider call or a wedged subprocess burns hours while spending almost
16508
+ # nothing and completing no iteration, so neither existing breaker ever trips.
16509
+ # It runs until something external kills it -- and an external kill leaves no
16510
+ # terminal status, so the receipt cannot say what happened.
16511
+ #
16512
+ # Kubernetes operators already have activeDeadlineSeconds in the Job spec, but
16513
+ # that SIGKILLs the pod: no status is written, no receipt, and the platform
16514
+ # sees a crash rather than a deliberate stop. This cap stops the loop cleanly
16515
+ # at the next iteration boundary so the run still explains itself.
16516
+ #
16517
+ # It is checked at the boundary, not mid-iteration: interrupting an agent
16518
+ # mid-write is how you get a half-applied change. So the effective stop time is
16519
+ # the cap plus the remainder of the current iteration, which the log states
16520
+ # rather than pretending to be exact.
16521
+ check_max_duration() {
16522
+ local cap="${LOKI_MAX_DURATION:-0}"
16523
+ case "$cap" in
16524
+ ''|*[!0-9]*) return 1 ;; # unset or non-numeric: no cap, never stop
16525
+ esac
16526
+ [ "$cap" -eq 0 ] && return 1
16527
+
16528
+ local start="${_LOKI_RUN_START_EPOCH:-0}"
16529
+ [ "$start" -eq 0 ] 2>/dev/null && return 1
16530
+
16531
+ local now elapsed
16532
+ now=$(date +%s)
16533
+ elapsed=$((now - start))
16534
+ if [ "$elapsed" -ge "$cap" ]; then
16535
+ log_warn "Wall-clock cap reached (${elapsed}s elapsed, LOKI_MAX_DURATION=${cap}s). Stopping at the iteration boundary."
16536
+ return 0
16537
+ fi
16538
+ return 1
16539
+ }
16540
+
16473
16541
  # Load latest ledger content for context injection
16474
16542
  load_ledger_context() {
16475
16543
  local ledger_content=""
@@ -20577,6 +20645,18 @@ except Exception as exc:
20577
20645
  return 20
20578
20646
  fi
20579
20647
 
20648
+ # Wall-clock cap. Checked alongside the iteration cap and treated the
20649
+ # same way: a distinct terminal status so the receipt and `loki why`
20650
+ # can say the run ran out of TIME rather than iterations or money, and
20651
+ # exit 20 because re-running the same spec under the same cap will hit
20652
+ # the same wall. The operator raises LOKI_MAX_DURATION (or narrows the
20653
+ # spec) and submits again.
20654
+ if check_max_duration; then
20655
+ save_state "$retry" "max_duration_reached" 20
20656
+ emit_completion_summary max_duration
20657
+ return 20
20658
+ fi
20659
+
20580
20660
  # Increment after all pre-attempt stop checks pass.
20581
20661
  ((ITERATION_COUNT++))
20582
20662
 
@@ -24266,11 +24346,12 @@ except Exception:
24266
24346
  #
24267
24347
  # Contract (LOKI_DURABLE_STATE=1 only; local/CI exit codes are unchanged):
24268
24348
  # 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.
24349
+ # promise, force-stop, or paused/interrupted/stopped where a HUMAN
24350
+ # chose to stop and will resume). Job -> Complete, no retry.
24271
24351
  # 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
24352
+ # max_retries_exceeded, budget_exceeded, max_duration_reached,
24353
+ # policy_blocked). Re-running on
24354
+ # the same inputs fails the same way -> Job must NOT retry. The Helm Job pairs
24274
24355
  # this with restartPolicy: Never + a podFailurePolicy rule that maps
24275
24356
  # exit 20 to FailJob (no retry), so a deterministic failure does not
24276
24357
  # burn the backoffLimit (the Job records the failure; an operator
@@ -24283,9 +24364,25 @@ except Exception:
24283
24364
  _final_state_file="$(_loki_state_file)"
24284
24365
  _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
24366
  case "$_final_status" in
24286
- council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
24367
+ council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|force_stopped|paused|interrupted|stopped)
24287
24368
  result=0 ;;
24288
- failed|max_iterations_reached|max_retries_exceeded|policy_blocked|inconclusive_spec_contradiction)
24369
+ # budget_exceeded belongs HERE, not with the human-controlled stops.
24370
+ # It sat in the result=0 arm on the rationale that "a human will
24371
+ # resume", which is true of `paused` (a human pressed pause) and
24372
+ # false of a cost breaker firing inside a k8s Job or a CI pipeline,
24373
+ # where there is no human. A build killed mid-work then reported
24374
+ # SUCCESS: the Job went Complete, the pipeline went green, and an
24375
+ # incomplete build looked finished. That is a false green produced
24376
+ # by our own gate, which is precisely what the Evidence Receipt
24377
+ # exists to prevent.
24378
+ #
24379
+ # It is deterministic rather than retryable: re-running the same
24380
+ # inputs against the same cap exhausts the same budget and fails
24381
+ # identically, so retrying only burns money to reach the same place.
24382
+ # The operator raises the cap (or narrows the spec) and submits a
24383
+ # NEW Job -- the same remedy as max_iterations_reached, which is why
24384
+ # it shares that code.
24385
+ failed|max_iterations_reached|max_retries_exceeded|budget_exceeded|max_duration_reached|policy_blocked|inconclusive_spec_contradiction)
24289
24386
  result=20 ;;
24290
24387
  *)
24291
24388
  # 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"