loki-mode 10.5.15 → 10.5.17

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.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/README.md +2 -0
  3. package/SKILL.md +2 -2
  4. package/VERSION +1 -1
  5. package/autonomy/lib/backlog.py +280 -0
  6. package/autonomy/lib/claude-flags.sh +15 -5
  7. package/autonomy/lib/loki_yaml.py +225 -0
  8. package/autonomy/loki +65 -6
  9. package/autonomy/run.sh +33 -7
  10. package/autonomy/verify.sh +11 -0
  11. package/completions/_loki +1 -0
  12. package/completions/loki.bash +2 -2
  13. package/dashboard/__init__.py +1 -1
  14. package/docs/INSTALLATION.md +1 -1
  15. package/docs/exit-codes.md +15 -0
  16. package/docs/v10/BOARD.md +20 -10
  17. package/docs/v10/DECISIONS.md +36 -0
  18. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  19. package/docs/v10/PROGRESS.md +59 -0
  20. package/loki-ts/dist/loki.js +476 -468
  21. package/mcp/__init__.py +1 -1
  22. package/package.json +1 -1
  23. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  24. package/web-app/dist/assets/{AdminPage-1661YC4g.js → AdminPage-DxGZSpFg.js} +1 -1
  25. package/web-app/dist/assets/{Avatar-CCdkODwW.js → Avatar-BZEp23D8.js} +1 -1
  26. package/web-app/dist/assets/{Badge-DubiNUZt.js → Badge-DnDwqDLu.js} +1 -1
  27. package/web-app/dist/assets/{Button-BijTQ2Hp.js → Button-C9KDP4c1.js} +1 -1
  28. package/web-app/dist/assets/{CockpitPage-BBzWm20i.js → CockpitPage-DRlh3LV2.js} +1 -1
  29. package/web-app/dist/assets/{ComparePage-CV1dk0YH.js → ComparePage-C_gE45dQ.js} +1 -1
  30. package/web-app/dist/assets/{ErrorBoundary-G43S3loS.js → ErrorBoundary-CL-J9-Cs.js} +1 -1
  31. package/web-app/dist/assets/{EvidenceReceiptPanel-CMxsfL9Z.js → EvidenceReceiptPanel-DABd176Y.js} +1 -1
  32. package/web-app/dist/assets/{GitHubIssuesPanel-B8MNoAuP.js → GitHubIssuesPanel-C4lYbm6O.js} +1 -1
  33. package/web-app/dist/assets/{GitHubPRsPanel-4qgjR27S.js → GitHubPRsPanel-CgiUixwo.js} +1 -1
  34. package/web-app/dist/assets/{HomePage-BhtvClDY.js → HomePage-DC99RIrz.js} +2 -2
  35. package/web-app/dist/assets/{LoginPage-ByyJjjmL.js → LoginPage-Bd72QWNk.js} +1 -1
  36. package/web-app/dist/assets/{MagicPage-CtcPSQs8.js → MagicPage-CBouFs2K.js} +1 -1
  37. package/web-app/dist/assets/{MetricsPage-ClJgMw3s.js → MetricsPage-c9MyDcBp.js} +1 -1
  38. package/web-app/dist/assets/{NotFoundPage-Dqd4hF4i.js → NotFoundPage-ZPhiebiW.js} +1 -1
  39. package/web-app/dist/assets/{ProjectPage-Dk4djs43.js → ProjectPage-DKwrALUb.js} +3 -3
  40. package/web-app/dist/assets/{ProjectsPage-CsBlltJT.js → ProjectsPage-X1uZKZC_.js} +1 -1
  41. package/web-app/dist/assets/{SettingsPage-uCfcY7iV.js → SettingsPage-CiZKZRto.js} +1 -1
  42. package/web-app/dist/assets/{ShowcasePage-CQI_upZe.js → ShowcasePage-k_imKfeh.js} +1 -1
  43. package/web-app/dist/assets/{SystemSettingsPage-Cj-Lzdfz.js → SystemSettingsPage-Wx9ESLll.js} +1 -1
  44. package/web-app/dist/assets/{TeamsPage-BDx2oGna.js → TeamsPage-B7upn-I7.js} +1 -1
  45. package/web-app/dist/assets/{TemplatesPage-BQmxaXw-.js → TemplatesPage-oJf_VKgh.js} +1 -1
  46. package/web-app/dist/assets/{TerminalOutput-CRHC4wYM.js → TerminalOutput-BdfPlgLI.js} +1 -1
  47. package/web-app/dist/assets/{activity-V-Uelube.js → activity-BcZTsgWH.js} +1 -1
  48. package/web-app/dist/assets/{bell-C-87SkAH.js → bell-JNEkjWQ2.js} +1 -1
  49. package/web-app/dist/assets/{bot-C7LozwoL.js → bot-DMAJobP_.js} +1 -1
  50. package/web-app/dist/assets/{check-C_vTc9Wn.js → check-DPTry-z2.js} +1 -1
  51. package/web-app/dist/assets/{chevron-left-BaUVoa6G.js → chevron-left-DSqOEtTV.js} +1 -1
  52. package/web-app/dist/assets/{circle-alert-DlX6JSco.js → circle-alert-BbL8Vysv.js} +1 -1
  53. package/web-app/dist/assets/{clock-VmX2uYbD.js → clock-BHEr5lGW.js} +1 -1
  54. package/web-app/dist/assets/{cloud-D_39zt_T.js → cloud-C48PunYo.js} +1 -1
  55. package/web-app/dist/assets/{code-xml-DdlM7uo3.js → code-xml-Boa7kRqT.js} +1 -1
  56. package/web-app/dist/assets/{database-BpMQCo28.js → database-Eu35Gv7z.js} +1 -1
  57. package/web-app/dist/assets/{dollar-sign-Bwa1rbCN.js → dollar-sign-BO9fCnHW.js} +1 -1
  58. package/web-app/dist/assets/{file-code-corner-8d8iVZR0.js → file-code-corner-DHsSDIL_.js} +1 -1
  59. package/web-app/dist/assets/{file-plus-DaR4_dNZ.js → file-plus-BYiQLe5l.js} +1 -1
  60. package/web-app/dist/assets/{globe-qpF9WsVW.js → globe-BPZ5sQI2.js} +1 -1
  61. package/web-app/dist/assets/{hammer-CUuB3N-9.js → hammer-DlOLTsH3.js} +1 -1
  62. package/web-app/dist/assets/{index-CjHC75iU.js → index-BrgkuqSA.js} +3 -3
  63. package/web-app/dist/assets/{layers-Bg8D3b7W.js → layers-DMNdn1GR.js} +1 -1
  64. package/web-app/dist/assets/{loader-circle-D6KvToOI.js → loader-circle-_e9fypHL.js} +1 -1
  65. package/web-app/dist/assets/{lock-7JNLBG67.js → lock-D_elxMZb.js} +1 -1
  66. package/web-app/dist/assets/{package-BHJiWZ8O.js → package-Bw-7g3JI.js} +1 -1
  67. package/web-app/dist/assets/{plus-C08Uh-ub.js → plus-CZeq0pgJ.js} +1 -1
  68. package/web-app/dist/assets/{refresh-cw-Du1eZj1a.js → refresh-cw-D0WMyffs.js} +1 -1
  69. package/web-app/dist/assets/{rotate-ccw-k3kR3GkM.js → rotate-ccw-CcKyNwXG.js} +1 -1
  70. package/web-app/dist/assets/{scroll-text-DXn0Z1aO.js → scroll-text-C0L9Vqpm.js} +1 -1
  71. package/web-app/dist/assets/{server-Co4areOg.js → server-SgwFv6cd.js} +1 -1
  72. package/web-app/dist/assets/{shield-alert-BcVL7YGG.js → shield-alert-D1tESlfI.js} +1 -1
  73. package/web-app/dist/assets/{thumbs-up-h1XMb-L3.js → thumbs-up-bUtUA15O.js} +1 -1
  74. package/web-app/dist/assets/{trash-2-B4AamWeu.js → trash-2-C9Nv1kKk.js} +1 -1
  75. package/web-app/dist/assets/{trending-down-B_boYLT1.js → trending-down-Bh107AJe.js} +1 -1
  76. package/web-app/dist/assets/{trending-up-JE2ittyH.js → trending-up-25BqhLVq.js} +1 -1
  77. package/web-app/dist/assets/{upload-DGPQNnqT.js → upload-DDYtMbN5.js} +1 -1
  78. package/web-app/dist/assets/{usePolling-Br26QTdJ.js → usePolling-BSDT0il_.js} +1 -1
  79. package/web-app/dist/assets/{user-_PdrcqfM.js → user-CKpUEUhK.js} +1 -1
  80. package/web-app/dist/index.html +1 -1
@@ -23,6 +23,18 @@
23
23
  "mcp",
24
24
  "loki"
25
25
  ]
26
+ },
27
+ {
28
+ "name": "loki-seal",
29
+ "source": "./packages/loki-seal",
30
+ "description": "Stop hook that refuses 'done' while tests are red or were deleted, skipped, xfailed or weakened. Runs locally, no model calls.",
31
+ "homepage": "https://github.com/asklokesh/loki-mode",
32
+ "category": "testing",
33
+ "keywords": [
34
+ "verification",
35
+ "tests",
36
+ "hooks"
37
+ ]
26
38
  }
27
39
  ]
28
40
  }
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  Your agent says done. Loki proves it.
2
2
 
3
3
  An autonomous software factory that knows what it is supposed to deliver, and proves it did.
4
+ Install: `/plugin marketplace add asklokesh/loki-mode` then `/plugin install loki-seal@loki-mode`
5
+
4
6
 
5
7
  <!-- 20-second loki-seal GIF: placeholder until recorded. Script: packages/loki-seal/demo/demo.tape -->
6
8
  [loki-seal demo script](packages/loki-seal/demo/demo.tape)
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: An autonomous software factory that knows what it is supposed to deliver, and proves it did. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v10.5.15
6
+ # Loki Mode v10.5.17
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v10.5.15 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v10.5.17 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 10.5.15
1
+ 10.5.17
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """loki backlog: run one v10 issue-mode run per open issue, N in parallel (D51).
3
+
4
+ loki backlog owner/repo --all | --label X | --issues 1,2,3
5
+ [--concurrency N] [--dry-run]
6
+
7
+ Each issue gets its own git worktree and branch (loki/backlog-N) beside the
8
+ checkout; the launcher runs inside it. Success = the child exited 0 (the engine's
9
+ ladder: 0 VERIFIED, 1 FAILED, 3 BUDGET_STOP, 4 BLOCKED, 5 STALLED). A draft PR
10
+ from a failed run is still FAILED. Exit: 0 all ok, 3 only budget stops, 1 other
11
+ failures, 2 usage/config error.
12
+
13
+ Test seam: LOKI_BACKLOG_LAUNCHER replaces the real launcher (called with the
14
+ issue ref, cwd = the issue worktree).
15
+ """
16
+ import argparse
17
+ import datetime
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import time
26
+
27
+ HERE = os.path.dirname(os.path.abspath(__file__))
28
+ sys.path.insert(0, HERE)
29
+ import loki_yaml # noqa: E402
30
+
31
+ REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
32
+ REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
33
+
34
+
35
+ def say(msg):
36
+ print(msg, flush=True)
37
+
38
+
39
+ def die(msg, rc=2):
40
+ print("loki backlog: " + msg, file=sys.stderr)
41
+ return rc
42
+
43
+
44
+ def redact(text, token):
45
+ return text.replace(token, "***") if token and len(token) >= 6 else text
46
+
47
+
48
+ def git(*args, cwd=None):
49
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
50
+
51
+
52
+ def origin_slug(cwd):
53
+ url = git("remote", "get-url", "origin", cwd=cwd).stdout.strip()
54
+ m = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$", url)
55
+ return m.group(1) if m else ""
56
+
57
+
58
+ def list_issues(repo, label, env):
59
+ cmd = ["gh", "issue", "list", "--repo", repo, "--state", "open", "--limit", "200",
60
+ "--json", "number,title,labels"]
61
+ if label:
62
+ cmd += ["--label", label]
63
+ r = subprocess.run(cmd, capture_output=True, text=True, env=env)
64
+ if r.returncode != 0:
65
+ raise RuntimeError("gh issue list failed: " + (r.stderr.strip().splitlines() or ["unknown error"])[0])
66
+ return sorted(({"number": i["number"], "title": i.get("title", "")} for i in json.loads(r.stdout or "[]")),
67
+ key=lambda i: i["number"])
68
+
69
+
70
+ # ---- daily spend ledger (outside any worktree) -----------------------------
71
+ def ledger_path():
72
+ return os.path.join(os.path.expanduser("~"), ".loki", "backlog-spend.json")
73
+
74
+
75
+ def spent_today():
76
+ try:
77
+ with open(ledger_path()) as f:
78
+ return float(json.load(f).get(datetime.date.today().isoformat(), 0))
79
+ except (OSError, ValueError):
80
+ return 0.0
81
+
82
+
83
+ def add_spend(usd):
84
+ p = ledger_path()
85
+ try:
86
+ with open(p) as f:
87
+ data = json.load(f)
88
+ except (OSError, ValueError):
89
+ data = {}
90
+ k = datetime.date.today().isoformat()
91
+ data[k] = round(float(data.get(k, 0)) + usd, 6)
92
+ os.makedirs(os.path.dirname(p), exist_ok=True)
93
+ with open(p, "w") as f:
94
+ json.dump(data, f)
95
+
96
+
97
+ def parse_result(rc, text):
98
+ """-> (ok, status line text, cost_usd or None)."""
99
+ pr = re.search(r"^PR:\s+(https?://\S+)", text, re.M)
100
+ why = re.search(r"^Reason:\s+(.+)$", text, re.M)
101
+ cost = re.search(r"^Cost:\s+(?:partial:\s+)?\$([0-9]+(?:\.[0-9]+)?)", text, re.M)
102
+ usd = float(cost.group(1)) if cost else None
103
+ reason = why.group(1).strip() if why else ""
104
+ if rc == 0:
105
+ return True, ("PR " + pr.group(1)) if pr else "VERIFIED", usd
106
+ if rc == 4:
107
+ return False, "BLOCKED: " + (reason or "see the run log"), usd
108
+ if rc == 3:
109
+ return False, "BUDGET_STOP: " + (reason or "run cost cap reached"), usd
110
+ return False, "FAILED: " + (reason or "exit %d" % rc), usd
111
+
112
+
113
+ def main(argv):
114
+ ap = argparse.ArgumentParser(prog="loki backlog", add_help=True)
115
+ ap.add_argument("repo")
116
+ sel = ap.add_mutually_exclusive_group()
117
+ sel.add_argument("--all", action="store_true")
118
+ sel.add_argument("--label")
119
+ sel.add_argument("--issues")
120
+ ap.add_argument("--concurrency", type=int)
121
+ ap.add_argument("--dry-run", action="store_true")
122
+ a = ap.parse_args(argv)
123
+
124
+ if not REPO_RE.match(a.repo):
125
+ return die("repo must be owner/repo, got %r" % a.repo)
126
+ if not (a.all or a.label or a.issues):
127
+ return die("choose one of --all, --label X, --issues 1,2,3")
128
+ numbers = None
129
+ if a.issues:
130
+ if not re.match(r"^\d+(,\d+)*$", a.issues):
131
+ return die("--issues takes a comma-separated list of numbers, got %r" % a.issues)
132
+ numbers = [int(n) for n in a.issues.split(",")]
133
+
134
+ try:
135
+ cfg, cfg_path, errors = loki_yaml.load()
136
+ except loki_yaml.CannotCheck as e:
137
+ return die("could not read loki.yaml: %s" % e)
138
+ if errors:
139
+ print("loki backlog: invalid configuration (%s):" % (cfg_path or "environment"), file=sys.stderr)
140
+ for e in errors:
141
+ print(" - " + e, file=sys.stderr)
142
+ return 2
143
+ conc = a.concurrency or cfg.get("concurrency", 2)
144
+ if conc < 1:
145
+ return die("--concurrency must be >= 1")
146
+
147
+ env = dict(os.environ)
148
+ token = ""
149
+ tok_env = cfg.get("git", {}).get("token_env")
150
+ if tok_env:
151
+ token = os.environ.get(tok_env, "")
152
+ if not token:
153
+ return die("git.token_env names %s but that environment variable is not set" % tok_env)
154
+ env["GH_TOKEN"] = env["GITHUB_TOKEN"] = token
155
+
156
+ top = git("rev-parse", "--show-toplevel").stdout.strip()
157
+ if not top:
158
+ return die("run this from a checkout of %s" % a.repo)
159
+ if origin_slug(top).lower() != a.repo.lower():
160
+ return die("this checkout's origin is %r, not %s; cd into a checkout of %s"
161
+ % (origin_slug(top) or "unknown", a.repo, a.repo))
162
+
163
+ try:
164
+ issues = ([{"number": n, "title": ""} for n in numbers] if numbers
165
+ else list_issues(a.repo, a.label, env))
166
+ except RuntimeError as e:
167
+ return die(redact(str(e), token), 1)
168
+
169
+ say("backlog: %s concurrency %d, %d issue(s)" % (a.repo, conc, len(issues)))
170
+ if a.dry_run:
171
+ for i in issues:
172
+ say("backlog: would run #%d %s" % (i["number"], i["title"]))
173
+ return 0
174
+ if not issues:
175
+ say("backlog: nothing to do")
176
+ return 0
177
+
178
+ # ---- launch ------------------------------------------------------------
179
+ wt_root = top.rstrip("/") + "-backlog"
180
+ os.makedirs(os.path.join(wt_root, "logs"), exist_ok=True)
181
+ launcher = os.environ.get("LOKI_BACKLOG_LAUNCHER") or os.path.join(REPO_ROOT, "bin", "loki")
182
+ child_env = dict(env, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
183
+ for key, var in (("models.default", "LOKI_MODEL_DEVELOPMENT"), ("models.cheap", "LOKI_MODEL_FAST")):
184
+ sect, name = key.split(".")
185
+ if cfg.get(sect, {}).get(name):
186
+ child_env[var] = cfg[sect][name]
187
+ if cfg.get("provider"):
188
+ child_env["LOKI_PROVIDER"] = cfg["provider"]
189
+ # ponytail: per-run cap is forwarded only; the v10 engine itself caps on time
190
+ if cfg.get("budgets", {}).get("per_run_usd"):
191
+ child_env["LOKI_BUDGET_LIMIT"] = str(cfg["budgets"]["per_run_usd"])
192
+ day_cap = cfg.get("budgets", {}).get("per_day_usd")
193
+
194
+ state = {i["number"]: {"status": "queued", "ok": None, "cost": None, "ran": False} for i in issues}
195
+ for n in state:
196
+ say("backlog: #%d queued" % n)
197
+ pending = [i["number"] for i in issues]
198
+ running = {} # n -> (Popen, log path, worktree)
199
+ unmeasured = 0
200
+
201
+ def stop_children(*_):
202
+ for p, _l, _w in running.values():
203
+ try:
204
+ os.killpg(p.pid, signal.SIGTERM)
205
+ except OSError:
206
+ pass
207
+ sys.exit(130)
208
+ signal.signal(signal.SIGTERM, stop_children)
209
+ signal.signal(signal.SIGINT, stop_children)
210
+
211
+ def finish(n):
212
+ nonlocal unmeasured
213
+ p, log, wt = running.pop(n)
214
+ try:
215
+ with open(log, errors="replace") as f:
216
+ text = redact(f.read(), token)
217
+ with open(log, "w") as f:
218
+ f.write(text)
219
+ except OSError:
220
+ text = ""
221
+ ok, status, usd = parse_result(p.returncode, text)
222
+ if usd is None:
223
+ unmeasured += 1
224
+ else:
225
+ add_spend(usd)
226
+ state[n].update(status=status, ok=ok, cost=usd)
227
+ say("backlog: #%d %s" % (n, status))
228
+ if ok:
229
+ git("worktree", "remove", "--force", wt, cwd=top)
230
+ git("branch", "-D", "loki/backlog-%d" % n, cwd=top)
231
+
232
+ def launch(n):
233
+ wt = os.path.join(wt_root, "issue-%d" % n)
234
+ if os.path.exists(wt):
235
+ git("worktree", "remove", "--force", wt, cwd=top)
236
+ r = git("worktree", "add", "-B", "loki/backlog-%d" % n, wt, "HEAD", cwd=top)
237
+ if r.returncode != 0:
238
+ err = (r.stderr.strip().splitlines() or ["git error"])[-1]
239
+ state[n].update(status="FAILED: worktree: " + redact(err, token), ok=False)
240
+ say("backlog: #%d %s" % (n, state[n]["status"]))
241
+ return
242
+ log = os.path.join(wt_root, "logs", "issue-%d.log" % n)
243
+ cmd = [launcher, "%s#%d" % (a.repo, n)]
244
+ with open(log, "w") as lf:
245
+ p = subprocess.Popen(cmd, cwd=wt, env=child_env, stdout=lf, stderr=subprocess.STDOUT,
246
+ stdin=subprocess.DEVNULL, start_new_session=True)
247
+ running[n] = (p, log, wt)
248
+ state[n].update(status="running", ran=True)
249
+ say("backlog: #%d running" % n)
250
+
251
+ while pending or running:
252
+ for n in [n for n, (p, _l, _w) in running.items() if p.poll() is not None]:
253
+ finish(n)
254
+ while pending and len(running) < conc:
255
+ if day_cap and spent_today() >= day_cap:
256
+ for n in pending:
257
+ state[n].update(status="BUDGET_STOP: daily budget $%.2f reached ($%.2f spent)" % (day_cap, spent_today()), ok=False)
258
+ say("backlog: #%d %s" % (n, state[n]["status"]))
259
+ pending = []
260
+ break
261
+ launch(pending.pop(0))
262
+ if running:
263
+ time.sleep(0.2)
264
+
265
+ # ---- summary -----------------------------------------------------------
266
+ say("")
267
+ say("Summary: %s" % a.repo)
268
+ say("%-8s %-8s %s" % ("ISSUE", "COST", "RESULT"))
269
+ for n, s in state.items():
270
+ say("%-8s %-8s %s" % ("#%d" % n, ("$%.2f" % s["cost"] if s["cost"] is not None else "unmeasured" if s["ran"] else "-"), s["status"]))
271
+ measured = sum(s["cost"] or 0 for s in state.values())
272
+ say("Total measured cost: $%.2f%s" % (measured, " (%d run(s) unmeasured, not counted as $0)" % unmeasured if unmeasured else ""))
273
+ bad = [s for s in state.values() if not s["ok"]]
274
+ if not bad:
275
+ return 0
276
+ return 3 if all(s["status"].startswith("BUDGET_STOP") for s in bad) else 1
277
+
278
+
279
+ if __name__ == "__main__":
280
+ sys.exit(main(sys.argv[1:]))
@@ -784,6 +784,16 @@ loki_caveman_suppress_env() {
784
784
  # files, lockfiles, or compiled Python cache entries from polluting the user's
785
785
  # feature branch and proof diff. caveman's installer is already auto-
786
786
  # non-interactive without a TTY, but we belt-and-suspenders it.
787
+ # Caveman bootstrap notices. In quiet `loki quick` (inner run, not verbose) they
788
+ # go to a run log next to quick-run.log, not the user's terminal; otherwise stderr.
789
+ _loki_caveman_say() {
790
+ if [ -n "${LOKI_QUICK_INNER:-}" ] && [ "${LOKI_VERBOSE:-0}" != "1" ] && [ -d "${LOKI_QUICK_DIR:-}" ]; then
791
+ printf '%s\n' "$1" >>"$LOKI_QUICK_DIR/quick-run.notices.log" 2>/dev/null || true
792
+ else
793
+ printf '%s\n' "$1" >&2
794
+ fi
795
+ }
796
+
787
797
  loki_caveman_bootstrap() {
788
798
  [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
789
799
  [ "${LOKI_CAVEMAN_AUTO_BOOTSTRAP:-1}" = "0" ] && return 1
@@ -801,11 +811,11 @@ loki_caveman_bootstrap() {
801
811
  _loki_caveman_installed && return 0 || return 1
802
812
  fi
803
813
  if ! command -v node >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1; then
804
- printf '%s\n' "[caveman] node>=18 + npx required to bootstrap; skipping (run proceeds uncompressed). Install Node or set LOKI_CAVEMAN=0 to silence." >&2
814
+ _loki_caveman_say "[caveman] node>=18 + npx required to bootstrap; skipping (run proceeds uncompressed). Install Node or set LOKI_CAVEMAN=0 to silence."
805
815
  mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
806
816
  return 1
807
817
  fi
808
- printf '%s\n' "[caveman] bootstrapping output-token compressor v${ver} (one-time, pinned). NOTE: caveman installs GLOBALLY (a Claude Code SessionStart hook in ~/.claude affecting every Claude Code session). Loki applies it only to free-form generation, NEVER to trust-gate subcalls. Opt out: LOKI_CAVEMAN=0." >&2
818
+ _loki_caveman_say "[caveman] bootstrapping output-token compressor v${ver} (one-time, pinned). NOTE: caveman installs GLOBALLY (a Claude Code SessionStart hook in ~/.claude affecting every Claude Code session). Loki applies it only to free-form generation, NEVER to trust-gate subcalls. Opt out: LOKI_CAVEMAN=0."
809
819
  # Pin via the git tag (v-prefixed) on the npx ref AND CAVEMAN_REF so the
810
820
  # downloaded hooks match the pinned release. Default install (no --all) wires
811
821
  # the Claude Code hook for the detected `claude` CLI. A timeout backstops a
@@ -816,7 +826,7 @@ loki_caveman_bootstrap() {
816
826
  fi
817
827
  local bootstrap_tmp=""
818
828
  bootstrap_tmp="$(mktemp -d "${TMPDIR:-/tmp}/loki-caveman-bootstrap.XXXXXX")" || {
819
- printf '%s\n' "[caveman] could not allocate an isolated bootstrap directory; run proceeds uncompressed." >&2
829
+ _loki_caveman_say "[caveman] could not allocate an isolated bootstrap directory; run proceeds uncompressed."
820
830
  mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
821
831
  return 1
822
832
  }
@@ -829,11 +839,11 @@ loki_caveman_bootstrap() {
829
839
  if [ "$bootstrap_rc" -eq 0 ]; then
830
840
  mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
831
841
  if _loki_caveman_installed; then
832
- printf '%s\n' "[caveman] installed v${ver}." >&2
842
+ _loki_caveman_say "[caveman] installed v${ver}."
833
843
  return 0
834
844
  fi
835
845
  fi
836
- printf '%s\n' "[caveman] bootstrap unavailable (upstream unreachable, timed out, or install failed); run proceeds uncompressed." >&2
846
+ _loki_caveman_say "[caveman] bootstrap unavailable (upstream unreachable, timed out, or install failed); run proceeds uncompressed."
837
847
  mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
838
848
  return 1
839
849
  }
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env python3
2
+ """loki.yaml: lookup, env overrides and validation (D51).
3
+
4
+ Shared by `loki backlog`, `loki config validate` and the dashboard.
5
+ Single source of truth for the key set: schemas/loki-yaml.schema.json.
6
+
7
+ from loki_yaml import load
8
+ cfg, path, errors = load()
9
+
10
+ CLI:
11
+ loki_yaml.py validate [FILE] rc 0 ok, 1 invalid or none found, 2 cannot check
12
+ loki_yaml.py show effective config as JSON (env overrides applied)
13
+
14
+ Never echoes a string value for an x-secret-ref field: a user who pastes a real
15
+ token into token_env must not see it printed back.
16
+ """
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ SCHEMA_PATH = os.path.join(HERE, "..", "..", "schemas", "loki-yaml.schema.json")
24
+
25
+ # dotted key -> env var (CLI flag > env > loki.yaml > default)
26
+ ENV_OVERRIDES = {
27
+ "provider": "LOKI_PROVIDER",
28
+ "models.default": "LOKI_MODEL_DEVELOPMENT",
29
+ "models.cheap": "LOKI_MODEL_FAST",
30
+ "git.token_env": "LOKI_GIT_TOKEN_ENV",
31
+ "concurrency": "LOKI_BACKLOG_CONCURRENCY",
32
+ "budgets.per_run_usd": "LOKI_BUDGET_PER_RUN_USD",
33
+ "budgets.per_day_usd": "LOKI_BUDGET_PER_DAY_USD",
34
+ "notifications.slack_webhook_env": "LOKI_SLACK_WEBHOOK_ENV",
35
+ }
36
+
37
+
38
+ class CannotCheck(Exception):
39
+ """No usable YAML parser: callers must not read this as 'valid'."""
40
+
41
+
42
+ def load_schema():
43
+ with open(SCHEMA_PATH) as f:
44
+ return json.load(f)
45
+
46
+
47
+ def find_config(cwd=None, home=None):
48
+ """Repo-root loki.yaml first, else ~/.loki/loki.yaml. First found wins."""
49
+ cwd = cwd or os.getcwd()
50
+ home = home or os.path.expanduser("~")
51
+ try:
52
+ top = subprocess.run(["git", "-C", cwd, "rev-parse", "--show-toplevel"],
53
+ capture_output=True, text=True).stdout.strip()
54
+ except OSError:
55
+ top = ""
56
+ for cand in (os.path.join(top or cwd, "loki.yaml"),
57
+ os.path.join(home, ".loki", "loki.yaml")):
58
+ if os.path.isfile(cand):
59
+ return cand
60
+ return None
61
+
62
+
63
+ def parse_yaml(path):
64
+ try:
65
+ import yaml
66
+ with open(path) as f:
67
+ return yaml.safe_load(f)
68
+ except ImportError:
69
+ pass
70
+ try:
71
+ r = subprocess.run(["yq", "eval", "-o=json", ".", path], capture_output=True, text=True)
72
+ except OSError:
73
+ raise CannotCheck("no YAML parser: install PyYAML (pip install pyyaml) or yq")
74
+ if r.returncode != 0:
75
+ raise ValueError("invalid YAML: " + (r.stderr.strip().splitlines() or ["parse error"])[0])
76
+ return json.loads(r.stdout)
77
+
78
+
79
+ def _type_ok(val, t):
80
+ if t == "object":
81
+ return isinstance(val, dict)
82
+ if t == "array":
83
+ return isinstance(val, list)
84
+ if t == "string":
85
+ return isinstance(val, str)
86
+ if t == "integer":
87
+ return isinstance(val, int) and not isinstance(val, bool)
88
+ if t == "number":
89
+ return isinstance(val, (int, float)) and not isinstance(val, bool)
90
+ return True
91
+
92
+
93
+ def validate(data, schema, path="", out=None):
94
+ """Validate against the JSON-schema subset used by loki-yaml.schema.json."""
95
+ import re
96
+ out = [] if out is None else out
97
+ label = path or "(top level)"
98
+ t = schema.get("type")
99
+ secret = schema.get("x-secret-ref")
100
+ shown = "" if secret or not isinstance(data, (str, int, float)) else " (got %r)" % (data,)
101
+ if t and not _type_ok(data, t):
102
+ out.append("%s: must be %s%s" % (label, {"integer": "an integer", "object": "a mapping",
103
+ "array": "a list", "string": "a string", "number": "a number"}[t], shown))
104
+ return out
105
+ if isinstance(data, dict):
106
+ props = schema.get("properties", {})
107
+ for k, v in data.items():
108
+ kp = (path + "." if path else "") + str(k)
109
+ if k not in props:
110
+ if schema.get("additionalProperties") is False:
111
+ out.append("%s: unknown key (allowed: %s)" % (kp, ", ".join(sorted(props))))
112
+ else:
113
+ validate(v, props[k], kp, out)
114
+ elif isinstance(data, list):
115
+ for i, v in enumerate(data):
116
+ validate(v, schema.get("items", {}), "%s[%d]" % (path, i), out)
117
+ elif isinstance(data, str):
118
+ if secret and not re.match(schema.get("pattern", ""), data):
119
+ out.append("%s: must be the NAME of an environment variable (UPPER_CASE letters, "
120
+ "digits, underscore), not the secret itself" % label)
121
+ elif "pattern" in schema and not re.match(schema["pattern"], data):
122
+ out.append("%s: does not match %s%s" % (label, schema["pattern"], shown))
123
+ elif "enum" in schema and data not in schema["enum"]:
124
+ out.append("%s: must be one of %s%s" % (label, ", ".join(schema["enum"]), shown))
125
+ elif len(data) < schema.get("minLength", 0):
126
+ out.append("%s: must not be empty" % label)
127
+ if _type_ok(data, "number") and not isinstance(data, bool):
128
+ if "minimum" in schema and data < schema["minimum"]:
129
+ out.append("%s: must be >= %s%s" % (label, schema["minimum"], shown))
130
+ elif "maximum" in schema and data > schema["maximum"]:
131
+ out.append("%s: must be <= %s%s" % (label, schema["maximum"], shown))
132
+ elif "exclusiveMinimum" in schema and data <= schema["exclusiveMinimum"]:
133
+ out.append("%s: must be > %s%s" % (label, schema["exclusiveMinimum"], shown))
134
+ return out
135
+
136
+
137
+ def _schema_type(schema, dotted):
138
+ for part in dotted.split("."):
139
+ schema = schema["properties"][part]
140
+ return schema.get("type")
141
+
142
+
143
+ def apply_env(cfg, schema, env):
144
+ """Overlay env overrides onto cfg. Returns {dotted_key: env_var} of what was applied."""
145
+ applied = {}
146
+ for dotted, var in ENV_OVERRIDES.items():
147
+ raw = env.get(var)
148
+ if raw is None or raw == "":
149
+ continue
150
+ val = raw
151
+ t = _schema_type(schema, dotted)
152
+ try:
153
+ if t == "integer":
154
+ val = int(raw)
155
+ elif t == "number":
156
+ val = float(raw)
157
+ except ValueError:
158
+ pass # stays a string; validation reports it, naming the env var
159
+ node = cfg
160
+ parts = dotted.split(".")
161
+ for p in parts[:-1]:
162
+ node = node.setdefault(p, {})
163
+ node[parts[-1]] = val
164
+ applied[dotted] = var
165
+ return applied
166
+
167
+
168
+ def load(cwd=None, env=None, home=None, path=None):
169
+ """Return (cfg, path_or_None, errors). Raises CannotCheck when YAML cannot be parsed."""
170
+ env = os.environ if env is None else env
171
+ schema = load_schema()
172
+ path = path or find_config(cwd, home)
173
+ cfg, errors = {}, []
174
+ if path:
175
+ try:
176
+ data = parse_yaml(path)
177
+ except CannotCheck:
178
+ raise
179
+ except Exception as e:
180
+ return {}, path, ["%s: %s" % (path, e)]
181
+ if data is None:
182
+ data = {}
183
+ if not isinstance(data, dict):
184
+ return {}, path, ["%s: top level must be a mapping" % path]
185
+ cfg = data
186
+ applied = apply_env(cfg, schema, env)
187
+ for e in validate(cfg, schema):
188
+ key = e.split(":", 1)[0]
189
+ if key in applied:
190
+ e = e.replace(key + ":", "%s (from env %s):" % (key, applied[key]), 1)
191
+ errors.append(e)
192
+ return cfg, path, errors
193
+
194
+
195
+ def main(argv):
196
+ cmd = argv[0] if argv else ""
197
+ if cmd not in ("validate", "show"):
198
+ print("usage: loki_yaml.py validate [FILE] | show", file=sys.stderr)
199
+ return 2
200
+ explicit = argv[1] if cmd == "validate" and len(argv) > 1 else None
201
+ if explicit and not os.path.isfile(explicit):
202
+ print("loki: config validate: file not found: %s" % explicit, file=sys.stderr)
203
+ return 1
204
+ try:
205
+ cfg, path, errors = load(path=explicit)
206
+ except CannotCheck as e:
207
+ print("loki: config validate: could not check: %s" % e, file=sys.stderr)
208
+ return 2
209
+ if cmd == "show":
210
+ print(json.dumps({"path": path, "config": cfg, "errors": errors}, indent=2))
211
+ return 1 if errors else 0
212
+ if path is None and not errors:
213
+ print("loki: no loki.yaml found (looked in the repo root and ~/.loki/loki.yaml)", file=sys.stderr)
214
+ return 1
215
+ if errors:
216
+ print("INVALID: %s" % (path or "environment overrides"))
217
+ for e in errors:
218
+ print(" - " + e)
219
+ return 1
220
+ print("OK: %s" % path)
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ sys.exit(main(sys.argv[1:]))