loki-mode 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -239,6 +239,46 @@ This needs Bun on your PATH (the SDK loop runs on the Bun runtime). `loki doctor
239
239
 
240
240
  **With a coding-agent CLI.** The classic path, and still the default: Loki drives a separate CLI (Claude Code is the recommended one) plus a couple of common tools on your PATH.
241
241
 
242
+ **With a different model or provider.** Loki is not tied to Anthropic. Anything
243
+ that speaks the Anthropic Messages API works: OpenRouter, Ollama, LiteLLM, vLLM,
244
+ or your own gateway. Point `ANTHROPIC_BASE_URL` at the endpoint and name the
245
+ model with `LOKI_MODEL_OVERRIDE`:
246
+
247
+ ```bash
248
+ # OpenRouter (hundreds of models, one key)
249
+ export ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
250
+ export ANTHROPIC_API_KEY=sk-or-...
251
+ export LOKI_MODEL_OVERRIDE=<model-id-from-openrouter.ai/models>
252
+ loki start prd.md
253
+
254
+ # Ollama, fully local (no API key, no per-token cost)
255
+ export ANTHROPIC_BASE_URL=http://localhost:11434/v1
256
+ export LOKI_MODEL_OVERRIDE=<model you have pulled, e.g. the output of `ollama list`>
257
+ loki start prd.md
258
+
259
+ # LiteLLM / vLLM / any self-hosted gateway
260
+ export ANTHROPIC_BASE_URL=https://your-gateway.internal/v1
261
+ export ANTHROPIC_API_KEY=...
262
+ export LOKI_MODEL_OVERRIDE=<whatever your gateway calls the model>
263
+ loki start prd.md
264
+ ```
265
+
266
+ **Set both variables.** `LOKI_MODEL_OVERRIDE` is what makes the alt-provider
267
+ path work: without it Loki keeps asking for `opus` / `sonnet` / `haiku`, which
268
+ only Anthropic resolves, and most providers reject those names outright. A
269
+ proxy that maps the aliases for you (LiteLLM can) is the one exception.
270
+
271
+ Model IDs are not listed here on purpose -- OpenRouter's catalogue changes every
272
+ week, and a stale ID in a README is a failure you would hit at runtime. Take the
273
+ exact string from your provider's own model list.
274
+
275
+ Both routes honor these variables identically -- the bundled-SDK path and the
276
+ Claude Code CLI path -- and `loki doctor` reports the endpoint it detected plus a
277
+ warning if the model override is missing.
278
+
279
+ The quality gates, the completion council, and the Evidence Receipt do not care
280
+ which model produced the code. They check what was actually built.
281
+
242
282
  Either way, run `loki doctor` any time and it tells you exactly what is present and what is missing, with a copy-pasteable install command for each gap.
243
283
 
244
284
  ```bash
@@ -715,7 +755,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
715
755
  **[Autonomi](https://www.autonomi.dev/)** | **[Documentation](wiki/Home.md)** | **[Changelog](CHANGELOG.md)** | **[Comparisons](references/competitive-analysis.md)**
716
756
 
717
757
  </div>
718
-
719
- ## Star History
720
-
721
- [![Star History Chart](https://api.star-history.com/chart?repos=asklokesh/loki-mode&type=timeline&legend=bottom-right)](https://www.star-history.com/?repos=asklokesh%2Floki-mode&type=timeline&legend=bottom-right)
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.0.0
6
+ # Loki Mode v8.0.2
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.0.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.0.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.0.0
1
+ 8.0.2
@@ -766,12 +766,58 @@ def _diff_sha256(files_changed):
766
766
 
767
767
 
768
768
  def _git_diffstat(target_dir, include_diffs):
769
- """Return the final worktree diff relative to this iteration's base."""
770
- return collect_workspace_diff(
771
- target_dir,
772
- os.environ.get("_LOKI_ITER_START_SHA", "").strip(),
773
- include_diffs,
774
- )
769
+ """Return the final worktree diff relative to the RUN's base.
770
+
771
+ The receipt is a run-level document, so its diff stat must span the WHOLE
772
+ run. This previously passed _LOKI_ITER_START_SHA -- the per-ITERATION
773
+ baseline -- so a multi-iteration run attested only to the changes since its
774
+ last iteration. Measured on a real 2-commit run: 850 insertions + 76
775
+ deletions reported where the run actually produced 1300 insertions.
776
+
777
+ That matters more than an ordinary display bug: the returned object flows
778
+ into _diff_sha256, the receipt's integrity hash, which is written on EVERY
779
+ run. A verifier recomputing it therefore attests to the understated stat,
780
+ and the receipt is exactly the artifact users are told to trust. (Detached
781
+ GPG signing is a separate opt-in layer gated on LOKI_PROOF_GPG_KEY and
782
+ default OFF; when enabled it signs these same bytes, but the integrity hash
783
+ is the always-on path.)
784
+
785
+ Order of preference:
786
+ 1. _LOKI_RUN_START_SHA -- the run's own baseline (run.sh exports it).
787
+ 2. The empty tree -- correct for a GREENFIELD run (a repo with no commits
788
+ at start), where "everything that now exists" IS the run's output and
789
+ there is no earlier commit to diff against.
790
+ 3. Empty string -- let workspace_diff apply its own fallbacks.
791
+ """
792
+ base = os.environ.get("_LOKI_RUN_START_SHA", "").strip()
793
+ if not base:
794
+ # Greenfield: no baseline commit existed when the run started.
795
+ base = _empty_tree_sha(target_dir)
796
+ files_changed, diffs = collect_workspace_diff(target_dir, base, include_diffs)
797
+ # Return the base too. git_facts records base_sha NEXT TO diff and
798
+ # diff_sha256; if they disagree the signed receipt is internally
799
+ # inconsistent and a verifier recomputing the diff from base_sha gets a
800
+ # different answer than the one that was signed.
801
+ return files_changed, diffs, base
802
+
803
+
804
+ def _empty_tree_sha(repo_dir):
805
+ """The canonical empty-tree object, or "" when git is unavailable.
806
+
807
+ Diffing against this yields "everything that currently exists", which is the
808
+ truthful baseline for a run that started from a repo with no commits.
809
+ """
810
+ try:
811
+ out = subprocess.run(
812
+ ["git", "hash-object", "-t", "tree", os.devnull],
813
+ cwd=repo_dir,
814
+ capture_output=True,
815
+ text=True,
816
+ timeout=10,
817
+ )
818
+ except Exception:
819
+ return ""
820
+ return out.stdout.strip() if out.returncode == 0 else ""
775
821
 
776
822
 
777
823
  def _collect_iterations(loki_dir):
@@ -908,7 +954,7 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
908
954
  provider_name = args.provider or os.environ.get("PROVIDER_NAME") or "claude"
909
955
  model = _collect_model(loki_dir, model_from_eff)
910
956
 
911
- files_changed, diffs = _git_diffstat(target_dir, args.include_diffs)
957
+ files_changed, diffs, diff_base_sha = _git_diffstat(target_dir, args.include_diffs)
912
958
  iterations = _collect_iterations(loki_dir)
913
959
  spec = _collect_spec(loki_dir, target_dir)
914
960
  council = _collect_council(loki_dir)
@@ -944,7 +990,8 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
944
990
  # recompute the hash. True non-forgeability requires the neutral signed record
945
991
  # (service-held key). See proof-verify.py verify() docstring.
946
992
  git_facts = {
947
- "base_sha": os.environ.get("_LOKI_ITER_START_SHA", "").strip(),
993
+ # Must be the SAME base the diff above was computed against.
994
+ "base_sha": diff_base_sha,
948
995
  "head_sha": _git_head_sha(target_dir),
949
996
  "diff": files_changed,
950
997
  "diff_sha256": _diff_sha256(files_changed),
@@ -113,11 +113,26 @@ def _is_git_repo(repo_dir):
113
113
 
114
114
 
115
115
  def _rev_resolvable(repo_dir, ref):
116
- """True iff `ref` resolves to a commit in repo_dir."""
116
+ """True iff `ref` names something in repo_dir that a diff can be taken from.
117
+
118
+ Accepts a COMMIT or a TREE. The tree case is load-bearing: a greenfield run
119
+ starts in a repo with no commits, so the generator records the EMPTY TREE
120
+ (4b825dc6...) as its baseline -- "everything that now exists". That object
121
+ is a tree, not a commit, so a commit-only check rejects it and the verifier
122
+ reports "base ref unresolvable" for a perfectly genuine proof.
123
+
124
+ `git diff <empty-tree>` works fine, so the diff really is re-derivable; only
125
+ the resolvability probe was too narrow. This does NOT loosen the forgery
126
+ defense -- an unknown or fabricated SHA still resolves as neither and is
127
+ still rejected.
128
+ """
117
129
  if not ref:
118
130
  return False
119
- out = _git(repo_dir, ["rev-parse", "--verify", "--quiet", str(ref) + "^{commit}"])
120
- return bool(out and out.strip())
131
+ for kind in ("commit", "tree"):
132
+ out = _git(repo_dir, ["rev-parse", "--verify", "--quiet", "%s^{%s}" % (ref, kind)])
133
+ if out and out.strip():
134
+ return True
135
+ return False
121
136
 
122
137
 
123
138
  def _numstat(repo_dir, base, head):
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import os
6
7
  import subprocess
7
8
 
8
9
 
@@ -74,6 +75,22 @@ def collect_workspace_diff(repo_dir, base, include_diffs=False):
74
75
  comparison = base or "HEAD~1"
75
76
  raw = _git(repo_dir, ["diff", "--no-renames", "--numstat", "-z", comparison, "--"])
76
77
  if raw is None:
78
+ # HEAD~1 is unusable (single-commit or empty repo). Fall back to the
79
+ # EMPTY TREE, not to a bare "HEAD".
80
+ #
81
+ # A bare "HEAD" compares HEAD to the working tree, so it sees only
82
+ # UNCOMMITTED changes and silently drops everything the run committed.
83
+ # Measured on a real greenfield run: bare HEAD reported 5 files where
84
+ # the truth was 9. The empty tree yields "everything that now exists",
85
+ # which is the correct answer when no earlier commit exists to diff
86
+ # against -- and it agrees with HEAD~1 on runs where both are valid.
87
+ empty_tree = _git(repo_dir, ["hash-object", "-t", "tree", os.devnull])
88
+ if empty_tree:
89
+ comparison = empty_tree.strip()
90
+ raw = _git(repo_dir, ["diff", "--no-renames", "--numstat", "-z", comparison, "--"])
91
+ if raw is None:
92
+ # Last resort: worktree-only. Undercounts a run that committed its work,
93
+ # so it is reached only when even the empty-tree diff failed.
77
94
  comparison = "HEAD"
78
95
  raw = _git(repo_dir, ["diff", "--no-renames", "--numstat", "-z", comparison, "--"])
79
96
  if raw is None:
package/autonomy/loki CHANGED
@@ -3896,6 +3896,8 @@ WHYJSON
3896
3896
  _LOKI_WHY_LAST_ERROR="$loki_dir/state/LAST_ERROR.json" \
3897
3897
  _LOKI_WHY_UNCERTAINTY="$loki_dir/signals/UNCERTAINTY_ESCALATION" \
3898
3898
  _LOKI_WHY_CONVERGENCE="$loki_dir/council/convergence.log" \
3899
+ _LOKI_WHY_GATE="$loki_dir/signals/GATE_ESCALATION.json" \
3900
+ _LOKI_WHY_TARGET="${TARGET_DIR:-$(dirname "$loki_dir")}" \
3899
3901
  _LOKI_WHY_HANDOFFS="$loki_dir/memory/handoffs" python3 - <<'WHYTXT'
3900
3902
  import json, os, glob
3901
3903
  def load(p):
@@ -3953,6 +3955,11 @@ GUIDE = {
3953
3955
  "If no build is active it likely crashed; in durable mode (LOKI_DURABLE_STATE=1) a restart resumes, else loki start re-runs."),
3954
3956
  "exited": ("The build process exited mid-iteration (likely a crash, kill, or empty provider output).",
3955
3957
  "Read .loki/logs/ for the last error; if nothing is running it crashed -- re-run with loki start, or loki resume in durable mode."),
3958
+ # A real terminal state that had no entry, so `loki why` answered the one
3959
+ # question it exists to answer with "No diagnosis mapping for this status".
3960
+ "inconclusive_spec_contradiction": (
3961
+ "Loki could not reconcile parts of the spec, so it stopped rather than guess.",
3962
+ "The specific contradictions are in .loki/assumptions/ledger.md -- resolve them in the spec, then re-run."),
3956
3963
  }
3957
3964
  meaning, action = GUIDE.get(status, ("No diagnosis mapping for this status; see the raw fields below.",
3958
3965
  "Check loki status and .loki/logs/ for detail."))
@@ -3979,13 +3986,66 @@ if iters is not None:
3979
3986
  print(f" Iterations : {iters}")
3980
3987
  if comp.get("branch"):
3981
3988
  print(f" Branch : {comp['branch']}{comp_label}")
3982
- if comp.get("files_changed") is not None:
3983
- print(f" Changes : {comp.get('files_changed',0)} files (+{comp.get('insertions',0)}/-{comp.get('deletions',0)}){comp_label}")
3989
+ _fc = comp.get("files_changed")
3990
+ if _fc is not None:
3991
+ if _fc == 0:
3992
+ # A recorded 0 is NOT trustworthy on its own: a greenfield run whose
3993
+ # baseline never resolved records 0 while having committed real work.
3994
+ # Saying "0 files" flatly told users nothing was built on a run that
3995
+ # produced 9 files and 1300 insertions. Re-derive from git, and only
3996
+ # claim zero when git agrees.
3997
+ _real = None
3998
+ try:
3999
+ import subprocess as _sp
4000
+ _tgt = os.environ.get("_LOKI_WHY_TARGET") or "."
4001
+ _base = (comp.get("start_sha") or "").strip()
4002
+ if not _base or _base == "HEAD":
4003
+ _base = _sp.run(["git", "-C", _tgt, "hash-object", "-t", "tree", os.devnull],
4004
+ capture_output=True, text=True, timeout=10).stdout.strip()
4005
+ if _base:
4006
+ _o = _sp.run(["git", "-C", _tgt, "diff", "--shortstat", f"{_base}..HEAD",
4007
+ "--", ".", ":(exclude).loki/"],
4008
+ capture_output=True, text=True, timeout=15).stdout.strip()
4009
+ if _o:
4010
+ _real = _o
4011
+ except Exception:
4012
+ _real = None
4013
+ if _real:
4014
+ print(f" Changes : {_real} (re-derived; the run record said 0)")
4015
+ else:
4016
+ print(f" Changes : none recorded (the run did not resolve a baseline)")
4017
+ else:
4018
+ print(f" Changes : {_fc} files (+{comp.get('insertions',0)}/-{comp.get('deletions',0)}){comp_label}")
3984
4019
  if comp.get("pr_url"):
3985
4020
  print(f" PR : {comp['pr_url']}{comp_label}")
4021
+
4022
+ # Name the gate that actually stopped the run. Without this the user is told to
4023
+ # "resume", which the run's own pause notice warns will stop at the same gate.
4024
+ _gate = load(os.environ.get("_LOKI_WHY_GATE", "")) or {}
4025
+ _gname = str(_gate.get("gate", "") or "").strip()
4026
+ if _gname:
4027
+ _gc, _gt = _gate.get("count"), _gate.get("threshold")
4028
+ _detail = f"{_gname} (failed {_gc} times, threshold {_gt})" if isinstance(_gc, int) and isinstance(_gt, int) else _gname
4029
+ print(f" Blocked by : {_detail}")
4030
+
3986
4031
  print()
3987
4032
  print(f" What happened: {meaning}")
3988
- print(f" What to do : {action}")
4033
+ if _gname:
4034
+ print(f" What to do : Read the {_gname} findings and fix what they name, then resume.")
4035
+ print(f" Resuming unchanged will stop at the same gate again.")
4036
+ _art = str(_gate.get("latest_artifact", "") or "")
4037
+ if _art:
4038
+ # Make the pointer relative to the workspace. The recorded value is an
4039
+ # absolute path from the machine that ran the build -- on the preserved
4040
+ # run it names a /var/folders temp dir that no longer exists, so the one
4041
+ # actionable pointer in the whole notice led nowhere. Anchor on
4042
+ # ".loki/" so it works even when the run happened elsewhere.
4043
+ _i = _art.find(".loki/")
4044
+ if _i != -1:
4045
+ _art = _art[_i:]
4046
+ print(f" Findings: {_art}")
4047
+ else:
4048
+ print(f" What to do : {action}")
3989
4049
 
3990
4050
  # RUN-25 iter 22b/iter 23 (Wave D #6): when a run is PAUSED / live and actually
3991
4051
  # STUCK, name the REAL stall reason instead of the generic "resume". The runner
@@ -11025,6 +11085,13 @@ except Exception:
11025
11085
  fi
11026
11086
  else
11027
11087
  echo -e " ${YELLOW}WARN${NC} Bun not found -- 'loki cockpit' uses the text summary + browser dashboard (install bun for the inline-image frame)"
11088
+ # Report the protocol line here too. Without it doctor's Cockpit section
11089
+ # is asymmetric: the bun branch prints BOTH "Inline-image protocol" and
11090
+ # "Render path", the no-bun branch printed only "Render path". A user
11091
+ # reading doctor on a machine without bun could not tell whether the
11092
+ # inline-image path was unsupported by their terminal or simply never
11093
+ # probed. Naming the reason keeps the section self-explanatory.
11094
+ echo -e " ${DIM} -- ${NC} Inline-image protocol: not probed (needs bun to run the cockpit renderer)"
11028
11095
  echo -e " ${DIM} -- ${NC} Render path: text + browser dashboard"
11029
11096
  fi
11030
11097
  echo ""
package/autonomy/run.sh CHANGED
@@ -2830,6 +2830,30 @@ _loki_check_workspace_writable() {
2830
2830
  # is missing, when LOKI_SKIP_NET_PREFLIGHT=1, when ANTHROPIC_BASE_URL is set (alt
2831
2831
  # provider endpoint we cannot assume), or for any provider whose endpoint we do
2832
2832
  # not know. It NEVER blocks the build.
2833
+ # Alt-provider guard: ANTHROPIC_BASE_URL routes Loki at a non-Anthropic endpoint
2834
+ # (OpenRouter, Ollama, LiteLLM, vLLM, self-hosted). The tier aliases Loki resolves
2835
+ # by default -- opus / sonnet / haiku -- are Anthropic-only names, and most
2836
+ # alt-providers reject them outright, so the run dies at the FIRST model call with
2837
+ # a provider-side error that is hard to trace back to the missing override.
2838
+ #
2839
+ # LOKI_MODEL_OVERRIDE is what makes that path work (providers/claude.sh:520 and
2840
+ # loki-ts/src/runner/providers.ts:328 both apply it only when BOTH vars are set).
2841
+ # `loki doctor` already warns on this, but doctor is opt-in and the run is not.
2842
+ #
2843
+ # NON-FATAL by design: a mapping proxy (LiteLLM can do this) legitimately resolves
2844
+ # the aliases server-side, so this is a warning and never a block. Emitted once per
2845
+ # run, and silent unless an alt endpoint is actually configured.
2846
+ _loki_warn_alt_provider_model_alias() {
2847
+ [ -n "${ANTHROPIC_BASE_URL:-}" ] || return 0
2848
+ [ -z "${LOKI_MODEL_OVERRIDE:-}" ] || return 0
2849
+ log_warn "ANTHROPIC_BASE_URL is set (${ANTHROPIC_BASE_URL}) but LOKI_MODEL_OVERRIDE is not."
2850
+ log_warn " Loki will ask for the Anthropic tier aliases (opus/sonnet/haiku), which most"
2851
+ log_warn " alt-providers do not serve. Set the exact model id your endpoint expects:"
2852
+ log_warn " export LOKI_MODEL_OVERRIDE=<model-id> # e.g. from 'ollama list' or your provider's model page"
2853
+ log_warn " Ignore this if your gateway maps those aliases for you (LiteLLM can)."
2854
+ return 0
2855
+ }
2856
+
2833
2857
  _loki_check_network_reachable() {
2834
2858
  local provider="${1:-claude}"
2835
2859
  [ "${LOKI_SKIP_NET_PREFLIGHT:-}" = "1" ] && return 0
@@ -2874,6 +2898,7 @@ validate_api_keys() {
2874
2898
  if ! _loki_check_network_reachable "$provider"; then
2875
2899
  return 1
2876
2900
  fi
2901
+ _loki_warn_alt_provider_model_alias
2877
2902
 
2878
2903
  # Zero-friction auth preflight for LOCAL runs (must run BEFORE the early
2879
2904
  # return below, which exits for non-Docker/K8s envs). When claude is the
@@ -3889,6 +3914,33 @@ notify_rate_limit() {
3889
3914
  # written even when desktop notifications are disabled. emit_completion_summary
3890
3915
  # below is the wrapper that writes the files AND (gated) fires the desktop ping.
3891
3916
  #===============================================================================
3917
+ # Read the gate escalation signal into one human line, or print nothing.
3918
+ # Shared by COMPLETION.txt's per-outcome guidance and PAUSED.md so the two never
3919
+ # disagree about why a run stopped. Best-effort: a missing or corrupt signal
3920
+ # yields an empty string, and the caller degrades to generic guidance.
3921
+ _loki_summary_gate_reason() {
3922
+ local loki_dir="${1:-${TARGET_DIR:-.}/.loki}"
3923
+ [ -s "$loki_dir/signals/GATE_ESCALATION.json" ] || return 0
3924
+ _LOKI_GR_FILE="$loki_dir/signals/GATE_ESCALATION.json" python3 -c '
3925
+ import json, os, sys
3926
+ try:
3927
+ d = json.load(open(os.environ["_LOKI_GR_FILE"]))
3928
+ except Exception:
3929
+ sys.exit(0)
3930
+ if not isinstance(d, dict):
3931
+ sys.exit(0)
3932
+ gate = str(d.get("gate", "") or "").strip()
3933
+ if not gate:
3934
+ sys.exit(0)
3935
+ count = d.get("count")
3936
+ thr = d.get("threshold")
3937
+ if isinstance(count, int) and isinstance(thr, int):
3938
+ print("%s (failed %d times, threshold %d)" % (gate, count, thr))
3939
+ else:
3940
+ print(gate)
3941
+ ' 2>/dev/null || true
3942
+ }
3943
+
3892
3944
  build_completion_summary() {
3893
3945
  local outcome="${1:-complete}"
3894
3946
  local loki_dir="${TARGET_DIR:-.}/.loki"
@@ -3944,7 +3996,30 @@ except Exception:
3944
3996
  fi
3945
3997
  review_cmd="git diff ${start_sha}..HEAD -- . ':(exclude).loki/'"
3946
3998
  else
3947
- review_cmd="git diff HEAD -- . ':(exclude).loki/'"
3999
+ # No baseline SHA. This is the GREENFIELD case: the run started in a repo
4000
+ # with no commits, so `git rev-parse --verify HEAD` correctly produced
4001
+ # nothing (see the start-sha capture). Everything the run built is new.
4002
+ #
4003
+ # Without this branch the counts stayed at zero and a user was told
4004
+ # "files_changed: 0" about a run that had produced real, committed,
4005
+ # working code -- observed on a PRD benchmark that built 4 files and
4006
+ # passed 28/28 of its own tests, yet reported 0. Diff against the empty
4007
+ # tree so the summary reports what was actually created.
4008
+ local _empty_tree
4009
+ _empty_tree="$( (cd "${TARGET_DIR:-.}" && git hash-object -t tree /dev/null) 2>/dev/null || true )"
4010
+ if [ -n "$_empty_tree" ] && (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD >/dev/null 2>&1); then
4011
+ diff_stat="$( (cd "${TARGET_DIR:-.}" && git diff --stat "${_empty_tree}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
4012
+ local _shortstat_new
4013
+ _shortstat_new="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${_empty_tree}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
4014
+ if [ -n "$_shortstat_new" ]; then
4015
+ files_changed="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' | head -1)"
4016
+ insertions="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' | head -1)"
4017
+ deletions="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' | head -1)"
4018
+ fi
4019
+ review_cmd="git diff ${_empty_tree}..HEAD -- . ':(exclude).loki/'"
4020
+ else
4021
+ review_cmd="git diff HEAD -- . ':(exclude).loki/'"
4022
+ fi
3948
4023
  fi
3949
4024
  [ -z "$files_changed" ] && files_changed=0
3950
4025
  [ -z "$insertions" ] && insertions=0
@@ -4073,6 +4148,52 @@ except Exception:
4073
4148
  esac
4074
4149
  ;;
4075
4150
  esac
4151
+
4152
+ # Per-outcome next step. Deliberately NOT nested inside the
4153
+ # files_changed==0 case above: a run that stops for a gate reason has
4154
+ # usually built plenty, and that is exactly the run whose user most needs
4155
+ # to be told what to do. Keying this on an empty diff would silence it
4156
+ # for the case that motivated it.
4157
+ #
4158
+ # Measured on the PRD benchmark: outcome=intervention with 9 files built
4159
+ # and 28/28 tests passing produced NO guidance at all, because the old
4160
+ # block covered only complete|max_iterations.
4161
+ case "$outcome" in
4162
+ intervention)
4163
+ echo ""
4164
+ echo "Loki stopped and needs a decision from you."
4165
+ _cs_gate="$(_loki_summary_gate_reason "$loki_dir")"
4166
+ if [ -n "$_cs_gate" ]; then
4167
+ echo " Blocked by: $_cs_gate"
4168
+ echo " The findings say exactly what to change. Fix those, then resume:"
4169
+ else
4170
+ echo " See .loki/PAUSED.md for the reason and .loki/CONTINUITY.md for context."
4171
+ echo " Resume with:"
4172
+ fi
4173
+ echo " rm .loki/PAUSE # resume this run"
4174
+ echo " touch .loki/STOP # end it instead"
4175
+ echo " Resuming without addressing the finding will stop at the same gate again."
4176
+ ;;
4177
+ failed)
4178
+ echo ""
4179
+ echo "The run failed. What went wrong:"
4180
+ echo " loki why # the classified cause, in one line"
4181
+ echo " .loki/logs/ # the full session log"
4182
+ echo "Anything already committed is listed above and is yours to keep or discard."
4183
+ ;;
4184
+ stopped|force_stopped)
4185
+ echo ""
4186
+ echo "The run was stopped before it finished."
4187
+ echo "Work committed up to that point is listed above. To continue from here:"
4188
+ echo " loki start <your spec> # a fresh run over the current tree"
4189
+ ;;
4190
+ inconclusive_spec_contradiction)
4191
+ echo ""
4192
+ echo "Loki could not reconcile parts of the spec, so it stopped rather than"
4193
+ echo "guess. The specific contradictions are in .loki/assumptions/ledger.md."
4194
+ echo "Resolve those in the spec, then re-run."
4195
+ ;;
4196
+ esac
4076
4197
  } > "$loki_dir/COMPLETION.txt" 2>/dev/null || true
4077
4198
 
4078
4199
  # ---- Durable machine-readable file: .loki/state/completion.json -----------
@@ -20065,7 +20186,17 @@ except Exception:
20065
20186
  fi
20066
20187
 
20067
20188
  if [ "${ITERATION_COUNT:-0}" -eq 0 ] || [ ! -s "$_start_sha_file" ]; then
20068
- (cd "${TARGET_DIR:-.}" && git rev-parse HEAD 2>/dev/null) > "$_start_sha_file" 2>/dev/null || true
20189
+ # --verify is load-bearing. In a repo with NO commits yet (every
20190
+ # greenfield build: git init, nothing committed), plain
20191
+ # `git rev-parse HEAD` exits 128 but still prints the literal string
20192
+ # "HEAD" ON STDOUT -- so 2>/dev/null does NOT suppress it and the
20193
+ # unresolved ref name gets captured as the start SHA. Every downstream
20194
+ # diff then becomes `git diff HEAD..HEAD`, which is ALWAYS empty: the
20195
+ # completion council blocks with reason "empty_diff" and can never see
20196
+ # the work, so it can never vote done and the run burns to the cap.
20197
+ # `--verify` resolves-or-fails, emitting nothing on failure, which
20198
+ # leaves the file empty and lets consumers apply their no-baseline path.
20199
+ (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD 2>/dev/null) > "$_start_sha_file" 2>/dev/null || true
20069
20200
  fi
20070
20201
  _LOKI_RUN_START_SHA="$(cat "$_start_sha_file" 2>/dev/null || echo "")"
20071
20202
  export _LOKI_RUN_START_SHA
@@ -20474,7 +20605,11 @@ except Exception as exc:
20474
20605
  # v7.6.4 B-3a fix: capture iteration-start git SHA so auto_capture_episode
20475
20606
  # can diff against this baseline (not just HEAD, which is empty after
20476
20607
  # loki's per-iteration auto-commit makes the new files HEAD).
20477
- _LOKI_ITER_START_SHA=$(cd "${TARGET_DIR:-.}" && git rev-parse HEAD 2>/dev/null || echo "")
20608
+ # --verify: without it, a no-commits-yet repo makes `git rev-parse HEAD`
20609
+ # print the literal "HEAD" on STDOUT while exiting 128, so the
20610
+ # `|| echo ""` fallback never fires and the unresolved ref name is
20611
+ # captured as a baseline. See the start-sha capture above.
20612
+ _LOKI_ITER_START_SHA=$(cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD 2>/dev/null || echo "")
20478
20613
  export _LOKI_ITER_START_SHA
20479
20614
  _LOKI_ITER_START_TREE=$(_loki_snapshot_workspace_tree "${TARGET_DIR:-.}" 2>/dev/null || echo "")
20480
20615
  export _LOKI_ITER_START_TREE
@@ -22925,6 +23060,46 @@ Current state is saved. You can inspect:
22925
23060
  - `.loki/logs/` - Session logs
22926
23061
  EOF
22927
23062
 
23063
+ # Say WHY it paused. The block above is static boilerplate: a user who comes
23064
+ # back to a paused run learns how to resume but not what stopped it, and has
23065
+ # to go spelunking in .loki/signals to find out. Observed on a PRD benchmark
23066
+ # where a gate escalation paused a run that had produced 9 files and 28
23067
+ # passing tests -- the pause was correct, the report told them nothing.
23068
+ # Best-effort and append-only: never let a formatting failure block a pause.
23069
+ {
23070
+ _pause_reason_file="$loki_dir/signals/GATE_ESCALATION"
23071
+ if [ -s "$_pause_reason_file" ]; then
23072
+ printf '\n## Why this paused\n\n'
23073
+ printf 'A quality gate stayed blocked across repeated attempts, so Loki stopped\n'
23074
+ printf 'rather than keep spending on a loop that was not converging.\n\n'
23075
+ _esc_gate="$(python3 -c 'import json,sys
23076
+ try:
23077
+ d = json.load(open(sys.argv[1]))
23078
+ print("%s (failed %s times, threshold %s)" % (d.get("gate","?"), d.get("count","?"), d.get("threshold","?")))
23079
+ except Exception:
23080
+ print("")' "$loki_dir/signals/GATE_ESCALATION.json" 2>/dev/null || true)"
23081
+ [ -n "$_esc_gate" ] && printf -- '- Gate: %s\n' "$_esc_gate"
23082
+ _esc_art="$(python3 -c 'import json,sys
23083
+ try:
23084
+ print(json.load(open(sys.argv[1])).get("latest_artifact","") or "")
23085
+ except Exception:
23086
+ print("")' "$loki_dir/signals/GATE_ESCALATION.json" 2>/dev/null || true)"
23087
+ [ -n "$_esc_art" ] && printf -- '- Findings: %s\n' "$_esc_art"
23088
+ printf '\nRead the findings, fix what they name, then resume. Resuming without a\nchange will most likely stop at the same gate again.\n'
23089
+ fi
23090
+ # What DID get built, so a paused run never reads as "nothing happened".
23091
+ _pause_head="$( (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD) 2>/dev/null || true )"
23092
+ if [ -n "$_pause_head" ]; then
23093
+ _pause_base="$( (cd "${TARGET_DIR:-.}" && git hash-object -t tree /dev/null) 2>/dev/null || true )"
23094
+ [ -s "$loki_dir/state/start-sha" ] && _pause_base="$(cat "$loki_dir/state/start-sha" 2>/dev/null)"
23095
+ _pause_stat="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${_pause_base}..HEAD" -- . ':(exclude).loki/') 2>/dev/null || true )"
23096
+ if [ -n "$_pause_stat" ]; then
23097
+ printf '\n## What was built before the pause\n\n%s\n' "$_pause_stat"
23098
+ printf '\nReview it with:\n git diff %s..HEAD -- . ":(exclude).loki/"\n' "$_pause_base"
23099
+ fi
23100
+ fi
23101
+ } >> "$loki_dir/PAUSED.md" 2>/dev/null || true
23102
+
22928
23103
  # Wait for resume signal (unified: file removal, keyboard, or STOP)
22929
23104
  while [ "$PAUSED" = "true" ]; do
22930
23105
  # Check for stop signal
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.0.0"
10
+ __version__ = "8.0.2"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var k_=Object.create;var{getPrototypeOf:x_,defineProperty:oK,getOwnPropertyNames:S_}=Object;var y_=Object.prototype.hasOwnProperty;function b_(Z){return this[Z]}var __,f_,h_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?__??=new WeakMap:f_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?k_(x_(Z)):{};let K=X||!Z||!Z.__esModule?oK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of S_(Z))if(!y_.call(K,$))oK(K,$,{get:b_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var VQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var v_=(Z)=>Z;function g_(Z,X){this[Z]=v_.bind(null,X)}var c0=(Z,X)=>{for(var Q in X)oK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:g_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var EO={};c0(EO,{lokiDir:()=>j0,homeLokiDir:()=>I4,findRepoRootForVersion:()=>tK,REPO_ROOT:()=>Q8});import{resolve as s7,dirname as rK}from"path";import{fileURLToPath as m_}from"url";import{existsSync as qQ}from"fs";import{homedir as u_}from"os";function p_(){let Z=DO;for(let X=0;X<6;X++){if(qQ(s7(Z,"VERSION"))&&qQ(s7(Z,"autonomy/run.sh")))return Z;let Q=rK(Z);if(Q===Z)break;Z=Q}return s7(DO,"..","..","..")}function tK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(qQ(s7(X,"VERSION"))&&qQ(s7(X,"autonomy/run.sh")))return X;let Y=rK(X);if(Y===X)break;X=Y}return s7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??s7(process.cwd(),".loki")}function I4(){return s7(u_(),".loki")}var DO,Q8;var G8=p(()=>{DO=rK(m_(import.meta.url));Q8=p_()});import{readFileSync as d_}from"fs";import{resolve as c_,dirname as l_}from"path";import{fileURLToPath as i_}from"url";function _3(){if(f5!==null)return f5;let Z="8.0.0";if(typeof Z==="string"&&Z.length>0)return f5=Z,f5;try{let X=l_(i_(import.meta.url)),Q=tK(X);f5=d_(c_(Q,"VERSION"),"utf-8").trim()}catch{f5="unknown"}return f5}var f5=null;var GQ=p(()=>{G8()});var kO={};c0(kO,{runOrThrow:()=>Wf,run:()=>D0,readStreamCapped:()=>HQ,commandVersion:()=>qf,commandExists:()=>H9,ShellError:()=>eK,MAX_STDOUT_BYTES:()=>RO});async function HQ(Z,X=RO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function D0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([HQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Wf(Z,X={}){let Q=await D0(Z,X);if(Q.exitCode!==0)throw new eK(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function H9(Z){let X=Vf(Z),Q=await D0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Vf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function qf(Z,X="--version"){if(!await H9(Z))return null;let Y=await D0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var RO=16777216,eK;var k9=p(()=>{eK=class eK extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function n7(Z){return Gf?"":Z}var Gf,M0,k8,l0,gW0,a0,H8,X9,g;var S6=p(()=>{Gf=(process.env.NO_COLOR??"").length>0;M0=n7("\x1B[0;31m"),k8=n7("\x1B[0;32m"),l0=n7("\x1B[1;33m"),gW0=n7("\x1B[0;34m"),a0=n7("\x1B[0;36m"),H8=n7("\x1B[1m"),X9=n7("\x1B[2m"),g=n7("\x1B[0m")});import{existsSync as wf}from"fs";async function F7(){if(R4!==void 0)return R4;let Z="/opt/homebrew/bin/python3.12";if(wf(Z))return R4=Z,Z;let X=await H9("python3.12");if(X)return R4=X,X;let Q=await H9("python3");return R4=Q,Q}async function D7(Z,X={}){let Q=await F7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return D0([Q,"-c",Z],X)}var R4;var o7=p(()=>{k9()});var oO={};c0(oO,{runStatus:()=>of});import{existsSync as Q9,readFileSync as h3,readdirSync as pO,statSync as dO}from"fs";import{resolve as h8,basename as mf}from"path";import{homedir as uf}from"os";function cO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function lO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*BQ/X);if(J>BQ)J=BQ;let z=BQ-J,K=k8;if(Y>=80)K=M0;else if(Y>=50)K=l0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=cO(Z),V=cO(X);return` ${H8}${Q}${g} ${K}[${$}]${g} ${Y}% (${W} / ${V})`}async function df(){if(await H9("jq"))return!0;return process.stdout.write(`${M0}Error: jq is required but not installed.${g}
2
+ var k_=Object.create;var{getPrototypeOf:x_,defineProperty:oK,getOwnPropertyNames:S_}=Object;var y_=Object.prototype.hasOwnProperty;function b_(Z){return this[Z]}var __,f_,h_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?__??=new WeakMap:f_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?k_(x_(Z)):{};let K=X||!Z||!Z.__esModule?oK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of S_(Z))if(!y_.call(K,$))oK(K,$,{get:b_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var VQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var v_=(Z)=>Z;function g_(Z,X){this[Z]=v_.bind(null,X)}var c0=(Z,X)=>{for(var Q in X)oK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:g_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var EO={};c0(EO,{lokiDir:()=>j0,homeLokiDir:()=>I4,findRepoRootForVersion:()=>tK,REPO_ROOT:()=>Q8});import{resolve as s7,dirname as rK}from"path";import{fileURLToPath as m_}from"url";import{existsSync as qQ}from"fs";import{homedir as u_}from"os";function p_(){let Z=DO;for(let X=0;X<6;X++){if(qQ(s7(Z,"VERSION"))&&qQ(s7(Z,"autonomy/run.sh")))return Z;let Q=rK(Z);if(Q===Z)break;Z=Q}return s7(DO,"..","..","..")}function tK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(qQ(s7(X,"VERSION"))&&qQ(s7(X,"autonomy/run.sh")))return X;let Y=rK(X);if(Y===X)break;X=Y}return s7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??s7(process.cwd(),".loki")}function I4(){return s7(u_(),".loki")}var DO,Q8;var G8=p(()=>{DO=rK(m_(import.meta.url));Q8=p_()});import{readFileSync as d_}from"fs";import{resolve as c_,dirname as l_}from"path";import{fileURLToPath as i_}from"url";function _3(){if(f5!==null)return f5;let Z="8.0.2";if(typeof Z==="string"&&Z.length>0)return f5=Z,f5;try{let X=l_(i_(import.meta.url)),Q=tK(X);f5=d_(c_(Q,"VERSION"),"utf-8").trim()}catch{f5="unknown"}return f5}var f5=null;var GQ=p(()=>{G8()});var kO={};c0(kO,{runOrThrow:()=>Wf,run:()=>D0,readStreamCapped:()=>HQ,commandVersion:()=>qf,commandExists:()=>H9,ShellError:()=>eK,MAX_STDOUT_BYTES:()=>RO});async function HQ(Z,X=RO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function D0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([HQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Wf(Z,X={}){let Q=await D0(Z,X);if(Q.exitCode!==0)throw new eK(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function H9(Z){let X=Vf(Z),Q=await D0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Vf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function qf(Z,X="--version"){if(!await H9(Z))return null;let Y=await D0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var RO=16777216,eK;var k9=p(()=>{eK=class eK extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function n7(Z){return Gf?"":Z}var Gf,M0,k8,l0,gW0,a0,H8,X9,g;var S6=p(()=>{Gf=(process.env.NO_COLOR??"").length>0;M0=n7("\x1B[0;31m"),k8=n7("\x1B[0;32m"),l0=n7("\x1B[1;33m"),gW0=n7("\x1B[0;34m"),a0=n7("\x1B[0;36m"),H8=n7("\x1B[1m"),X9=n7("\x1B[2m"),g=n7("\x1B[0m")});import{existsSync as wf}from"fs";async function F7(){if(R4!==void 0)return R4;let Z="/opt/homebrew/bin/python3.12";if(wf(Z))return R4=Z,Z;let X=await H9("python3.12");if(X)return R4=X,X;let Q=await H9("python3");return R4=Q,Q}async function D7(Z,X={}){let Q=await F7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return D0([Q,"-c",Z],X)}var R4;var o7=p(()=>{k9()});var oO={};c0(oO,{runStatus:()=>of});import{existsSync as Q9,readFileSync as h3,readdirSync as pO,statSync as dO}from"fs";import{resolve as h8,basename as mf}from"path";import{homedir as uf}from"os";function cO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function lO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*BQ/X);if(J>BQ)J=BQ;let z=BQ-J,K=k8;if(Y>=80)K=M0;else if(Y>=50)K=l0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=cO(Z),V=cO(X);return` ${H8}${Q}${g} ${K}[${$}]${g} ${Y}% (${W} / ${V})`}async function df(){if(await H9("jq"))return!0;return process.stdout.write(`${M0}Error: jq is required but not installed.${g}
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)
@@ -1203,4 +1203,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1203
1203
  `),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (I_(),E_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
1204
1204
  `),process.stderr.write(P_),2}}mO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var TW0=await MW0(Bun.argv.slice(2));process.exit(TW0);
1205
1205
 
1206
- //# debugId=95E883C64E57C32964756E2164756E21
1206
+ //# debugId=0B4A1F7E210B77BE64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '8.0.0'
60
+ __version__ = '8.0.2'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "8.0.0",
4
+ "version": "8.0.2",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "8.0.0",
5
+ "version": "8.0.2",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -31,6 +31,48 @@ export LOKI_PROVIDER=claude # or codex, cline, aider
31
31
  loki start --provider cline ./prd.md
32
32
  ```
33
33
 
34
+ ## Any Model, Any Provider (ANTHROPIC_BASE_URL)
35
+
36
+ Independent of the four CLI providers below. Loki speaks the Anthropic Messages
37
+ API, so ANY endpoint that implements it works: OpenRouter, Ollama, LiteLLM,
38
+ vLLM, or a self-hosted gateway. Nothing needs installing.
39
+
40
+ ```bash
41
+ export ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 # or http://localhost:11434/v1
42
+ export ANTHROPIC_API_KEY=... # omit for a keyless local Ollama
43
+ export LOKI_MODEL_OVERRIDE=<exact model id from your provider>
44
+ loki start prd.md
45
+ ```
46
+
47
+ **BOTH variables are required.** The override at `providers/claude.sh:520` fires
48
+ only when `ANTHROPIC_BASE_URL` AND `LOKI_MODEL_OVERRIDE` are both set. With only
49
+ the base URL, Loki keeps resolving the tier aliases `opus` / `sonnet` / `haiku`,
50
+ which only Anthropic serves -- most providers reject them and the run dies at the
51
+ first model call. (A proxy that maps the aliases, such as LiteLLM, is the
52
+ exception.) `loki doctor` warns on exactly this condition.
53
+
54
+ Implementation, byte-mirrored across both routes -- edit BOTH or the parity
55
+ fixtures diverge:
56
+
57
+ | Route | Site |
58
+ |---|---|
59
+ | bash | `providers/claude.sh:520`, `autonomy/run.sh:693` |
60
+ | Bun | `loki-ts/src/runner/providers.ts:328` |
61
+ | doctor | `loki-ts/src/commands/doctor.ts:652` |
62
+
63
+ `ANTHROPIC_BASE_URL` is passed through unchanged; Loki never rewrites it.
64
+
65
+ **No model IDs are listed here deliberately.** Alt-provider catalogues change
66
+ weekly, and a stale ID in documentation becomes a runtime failure for the user.
67
+ Read the exact string from the provider (`ollama list`, OpenRouter's model page,
68
+ your gateway's config). For the same reason `LOKI_MODEL_OVERRIDE` is NOT
69
+ validated against an allowlist -- any string the provider accepts is passed
70
+ straight through.
71
+
72
+ Quality gates, the completion council, and the Evidence Receipt are all
73
+ model-agnostic: they assert on the artifact that was built, not on which model
74
+ built it. A cheaper or local model gets the same verification as Opus.
75
+
34
76
  ## Claude Code (Default, Full Features)
35
77
 
36
78
  **Best for:** All use cases. Full autonomous capability.