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.
- package/.claude-plugin/marketplace.json +12 -0
- package/README.md +2 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +280 -0
- package/autonomy/lib/claude-flags.sh +15 -5
- package/autonomy/lib/loki_yaml.py +225 -0
- package/autonomy/loki +65 -6
- package/autonomy/run.sh +33 -7
- package/autonomy/verify.sh +11 -0
- package/completions/_loki +1 -0
- package/completions/loki.bash +2 -2
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/exit-codes.md +15 -0
- package/docs/v10/BOARD.md +20 -10
- package/docs/v10/DECISIONS.md +36 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/PROGRESS.md +59 -0
- package/loki-ts/dist/loki.js +476 -468
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-1661YC4g.js → AdminPage-DxGZSpFg.js} +1 -1
- package/web-app/dist/assets/{Avatar-CCdkODwW.js → Avatar-BZEp23D8.js} +1 -1
- package/web-app/dist/assets/{Badge-DubiNUZt.js → Badge-DnDwqDLu.js} +1 -1
- package/web-app/dist/assets/{Button-BijTQ2Hp.js → Button-C9KDP4c1.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-BBzWm20i.js → CockpitPage-DRlh3LV2.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CV1dk0YH.js → ComparePage-C_gE45dQ.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-G43S3loS.js → ErrorBoundary-CL-J9-Cs.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CMxsfL9Z.js → EvidenceReceiptPanel-DABd176Y.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B8MNoAuP.js → GitHubIssuesPanel-C4lYbm6O.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-4qgjR27S.js → GitHubPRsPanel-CgiUixwo.js} +1 -1
- package/web-app/dist/assets/{HomePage-BhtvClDY.js → HomePage-DC99RIrz.js} +2 -2
- package/web-app/dist/assets/{LoginPage-ByyJjjmL.js → LoginPage-Bd72QWNk.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CtcPSQs8.js → MagicPage-CBouFs2K.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-ClJgMw3s.js → MetricsPage-c9MyDcBp.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-Dqd4hF4i.js → NotFoundPage-ZPhiebiW.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-Dk4djs43.js → ProjectPage-DKwrALUb.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CsBlltJT.js → ProjectsPage-X1uZKZC_.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-uCfcY7iV.js → SettingsPage-CiZKZRto.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-CQI_upZe.js → ShowcasePage-k_imKfeh.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Cj-Lzdfz.js → SystemSettingsPage-Wx9ESLll.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BDx2oGna.js → TeamsPage-B7upn-I7.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-BQmxaXw-.js → TemplatesPage-oJf_VKgh.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CRHC4wYM.js → TerminalOutput-BdfPlgLI.js} +1 -1
- package/web-app/dist/assets/{activity-V-Uelube.js → activity-BcZTsgWH.js} +1 -1
- package/web-app/dist/assets/{bell-C-87SkAH.js → bell-JNEkjWQ2.js} +1 -1
- package/web-app/dist/assets/{bot-C7LozwoL.js → bot-DMAJobP_.js} +1 -1
- package/web-app/dist/assets/{check-C_vTc9Wn.js → check-DPTry-z2.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BaUVoa6G.js → chevron-left-DSqOEtTV.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DlX6JSco.js → circle-alert-BbL8Vysv.js} +1 -1
- package/web-app/dist/assets/{clock-VmX2uYbD.js → clock-BHEr5lGW.js} +1 -1
- package/web-app/dist/assets/{cloud-D_39zt_T.js → cloud-C48PunYo.js} +1 -1
- package/web-app/dist/assets/{code-xml-DdlM7uo3.js → code-xml-Boa7kRqT.js} +1 -1
- package/web-app/dist/assets/{database-BpMQCo28.js → database-Eu35Gv7z.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Bwa1rbCN.js → dollar-sign-BO9fCnHW.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-8d8iVZR0.js → file-code-corner-DHsSDIL_.js} +1 -1
- package/web-app/dist/assets/{file-plus-DaR4_dNZ.js → file-plus-BYiQLe5l.js} +1 -1
- package/web-app/dist/assets/{globe-qpF9WsVW.js → globe-BPZ5sQI2.js} +1 -1
- package/web-app/dist/assets/{hammer-CUuB3N-9.js → hammer-DlOLTsH3.js} +1 -1
- package/web-app/dist/assets/{index-CjHC75iU.js → index-BrgkuqSA.js} +3 -3
- package/web-app/dist/assets/{layers-Bg8D3b7W.js → layers-DMNdn1GR.js} +1 -1
- package/web-app/dist/assets/{loader-circle-D6KvToOI.js → loader-circle-_e9fypHL.js} +1 -1
- package/web-app/dist/assets/{lock-7JNLBG67.js → lock-D_elxMZb.js} +1 -1
- package/web-app/dist/assets/{package-BHJiWZ8O.js → package-Bw-7g3JI.js} +1 -1
- package/web-app/dist/assets/{plus-C08Uh-ub.js → plus-CZeq0pgJ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-Du1eZj1a.js → refresh-cw-D0WMyffs.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-k3kR3GkM.js → rotate-ccw-CcKyNwXG.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DXn0Z1aO.js → scroll-text-C0L9Vqpm.js} +1 -1
- package/web-app/dist/assets/{server-Co4areOg.js → server-SgwFv6cd.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BcVL7YGG.js → shield-alert-D1tESlfI.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-h1XMb-L3.js → thumbs-up-bUtUA15O.js} +1 -1
- package/web-app/dist/assets/{trash-2-B4AamWeu.js → trash-2-C9Nv1kKk.js} +1 -1
- package/web-app/dist/assets/{trending-down-B_boYLT1.js → trending-down-Bh107AJe.js} +1 -1
- package/web-app/dist/assets/{trending-up-JE2ittyH.js → trending-up-25BqhLVq.js} +1 -1
- package/web-app/dist/assets/{upload-DGPQNnqT.js → upload-DDYtMbN5.js} +1 -1
- package/web-app/dist/assets/{usePolling-Br26QTdJ.js → usePolling-BSDT0il_.js} +1 -1
- package/web-app/dist/assets/{user-_PdrcqfM.js → user-CKpUEUhK.js} +1 -1
- 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.
|
|
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.
|
|
473
|
+
**v10.5.17 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
10.5.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
842
|
+
_loki_caveman_say "[caveman] installed v${ver}."
|
|
833
843
|
return 0
|
|
834
844
|
fi
|
|
835
845
|
fi
|
|
836
|
-
|
|
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:]))
|