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.
Files changed (99) hide show
  1. package/README.md +20 -2
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +19 -3
  5. package/autonomy/lib/fast_verify.py +19 -1
  6. package/autonomy/lib/proof-pr.sh +34 -3
  7. package/autonomy/lib/workspace.py +27 -1
  8. package/autonomy/lib/workspace_metrics.py +85 -0
  9. package/autonomy/lib/worktree_prep.py +45 -3
  10. package/autonomy/loki +62 -3
  11. package/autonomy/run.sh +95 -6
  12. package/autonomy/verify.sh +87 -20
  13. package/dashboard/__init__.py +1 -1
  14. package/dashboard/api_operator.py +27 -0
  15. package/dashboard/api_runs.py +52 -0
  16. package/dashboard/api_workspaces.py +140 -0
  17. package/dashboard/audit.py +8 -2
  18. package/dashboard/server.py +42 -21
  19. package/dashboard/static/index.html +4 -3
  20. package/dashboard/static/proofs.html +5 -0
  21. package/dashboard/static/start.html +32 -1
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/SIGNED-RECEIPTS.md +39 -0
  24. package/docs/UNREACHABLE-TESTS.md +1 -1
  25. package/docs/WORKSPACES.md +14 -0
  26. package/docs/audit-logging.md +13 -0
  27. package/docs/dev/release-checklist.md +5 -0
  28. package/docs/exit-codes.md +44 -9
  29. package/docs/v10/BOARD.md +78 -26
  30. package/docs/v10/DECISIONS.md +29 -0
  31. package/docs/v10/E2E-D65.md +8 -0
  32. package/docs/v10/GUIDE.md +1 -1
  33. package/docs/v10/PROGRESS.md +68 -0
  34. package/loki-ts/dist/loki.js +545 -536
  35. package/mcp/__init__.py +1 -1
  36. package/mcp/tests/test_v10_tools.py +41 -0
  37. package/mcp/v10_tools.py +13 -0
  38. package/package.json +1 -1
  39. package/packages/control-plane/dist/server.js +14 -1
  40. package/packages/control-plane/drizzle/meta/0001_snapshot.json +364 -0
  41. package/packages/control-plane/src/server/app.ts +7 -1
  42. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  43. package/web-app/dist/assets/{AdminPage-OaSCF3M4.js → AdminPage-oc63I7OH.js} +1 -1
  44. package/web-app/dist/assets/{Avatar-xa3G7_vs.js → Avatar-BziJJn7b.js} +1 -1
  45. package/web-app/dist/assets/{Badge-Ci_UcHij.js → Badge-DeTNCD_k.js} +1 -1
  46. package/web-app/dist/assets/{Button-CFrrmbvL.js → Button-D9wjGlQI.js} +1 -1
  47. package/web-app/dist/assets/{CockpitPage-6hyVl_QZ.js → CockpitPage-Bw4_akgH.js} +1 -1
  48. package/web-app/dist/assets/{ComparePage-B5H56KCG.js → ComparePage-XmdsH_wC.js} +1 -1
  49. package/web-app/dist/assets/{ErrorBoundary-CBbRHsYr.js → ErrorBoundary-B0_IF9Ew.js} +1 -1
  50. package/web-app/dist/assets/{EvidenceReceiptPanel-D-Zoneum.js → EvidenceReceiptPanel-Da3d60pX.js} +1 -1
  51. package/web-app/dist/assets/{GitHubIssuesPanel-D7XPRsic.js → GitHubIssuesPanel-D9Jiuoyc.js} +1 -1
  52. package/web-app/dist/assets/{GitHubPRsPanel-CzHbBPAn.js → GitHubPRsPanel-DQCUsWAb.js} +1 -1
  53. package/web-app/dist/assets/{HomePage-ClOx5qF1.js → HomePage-rfyNY25A.js} +2 -2
  54. package/web-app/dist/assets/{LoginPage-3Yz_9j8E.js → LoginPage-BGHFcJj8.js} +1 -1
  55. package/web-app/dist/assets/{MagicPage-Dumn48gC.js → MagicPage-CdoeZ5aB.js} +1 -1
  56. package/web-app/dist/assets/{MetricsPage-CYCYVG_8.js → MetricsPage-CwKr2759.js} +2 -2
  57. package/web-app/dist/assets/{NotFoundPage-CV-x_1qH.js → NotFoundPage-9qNm-fWi.js} +1 -1
  58. package/web-app/dist/assets/{ProjectPage-CeO7dnyM.js → ProjectPage-BbeRxI8t.js} +67 -67
  59. package/web-app/dist/assets/{ProjectsPage-BT9mz6iD.js → ProjectsPage-hw9XHZXR.js} +1 -1
  60. package/web-app/dist/assets/{SettingsPage-BL8X9fAB.js → SettingsPage-Cq6duAHh.js} +1 -1
  61. package/web-app/dist/assets/{ShowcasePage-8f3lnSpc.js → ShowcasePage-DcpXgNKy.js} +1 -1
  62. package/web-app/dist/assets/{SystemSettingsPage-BUNAAzh2.js → SystemSettingsPage-BMWp2LCp.js} +1 -1
  63. package/web-app/dist/assets/{TeamsPage-Qj-VEZJz.js → TeamsPage-Dh1rte-l.js} +1 -1
  64. package/web-app/dist/assets/{TemplatesPage-b8lKqe5q.js → TemplatesPage-BUIMiu9V.js} +1 -1
  65. package/web-app/dist/assets/{TerminalOutput-jOnfQKB4.js → TerminalOutput-g9_MPOQq.js} +1 -1
  66. package/web-app/dist/assets/{activity-w7GnyBNp.js → activity-BYGoe6e6.js} +1 -1
  67. package/web-app/dist/assets/{bell-2i4ejMOA.js → bell-CxCZpUMb.js} +1 -1
  68. package/web-app/dist/assets/{bot-jaPm4NIs.js → bot-Cpbkdqjb.js} +1 -1
  69. package/web-app/dist/assets/{check-CvNtxeKM.js → check-fO48BSWF.js} +1 -1
  70. package/web-app/dist/assets/{chevron-left-zzBjHAtX.js → chevron-left-C3J6cmqz.js} +1 -1
  71. package/web-app/dist/assets/{circle-alert-CCU0IU2m.js → circle-alert-DhdR0TJR.js} +1 -1
  72. package/web-app/dist/assets/{clock-BvZckMwN.js → clock-Crp889Lj.js} +1 -1
  73. package/web-app/dist/assets/{cloud-hrMWDdYp.js → cloud-CUoa_Aqa.js} +1 -1
  74. package/web-app/dist/assets/{code-xml-v6l40T-K.js → code-xml-BZSDiROr.js} +1 -1
  75. package/web-app/dist/assets/{database-l2TMUrQd.js → database-BC_OF6lp.js} +1 -1
  76. package/web-app/dist/assets/{dollar-sign-C2V6OvpO.js → dollar-sign-DJUXGU2U.js} +1 -1
  77. package/web-app/dist/assets/{file-code-corner-Bytb5JGA.js → file-code-corner-CwHaIaDe.js} +1 -1
  78. package/web-app/dist/assets/{file-plus-BynCYQB8.js → file-plus-DD8rC5Gt.js} +1 -1
  79. package/web-app/dist/assets/{globe-DifDdNMH.js → globe-CwDBjzLT.js} +1 -1
  80. package/web-app/dist/assets/{hammer-D7TsOJD6.js → hammer-CzeZBlk1.js} +1 -1
  81. package/web-app/dist/assets/{index-Zohfqaz4.js → index-BgFxf62u.js} +3 -3
  82. package/web-app/dist/assets/{layers-Bk8c2YLS.js → layers-DytzdbMj.js} +1 -1
  83. package/web-app/dist/assets/{loader-circle-BrLdbMwP.js → loader-circle-D8hG34Pb.js} +1 -1
  84. package/web-app/dist/assets/{lock-COpmjMyJ.js → lock-hk6B4D77.js} +1 -1
  85. package/web-app/dist/assets/{package-BMzYdY7F.js → package-DO_92_a-.js} +1 -1
  86. package/web-app/dist/assets/{plus-B9xpu1Pg.js → plus-BKQDzXcw.js} +1 -1
  87. package/web-app/dist/assets/{refresh-cw-C2Lzh-AA.js → refresh-cw-DKTkN-7t.js} +1 -1
  88. package/web-app/dist/assets/{rotate-ccw-2Yyy-uQd.js → rotate-ccw-Yhmj5NtP.js} +1 -1
  89. package/web-app/dist/assets/{scroll-text-DiK2yhsT.js → scroll-text-Cc-NigJs.js} +1 -1
  90. package/web-app/dist/assets/{server-xlJBgleU.js → server-DYIet42E.js} +1 -1
  91. package/web-app/dist/assets/{shield-alert-D9w4yBCi.js → shield-alert-Cy_zkAp6.js} +1 -1
  92. package/web-app/dist/assets/{thumbs-up-CcWfvcE9.js → thumbs-up-DtLybu1S.js} +1 -1
  93. package/web-app/dist/assets/{trash-2-_hk_pEAp.js → trash-2-n9tqmPW_.js} +1 -1
  94. package/web-app/dist/assets/{trending-down-CDZT99tC.js → trending-down-zHpd0XJs.js} +1 -1
  95. package/web-app/dist/assets/{trending-up-A0oreTXc.js → trending-up-d8bugNud.js} +1 -1
  96. package/web-app/dist/assets/{upload-DfLwhGG2.js → upload-CkiOu1GK.js} +1 -1
  97. package/web-app/dist/assets/{usePolling-B7gW2YY_.js → usePolling-B9aSXIDR.js} +1 -1
  98. package/web-app/dist/assets/{user-C-y7_Oon.js → user-DbfT_krW.js} +1 -1
  99. 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
- ### Visual evidence (opt-in)
238
+ ### Export as a DSSE envelope
238
239
 
239
- With `LOKI_VISUAL_EVIDENCE=1`, 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). API-only repos with an openapi file 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. Off by default.
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.10
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.10 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
476
+ **v10.6.12 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 10.6.10
1
+ 10.6.12
@@ -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 (OSError, ValueError):
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
- if day_cap and spent_today() >= day_cap:
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
- state[k].update(status="BUDGET_STOP: daily budget $%.2f reached ($%.2f spent)" % (day_cap, spent_today()), ok=False)
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
- return 1 if r.verdict == "FAIL" else 0
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__":
@@ -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
- if ! command -v python3 >/dev/null 2>&1; then
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="$(python3 - "$proof_json_path" "$expected_head_sha" <<'PROOF_PR_PY' 2>/dev/null
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 (OSError, ValueError):
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 has an absolute symlink or an editable finder."""
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
- if os.path.isabs(os.readlink(p)):
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
- fcntl.flock(lock, fcntl.LOCK_EX)
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'])); print(round(d.get('budget_used', 0), 2))" 2>/dev/null || echo "0")
6219
- if [ "$budget_limit" != "0" ]; then
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
- print(f' Estimated cost: \${total_cost:.2f}')
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