loki-mode 10.5.16 → 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/loki_yaml.py +225 -0
- package/autonomy/loki +65 -6
- 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 +19 -11
- package/docs/v10/DECISIONS.md +36 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/PROGRESS.md +41 -0
- 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-DxGZSpFg.js} +1 -1
- package/web-app/dist/assets/{Avatar-DKjAJ_w7.js → Avatar-BZEp23D8.js} +1 -1
- package/web-app/dist/assets/{Badge-DP-UrPLv.js → Badge-DnDwqDLu.js} +1 -1
- package/web-app/dist/assets/{Button-c8qBEA2K.js → Button-C9KDP4c1.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-CFljzGbi.js → CockpitPage-DRlh3LV2.js} +1 -1
- package/web-app/dist/assets/{ComparePage-BNKqdNRu.js → ComparePage-C_gE45dQ.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-DNkpXGt5.js → ErrorBoundary-CL-J9-Cs.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CQd49V4Z.js → EvidenceReceiptPanel-DABd176Y.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B9wji_yi.js → GitHubIssuesPanel-C4lYbm6O.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BkfCV4ag.js → GitHubPRsPanel-CgiUixwo.js} +1 -1
- package/web-app/dist/assets/{HomePage-BVV3Cowy.js → HomePage-DC99RIrz.js} +2 -2
- package/web-app/dist/assets/{LoginPage-CWiOenLJ.js → LoginPage-Bd72QWNk.js} +1 -1
- package/web-app/dist/assets/{MagicPage-DckrjTVd.js → MagicPage-CBouFs2K.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DNBnH68C.js → MetricsPage-c9MyDcBp.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-Ca8za8mL.js → NotFoundPage-ZPhiebiW.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-C92sm9xi.js → ProjectPage-DKwrALUb.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BxsCyJ95.js → ProjectsPage-X1uZKZC_.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BKg-6aHI.js → SettingsPage-CiZKZRto.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Bw_fI5rG.js → ShowcasePage-k_imKfeh.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Bc-2OWUp.js → SystemSettingsPage-Wx9ESLll.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-GsYRD1b9.js → TeamsPage-B7upn-I7.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-BN7ZlULA.js → TemplatesPage-oJf_VKgh.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-d7__ysTd.js → TerminalOutput-BdfPlgLI.js} +1 -1
- package/web-app/dist/assets/{activity-ycOMpCGz.js → activity-BcZTsgWH.js} +1 -1
- package/web-app/dist/assets/{bell-B93o4lsL.js → bell-JNEkjWQ2.js} +1 -1
- package/web-app/dist/assets/{bot-CB0kmriG.js → bot-DMAJobP_.js} +1 -1
- package/web-app/dist/assets/{check-BAYNAOEO.js → check-DPTry-z2.js} +1 -1
- package/web-app/dist/assets/{chevron-left-Bgu8UgyR.js → chevron-left-DSqOEtTV.js} +1 -1
- package/web-app/dist/assets/{circle-alert-n_FciWUP.js → circle-alert-BbL8Vysv.js} +1 -1
- package/web-app/dist/assets/{clock-CNceeS66.js → clock-BHEr5lGW.js} +1 -1
- package/web-app/dist/assets/{cloud-CJBEdb7m.js → cloud-C48PunYo.js} +1 -1
- package/web-app/dist/assets/{code-xml-BHnKVqrH.js → code-xml-Boa7kRqT.js} +1 -1
- package/web-app/dist/assets/{database-DSVoL7Pl.js → database-Eu35Gv7z.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-48dvOe4H.js → dollar-sign-BO9fCnHW.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-8yzS5DeZ.js → file-code-corner-DHsSDIL_.js} +1 -1
- package/web-app/dist/assets/{file-plus-BcuVrrey.js → file-plus-BYiQLe5l.js} +1 -1
- package/web-app/dist/assets/{globe-Bb-O58oN.js → globe-BPZ5sQI2.js} +1 -1
- package/web-app/dist/assets/{hammer-DeMMY6xW.js → hammer-DlOLTsH3.js} +1 -1
- package/web-app/dist/assets/{index-9hue9wt2.js → index-BrgkuqSA.js} +3 -3
- package/web-app/dist/assets/{layers-DmCoMrQC.js → layers-DMNdn1GR.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CJvjM7JW.js → loader-circle-_e9fypHL.js} +1 -1
- package/web-app/dist/assets/{lock-Dpje5cWO.js → lock-D_elxMZb.js} +1 -1
- package/web-app/dist/assets/{package-mA_hCaf8.js → package-Bw-7g3JI.js} +1 -1
- package/web-app/dist/assets/{plus-Db0JnAoC.js → plus-CZeq0pgJ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DbCL1y7h.js → refresh-cw-D0WMyffs.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-Bt6pp50d.js → rotate-ccw-CcKyNwXG.js} +1 -1
- package/web-app/dist/assets/{scroll-text-7j0hXxm7.js → scroll-text-C0L9Vqpm.js} +1 -1
- package/web-app/dist/assets/{server-BMJYtpVZ.js → server-SgwFv6cd.js} +1 -1
- package/web-app/dist/assets/{shield-alert-Bu8Pbdy-.js → shield-alert-D1tESlfI.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-8erzRJ8k.js → thumbs-up-bUtUA15O.js} +1 -1
- package/web-app/dist/assets/{trash-2-DBC_D_Hv.js → trash-2-C9Nv1kKk.js} +1 -1
- package/web-app/dist/assets/{trending-down-DMNq5hSm.js → trending-down-Bh107AJe.js} +1 -1
- package/web-app/dist/assets/{trending-up-9b1IqBqR.js → trending-up-25BqhLVq.js} +1 -1
- package/web-app/dist/assets/{upload-SoA0iiLG.js → upload-DDYtMbN5.js} +1 -1
- package/web-app/dist/assets/{usePolling-DLP2FX9M.js → usePolling-BSDT0il_.js} +1 -1
- package/web-app/dist/assets/{user-D7Pb9wW3.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:]))
|
|
@@ -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:]))
|
package/autonomy/loki
CHANGED
|
@@ -1627,6 +1627,7 @@ show_help() {
|
|
|
1627
1627
|
echo "Build:"
|
|
1628
1628
|
echo " start [SPEC] Start a build (PRD file, GitHub/GitLab/Jira/Azure issue, or no arg)"
|
|
1629
1629
|
echo " plan <PRD-file> Dry-run analysis: complexity, cost, execution plan"
|
|
1630
|
+
echo " backlog <owner/repo> --all|--label X|--issues N,N Run every matching open issue, N in parallel"
|
|
1630
1631
|
echo " quickstart [idea] Guided first build: setup, idea, template, plan, go"
|
|
1631
1632
|
echo ""
|
|
1632
1633
|
echo "Session:"
|
|
@@ -1765,7 +1766,7 @@ show_help() {
|
|
|
1765
1766
|
# missing from this list, so a new command cannot ship invisible.
|
|
1766
1767
|
echo ""
|
|
1767
1768
|
echo "All commands:"
|
|
1768
|
-
echo " agent analyze api assets audit bench checkpoint (cp) ci cleanup"
|
|
1769
|
+
echo " agent analyze api assets audit backlog bench checkpoint (cp) ci cleanup"
|
|
1769
1770
|
echo " cluster cockpit code compliance completions compound config context (ctx)"
|
|
1770
1771
|
echo " cost council crash dashboard demo deploy docker docs doctor dogfood"
|
|
1771
1772
|
echo " enterprise estimate explain export failover github grill heal help"
|
|
@@ -9306,6 +9307,32 @@ _deploy_receipt_verdict() {
|
|
|
9306
9307
|
# evaluated, so no key set is written and the verdict is UNCHECKED, never
|
|
9307
9308
|
# TAMPERED: the hash is fine, we just cannot vouch for who signed it. A
|
|
9308
9309
|
# local key only proves "a holder of this key", not an independent build.
|
|
9310
|
+
# Token SHAPE before any key lookup (mirrors engine10 checkAttestation): a present
|
|
9311
|
+
# attestation that is not a string, not a 3-part JWS, or whose header/payload are not
|
|
9312
|
+
# JSON with alg EdDSA is junk, so TAMPERED, never UNCHECKED, with or without a key.
|
|
9313
|
+
if ! python3 -E - "$pj" >/dev/null 2>&1 <<'PYSHAPE'
|
|
9314
|
+
import sys
|
|
9315
|
+
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
|
|
9316
|
+
import json, base64
|
|
9317
|
+
def dec(x):
|
|
9318
|
+
return json.loads(base64.urlsafe_b64decode(x + "=" * (-len(x) % 4)))
|
|
9319
|
+
v = json.load(open(sys.argv[1])).get("verification") or {}
|
|
9320
|
+
t = v.get("attestation")
|
|
9321
|
+
if t is None or t == "":
|
|
9322
|
+
sys.exit(0)
|
|
9323
|
+
if not isinstance(t, str):
|
|
9324
|
+
sys.exit(1)
|
|
9325
|
+
parts = t.split(".")
|
|
9326
|
+
if len(parts) != 3 or not all(parts):
|
|
9327
|
+
sys.exit(1)
|
|
9328
|
+
try:
|
|
9329
|
+
h = dec(parts[0]); dec(parts[1])
|
|
9330
|
+
except Exception:
|
|
9331
|
+
sys.exit(1)
|
|
9332
|
+
sys.exit(0 if isinstance(h, dict) and h.get("alg") == "EdDSA" else 1)
|
|
9333
|
+
PYSHAPE
|
|
9334
|
+
then printf '%s' "TAMPERED"; return 0; fi
|
|
9335
|
+
|
|
9309
9336
|
local jwks att
|
|
9310
9337
|
jwks="$(mktemp "${TMPDIR:-/tmp}/loki-jwks.XXXXXX")" || { printf '%s' "UNCHECKED"; return 0; }
|
|
9311
9338
|
python3 -E - "${_LOKI_SCRIPT_DIR}" "$pj" >"$jwks" 2>/dev/null <<'PY' || true
|
|
@@ -12593,7 +12620,7 @@ cmd_config() {
|
|
|
12593
12620
|
echo " get KEY Get a configuration value"
|
|
12594
12621
|
echo " example Print an annotated config template (for --config)"
|
|
12595
12622
|
echo " schema Print the key -> env-var mapping table"
|
|
12596
|
-
echo " validate FILE
|
|
12623
|
+
echo " validate [FILE] Validate loki.yaml (no FILE: repo root, then ~/.loki) or a --config file"
|
|
12597
12624
|
echo ""
|
|
12598
12625
|
echo "Settable keys (v6.0.0):"
|
|
12599
12626
|
echo " maxTier Cost ceiling: opus, sonnet, haiku (default: opus)"
|
|
@@ -12797,14 +12824,43 @@ cmd_config_schema() {
|
|
|
12797
12824
|
loki_config_generate_schema
|
|
12798
12825
|
}
|
|
12799
12826
|
|
|
12827
|
+
# D51: headless backlog runner. One v10 issue-mode run per open issue, each in
|
|
12828
|
+
# its own worktree and branch, at most N in parallel (loki.yaml concurrency).
|
|
12829
|
+
cmd_backlog() {
|
|
12830
|
+
case "${1:-}" in
|
|
12831
|
+
""|--help|-h|help)
|
|
12832
|
+
echo -e "${BOLD}loki backlog${NC} - run a v10 issue-mode run for every matching open issue"
|
|
12833
|
+
echo ""
|
|
12834
|
+
echo "Usage: loki backlog owner/repo (--all | --label X | --issues 1,2,3) [--concurrency N] [--dry-run]"
|
|
12835
|
+
echo ""
|
|
12836
|
+
echo " --all Every open issue"
|
|
12837
|
+
echo " --label X Open issues with label X"
|
|
12838
|
+
echo " --issues 1,2,3 Exactly these issue numbers"
|
|
12839
|
+
echo " --concurrency N Max runs in parallel (default: loki.yaml concurrency, else 2)"
|
|
12840
|
+
echo " --dry-run List what would run; launch nothing"
|
|
12841
|
+
echo ""
|
|
12842
|
+
echo "Run from a checkout of owner/repo. Each issue runs in its own worktree on loki/backlog-N."
|
|
12843
|
+
echo "Config: loki.yaml (repo root, then ~/.loki/loki.yaml); see docs/loki.yaml.example."
|
|
12844
|
+
echo "Exit: 0 every issue ended VERIFIED or with a PR; 3 only budget stops; 1 otherwise; 2 usage."
|
|
12845
|
+
return 0
|
|
12846
|
+
;;
|
|
12847
|
+
esac
|
|
12848
|
+
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}loki backlog: python3 is required${NC}" >&2; return 2; }
|
|
12849
|
+
python3 "$_LOKI_SCRIPT_DIR/lib/backlog.py" "$@"
|
|
12850
|
+
}
|
|
12851
|
+
|
|
12800
12852
|
# #691: validate a config file (--config) WITHOUT applying it. Reports
|
|
12801
12853
|
# unresolved ${VAR} refs, raw-secret literals (error), and per-value validation
|
|
12802
12854
|
# failures. Non-zero exit on any failure.
|
|
12803
12855
|
cmd_config_validate() {
|
|
12804
|
-
local file="${1:-}"
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12856
|
+
local file="${1:-}" _loki_yaml
|
|
12857
|
+
# D51: loki.yaml (UI + headless config) has its own schema; no argument
|
|
12858
|
+
# validates the discovered loki.yaml (repo root, then ~/.loki/loki.yaml).
|
|
12859
|
+
case "$(basename -- "$file")" in "" | loki.yaml | loki.yml | loki.yaml.example) _loki_yaml=1 ;; *) _loki_yaml=0 ;; esac
|
|
12860
|
+
if [ "$_loki_yaml" = 1 ]; then
|
|
12861
|
+
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}config validate: python3 is required${NC}" >&2; return 2; }
|
|
12862
|
+
python3 "$_LOKI_SCRIPT_DIR/lib/loki_yaml.py" validate ${file:+"$file"}
|
|
12863
|
+
return $?
|
|
12808
12864
|
fi
|
|
12809
12865
|
if ! declare -f loki_config_validate_file >/dev/null 2>&1; then
|
|
12810
12866
|
echo -e "${RED}config validate: config-map.sh helper not loaded${NC}" >&2
|
|
@@ -21767,6 +21823,9 @@ main() {
|
|
|
21767
21823
|
assets)
|
|
21768
21824
|
cmd_assets "$@"
|
|
21769
21825
|
;;
|
|
21826
|
+
backlog)
|
|
21827
|
+
cmd_backlog "$@"
|
|
21828
|
+
;;
|
|
21770
21829
|
config)
|
|
21771
21830
|
cmd_config "$@"
|
|
21772
21831
|
;;
|