loki-mode 10.6.10 → 10.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +19 -3
- package/autonomy/lib/fast_verify.py +19 -1
- package/autonomy/lib/proof-pr.sh +34 -3
- package/autonomy/lib/workspace.py +27 -1
- package/autonomy/lib/workspace_metrics.py +85 -0
- package/autonomy/lib/worktree_prep.py +45 -3
- package/autonomy/loki +62 -3
- package/autonomy/run.sh +95 -6
- package/autonomy/verify.sh +87 -20
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_operator.py +27 -0
- package/dashboard/api_runs.py +52 -0
- package/dashboard/api_workspaces.py +140 -0
- package/dashboard/audit.py +8 -2
- package/dashboard/server.py +42 -21
- package/dashboard/static/index.html +4 -3
- package/dashboard/static/proofs.html +5 -0
- package/dashboard/static/start.html +32 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/SIGNED-RECEIPTS.md +39 -0
- package/docs/UNREACHABLE-TESTS.md +1 -1
- package/docs/WORKSPACES.md +14 -0
- package/docs/audit-logging.md +13 -0
- package/docs/dev/release-checklist.md +5 -0
- package/docs/exit-codes.md +44 -9
- package/docs/v10/BOARD.md +78 -26
- package/docs/v10/DECISIONS.md +29 -0
- package/docs/v10/E2E-D65.md +8 -0
- package/docs/v10/GUIDE.md +1 -1
- package/docs/v10/PROGRESS.md +68 -0
- package/loki-ts/dist/loki.js +545 -536
- package/mcp/__init__.py +1 -1
- package/mcp/tests/test_v10_tools.py +41 -0
- package/mcp/v10_tools.py +13 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +14 -1
- package/packages/control-plane/drizzle/meta/0001_snapshot.json +364 -0
- package/packages/control-plane/src/server/app.ts +7 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-OaSCF3M4.js → AdminPage-oc63I7OH.js} +1 -1
- package/web-app/dist/assets/{Avatar-xa3G7_vs.js → Avatar-BziJJn7b.js} +1 -1
- package/web-app/dist/assets/{Badge-Ci_UcHij.js → Badge-DeTNCD_k.js} +1 -1
- package/web-app/dist/assets/{Button-CFrrmbvL.js → Button-D9wjGlQI.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-6hyVl_QZ.js → CockpitPage-Bw4_akgH.js} +1 -1
- package/web-app/dist/assets/{ComparePage-B5H56KCG.js → ComparePage-XmdsH_wC.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-CBbRHsYr.js → ErrorBoundary-B0_IF9Ew.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-D-Zoneum.js → EvidenceReceiptPanel-Da3d60pX.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-D7XPRsic.js → GitHubIssuesPanel-D9Jiuoyc.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CzHbBPAn.js → GitHubPRsPanel-DQCUsWAb.js} +1 -1
- package/web-app/dist/assets/{HomePage-ClOx5qF1.js → HomePage-rfyNY25A.js} +2 -2
- package/web-app/dist/assets/{LoginPage-3Yz_9j8E.js → LoginPage-BGHFcJj8.js} +1 -1
- package/web-app/dist/assets/{MagicPage-Dumn48gC.js → MagicPage-CdoeZ5aB.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CYCYVG_8.js → MetricsPage-CwKr2759.js} +2 -2
- package/web-app/dist/assets/{NotFoundPage-CV-x_1qH.js → NotFoundPage-9qNm-fWi.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CeO7dnyM.js → ProjectPage-BbeRxI8t.js} +67 -67
- package/web-app/dist/assets/{ProjectsPage-BT9mz6iD.js → ProjectsPage-hw9XHZXR.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BL8X9fAB.js → SettingsPage-Cq6duAHh.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-8f3lnSpc.js → ShowcasePage-DcpXgNKy.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-BUNAAzh2.js → SystemSettingsPage-BMWp2LCp.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Qj-VEZJz.js → TeamsPage-Dh1rte-l.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-b8lKqe5q.js → TemplatesPage-BUIMiu9V.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-jOnfQKB4.js → TerminalOutput-g9_MPOQq.js} +1 -1
- package/web-app/dist/assets/{activity-w7GnyBNp.js → activity-BYGoe6e6.js} +1 -1
- package/web-app/dist/assets/{bell-2i4ejMOA.js → bell-CxCZpUMb.js} +1 -1
- package/web-app/dist/assets/{bot-jaPm4NIs.js → bot-Cpbkdqjb.js} +1 -1
- package/web-app/dist/assets/{check-CvNtxeKM.js → check-fO48BSWF.js} +1 -1
- package/web-app/dist/assets/{chevron-left-zzBjHAtX.js → chevron-left-C3J6cmqz.js} +1 -1
- package/web-app/dist/assets/{circle-alert-CCU0IU2m.js → circle-alert-DhdR0TJR.js} +1 -1
- package/web-app/dist/assets/{clock-BvZckMwN.js → clock-Crp889Lj.js} +1 -1
- package/web-app/dist/assets/{cloud-hrMWDdYp.js → cloud-CUoa_Aqa.js} +1 -1
- package/web-app/dist/assets/{code-xml-v6l40T-K.js → code-xml-BZSDiROr.js} +1 -1
- package/web-app/dist/assets/{database-l2TMUrQd.js → database-BC_OF6lp.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-C2V6OvpO.js → dollar-sign-DJUXGU2U.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Bytb5JGA.js → file-code-corner-CwHaIaDe.js} +1 -1
- package/web-app/dist/assets/{file-plus-BynCYQB8.js → file-plus-DD8rC5Gt.js} +1 -1
- package/web-app/dist/assets/{globe-DifDdNMH.js → globe-CwDBjzLT.js} +1 -1
- package/web-app/dist/assets/{hammer-D7TsOJD6.js → hammer-CzeZBlk1.js} +1 -1
- package/web-app/dist/assets/{index-Zohfqaz4.js → index-BgFxf62u.js} +3 -3
- package/web-app/dist/assets/{layers-Bk8c2YLS.js → layers-DytzdbMj.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BrLdbMwP.js → loader-circle-D8hG34Pb.js} +1 -1
- package/web-app/dist/assets/{lock-COpmjMyJ.js → lock-hk6B4D77.js} +1 -1
- package/web-app/dist/assets/{package-BMzYdY7F.js → package-DO_92_a-.js} +1 -1
- package/web-app/dist/assets/{plus-B9xpu1Pg.js → plus-BKQDzXcw.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-C2Lzh-AA.js → refresh-cw-DKTkN-7t.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-2Yyy-uQd.js → rotate-ccw-Yhmj5NtP.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DiK2yhsT.js → scroll-text-Cc-NigJs.js} +1 -1
- package/web-app/dist/assets/{server-xlJBgleU.js → server-DYIet42E.js} +1 -1
- package/web-app/dist/assets/{shield-alert-D9w4yBCi.js → shield-alert-Cy_zkAp6.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CcWfvcE9.js → thumbs-up-DtLybu1S.js} +1 -1
- package/web-app/dist/assets/{trash-2-_hk_pEAp.js → trash-2-n9tqmPW_.js} +1 -1
- package/web-app/dist/assets/{trending-down-CDZT99tC.js → trending-down-zHpd0XJs.js} +1 -1
- package/web-app/dist/assets/{trending-up-A0oreTXc.js → trending-up-d8bugNud.js} +1 -1
- package/web-app/dist/assets/{upload-DfLwhGG2.js → upload-CkiOu1GK.js} +1 -1
- package/web-app/dist/assets/{usePolling-B7gW2YY_.js → usePolling-B9aSXIDR.js} +1 -1
- package/web-app/dist/assets/{user-C-y7_Oon.js → user-DbfT_krW.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Before a build counts as done, a review council selects reviewers from a special
|
|
|
19
19
|
- [Loki 10 engine](#loki-10-engine)
|
|
20
20
|
- [Outcomes and exit codes](#outcomes-and-exit-codes)
|
|
21
21
|
- [Signed receipts and loki verify](#signed-receipts-and-loki-verify)
|
|
22
|
+
- [loki workspace](#loki-workspace)
|
|
22
23
|
- [Quiet output](#quiet-output)
|
|
23
24
|
- [Release channels: next and latest](#release-channels-next-and-latest)
|
|
24
25
|
- [loki doctor](#loki-doctor)
|
|
@@ -234,9 +235,26 @@ loki verify
|
|
|
234
235
|
|
|
235
236
|
A verified receipt is bound to the run's event log, so a receipt lifted out of its run, or a log edited after sealing, does not verify. Signing proves the receipt came from the key holder; it does not prove the generated code is bug-free. A receipt only claims what its checks ran, and states what they did not.
|
|
236
237
|
|
|
237
|
-
###
|
|
238
|
+
### Export as a DSSE envelope
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
`loki verify <run> --export-dsse > receipt.dsse.json` wraps a verified v10 receipt as a DSSE envelope whose payload is an in-toto Statement v1, signed with the Ed25519 receipt key. The export refuses unless the receipt verifies (exit 1 if tampered, 2 if unsigned or unchecked, 66 if no signing key is found) and its run outcome is `VERIFIED` or `ALREADY_SATISFIED` (exit 4 otherwise, empty stdout). It takes a receipt, not an envelope (exit 1), and an envelope found under a run id must carry that run id (exit 1). Example: `loki verify 20261003-abc --export-dsse > r.dsse.json; echo $?` prints 4 for a failed run. `loki verify receipt.dsse.json` accepts the envelope too; add `--pubkey FILE` on another machine. Details: [docs/SIGNED-RECEIPTS.md](docs/SIGNED-RECEIPTS.md).
|
|
241
|
+
|
|
242
|
+
### Visual evidence (on by default)
|
|
243
|
+
|
|
244
|
+
By default (set `LOKI_VISUAL_EVIDENCE=0` to turn it off), a v10 run that changed web page files (html, jsx, tsx, vue, svelte under app/, pages/, src/ or public/) starts the repo's dev, preview or start script and screenshots each changed route with the repo's own Playwright (nothing is downloaded) into a fresh per-run directory `.loki/runs/<run_id>/evidence/<random>/` (at most 5 routes, total capture budget 25s, aborted with the seal stage). With an explicit `LOKI_VISUAL_EVIDENCE=1`, API-only repos with an openapi file also get an HTTP transcript at `.loki/runs/<run_id>/evidence/<random>/http.json` (not hashed into the receipt). Each screenshot's sha256 is recorded in the receipt as `evidence_screens`, the PR body gets an Evidence section, and `loki verify` reports TAMPERED if a recorded screenshot is altered, missing or a symlink. Capture never fails the run; a skip is listed in NOT PROVEN. On by default; it needs Playwright in the repo and changed pages, otherwise it only adds one NOT PROVEN line.
|
|
245
|
+
|
|
246
|
+
## loki workspace
|
|
247
|
+
|
|
248
|
+
`loki workspace` runs one issue across several repos. Define groups under the `workspaces` key in `loki.yaml` (each with `repos`, optional per-repo `path`, `setup` and `after`, and an optional `integration` command), then:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
loki workspace list # configured workspaces
|
|
252
|
+
loki workspace show <name> # one workspace's repos and order
|
|
253
|
+
loki workspace run <name> owner/repo#N # or a task text instead of an issue ref
|
|
254
|
+
loki workspace status [<run-id>] # recorded runs, one row per repo
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Each repo gets its own worktree and engine run. The command exits 0 only when every repo and the integration step succeed, 3 when the only failures are budget stops, and 1 otherwise. After the integration step, a PR comment is posted on each repo's PR (when one exists) showing the integration status and every head SHA; `LOKI_WORKSPACE_COMMENT=0` turns it off. `LOKI_WORKSPACES=0` disables the whole command. Full reference: [docs/WORKSPACES.md](docs/WORKSPACES.md).
|
|
240
258
|
|
|
241
259
|
## Quiet output
|
|
242
260
|
|
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.6.
|
|
6
|
+
# Loki Mode v10.6.12
|
|
7
7
|
|
|
8
8
|
**Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the legacy engine, which still ships and is being removed (`loki start owner/repo#N` still routes to it; prefer `loki owner/repo#N`).
|
|
9
9
|
|
|
@@ -473,4 +473,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
473
473
|
|
|
474
474
|
---
|
|
475
475
|
|
|
476
|
-
**v10.6.
|
|
476
|
+
**v10.6.12 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
10.6.
|
|
1
|
+
10.6.12
|
package/autonomy/lib/backlog.py
CHANGED
|
@@ -84,11 +84,14 @@ def ledger_path():
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def spent_today():
|
|
87
|
+
"""USD spent today, 0.0 if no ledger yet, None if the ledger is unreadable (fail closed)."""
|
|
87
88
|
try:
|
|
88
89
|
with open(ledger_path()) as f:
|
|
89
90
|
return float(json.load(f).get(datetime.date.today().isoformat(), 0))
|
|
90
|
-
except
|
|
91
|
+
except FileNotFoundError:
|
|
91
92
|
return 0.0
|
|
93
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
94
|
+
return None
|
|
92
95
|
|
|
93
96
|
|
|
94
97
|
def add_spend(usd):
|
|
@@ -96,7 +99,16 @@ def add_spend(usd):
|
|
|
96
99
|
try:
|
|
97
100
|
with open(p) as f:
|
|
98
101
|
data = json.load(f)
|
|
102
|
+
if not isinstance(data, dict):
|
|
103
|
+
raise ValueError("ledger is not an object")
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
data = {}
|
|
99
106
|
except (OSError, ValueError):
|
|
107
|
+
# Keep the unreadable bytes; never overwrite history.
|
|
108
|
+
try:
|
|
109
|
+
os.replace(p, "%s.corrupt-%d" % (p, int(time.time())))
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
100
112
|
data = {}
|
|
101
113
|
k = datetime.date.today().isoformat()
|
|
102
114
|
data[k] = round(float(data.get(k, 0)) + usd, 6)
|
|
@@ -380,9 +392,13 @@ def main(argv):
|
|
|
380
392
|
for k in [k for k, (p, _l, _w) in running.items() if p.poll() is not None]:
|
|
381
393
|
finish(k)
|
|
382
394
|
while pending and len(running) < conc:
|
|
383
|
-
|
|
395
|
+
spent = spent_today() if day_cap else 0.0
|
|
396
|
+
if day_cap and (spent is None or spent >= day_cap):
|
|
384
397
|
for k in pending:
|
|
385
|
-
|
|
398
|
+
if spent is None:
|
|
399
|
+
state[k].update(status="BUDGET_STOP: daily budget $%.2f set but spend ledger unreadable (ledger unreadable)" % day_cap, ok=False)
|
|
400
|
+
else:
|
|
401
|
+
state[k].update(status="BUDGET_STOP: daily budget $%.2f reached ($%.2f spent)" % (day_cap, spent), ok=False)
|
|
386
402
|
say("backlog: %s %s" % (spec[k]["label"], state[k]["status"]))
|
|
387
403
|
pending = []
|
|
388
404
|
break
|
|
@@ -314,6 +314,10 @@ def verify(root: str = ".", diff_base: str = "", use_cache: bool = True) -> Resu
|
|
|
314
314
|
)
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
USAGE = """usage: fast_verify.py [ROOT] [--path DIR] [--diff-base REF] [--no-cache] [--json] [-h|--help]
|
|
318
|
+
exit codes: 0 PASS, 1 FAIL, 2 root not found, 3 INCONCLUSIVE, 64 unknown option"""
|
|
319
|
+
|
|
320
|
+
|
|
317
321
|
def main(argv: list) -> int:
|
|
318
322
|
root, base, use_cache, as_json = ".", "", True, False
|
|
319
323
|
i = 0
|
|
@@ -321,14 +325,26 @@ def main(argv: list) -> int:
|
|
|
321
325
|
a = argv[i]
|
|
322
326
|
if a == "--diff-base" and i + 1 < len(argv):
|
|
323
327
|
base = argv[i + 1]; i += 1
|
|
328
|
+
elif a == "--path" and i + 1 < len(argv):
|
|
329
|
+
root = argv[i + 1]; i += 1
|
|
330
|
+
elif a in ("-h", "--help"):
|
|
331
|
+
print(USAGE)
|
|
332
|
+
return 0
|
|
324
333
|
elif a == "--no-cache":
|
|
325
334
|
use_cache = False
|
|
326
335
|
elif a == "--json":
|
|
327
336
|
as_json = True
|
|
328
337
|
elif not a.startswith("-"):
|
|
329
338
|
root = a
|
|
339
|
+
else:
|
|
340
|
+
print(f"fast_verify: unknown option: {a}", file=sys.stderr)
|
|
341
|
+
return 64
|
|
330
342
|
i += 1
|
|
331
343
|
|
|
344
|
+
if not os.path.isdir(root):
|
|
345
|
+
print(f"fast_verify: root not found: {root}", file=sys.stderr)
|
|
346
|
+
return 2
|
|
347
|
+
|
|
332
348
|
r = verify(root, base, use_cache)
|
|
333
349
|
if as_json:
|
|
334
350
|
print(json.dumps(asdict(r), indent=2))
|
|
@@ -339,7 +355,9 @@ def main(argv: list) -> int:
|
|
|
339
355
|
print(f" [{f['severity']}] {f['rule']} {f['path']}:{f['line']} - {f['message']}")
|
|
340
356
|
if len(r.findings) > 20:
|
|
341
357
|
print(f" ... and {len(r.findings) - 20} more")
|
|
342
|
-
|
|
358
|
+
if r.verdict == "FAIL":
|
|
359
|
+
return 1
|
|
360
|
+
return 3 if r.verdict == "INCONCLUSIVE" else 0
|
|
343
361
|
|
|
344
362
|
|
|
345
363
|
if __name__ == "__main__":
|
package/autonomy/lib/proof-pr.sh
CHANGED
|
@@ -32,6 +32,35 @@
|
|
|
32
32
|
# every optional tool is `command -v`-guarded. All print paths `return 0` so a
|
|
33
33
|
# sourced call cannot abort the caller under set -e.
|
|
34
34
|
|
|
35
|
+
# S-215: callers render from the agent's repo, and a bare `python3 -` puts the
|
|
36
|
+
# cwd first on sys.path, so a committed json.py forged the headline. The
|
|
37
|
+
# renderer resolves its interpreter through _loki_snapshot_py_tool and runs it
|
|
38
|
+
# -I -S. The copy below only serves callers that source this file on its own;
|
|
39
|
+
# run.sh's definition is the source of truth: keep this copy byte-identical to
|
|
40
|
+
# it. Pinned by tests/test-proof-pr-no-cwd-shadow.sh and
|
|
41
|
+
# tests/test-council-py-tool-identity.sh.
|
|
42
|
+
declare -F _loki_snapshot_py_tool >/dev/null 2>&1 || \
|
|
43
|
+
_loki_snapshot_py_tool() {
|
|
44
|
+
local c
|
|
45
|
+
for c in /usr/bin/python3 /bin/python3; do
|
|
46
|
+
[ -x "$c" ] && [ ! -d "$c" ] && "$c" -I -S -c '' >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
|
|
47
|
+
done
|
|
48
|
+
local dir
|
|
49
|
+
local IFS=:
|
|
50
|
+
for dir in $PATH; do
|
|
51
|
+
case "$dir" in
|
|
52
|
+
/*) ;;
|
|
53
|
+
*) continue ;;
|
|
54
|
+
esac
|
|
55
|
+
if [ -x "$dir/python3" ] && [ ! -d "$dir/python3" ] \
|
|
56
|
+
&& "$dir/python3" -I -S -c '' >/dev/null 2>&1; then
|
|
57
|
+
printf '%s\n' "$dir/python3"
|
|
58
|
+
return 0
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
# Double-source guard.
|
|
36
65
|
[ -n "${_PROOF_PR_SH:-}" ] && return 0
|
|
37
66
|
_PROOF_PR_SH=1
|
|
@@ -52,8 +81,10 @@ render_evidence_receipt_md() {
|
|
|
52
81
|
local _expected_base_sha="${3:-}"
|
|
53
82
|
: "$_expected_base_sha"
|
|
54
83
|
|
|
55
|
-
# No python3 -> degrade honestly, never crash.
|
|
56
|
-
|
|
84
|
+
# No isolated python3 -> degrade honestly, never crash.
|
|
85
|
+
local _pr_py=""
|
|
86
|
+
_pr_py="$(_loki_snapshot_py_tool)" || _pr_py=""
|
|
87
|
+
if [ -z "$_pr_py" ]; then
|
|
57
88
|
printf '%s\n' "Evidence Receipt: unavailable for this run."
|
|
58
89
|
return 0
|
|
59
90
|
fi
|
|
@@ -67,7 +98,7 @@ render_evidence_receipt_md() {
|
|
|
67
98
|
# resort (interpreter crash) -> print the single honest line.
|
|
68
99
|
local _receipt_out=""
|
|
69
100
|
local _receipt_rc=0
|
|
70
|
-
_receipt_out="$(
|
|
101
|
+
_receipt_out="$("$_pr_py" -I -S - "$proof_json_path" "$expected_head_sha" <<'PROOF_PR_PY' 2>/dev/null
|
|
71
102
|
import json
|
|
72
103
|
import sys
|
|
73
104
|
|
|
@@ -208,6 +208,7 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
208
208
|
child_env_base = dict(os.environ, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
|
|
209
209
|
|
|
210
210
|
outcome, worktrees, heads, kind = {}, {}, {}, {}
|
|
211
|
+
timing = {} # repo -> {started_at, finished_at} epoch seconds
|
|
211
212
|
running = {} # repo -> (Popen, log file handle)
|
|
212
213
|
try:
|
|
213
214
|
conc = max(1, int(os.environ.get("LOKI_WORKSPACE_CONCURRENCY") or ws.get("concurrency") or 2))
|
|
@@ -220,6 +221,22 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
220
221
|
os.killpg(p.pid, signal.SIGTERM)
|
|
221
222
|
except OSError:
|
|
222
223
|
pass
|
|
224
|
+
now = time.time()
|
|
225
|
+
for r in list(running):
|
|
226
|
+
outcome[r] = "INTERRUPTED"
|
|
227
|
+
kind[r] = "fail"
|
|
228
|
+
timing[r]["finished_at"] = now
|
|
229
|
+
for e in pending:
|
|
230
|
+
outcome.setdefault(e["repo"], "INTERRUPTED (not started)")
|
|
231
|
+
try:
|
|
232
|
+
ev_i = {"status": "interrupted", "outcomes": outcome,
|
|
233
|
+
"timing": {r: t for r, t in timing.items() if "finished_at" in t}}
|
|
234
|
+
ip = os.path.join(run_dir, "integration.json")
|
|
235
|
+
with open(ip + ".tmp", "w") as f:
|
|
236
|
+
json.dump(ev_i, f, indent=2, sort_keys=True)
|
|
237
|
+
os.replace(ip + ".tmp", ip)
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
223
240
|
sys.exit(130)
|
|
224
241
|
if threading.current_thread() is threading.main_thread():
|
|
225
242
|
signal.signal(signal.SIGTERM, stop)
|
|
@@ -260,12 +277,14 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
260
277
|
p = subprocess.Popen([launcher, arg], cwd=wt, env=dict(child_env_base, **extra), stdout=lf,
|
|
261
278
|
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
262
279
|
running[repo] = (p, lf)
|
|
280
|
+
timing[repo] = {"started_at": time.time()}
|
|
263
281
|
for repo, (p, lf) in list(running.items()):
|
|
264
282
|
rc = p.poll()
|
|
265
283
|
if rc is None:
|
|
266
284
|
continue
|
|
267
285
|
lf.close()
|
|
268
286
|
running.pop(repo)
|
|
287
|
+
timing[repo]["finished_at"] = time.time()
|
|
269
288
|
if rc == 0:
|
|
270
289
|
outcome[repo], kind[repo] = "ok", "ok"
|
|
271
290
|
else:
|
|
@@ -281,6 +300,7 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
281
300
|
|
|
282
301
|
ev = run_integration(ws, run_dir, worktrees, heads)
|
|
283
302
|
ev["outcomes"] = outcome
|
|
303
|
+
ev["timing"] = {r: t for r, t in timing.items() if "finished_at" in t}
|
|
284
304
|
ev_path = os.path.join(run_dir, "integration.json")
|
|
285
305
|
tmp = ev_path + ".tmp"
|
|
286
306
|
with open(tmp, "w") as f:
|
|
@@ -314,8 +334,11 @@ def _runs(base_dir):
|
|
|
314
334
|
try:
|
|
315
335
|
with open(path) as f:
|
|
316
336
|
yield name, run_id, json.load(f)
|
|
317
|
-
except
|
|
337
|
+
except FileNotFoundError:
|
|
318
338
|
continue
|
|
339
|
+
except (OSError, ValueError) as e:
|
|
340
|
+
yield name, run_id, {"state": "unreadable", "status": "unreadable",
|
|
341
|
+
"error": "%s: %s" % (type(e).__name__, e)}
|
|
319
342
|
|
|
320
343
|
|
|
321
344
|
def status(base_dir, run_id=None, say=say):
|
|
@@ -328,6 +351,9 @@ def status(base_dir, run_id=None, say=say):
|
|
|
328
351
|
say("%s %s" % (name, rid))
|
|
329
352
|
for repo, res in sorted((ev.get("outcomes") or {}).items()):
|
|
330
353
|
say("%-30s %s" % (repo, res))
|
|
354
|
+
if ev.get("state") == "unreadable":
|
|
355
|
+
say("%-30s %s (%s)" % ("integration", "unreadable", ev.get("error")))
|
|
356
|
+
continue
|
|
331
357
|
say("%-30s %s" % ("integration", ev.get("status")))
|
|
332
358
|
return 0
|
|
333
359
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""loki workspace metrics: PRs per wall-clock hour and per attention minute (D51-B15).
|
|
3
|
+
|
|
4
|
+
python3 workspace_metrics.py <integration.json | run-dir> [--attention-min N]
|
|
5
|
+
|
|
6
|
+
Reads the per-repo `timing` ({repo: {started_at, finished_at}} in epoch
|
|
7
|
+
seconds) that `loki workspace run` records in integration.json. A PR is a repo
|
|
8
|
+
whose outcome is "ok". Wall clock is the earliest start to the latest finish.
|
|
9
|
+
A run without timestamps (an older integration.json, or a repo missing its
|
|
10
|
+
timing) is reported as "unmeasured", never as zero. Attention minutes are the
|
|
11
|
+
human minutes the operator spent; they are not recorded by the runner, so the
|
|
12
|
+
per-attention rate is "unmeasured" unless --attention-min is given.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
UNMEASURED = "unmeasured"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute(ev, attention_min=None):
|
|
22
|
+
"""Return a dict of metric name -> number or UNMEASURED."""
|
|
23
|
+
outcomes = ev.get("outcomes") or {}
|
|
24
|
+
timing = ev.get("timing") or {}
|
|
25
|
+
ok_repos = [r for r, o in outcomes.items() if o == "ok"]
|
|
26
|
+
out = {"prs": len(ok_repos), "wall_clock_seconds": UNMEASURED,
|
|
27
|
+
"prs_per_hour": UNMEASURED, "prs_per_attention_minute": UNMEASURED}
|
|
28
|
+
|
|
29
|
+
spans = []
|
|
30
|
+
complete = bool(timing) and all(
|
|
31
|
+
isinstance(timing.get(r), dict)
|
|
32
|
+
and isinstance(timing[r].get("started_at"), (int, float))
|
|
33
|
+
and isinstance(timing[r].get("finished_at"), (int, float))
|
|
34
|
+
for r in ok_repos)
|
|
35
|
+
if complete:
|
|
36
|
+
for t in timing.values():
|
|
37
|
+
if isinstance(t, dict) and isinstance(t.get("started_at"), (int, float)) \
|
|
38
|
+
and isinstance(t.get("finished_at"), (int, float)):
|
|
39
|
+
spans.append((t["started_at"], t["finished_at"]))
|
|
40
|
+
wall = (max(f for _, f in spans) - min(s for s, _ in spans)) if spans else None
|
|
41
|
+
if wall is not None and wall > 0:
|
|
42
|
+
out["wall_clock_seconds"] = round(wall, 1)
|
|
43
|
+
out["prs_per_hour"] = out["prs"] / (wall / 3600.0)
|
|
44
|
+
if out["prs_per_hour"] != UNMEASURED and attention_min is not None and attention_min > 0:
|
|
45
|
+
out["prs_per_attention_minute"] = out["prs"] / attention_min
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fmt(key, val):
|
|
50
|
+
if val == UNMEASURED or key in ("prs", "wall_clock_seconds"):
|
|
51
|
+
return str(val)
|
|
52
|
+
return "%.4f" % val if key == "prs_per_attention_minute" else "%.2f" % val
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv):
|
|
56
|
+
attention = None
|
|
57
|
+
args = list(argv)
|
|
58
|
+
if "--attention-min" in args:
|
|
59
|
+
i = args.index("--attention-min")
|
|
60
|
+
try:
|
|
61
|
+
attention = float(args[i + 1])
|
|
62
|
+
del args[i:i + 2]
|
|
63
|
+
except (IndexError, ValueError):
|
|
64
|
+
print("workspace_metrics: --attention-min needs a number")
|
|
65
|
+
return 2
|
|
66
|
+
if len(args) != 1:
|
|
67
|
+
print("Usage: workspace_metrics.py <integration.json | run-dir> [--attention-min N]")
|
|
68
|
+
return 2
|
|
69
|
+
path = args[0]
|
|
70
|
+
if os.path.isdir(path):
|
|
71
|
+
path = os.path.join(path, "integration.json")
|
|
72
|
+
try:
|
|
73
|
+
with open(path) as f:
|
|
74
|
+
ev = json.load(f)
|
|
75
|
+
except (OSError, ValueError) as e:
|
|
76
|
+
print("workspace_metrics: cannot read %s: %s" % (path, e))
|
|
77
|
+
return 2
|
|
78
|
+
res = compute(ev, attention)
|
|
79
|
+
for key in ("prs", "wall_clock_seconds", "prs_per_hour", "prs_per_attention_minute"):
|
|
80
|
+
print("%s: %s" % (key, _fmt(key, res[key])))
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -12,6 +12,7 @@ import os
|
|
|
12
12
|
import re
|
|
13
13
|
import subprocess
|
|
14
14
|
import sys
|
|
15
|
+
import time
|
|
15
16
|
|
|
16
17
|
DEP_DIRS = ("node_modules", ".venv", "venv", "vendor")
|
|
17
18
|
|
|
@@ -65,18 +66,59 @@ def _cow_copy(src, dst):
|
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def _escapes_source(path):
|
|
68
|
-
"""True if a dep dir
|
|
69
|
+
"""True if a dep dir links or points outside itself.
|
|
70
|
+
|
|
71
|
+
Flags absolute symlinks, relative symlinks resolving outside the dep dir,
|
|
72
|
+
editable finders, and bin/* scripts whose shebang names the source path.
|
|
73
|
+
"""
|
|
74
|
+
base = os.path.realpath(path)
|
|
75
|
+
sources = {os.path.dirname(base).encode(), os.path.dirname(os.path.abspath(path)).encode()}
|
|
69
76
|
for root, dirs, files in os.walk(path):
|
|
70
77
|
for name in dirs + files:
|
|
71
78
|
p = os.path.join(root, name)
|
|
72
79
|
if os.path.islink(p):
|
|
73
|
-
|
|
80
|
+
target = os.readlink(p)
|
|
81
|
+
if os.path.isabs(target):
|
|
82
|
+
return True
|
|
83
|
+
real = os.path.realpath(os.path.join(root, target))
|
|
84
|
+
if real != base and not real.startswith(base + os.sep):
|
|
74
85
|
return True
|
|
75
86
|
elif name.endswith(".pth") or name.startswith("__editable__"):
|
|
76
87
|
return True
|
|
88
|
+
if os.path.basename(root) == "bin":
|
|
89
|
+
for name in files:
|
|
90
|
+
p = os.path.join(root, name)
|
|
91
|
+
if os.path.islink(p):
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
with open(p, "rb") as f:
|
|
95
|
+
first = f.readline(4096)
|
|
96
|
+
except OSError:
|
|
97
|
+
continue
|
|
98
|
+
if first.startswith(b"#!") and any(x in first for x in sources):
|
|
99
|
+
return True
|
|
77
100
|
return False
|
|
78
101
|
|
|
79
102
|
|
|
103
|
+
def _lock_timeout():
|
|
104
|
+
try:
|
|
105
|
+
return float(os.environ.get("LOKI_PREP_LOCK_TIMEOUT", "120"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return 120.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _acquire_lock(lock):
|
|
111
|
+
deadline = time.monotonic() + _lock_timeout()
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
115
|
+
return
|
|
116
|
+
except OSError:
|
|
117
|
+
if time.monotonic() >= deadline:
|
|
118
|
+
raise RuntimeError("FAILED: lock contention")
|
|
119
|
+
time.sleep(0.1)
|
|
120
|
+
|
|
121
|
+
|
|
80
122
|
def _copy_deps(source, dest, start):
|
|
81
123
|
copied = 0
|
|
82
124
|
for name in DEP_DIRS:
|
|
@@ -100,7 +142,7 @@ def prepare_worktree(source_repo, dest, branch, setup=None, base=None):
|
|
|
100
142
|
dest = os.path.abspath(dest)
|
|
101
143
|
lock = open(_lock_path(source_repo), "w")
|
|
102
144
|
try:
|
|
103
|
-
|
|
145
|
+
_acquire_lock(lock)
|
|
104
146
|
left_out = _left_out_changes(source_repo)
|
|
105
147
|
start = _start_point(source_repo, base)
|
|
106
148
|
base_sha = _git(source_repo, "rev-parse", start + "^{commit}").stdout.strip()
|
package/autonomy/loki
CHANGED
|
@@ -1589,6 +1589,48 @@ PYEOF
|
|
|
1589
1589
|
}
|
|
1590
1590
|
|
|
1591
1591
|
# Show help
|
|
1592
|
+
# `loki help answer`: `loki answer` is a v10 (loki-ts) command with no bash
|
|
1593
|
+
# implementation, so its help lives here rather than behind delegation.
|
|
1594
|
+
_help_answer() {
|
|
1595
|
+
echo -e "${BOLD}loki answer${NC} - Resume a BLOCKED run with an answer"
|
|
1596
|
+
echo ""
|
|
1597
|
+
echo "Usage: loki answer [<run-id>] [--text \"...\"]"
|
|
1598
|
+
echo ""
|
|
1599
|
+
echo "A run BLOCKED on a spec conflict has no in-place resume. This command"
|
|
1600
|
+
echo "starts a fresh run whose task carries the original task plus the"
|
|
1601
|
+
echo "question and your answer as context, then prints the new run id."
|
|
1602
|
+
echo ""
|
|
1603
|
+
echo "Arguments:"
|
|
1604
|
+
echo " <run-id> BLOCKED run to answer. Default: the newest BLOCKED run"
|
|
1605
|
+
echo " in the current repo (else the latest run)."
|
|
1606
|
+
echo ""
|
|
1607
|
+
echo "Options:"
|
|
1608
|
+
echo " --text \"...\" The answer text (also --text=...). Without it, the answer is"
|
|
1609
|
+
echo " read from the Control Plane answer file"
|
|
1610
|
+
echo " \$LOKI_CONTROL_ANSWER_DIR/<source>/<run-id>.answer.txt"
|
|
1611
|
+
echo " (default dir: ~/.loki/control/answers)."
|
|
1612
|
+
echo ""
|
|
1613
|
+
echo "Exit codes: 0 the new run succeeded (otherwise its exit code), 2 no runs,"
|
|
1614
|
+
echo "run not BLOCKED, no answer supplied, or bad usage."
|
|
1615
|
+
echo "The child run drops SLACK_* and LOKI_CONTROL_TOKEN from its environment"
|
|
1616
|
+
echo "and sets LOKI_NO_BROWSER=1."
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
# Appended to `loki help verify`: the v10 DSSE export flag (docs/exit-codes.md).
|
|
1620
|
+
_help_verify_dsse() {
|
|
1621
|
+
echo ""
|
|
1622
|
+
echo "DSSE EXPORT (v10 engine):"
|
|
1623
|
+
echo " loki verify <run-id|receipt.json> --export-dsse"
|
|
1624
|
+
echo " Print a VERIFIED or ALREADY_SATISFIED run receipt as an in-toto"
|
|
1625
|
+
echo " Statement v1 inside a DSSE envelope (Ed25519 over PAE). Refusals"
|
|
1626
|
+
echo " print nothing on stdout. Exit codes:"
|
|
1627
|
+
echo " 0 envelope printed"
|
|
1628
|
+
echo " 1 TAMPERED, an envelope given as input, or run_id mismatch"
|
|
1629
|
+
echo " 2 UNSIGNED, UNCHECKED, or unreadable receipt"
|
|
1630
|
+
echo " 4 run outcome is not VERIFIED or ALREADY_SATISFIED"
|
|
1631
|
+
echo " 66 no signing key, or no runs"
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1592
1634
|
show_help() {
|
|
1593
1635
|
local version=$(get_version)
|
|
1594
1636
|
# Strip color when stdout is not a TTY (piped/redirected) so captured help
|
|
@@ -6215,8 +6257,14 @@ cmd_status() {
|
|
|
6215
6257
|
if [ -f "$LOKI_DIR/metrics/budget.json" ]; then
|
|
6216
6258
|
local budget_limit budget_used budget_remaining
|
|
6217
6259
|
budget_limit=$(LOKI_BUDGET_FILE="$LOKI_DIR/metrics/budget.json" python3 -c "import json,os; d=json.load(open(os.environ['LOKI_BUDGET_FILE'])); print(d.get('budget_limit', 0))" 2>/dev/null || echo "0")
|
|
6218
|
-
budget_used=$(LOKI_BUDGET_FILE="$LOKI_DIR/metrics/budget.json" python3 -c "import json,os; d=json.load(open(os.environ['LOKI_BUDGET_FILE']));
|
|
6219
|
-
if [ "$
|
|
6260
|
+
budget_used=$(LOKI_BUDGET_FILE="$LOKI_DIR/metrics/budget.json" python3 -c "import json,os; d=json.load(open(os.environ['LOKI_BUDGET_FILE'])); v=d.get('budget_used'); print('unmeasured' if v is None else round(v, 2))" 2>/dev/null || echo "0")
|
|
6261
|
+
if [ "$budget_used" = "unmeasured" ]; then
|
|
6262
|
+
if [ "$budget_limit" != "0" ]; then
|
|
6263
|
+
echo -e "${CYAN}Budget:${NC} unmeasured / \$$budget_limit"
|
|
6264
|
+
else
|
|
6265
|
+
echo -e "${CYAN}Cost:${NC} unmeasured"
|
|
6266
|
+
fi
|
|
6267
|
+
elif [ "$budget_limit" != "0" ]; then
|
|
6220
6268
|
echo -e "${CYAN}Budget:${NC} \$$budget_used / \$$budget_limit"
|
|
6221
6269
|
# Show budget gauge if TUI is loaded
|
|
6222
6270
|
if type context_gauge &>/dev/null; then
|
|
@@ -6697,6 +6745,7 @@ total_input = sum(it.get('input_tokens', 0) for it in iterations)
|
|
|
6697
6745
|
total_output = sum(it.get('output_tokens', 0) for it in iterations)
|
|
6698
6746
|
total_tokens = total_input + total_output
|
|
6699
6747
|
total_cost = sum(it.get('cost_usd', 0) for it in iterations)
|
|
6748
|
+
cost_measured = any(it.get('cost_usd') is not None for it in iterations)
|
|
6700
6749
|
total_duration = sum(it.get('duration_seconds', 0) for it in iterations)
|
|
6701
6750
|
|
|
6702
6751
|
# Budget
|
|
@@ -6854,7 +6903,10 @@ if iterations:
|
|
|
6854
6903
|
print(f' Input tokens: {fmt_number(total_input)}')
|
|
6855
6904
|
print(f' Output tokens: {fmt_number(total_output)}')
|
|
6856
6905
|
print(f' Total tokens: {fmt_number(total_tokens)}')
|
|
6857
|
-
|
|
6906
|
+
if cost_measured:
|
|
6907
|
+
print(f' Estimated cost: \${total_cost:.2f}')
|
|
6908
|
+
else:
|
|
6909
|
+
print(' Estimated cost: unmeasured')
|
|
6858
6910
|
else:
|
|
6859
6911
|
print(' N/A (no iteration metrics found)')
|
|
6860
6912
|
print()
|
|
@@ -22328,8 +22380,15 @@ main() {
|
|
|
22328
22380
|
# and a future spelling would slip past it.
|
|
22329
22381
|
if [ "${LOKI_HELP_ONLY:-}" = "1" ]; then
|
|
22330
22382
|
show_help
|
|
22383
|
+
elif [ "$_help_target" = "answer" ]; then
|
|
22384
|
+
_help_answer
|
|
22331
22385
|
else
|
|
22332
22386
|
LOKI_HELP_ONLY=1 "$0" "$_help_target" --help "$@"
|
|
22387
|
+
local _help_rc=$?
|
|
22388
|
+
if [ "$_help_target" = "verify" ] && [ "$_help_rc" -eq 0 ]; then
|
|
22389
|
+
_help_verify_dsse
|
|
22390
|
+
fi
|
|
22391
|
+
return "$_help_rc"
|
|
22333
22392
|
fi
|
|
22334
22393
|
;;
|
|
22335
22394
|
esac
|