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/autonomy/run.sh CHANGED
@@ -9884,12 +9884,19 @@ setup_agent_branch() {
9884
9884
  # file is gone when the user switches back. On conflict the resume
9885
9885
  # is refused and a fresh session branch (with a fresh snapshot) is
9886
9886
  # minted below.
9887
- if git checkout --no-overwrite-ignore "$recorded" >/dev/null 2>&1; then
9887
+ local resume_err="" resume_files=""
9888
+ if resume_err="$(git checkout --no-overwrite-ignore "$recorded" 2>&1 >/dev/null)"; then
9888
9889
  log_info "Resuming on recorded agent branch: ${recorded}"
9889
9890
  _loki_resume_snapshot
9890
9891
  return 0
9891
9892
  fi
9892
- log_warn "Recorded agent branch ${recorded} could not be checked out - creating a new one"
9893
+ # git lists the blocking paths as tab-indented lines.
9894
+ resume_files="$(printf '%s\n' "$resume_err" | sed -n 's/^[[:space:]][[:space:]]*//p' | head -5 | paste -sd, - | sed 's/,/, /g')"
9895
+ if [ -n "$resume_files" ]; then
9896
+ log_warn "Recorded agent branch ${recorded} could not be checked out because it would overwrite your ignored file(s): ${resume_files} - creating a new one; the earlier commits stay on ${recorded}"
9897
+ else
9898
+ log_warn "Recorded agent branch ${recorded} could not be checked out - creating a new one; the earlier commits stay on ${recorded}"
9899
+ fi
9893
9900
  fi
9894
9901
  fi
9895
9902
 
@@ -10640,10 +10647,70 @@ _loki_snapshot_preexisting() {
10640
10647
  # entirely absent from the sealed snapshot this function ultimately feeds.
10641
10648
  _loki_untracked_status() {
10642
10649
  local top="" prefix="" gittool=""
10650
+ local genv=() fk="" fn="" n=0
10643
10651
  gittool="$(_loki_snapshot_git_tool)" || return 1
10644
- top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
10645
- prefix="$("$gittool" rev-parse --show-prefix 2>/dev/null)" || return 1
10646
- "$gittool" -C "$top" --no-optional-locks status --porcelain -z --no-renames -uall \
10652
+ # Version floor: GIT_CONFIG_COUNT needs git >= 2.31 and GIT_NO_LAZY_FETCH
10653
+ # (no hook-running lazy fetch) needs git >= 2.44. Unparseable also refuses.
10654
+ local gver="" gmaj="" gmin=""
10655
+ gver="$(LC_ALL=C "$gittool" --version 2>/dev/null)" || gver=""
10656
+ gver="${gver#git version }"
10657
+ gmaj="${gver%%.*}"
10658
+ gmin="${gver#*.}"
10659
+ gmin="${gmin%%.*}"
10660
+ case "${gmaj}${gmin}" in
10661
+ '' | *[!0-9]*) gmaj="" ;;
10662
+ esac
10663
+ if [ -z "$gmaj" ] || [ -z "$gmin" ] || [ "$gmaj" -lt 2 ] || { [ "$gmaj" -eq 2 ] && [ "$gmin" -lt 44 ]; }; then
10664
+ echo "snapshot: git >= 2.44 required (got '${gver:-unknown}'); refusing to run status unprotected" >&2
10665
+ return 1
10666
+ fi
10667
+ # Inherited GIT_DIR / GIT_WORK_TREE would repoint every call below.
10668
+ top="$(env -u GIT_DIR -u GIT_WORK_TREE "$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
10669
+ prefix="$(env -u GIT_DIR -u GIT_WORK_TREE "$gittool" rev-parse --show-prefix 2>/dev/null)" || return 1
10670
+ # BACKLOG 131c: a repo-local core.fsmonitor is a command git runs on
10671
+ # status; the agent can write .git/config, so disable it (and the
10672
+ # untracked cache it could have poisoned) for this one call.
10673
+ # A filter driver named by attributes (clean/smudge/process) is also a
10674
+ # command git runs when status compares a stat-dirty tracked file, so every
10675
+ # configured driver is blanked: clean/smudge/process become empty (never
10676
+ # `cat`, which resolves from PATH) and required is false.
10677
+ # The overrides travel as GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n, never as
10678
+ # `-c key=value`: git splits -c at the first "=", so a driver named "x=y"
10679
+ # would be missed. GIT_NO_LAZY_FETCH stops a partial-clone status from
10680
+ # fetching a missing blob over an agent-written core.sshCommand. Inherited
10681
+ # GIT_DIR, GIT_WORK_TREE, GIT_CONFIG_PARAMETERS and GIT_CONFIG_GLOBAL are
10682
+ # dropped and GIT_CONFIG_COUNT is overwritten, never appended to. LC_ALL=C
10683
+ # keeps the name lookup byte-exact for non-UTF-8 driver names.
10684
+ genv=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_GLOBAL
10685
+ LC_ALL=C GIT_NO_LAZY_FETCH=1)
10686
+ # The NUL-delimited listing goes to a file beside the output path, read by
10687
+ # a loop in this shell (it appends to genv and n); removed right after the
10688
+ # loop, before any later return path.
10689
+ : > "${1}.filters" || return 1
10690
+ "${genv[@]}" "$gittool" -C "$top" config -z --name-only --get-regexp \
10691
+ '^filter\..*\.(clean|smudge|process)$' > "${1}.filters" 2>/dev/null || true
10692
+ while IFS= read -r -d '' fk; do
10693
+ fn="${fk%.*}"
10694
+ genv+=("GIT_CONFIG_KEY_${n}=${fk}" "GIT_CONFIG_VALUE_${n}=")
10695
+ n=$((n + 1))
10696
+ genv+=("GIT_CONFIG_KEY_${n}=${fn}.required" "GIT_CONFIG_VALUE_${n}=false")
10697
+ n=$((n + 1))
10698
+ done < "${1}.filters"
10699
+ rm -f "${1}.filters"
10700
+ # Prove the overrides are visible with a per-call random nonce (a constant
10701
+ # could be planted in .git/config by the agent), or refuse to run.
10702
+ local nonce=""
10703
+ nonce="$(od -An -tx8 -N8 /dev/urandom 2>/dev/null | tr -d ' \n')"
10704
+ [ -n "$nonce" ] || return 1
10705
+ genv+=("GIT_CONFIG_KEY_${n}=loki.snapshot.sentinel" "GIT_CONFIG_VALUE_${n}=${nonce}")
10706
+ n=$((n + 1))
10707
+ genv+=("GIT_CONFIG_COUNT=${n}")
10708
+ if [ "$("${genv[@]}" "$gittool" -C "$top" config --get loki.snapshot.sentinel 2>/dev/null)" != "$nonce" ]; then
10709
+ echo "snapshot: git does not honor GIT_CONFIG_COUNT; refusing to run status unprotected" >&2
10710
+ return 1
10711
+ fi
10712
+ "${genv[@]}" "$gittool" -C "$top" -c core.fsmonitor=false -c core.untrackedCache=false \
10713
+ --no-optional-locks status --porcelain -z --no-renames -uall \
10647
10714
  --ignored=matching --ignore-submodules=all -- ":(exclude,literal)${prefix}.loki" \
10648
10715
  > "$1" 2>/dev/null && return 0
10649
10716
  rm -f "$1"
@@ -11045,12 +11112,23 @@ _loki_untrack_agent_committed_user_files() {
11045
11112
  fi
11046
11113
  mv -f "$rec.new" "$rec"
11047
11114
  names="$(_loki_nul_names "$rec")"
11115
+ # S-220: keep what the agent force-staged. Save the index as a tree, make the
11116
+ # removal commit from the branch tip alone, then restore the saved index minus
11117
+ # the removed paths. A global `git reset -q` here dropped those staged files
11118
+ # from the session commit that follows. No saved tree (unmerged index): the
11119
+ # old full reset still applies.
11120
+ local saved_tree=""
11121
+ saved_tree="$(git write-tree 2>/dev/null)" || saved_tree=""
11048
11122
  git reset -q >/dev/null 2>&1 || true
11049
11123
  if ! git -C "$top" update-index -z --force-remove --stdin < "$rec" >/dev/null 2>&1 \
11050
11124
  || ! git commit -q -m "Loki Mode: untrack pre-existing user files the agent committed" >/dev/null 2>&1; then
11051
11125
  log_warn "The agent committed your pre-existing files on $1 and Loki could not remove them from the branch: ${names}. They are on disk. Before switching branches, run git rm --cached on each and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
11052
11126
  return 1
11053
11127
  fi
11128
+ if [ -n "$saved_tree" ]; then
11129
+ { git read-tree "$saved_tree" \
11130
+ && git -C "$top" update-index -z --force-remove --stdin < "$rec"; } >/dev/null 2>&1 || true
11131
+ fi
11054
11132
  log_warn "The agent committed your pre-existing untracked or ignored files on $1: ${names}. Loki removed them from the branch tip; they stay on disk, untracked. The branch history still holds them: do not push $1 as-is. To drop them from its history: git reset --soft ${fork} && git commit"
11055
11133
  audit_agent_action "git_untrack_user_files" "Removed pre-existing user files the agent committed" "files=${names}" || true
11056
11134
  return 0
@@ -11156,11 +11234,22 @@ commit_session_changes() {
11156
11234
  # (_commit_path_looks_secret + _commit_scan_secret_file over EVERY staged
11157
11235
  # file) is the actual guarantee for nested/weak secrets; these excludes are a
11158
11236
  # cheap first cut for the obvious top-level files only.
11237
+ # S-219: a failed add must not fall through to the "nothing staged" no-op,
11238
+ # which would silently commit nothing. Unstage any partial result and say so.
11239
+ # rc 1 is benign: git exits 1 after staging everything else when a pathspec
11240
+ # (':!.loki') names an ignored path, which is the normal case. A real
11241
+ # failure (unreadable file, lock) exits 128.
11242
+ local add_rc=0
11159
11243
  git add -A \
11160
11244
  ':!.loki' ':!.loki/' \
11161
11245
  ':!.env' ':!.env.*' ':!*.env' \
11162
11246
  ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
11163
- ':!id_rsa*' ':!*.token' ':!credentials*' 2>/dev/null || true
11247
+ ':!id_rsa*' ':!*.token' ':!credentials*' 2>/dev/null || add_rc=$?
11248
+ if [ "$add_rc" -gt 1 ]; then
11249
+ git reset -q >/dev/null 2>&1 || true
11250
+ log_warn "Left uncommitted: could not stage the session's changes (git add failed). Review and commit manually."
11251
+ return 0
11252
+ fi
11164
11253
 
11165
11254
  # Unstage exactly the paths recorded as untracked or gitignored when the
11166
11255
  # session started (setup_agent_branch; a "dir/" entry covers its subtree):
@@ -68,6 +68,46 @@ VERIFY_EXIT_BLOCKED=2
68
68
  VERIFY_EXIT_ERROR=3
69
69
  VERIFY_SCHEMA_VERSION="1.0"
70
70
 
71
+ # S-216: verify runs from the tree under review (tree="."), and a bare
72
+ # `python3 -c` / `python3 -` puts that cwd first on sys.path, so a planted
73
+ # json.py in the reviewed tree replaced the stdlib module and forged recorded
74
+ # gates (a dependency_audit with high CVEs read as pass). The inline readers
75
+ # run through _verify_py, which resolves the interpreter with
76
+ # _loki_snapshot_py_tool and runs it -I -S. When none resolves it exits 127
77
+ # with no output, exactly as a missing python3 did, so no reader reads as pass.
78
+ # This module does not source run.sh, so it carries a guarded copy; run.sh is
79
+ # the source of truth: keep it byte-identical. Pinned by
80
+ # tests/test-verify-no-cwd-shadow.sh and tests/test-council-py-tool-identity.sh.
81
+ # Only the unittest run stays on bare python3: it runs the reviewed tree's own
82
+ # tests on purpose. py_compile is a stdlib verdict, so it runs isolated too.
83
+ declare -F _loki_snapshot_py_tool >/dev/null 2>&1 || \
84
+ _loki_snapshot_py_tool() {
85
+ local c
86
+ for c in /usr/bin/python3 /bin/python3; do
87
+ [ -x "$c" ] && [ ! -d "$c" ] && "$c" -I -S -c '' >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
88
+ done
89
+ local dir
90
+ local IFS=:
91
+ for dir in $PATH; do
92
+ case "$dir" in
93
+ /*) ;;
94
+ *) continue ;;
95
+ esac
96
+ if [ -x "$dir/python3" ] && [ ! -d "$dir/python3" ] \
97
+ && "$dir/python3" -I -S -c '' >/dev/null 2>&1; then
98
+ printf '%s\n' "$dir/python3"
99
+ return 0
100
+ fi
101
+ done
102
+ return 1
103
+ }
104
+
105
+ _verify_py() {
106
+ local py
107
+ py="$(_loki_snapshot_py_tool)" || return 127
108
+ "$py" -I -S "$@"
109
+ }
110
+
71
111
  # Resolve tool version from the VERSION file shipped alongside the repo.
72
112
  # ---------------------------------------------------------------------------
73
113
  # LLM review stage (v9.26.0). Phase 2 of the spec; the deterministic MVP shipped
@@ -187,7 +227,7 @@ SCHEMA
187
227
 
188
228
  # Parse defensively: a malformed payload is "unavailable", never a pass.
189
229
  local parsed
190
- parsed="$(printf '%s' "$out" | python3 -c '
230
+ parsed="$(printf '%s' "$out" | _verify_py -c '
191
231
  import json, sys
192
232
  try:
193
233
  d = json.load(sys.stdin)
@@ -588,7 +628,7 @@ ZT_MATCHES_EOF
588
628
  _verify_pkg_test_script() {
589
629
  local tree="$1"
590
630
  [ -f "$tree/package.json" ] || return 0
591
- python3 -c '
631
+ _verify_py -c '
592
632
  import json,sys
593
633
  try:
594
634
  with open(sys.argv[1]) as fh:
@@ -918,7 +958,7 @@ verify_gate_static() {
918
958
  [ -z "$f" ] && continue
919
959
  [ -f "$tree/$f" ] || continue
920
960
  checked=$((checked + 1))
921
- python3 -m py_compile "$tree/$f" >/dev/null 2>&1 || {
961
+ _verify_py -m py_compile "$tree/$f" >/dev/null 2>&1 || {
922
962
  findings=$((findings + 1))
923
963
  details="${details}Python syntax error: $f. "
924
964
  _verify_add_finding "Medium" "lint" "deterministic:py_compile" "$f" "null" \
@@ -1266,9 +1306,9 @@ verify_gate_dependency_audit() {
1266
1306
  out="$(cd "$tree" && npm audit --json 2>/dev/null)" || rc=$?
1267
1307
  # npm audit exits nonzero when vulns are found; rc alone is not an
1268
1308
  # error. Distinguish "ran and found vulns" from "could not run".
1269
- if printf '%s' "$out" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
1309
+ if printf '%s' "$out" | _verify_py -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
1270
1310
  local sev
1271
- sev="$(printf '%s' "$out" | python3 -c '
1311
+ sev="$(printf '%s' "$out" | _verify_py -c '
1272
1312
  import sys, json
1273
1313
  d = json.load(sys.stdin)
1274
1314
  v = d.get("metadata", {}).get("vulnerabilities", {})
@@ -1292,7 +1332,7 @@ print("%d %d %d %d" % (crit, high, mod, low))
1292
1332
  # count different dependency graphs, so arithmetic on the
1293
1333
  # totals would not be sound.
1294
1334
  local _prod_hc=""
1295
- _prod_hc="$(cd "$tree" && npm audit --omit=dev --json 2>/dev/null | python3 -c '
1335
+ _prod_hc="$(cd "$tree" && npm audit --omit=dev --json 2>/dev/null | _verify_py -c '
1296
1336
  import sys, json
1297
1337
  try:
1298
1338
  v = json.load(sys.stdin).get("metadata", {}).get("vulnerabilities", {})
@@ -1334,9 +1374,9 @@ print(v.get("critical", 0) + v.get("high", 0))
1334
1374
  if command -v pip-audit >/dev/null 2>&1; then
1335
1375
  local out rc=0
1336
1376
  out="$(cd "$tree" && pip-audit --format json 2>/dev/null)" || rc=$?
1337
- if printf '%s' "$out" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
1377
+ if printf '%s' "$out" | _verify_py -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
1338
1378
  local count
1339
- count="$(printf '%s' "$out" | python3 -c '
1379
+ count="$(printf '%s' "$out" | _verify_py -c '
1340
1380
  import sys, json
1341
1381
  d = json.load(sys.stdin)
1342
1382
  deps = d.get("dependencies", d if isinstance(d, list) else [])
@@ -1438,7 +1478,7 @@ _verify_runtime_detect() {
1438
1478
  # 0b. Rank 9 setup recipe (opportunistic; NOT yet built -- absence is normal).
1439
1479
  if [ -z "$method" ] && [ -f "$dir/.loki/setup-recipe.json" ] && command -v python3 >/dev/null 2>&1; then
1440
1480
  local recipe
1441
- recipe="$(python3 - "$dir/.loki/setup-recipe.json" <<'PYEOF' 2>/dev/null || true
1481
+ recipe="$(_verify_py - "$dir/.loki/setup-recipe.json" <<'PYEOF' 2>/dev/null || true
1442
1482
  import json, sys
1443
1483
  try:
1444
1484
  with open(sys.argv[1]) as f:
@@ -1611,7 +1651,7 @@ _verify_write_setup_recipe() {
1611
1651
  _SR_DIR="$tree/.loki" _SR_INSTALL="$install" _SR_SEED="$seed" \
1612
1652
  _SR_START="$start" _SR_HEALTH="$health_path" _SR_PORT="$port" \
1613
1653
  _SR_ENVSRC="$env_src" \
1614
- python3 - <<'PYEOF' 2>/dev/null || return 0
1654
+ _verify_py - <<'PYEOF' 2>/dev/null || return 0
1615
1655
  import json, os, re
1616
1656
 
1617
1657
  out_dir = os.environ["_SR_DIR"]
@@ -1815,7 +1855,7 @@ verify_gate_runtime() {
1815
1855
  # reproducible (command + url + status + artifact are all captured).
1816
1856
  _VR_DIR="$artifact_dir" _VR_METHOD="$method" _VR_URL="$url" \
1817
1857
  _VR_STATUS="$http_status" _VR_ART="$artifact_field" _VR_TO="$boot_timeout" \
1818
- python3 - <<'PYEOF' 2>/dev/null || true
1858
+ _verify_py - <<'PYEOF' 2>/dev/null || true
1819
1859
  import json, os
1820
1860
  rec = {
1821
1861
  "start_command": os.environ.get("_VR_METHOD", ""),
@@ -2213,7 +2253,7 @@ verify_emit_evidence() {
2213
2253
  _V_SCOPE_SOFT_NET="${VERIFY_SCOPE_SOFT_NET_LINES:-}" \
2214
2254
  _V_SCOPE_MAX_FILES="${VERIFY_SCOPE_MAX_FILES:-}" \
2215
2255
  _V_SCOPE_MAX_NET="${VERIFY_SCOPE_MAX_NET_LINES:-}" \
2216
- python3 - <<'PYEOF'
2256
+ _verify_py - <<'PYEOF'
2217
2257
  import json, os, hashlib, sys
2218
2258
 
2219
2259
  out_dir = os.environ["_VERIFY_OUT_DIR"]
@@ -2671,7 +2711,7 @@ verify_expectation_ledger_gate() {
2671
2711
  _LK_ITER="$iter" \
2672
2712
  _LK_OBSERVED="$observed_file" \
2673
2713
  _LK_MOD="$mod" \
2674
- python3 - <<'PYEOF' 2>/dev/null
2714
+ _verify_py - <<'PYEOF' 2>/dev/null
2675
2715
  import importlib.util, json, os, sys
2676
2716
 
2677
2717
  mod_path = os.environ["_LK_MOD"]
@@ -2856,7 +2896,7 @@ verify_hosted_enrich() {
2856
2896
 
2857
2897
  # Fold the engine payload into evidence.json under "hosted". A parse failure
2858
2898
  # at any step leaves the file byte-for-byte unchanged.
2859
- _V_EV="$ev_path" _V_ENGINE="$engine_out" python3 - <<'PYEOF' || {
2899
+ _V_EV="$ev_path" _V_ENGINE="$engine_out" _verify_py - <<'PYEOF' || {
2860
2900
  import json, os, sys
2861
2901
 
2862
2902
  ev_path = os.environ["_V_EV"]
@@ -2906,7 +2946,7 @@ PYEOF
2906
2946
  # Built from the engine payload via python3 (already a precondition above);
2907
2947
  # any parse hiccup leaves the summary empty and the banner simply omits the
2908
2948
  # line -- never a fabricated summary.
2909
- VERIFY_HOSTED_SUMMARY="$(_V_ENGINE="$engine_out" _V_VERDICT="${VERIFY_VERDICT:-}" python3 - <<'PYEOF' 2>/dev/null || true
2949
+ VERIFY_HOSTED_SUMMARY="$(_V_ENGINE="$engine_out" _V_VERDICT="${VERIFY_VERDICT:-}" _verify_py - <<'PYEOF' 2>/dev/null || true
2910
2950
  import json, os
2911
2951
  try:
2912
2952
  e = json.loads(os.environ["_V_ENGINE"])
@@ -3002,7 +3042,7 @@ _verify_check_fresh() {
3002
3042
  # Parse the graded head_sha out of the evidence document. python3 is the same
3003
3043
  # dependency the emitter uses; a parse failure is fail-closed (ERROR).
3004
3044
  local graded_head parse_rc
3005
- graded_head="$(_VF_EV="$ev_path" python3 - <<'PYEOF' 2>/dev/null
3045
+ graded_head="$(_VF_EV="$ev_path" _verify_py - <<'PYEOF' 2>/dev/null
3006
3046
  import json, os, sys
3007
3047
  try:
3008
3048
  doc = json.load(open(os.environ["_VF_EV"]))
@@ -3053,9 +3093,26 @@ PYEOF
3053
3093
  _verify_receipt_digest() {
3054
3094
  local rid pj lib rc=0
3055
3095
  rid="$(cat .loki/state/last-proof-id.txt 2>/dev/null || true)"
3056
- case "$rid" in '' | *[!A-Za-z0-9._-]*) return 0 ;; esac
3096
+ # D76: a missing or unusable run-id pointer must not silently skip the receipt check.
3097
+ # Proofs on disk with no pointer to one is NOT VERIFIED; a tree that never recorded a
3098
+ # proof (nothing to verify) says so aloud and does not claim a receipt check.
3099
+ if [ -z "$rid" ]; then
3100
+ if compgen -G ".loki/proofs/*/proof.json" >/dev/null 2>&1; then
3101
+ echo "receipt: NOT VERIFIED (no last-proof-id.txt pointer, but proofs exist under .loki/proofs; the receipt was not checked)"
3102
+ return 1
3103
+ fi
3104
+ echo "receipt: NONE (no proof was recorded in this tree; no receipt was checked)"
3105
+ return 0
3106
+ fi
3107
+ case "$rid" in *[!A-Za-z0-9._-]*)
3108
+ echo "receipt: NOT VERIFIED (last-proof-id.txt holds an unusable id; the receipt was not checked)"
3109
+ return 1 ;;
3110
+ esac
3057
3111
  pj=".loki/proofs/$rid/proof.json"
3058
- [ -f "$pj" ] || return 0
3112
+ if [ ! -f "$pj" ]; then
3113
+ echo "receipt: NOT VERIFIED (last-proof-id.txt names ${rid} but .loki/proofs/${rid}/proof.json is missing; the receipt was not checked)"
3114
+ return 1
3115
+ fi
3059
3116
  lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib"
3060
3117
  python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
3061
3118
  import importlib.util, json
@@ -3069,9 +3126,9 @@ print('receipt_sha256: ' + h if ok else 'receipt: TAMPERED (integrity hash does
3069
3126
  sys.exit(0 if ok else 3)" "$lib" "$pj" 2>/dev/null || rc=$?
3070
3127
  [ "$rc" -ne 3 ] || return 1
3071
3128
  # Provenance: the deploy gate's own verdict (Ed25519 attestation against the local
3072
- # key plus LOKI_RECEIPT_RETIRED_PUBKEYS; unknown kid or no key is UNCHECKED).
3129
+ # key plus LOKI_RECEIPT_RETIRED_PUBKEYS; unknown kid or no key is UNCHECKED, which fails per D76).
3073
3130
  # Defined in autonomy/loki, so it is absent when verify.sh runs standalone.
3074
- declare -f _deploy_receipt_verdict >/dev/null 2>&1 || { echo "attestation: UNCHECKED (run via loki verify)"; return 0; }
3131
+ declare -f _deploy_receipt_verdict >/dev/null 2>&1 || { echo "attestation: UNCHECKED (run via loki verify)"; return 1; }
3075
3132
  local att
3076
3133
  att="$(_deploy_receipt_verdict "$pj")"
3077
3134
  # D47: UNSIGNED is never a pass; --allow-unsigned / LOKI_VERIFY_ALLOW_UNSIGNED=1 accepts it, said aloud.
@@ -3084,6 +3141,16 @@ sys.exit(0 if ok else 3)" "$lib" "$pj" 2>/dev/null || rc=$?
3084
3141
  return 1
3085
3142
  fi
3086
3143
  printf 'attestation: %s\n' "$att"
3144
+ # D76: UNCHECKED (a well-formed token whose kid no local key matches, or a check that
3145
+ # could not run) is not a pass: anyone can mint a foreign-kid token. rc 2, VERDICT not VERIFIED.
3146
+ if [ "$att" = "UNCHECKED" ]; then
3147
+ echo " Not verified: no local key matches this attestation, so who signed it is unproven."
3148
+ echo " Cross-machine path: loki verify --pubkey FILE <run-id> checks a Loki 10 run receipt"
3149
+ echo " (.loki/runs/<run-id>/receipt.json) against the signer's public key (JWK or PEM)."
3150
+ echo " It does not read a legacy .loki/proofs proof.json; for that, add the signer's public"
3151
+ echo " key to LOKI_RECEIPT_RETIRED_PUBKEYS on this machine."
3152
+ return 1
3153
+ fi
3087
3154
  [ "$att" != "TAMPERED" ]
3088
3155
  }
3089
3156
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "10.6.10"
10
+ __version__ = "10.6.12"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -294,3 +294,30 @@ def operator_phases():
294
294
  return api_phases.phase_history(_loki_dir())
295
295
  except Exception as exc:
296
296
  _fail("phase history", exc)
297
+
298
+
299
+ @router.get("/workspaces/runs", dependencies=_READ)
300
+ def operator_workspace_runs():
301
+ """Multi-repo workspace runs, read from each run's integration.json.
302
+
303
+ A corrupt file is a row with state "unreadable", never dropped.
304
+ """
305
+ try:
306
+ from . import api_workspaces
307
+ return api_workspaces.list_runs(_loki_dir())
308
+ except Exception as exc:
309
+ _fail("workspace runs", exc)
310
+
311
+
312
+ @router.get("/workspaces/runs/{ws}/{run_id}", dependencies=_READ)
313
+ def operator_workspace_run(ws: str, run_id: str):
314
+ """One workspace run with per-repo stale detection (recorded vs worktree head)."""
315
+ from . import api_workspaces
316
+ try:
317
+ return api_workspaces.get_run(_loki_dir(), ws, run_id)
318
+ except api_workspaces.BadName:
319
+ raise HTTPException(status_code=400, detail="invalid workspace or run id")
320
+ except api_workspaces.NotFound:
321
+ raise HTTPException(status_code=404, detail="unknown run")
322
+ except Exception as exc:
323
+ _fail("workspace run", exc)
@@ -158,6 +158,44 @@ def _efficiency_module():
158
158
  # per-iteration cost, from the current run's efficiency records
159
159
  # ---------------------------------------------------------------------------
160
160
 
161
+ def _errno_text(exc: OSError) -> str:
162
+ return "errno %s %s" % (exc.errno, exc.strerror or type(exc).__name__)
163
+
164
+
165
+ def _events_read_error(loki_dir: str) -> Optional[str]:
166
+ """errno text when trust-events.jsonl exists but cannot be read, else None.
167
+
168
+ A missing file (ENOENT) is not an error: it means nothing was recorded.
169
+ """
170
+ try:
171
+ with open(_p(loki_dir, *_TRUST_EVENTS), "r", encoding="utf-8") as fh:
172
+ fh.read(1)
173
+ except FileNotFoundError:
174
+ return None
175
+ except OSError as exc:
176
+ return _errno_text(exc)
177
+ return None
178
+
179
+
180
+ def _iterations_read_error(loki_dir: str) -> Optional[str]:
181
+ """Why iteration records are unreadable or corrupt, else None.
182
+
183
+ None means the directory is absent or holds no iteration-*.json files at
184
+ all, i.e. genuinely nothing recorded.
185
+ """
186
+ eff_dir = _p(loki_dir, *_EFFICIENCY_DIR)
187
+ try:
188
+ names = os.listdir(eff_dir)
189
+ except FileNotFoundError:
190
+ return None
191
+ except OSError as exc:
192
+ return _errno_text(exc)
193
+ cands = [n for n in names if n.startswith("iteration-") and n.endswith(".json")]
194
+ if cands and not _iteration_records(loki_dir):
195
+ return "%d iteration file(s) present but none parsed" % len(cands)
196
+ return None
197
+
198
+
161
199
  def _iteration_records(loki_dir: str) -> list:
162
200
  """[(iteration_int, record_dict, path)] sorted by iteration.
163
201
 
@@ -399,6 +437,13 @@ def list_runs(loki_dir: str, now: Optional[float] = None) -> dict:
399
437
  if current_id and current_id not in ids:
400
438
  ids.append(current_id)
401
439
 
440
+ if not ids and _events_read_error(loki_dir):
441
+ envelope["reason"] = (
442
+ "unreadable: .loki/metrics/trust-events.jsonl could not be read (%s)"
443
+ % _events_read_error(loki_dir)
444
+ )
445
+ return envelope
446
+
402
447
  if not ids:
403
448
  envelope["reason"] = (
404
449
  "no run id found: .loki/state/trust-run-id is absent and "
@@ -463,6 +508,13 @@ def get_run(loki_dir: str, run_id: str, now: Optional[float] = None) -> dict:
463
508
 
464
509
  mod = _efficiency_module()
465
510
  recs = _iteration_records(loki_dir)
511
+ if not recs and _iterations_read_error(loki_dir):
512
+ envelope["reason"] = (
513
+ "per-iteration records unreadable or corrupt at "
514
+ ".loki/metrics/efficiency/iteration-*.json (%s)"
515
+ % _iterations_read_error(loki_dir)
516
+ )
517
+ return envelope
466
518
  if not recs:
467
519
  envelope["reason"] = (
468
520
  "no per-iteration records at .loki/metrics/efficiency/iteration-*.json"
@@ -0,0 +1,140 @@
1
+ """Read-only view over multi-repo workspace runs (D51-B13r).
2
+
3
+ `loki workspace run` records one integration.json per run under
4
+ <loki_dir>/workspaces/<workspace>/<run_id>/. That file is the ONLY evidence a
5
+ run leaves (runs never produce group.json), so this reader lists and returns
6
+ exactly it.
7
+
8
+ Honesty rules:
9
+ - A missing workspaces directory is {runs: [], reason}, never a bare empty.
10
+ - An unreadable or corrupt integration.json is a row with state
11
+ "unreadable" and an error. It is never dropped.
12
+ - stale is True/False only when both the recorded head and the worktree head
13
+ are known. A missing worktree or recorded head reads None with a reason.
14
+
15
+ Nothing here mutates a run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ from typing import Any, Optional
25
+
26
+ __all__ = ["list_runs", "get_run", "valid_name", "NotFound", "BadName"]
27
+
28
+ _NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
29
+ _GIT_TIMEOUT_S = 5
30
+
31
+
32
+ class BadName(ValueError):
33
+ pass
34
+
35
+
36
+ class NotFound(LookupError):
37
+ pass
38
+
39
+
40
+ def valid_name(name: str) -> bool:
41
+ return bool(name) and ".." not in name and _NAME_RE.match(name) is not None
42
+
43
+
44
+ def _slug(repo: str) -> str:
45
+ # Mirrors autonomy/lib/workspace.py _slug.
46
+ return repo.replace("/", "__")
47
+
48
+
49
+ def _worktree_head(run_dir: str, repo: str) -> tuple:
50
+ wt = os.path.join(run_dir, "worktrees", _slug(repo))
51
+ if not os.path.isdir(wt):
52
+ return None, "worktree missing"
53
+ try:
54
+ r = subprocess.run(["git", "-C", wt, "rev-parse", "HEAD"],
55
+ capture_output=True, text=True, timeout=_GIT_TIMEOUT_S)
56
+ except (OSError, subprocess.SubprocessError) as exc:
57
+ return None, "git failed: %s" % type(exc).__name__
58
+ if r.returncode != 0:
59
+ return None, "git rev-parse failed"
60
+ return r.stdout.strip(), None
61
+
62
+
63
+ def _repo_rows(run_dir: str, ev: dict) -> list:
64
+ heads = ev.get("heads")
65
+ rows = []
66
+ if not isinstance(heads, dict):
67
+ return rows
68
+ for repo in sorted(heads):
69
+ recorded = heads[repo]
70
+ row: dict[str, Any] = {"repo": repo, "head": recorded,
71
+ "current_head": None, "stale": None, "reason": None}
72
+ if not recorded:
73
+ row["reason"] = "no recorded head"
74
+ else:
75
+ cur, why = _worktree_head(run_dir, repo)
76
+ row["current_head"] = cur
77
+ if cur is None:
78
+ row["reason"] = why
79
+ else:
80
+ row["stale"] = cur != recorded
81
+ rows.append(row)
82
+ return rows
83
+
84
+
85
+ def _row(root: str, ws: str, run_id: str, with_repos: bool = True) -> dict:
86
+ run_dir = os.path.join(root, ws, run_id)
87
+ path = os.path.join(run_dir, "integration.json")
88
+ row: dict[str, Any] = {"workspace": ws, "run_id": run_id, "state": "ok",
89
+ "status": None, "exit_code": None, "error": None,
90
+ "repos": []}
91
+ try:
92
+ with open(path) as fh:
93
+ ev = json.load(fh)
94
+ if not isinstance(ev, dict):
95
+ raise ValueError("integration.json is not an object")
96
+ except (OSError, ValueError) as exc:
97
+ row["state"] = "unreadable"
98
+ row["error"] = "%s: %s" % (type(exc).__name__, exc)
99
+ return row
100
+ row["status"] = ev.get("status")
101
+ row["exit_code"] = ev.get("exit_code")
102
+ row["outcomes"] = ev.get("outcomes") if isinstance(ev.get("outcomes"), dict) else {}
103
+ if with_repos:
104
+ row["repos"] = _repo_rows(run_dir, ev)
105
+ return row
106
+
107
+
108
+ def list_runs(loki_dir: str) -> dict:
109
+ root = os.path.join(loki_dir, "workspaces")
110
+ if not os.path.isdir(root):
111
+ return {"runs": [], "reason": "no workspaces directory at %s" % root,
112
+ "source": root}
113
+ runs = []
114
+ try:
115
+ names = sorted(os.listdir(root))
116
+ except OSError as exc:
117
+ return {"runs": [], "reason": "workspaces directory unreadable: %s" % exc,
118
+ "source": root}
119
+ for ws in names:
120
+ wdir = os.path.join(root, ws)
121
+ if not valid_name(ws) or not os.path.isdir(wdir):
122
+ continue
123
+ try:
124
+ ids = sorted(os.listdir(wdir))
125
+ except OSError:
126
+ continue
127
+ for run_id in ids:
128
+ if valid_name(run_id) and os.path.isdir(os.path.join(wdir, run_id)):
129
+ runs.append(_row(root, ws, run_id))
130
+ reason: Optional[str] = None if runs else "no workspace runs recorded"
131
+ return {"runs": runs, "reason": reason, "source": root}
132
+
133
+
134
+ def get_run(loki_dir: str, ws: str, run_id: str) -> dict:
135
+ if not valid_name(ws) or not valid_name(run_id):
136
+ raise BadName("invalid workspace or run id")
137
+ root = os.path.join(loki_dir, "workspaces")
138
+ if not os.path.isdir(os.path.join(root, ws, run_id)):
139
+ raise NotFound("unknown run")
140
+ return _row(root, ws, run_id)
@@ -862,7 +862,10 @@ def _unified_cli() -> int:
862
862
  python3 dashboard/audit.py verify <audit_dir>
863
863
 
864
864
  Prints a single JSON object to stdout. Returns process exit code 0
865
- on a valid chain, 1 on an invalid chain, 2 on usage error.
865
+ on a valid chain, 1 on an invalid chain, 2 on usage error. `verify`
866
+ also exits 2 when it checked zero files (missing dir, empty dir, or
867
+ only pre-integrity files): nothing checked is not a verified chain.
868
+ `tip` keeps exiting 0 on an empty chain (src/audit/crosslink.js reads it).
866
869
  """
867
870
  argv = sys.argv[1:]
868
871
  if len(argv) < 2 or argv[0] not in ("tip", "verify", "prefix"):
@@ -889,7 +892,10 @@ def _unified_cli() -> int:
889
892
  return 0 if out.get("found", False) else 1
890
893
  out = verify_all_logs_in_dir(audit_dir)
891
894
  print(json.dumps(out))
892
- return 0 if out.get("valid", False) else 1
895
+ if not out.get("valid", False):
896
+ return 1
897
+ # `valid` stays True for an empty chain; checking nothing is not a pass.
898
+ return 0 if out.get("files_checked", 0) > 0 else 2
893
899
 
894
900
 
895
901
  if __name__ == "__main__":