loki-mode 10.5.16 → 10.5.19
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 +878 -29
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +280 -0
- package/autonomy/lib/loki_yaml.py +225 -0
- package/autonomy/lib/proof-generator.py +251 -0
- package/autonomy/lib/proof-verify.py +1 -0
- package/autonomy/loki +117 -12
- package/autonomy/run.sh +27 -2
- package/autonomy/verify.sh +11 -0
- package/completions/_loki +1 -0
- package/completions/loki.bash +2 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +463 -0
- package/dashboard/server.py +31 -0
- package/dashboard/static/start.html +100 -0
- package/docs/COMPARISON.md +6 -19
- package/docs/COMPETITIVE-ANALYSIS.md +6 -41
- package/docs/CRASH-REPORTING-PLAN.md +1 -1
- package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/R8-SHAREABLE-TEAM-ASSETS-PLAN.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/architecture/STATE-MACHINES.md +18 -27
- package/docs/architecture/bmad-integration-validation.md +2 -2
- package/docs/audit-logging.md +12 -86
- package/docs/authentication.md +4 -4
- package/docs/authorization.md +15 -47
- package/docs/auto-claude-comparison.md +1 -33
- package/docs/certification/01-core-concepts/lab.md +3 -3
- package/docs/certification/01-core-concepts/lesson.md +11 -22
- package/docs/certification/01-core-concepts/quiz.md +7 -7
- package/docs/certification/02-enterprise-features/lab.md +0 -2
- package/docs/certification/02-enterprise-features/lesson.md +0 -10
- package/docs/certification/04-production-deployment/lab.md +1 -1
- package/docs/certification/05-troubleshooting/lab.md +2 -2
- package/docs/certification/README.md +1 -1
- package/docs/certification/answer-key.md +37 -20
- package/docs/certification/certification-exam.md +7 -7
- package/docs/competitive/emergence-others-analysis.md +3 -3
- package/docs/competitive/replit-lovable-analysis.md +2 -2
- package/docs/cursor-comparison.md +2 -2
- package/docs/dashboard-guide.md +14 -2
- package/docs/dev/architecture-reference.md +2 -2
- package/docs/dev/project-structure.md +1 -1
- package/docs/enterprise/architecture.md +11 -8
- package/docs/enterprise/integration-cookbook.md +23 -14
- package/docs/enterprise/migration.md +5 -9
- package/docs/enterprise/performance.md +3 -6
- package/docs/enterprise/sdk-guide.md +1 -1
- package/docs/enterprise/security.md +8 -17
- package/docs/exit-codes.md +29 -0
- package/docs/git-workflow.md +4 -62
- package/docs/network-security.md +3 -13
- package/docs/plans/ULTRAPLAN-FORGE-BAAS.md +1 -1
- package/docs/prd-purple-lab-platform-v2.md +1 -1
- package/docs/prd-purple-lab-platform.md +1 -3
- package/docs/research-2026-07/GAP-ANALYSIS-BACKLOG.md +1 -1
- package/docs/show-hn-post.md +3 -4
- package/docs/siem-integration.md +8 -10
- package/docs/test-scenarios/edge-cases.md +2 -2
- package/docs/test-scenarios/enterprise-scenarios.md +4 -4
- package/docs/v10/BOARD.md +21 -11
- package/docs/v10/DECISIONS.md +36 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/GUIDE.md +5 -3
- package/docs/v10/METRICS.md +36 -0
- package/docs/v10/PROGRESS.md +76 -0
- package/docs/walkthrough/README.md +1 -7
- package/loki-ts/dist/loki.js +415 -407
- 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-B1zdOJaF.js → AdminPage-BH_7fpoz.js} +1 -1
- package/web-app/dist/assets/{Avatar-DKjAJ_w7.js → Avatar-BzyhrX0s.js} +1 -1
- package/web-app/dist/assets/{Badge-DP-UrPLv.js → Badge-CL05xwgC.js} +1 -1
- package/web-app/dist/assets/{Button-c8qBEA2K.js → Button-BVHrGLGH.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-CFljzGbi.js → CockpitPage-B9BAEqD-.js} +1 -1
- package/web-app/dist/assets/{ComparePage-BNKqdNRu.js → ComparePage-D4grckhY.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-DNkpXGt5.js → ErrorBoundary-Bg52u7eg.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CQd49V4Z.js → EvidenceReceiptPanel-B15kRZbJ.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B9wji_yi.js → GitHubIssuesPanel-DP_BhJSC.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BkfCV4ag.js → GitHubPRsPanel-DJBu83u9.js} +1 -1
- package/web-app/dist/assets/{HomePage-BVV3Cowy.js → HomePage-6mw4uvLs.js} +2 -2
- package/web-app/dist/assets/{LoginPage-CWiOenLJ.js → LoginPage-BWWzFHif.js} +1 -1
- package/web-app/dist/assets/{MagicPage-DckrjTVd.js → MagicPage-CSyo7k5t.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DNBnH68C.js → MetricsPage-Buq03cch.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-Ca8za8mL.js → NotFoundPage-BG8vuV4g.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-C92sm9xi.js → ProjectPage-D7eJn--6.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BxsCyJ95.js → ProjectsPage-DXPycvjA.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BKg-6aHI.js → SettingsPage-Blvav87U.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Bw_fI5rG.js → ShowcasePage-ZCAKRsNN.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Bc-2OWUp.js → SystemSettingsPage-ClzjKfI4.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-GsYRD1b9.js → TeamsPage-CKp1-WGX.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-BN7ZlULA.js → TemplatesPage-C0yAp2Yl.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-d7__ysTd.js → TerminalOutput-C3KbV8E7.js} +1 -1
- package/web-app/dist/assets/{activity-ycOMpCGz.js → activity-CG5PtpVI.js} +1 -1
- package/web-app/dist/assets/{bell-B93o4lsL.js → bell-CyLp--NR.js} +1 -1
- package/web-app/dist/assets/{bot-CB0kmriG.js → bot-Dp0zY9t7.js} +1 -1
- package/web-app/dist/assets/{check-BAYNAOEO.js → check-ipranz8L.js} +1 -1
- package/web-app/dist/assets/{chevron-left-Bgu8UgyR.js → chevron-left-DhYs9D3t.js} +1 -1
- package/web-app/dist/assets/{circle-alert-n_FciWUP.js → circle-alert-BLAxBNgz.js} +1 -1
- package/web-app/dist/assets/{clock-CNceeS66.js → clock-DzocnapF.js} +1 -1
- package/web-app/dist/assets/{cloud-CJBEdb7m.js → cloud-B3YsIUwq.js} +1 -1
- package/web-app/dist/assets/{code-xml-BHnKVqrH.js → code-xml-BPcy_Eqm.js} +1 -1
- package/web-app/dist/assets/{database-DSVoL7Pl.js → database-DOuuclZf.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-48dvOe4H.js → dollar-sign-o77cj-xB.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-8yzS5DeZ.js → file-code-corner-BZ2VMnYA.js} +1 -1
- package/web-app/dist/assets/{file-plus-BcuVrrey.js → file-plus-DeHfGncj.js} +1 -1
- package/web-app/dist/assets/{globe-Bb-O58oN.js → globe-DmIW-phf.js} +1 -1
- package/web-app/dist/assets/{hammer-DeMMY6xW.js → hammer-CoW8R8V5.js} +1 -1
- package/web-app/dist/assets/{index-9hue9wt2.js → index-5rCTAnsF.js} +3 -3
- package/web-app/dist/assets/{layers-DmCoMrQC.js → layers-DGEk-2RQ.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CJvjM7JW.js → loader-circle-DXn-F-H5.js} +1 -1
- package/web-app/dist/assets/{lock-Dpje5cWO.js → lock-D5nAjeUu.js} +1 -1
- package/web-app/dist/assets/{package-mA_hCaf8.js → package-D8Drpzi8.js} +1 -1
- package/web-app/dist/assets/{plus-Db0JnAoC.js → plus-C-0T9dAu.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DbCL1y7h.js → refresh-cw-AyYQQPZG.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-Bt6pp50d.js → rotate-ccw-DYbslLfT.js} +1 -1
- package/web-app/dist/assets/{scroll-text-7j0hXxm7.js → scroll-text-CQFQhF_6.js} +1 -1
- package/web-app/dist/assets/{server-BMJYtpVZ.js → server-DxNytQEJ.js} +1 -1
- package/web-app/dist/assets/{shield-alert-Bu8Pbdy-.js → shield-alert-Xj5wGUSK.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-8erzRJ8k.js → thumbs-up-DW0j9Yxz.js} +1 -1
- package/web-app/dist/assets/{trash-2-DBC_D_Hv.js → trash-2-VOdpiJ1e.js} +1 -1
- package/web-app/dist/assets/{trending-down-DMNq5hSm.js → trending-down-eTizH4_b.js} +1 -1
- package/web-app/dist/assets/{trending-up-9b1IqBqR.js → trending-up-agYVl7bU.js} +1 -1
- package/web-app/dist/assets/{upload-SoA0iiLG.js → upload-DQ3NfXA_.js} +1 -1
- package/web-app/dist/assets/{usePolling-DLP2FX9M.js → usePolling-BKETnkDT.js} +1 -1
- package/web-app/dist/assets/{user-D7Pb9wW3.js → user-Cv6BNmOT.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/docs/README-FULL.md +0 -977
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.19
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -458,7 +458,7 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
458
458
|
|---------|--------|-------|
|
|
459
459
|
| Bun runtime (Phase 2+) | TBD | Migrate write-path commands; tracked on `feat/bun-migration` |
|
|
460
460
|
| Managed Agents multiagent path | TBD | `LOKI_EXPERIMENTAL_MANAGED_*` flags -- RESEARCH PREVIEW, not on live API |
|
|
461
|
-
| Benchmarks
|
|
461
|
+
| Benchmarks | TBD | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
462
462
|
| `loki run` removal | next major | Currently a deprecated alias for `loki start` |
|
|
463
463
|
|
|
464
464
|
## Deprecated
|
|
@@ -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.19 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
10.5.
|
|
1
|
+
10.5.19
|
|
@@ -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:]))
|
|
@@ -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:]))
|