loki-mode 9.53.0 → 9.54.2

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 (119) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/completion-council.sh +28 -5
  4. package/autonomy/council-v2.sh +13 -7
  5. package/autonomy/lib/done-recognition.sh +24 -16
  6. package/autonomy/lib/proof-check.sh +11 -3
  7. package/autonomy/lib/proof-generator.py +36 -5
  8. package/autonomy/lib/voter-agents.sh +8 -3
  9. package/autonomy/lib/workspace_diff.py +106 -12
  10. package/autonomy/prd-checklist.sh +19 -11
  11. package/autonomy/run.sh +503 -52
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/api_v2.py +4 -0
  14. package/dashboard/audit.py +9 -1
  15. package/dashboard/auth.py +16 -4
  16. package/dashboard/registry.py +34 -12
  17. package/dashboard/rigour_integration.py +25 -17
  18. package/dashboard/server.py +252 -104
  19. package/dashboard/static/cost.html +55 -26
  20. package/dashboard/static/index.html +475 -1544
  21. package/dashboard/static/proofs.html +1 -1
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/air-gapped.md +7 -2
  24. package/docs/image-provenance.md +4 -2
  25. package/docs/v10/BACKLOG.md +82 -2
  26. package/docs/v10/BOARD.md +105 -0
  27. package/docs/v10/DECISIONS.md +71 -0
  28. package/docs/v10/METRICS.md +2 -1
  29. package/docs/v10/PROGRESS.md +60 -0
  30. package/docs/v10/SWARM-PROMPT.md +98 -0
  31. package/docs/v10/SWARM.md +237 -0
  32. package/loki-ts/dist/loki.js +323 -322
  33. package/mcp/__init__.py +1 -1
  34. package/package.json +1 -1
  35. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  36. package/providers/aider.sh +8 -0
  37. package/providers/cline.sh +8 -0
  38. package/providers/codex.sh +9 -0
  39. package/providers/opencode.sh +9 -0
  40. package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
  41. package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
  42. package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
  43. package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
  44. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
  45. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
  46. package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
  47. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
  48. package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
  49. package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
  50. package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
  51. package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
  52. package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
  53. package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
  54. package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
  55. package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
  56. package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
  57. package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
  58. package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
  59. package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
  60. package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
  61. package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
  62. package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
  63. package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
  64. package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
  65. package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
  66. package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
  67. package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
  68. package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
  69. package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
  70. package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
  71. package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
  72. package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
  73. package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
  74. package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
  75. package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
  76. package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
  77. package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
  78. package/web-app/dist/assets/index-CI54rara.css +1 -0
  79. package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
  80. package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
  81. package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
  82. package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
  83. package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
  84. package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
  85. package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
  86. package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
  87. package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
  88. package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
  89. package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
  90. package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
  91. package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
  92. package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
  93. package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
  94. package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
  95. package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
  96. package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
  97. package/web-app/dist/index.html +2 -2
  98. package/web-app/server.py +122 -47
  99. package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
  100. package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
  101. package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
  102. package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
  103. package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
  104. package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
  105. package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
  106. package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
  107. package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
  108. package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
  109. package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
  110. package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
  111. package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
  112. package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
  113. package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
  114. package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
  115. package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
  116. package/web-app/dist/assets/mail-COu670_W.js +0 -6
  117. package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
  118. package/web-app/dist/assets/save-v8qep5cY.js +0 -6
  119. package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
package/autonomy/run.sh CHANGED
@@ -8765,10 +8765,20 @@ start_resource_monitor() {
8765
8765
  # Initial check
8766
8766
  check_system_resources
8767
8767
 
8768
- # Background monitoring loop
8768
+ # Background monitoring loop. BACKLOG 22: a bare `sleep N` inside this
8769
+ # subshell survives a TERM to the subshell -- caught traps do not carry
8770
+ # into a foreground child, so the sleep reparents to init and outlives the
8771
+ # run for up to RESOURCE_CHECK_INTERVAL seconds. Backgrounding the sleep
8772
+ # and recording its PID lets a TERM trap kill it directly; `wait` returns
8773
+ # immediately once the trap fires, so shutdown is not delayed either.
8769
8774
  (
8775
+ _loki_rm_sleep_pid=""
8776
+ trap '[ -n "$_loki_rm_sleep_pid" ] && kill "$_loki_rm_sleep_pid" 2>/dev/null; exit 0' TERM
8770
8777
  while true; do
8771
- sleep "$RESOURCE_CHECK_INTERVAL"
8778
+ sleep "$RESOURCE_CHECK_INTERVAL" &
8779
+ _loki_rm_sleep_pid=$!
8780
+ wait "$_loki_rm_sleep_pid"
8781
+ _loki_rm_sleep_pid=""
8772
8782
  check_system_resources
8773
8783
  done
8774
8784
  ) &
@@ -9064,6 +9074,10 @@ setup_agent_branch() {
9064
9074
  # Controlled by LOKI_BRANCH_PROTECTION env var (default: true). Set it to
9065
9075
  # "false" to opt out fully and work on the current branch (back-compat).
9066
9076
  local branch_protection="${LOKI_BRANCH_PROTECTION:-true}"
9077
+ # Set to 1 only once THIS run has a current snapshot (mint or resume
9078
+ # union); _loki_record_session_created records nothing otherwise, since a
9079
+ # leftover or stale snapshot would make it claim a user's file.
9080
+ _LOKI_SNAPSHOT_THIS_RUN=0
9067
9081
 
9068
9082
  if [ "$branch_protection" != "true" ]; then
9069
9083
  log_info "Branch protection disabled (LOKI_BRANCH_PROTECTION=${branch_protection})"
@@ -9087,8 +9101,10 @@ setup_agent_branch() {
9087
9101
  [ -f .loki/.gitignore ] || printf '*\n' > .loki/.gitignore 2>/dev/null || true
9088
9102
 
9089
9103
  # Capture the ref Loki was run from. Detached HEAD yields the literal "HEAD".
9104
+ # symbolic-ref also names an unborn branch (fresh `git init`), where
9105
+ # rev-parse printed "HEAD" and failed, recording "HEAD" twice as the base.
9090
9106
  local cur=""
9091
- cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
9107
+ cur="$(git symbolic-ref --short -q HEAD 2>/dev/null || echo HEAD)"
9092
9108
 
9093
9109
  # Detached HEAD: do NOT branch, do NOT fabricate a base (LOCK A2/A6).
9094
9110
  if [ "$cur" = "HEAD" ]; then
@@ -9098,11 +9114,15 @@ setup_agent_branch() {
9098
9114
 
9099
9115
  # Already on a loki branch (session-* or delegate-*): idempotent reuse, do
9100
9116
  # not nest a branch off a loki branch (LOCK A5/A7).
9117
+ # Both reuse paths add what is untracked now to the pre-existing list (a
9118
+ # file the user made between sessions is theirs), except the files an
9119
+ # unfinished previous session created (_loki_resume_snapshot).
9101
9120
  case "$cur" in
9102
9121
  loki/*)
9103
9122
  log_info "Already on loki branch ${cur}"
9104
9123
  mkdir -p .loki/state 2>/dev/null || true
9105
9124
  printf '%s\n' "$cur" > .loki/state/agent-branch.txt 2>/dev/null || true
9125
+ _loki_resume_snapshot
9106
9126
  return 0
9107
9127
  ;;
9108
9128
  esac
@@ -9113,39 +9133,37 @@ setup_agent_branch() {
9113
9133
  if [ -s .loki/state/agent-branch.txt ]; then
9114
9134
  recorded="$(cat .loki/state/agent-branch.txt 2>/dev/null || true)"
9115
9135
  if [ -n "$recorded" ] && git rev-parse --verify "$recorded" >/dev/null 2>&1; then
9116
- if git checkout "$recorded" >/dev/null 2>&1; then
9136
+ # --no-overwrite-ignore: git's default checkout silently replaces a
9137
+ # gitignored user file at a path the session branch tracks, and the
9138
+ # file is gone when the user switches back. On conflict the resume
9139
+ # is refused and a fresh session branch (with a fresh snapshot) is
9140
+ # minted below.
9141
+ if git checkout --no-overwrite-ignore "$recorded" >/dev/null 2>&1; then
9117
9142
  log_info "Resuming on recorded agent branch: ${recorded}"
9143
+ _loki_resume_snapshot
9118
9144
  return 0
9119
9145
  fi
9120
9146
  log_warn "Recorded agent branch ${recorded} could not be checked out - creating a new one"
9121
9147
  fi
9122
9148
  fi
9123
9149
 
9124
- # Fresh run: persist the base branch (fresh-run-only) BEFORE branching, then
9125
- # mint and check out the feature branch (LOCK A2).
9150
+ # Fresh run: mint and check out the feature branch, then persist its base
9151
+ # (fresh-run-only, LOCK A2).
9126
9152
  local timestamp
9127
9153
  timestamp=$(date +%s)
9128
9154
  local branch_name="loki/session-${timestamp}-$$"
9129
9155
 
9130
9156
  mkdir -p .loki/state 2>/dev/null || true
9131
- # Persist the base only once per run tree; never overwrite an existing base.
9132
- [ ! -s .loki/state/base-branch.txt ] && printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
9133
9157
 
9134
9158
  log_info "Branch protection enabled - creating agent branch: $branch_name (base: $cur)"
9135
9159
 
9136
- # Record the user's own untracked (not ignored) files, repo-wide,
9137
- # NUL-delimited and relative to the repo top, so commit_session_changes
9138
- # and the receipt (autonomy/lib/workspace_diff.py) leave them alone. Taken
9139
- # only when a branch is minted: on resume, untracked files can be the
9140
- # agent's own uncommitted work. .loki/ is self-ignored above.
9141
- local top="" snap=".loki/state/preexisting-untracked.z"
9142
- if top="$(git rev-parse --show-toplevel 2>/dev/null)" \
9143
- && git -C "$top" ls-files -z --others --exclude-standard > "$snap.tmp" 2>/dev/null \
9144
- && mv -f "$snap.tmp" "$snap" 2>/dev/null; then
9145
- :
9146
- else
9147
- rm -f "$snap.tmp" "$snap" 2>/dev/null
9148
- log_warn "Could not record pre-existing untracked files; the session commit may include them"
9160
+ # Record the user's own untracked and gitignored files so
9161
+ # commit_session_changes and the receipt leave them alone.
9162
+ local snap_ok=1 leftover=""
9163
+ if ! _loki_snapshot_or_fail_closed; then
9164
+ snap_ok=0
9165
+ rm -f .loki/state/preexisting-untracked.z .loki/state/preexisting-untracked.sha.z 2>/dev/null
9166
+ log_warn "Could not record pre-existing untracked files; this session will commit nothing (review and commit manually)"
9149
9167
  fi
9150
9168
 
9151
9169
  # Create and checkout the feature branch
@@ -9154,6 +9172,25 @@ setup_agent_branch() {
9154
9172
  return 1
9155
9173
  fi
9156
9174
 
9175
+ # A new session branch starts with an empty session-created record. Files
9176
+ # an unfinished earlier session left (reached here after a refused resume)
9177
+ # were just snapshotted as pre-existing: kept on disk, never committed.
9178
+ leftover="$(_loki_nul_names .loki/state/session-created.z)"
9179
+ if [ -n "$leftover" ]; then
9180
+ log_warn "Left uncommitted from an earlier unfinished session, now treated as your files: $leftover"
9181
+ fi
9182
+ rm -f .loki/state/session-created.z 2>/dev/null
9183
+ if [ "$snap_ok" = 1 ]; then _LOKI_SNAPSHOT_THIS_RUN=1; fi
9184
+
9185
+ # Every mint (a refused resume included) records its own base and the
9186
+ # commit it starts at (the empty tree when unborn); the resume paths above
9187
+ # keep both. A base left by an earlier session can name a branch deleted
9188
+ # since, and _loki_untrack_agent_committed_user_files then could not find
9189
+ # the user files the agent commits.
9190
+ printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
9191
+ { git rev-parse --verify -q HEAD || git hash-object -t tree /dev/null; } \
9192
+ > .loki/state/session-start-sha 2>/dev/null
9193
+
9157
9194
  # Store the branch name for later use (PR creation, cleanup)
9158
9195
  printf '%s\n' "$branch_name" > .loki/state/agent-branch.txt 2>/dev/null
9159
9196
 
@@ -9162,6 +9199,180 @@ setup_agent_branch() {
9162
9199
  echo "$branch_name"
9163
9200
  }
9164
9201
 
9202
+ # Snapshot, or fail closed. Any snapshot failure (for example git older than
9203
+ # 2.18, whose status lacks --no-renames / --ignored=matching) leaves a marker
9204
+ # that makes commit_session_changes commit nothing: a missing snapshot must
9205
+ # only ever mean an older session, never "sweep everything".
9206
+ _loki_snapshot_or_fail_closed() {
9207
+ if _loki_snapshot_preexisting "$@"; then
9208
+ rm -f .loki/state/preexisting-untracked.failed 2>/dev/null
9209
+ return 0
9210
+ fi
9211
+ mkdir -p .loki/state 2>/dev/null
9212
+ : > .loki/state/preexisting-untracked.failed 2>/dev/null
9213
+ return 1
9214
+ }
9215
+
9216
+ # _loki_snapshot_preexisting [union]
9217
+ # Write .loki/state/preexisting-untracked.z: every path git does not track,
9218
+ # untracked and gitignored, repo-wide, NUL-delimited, relative to the repo top.
9219
+ # commit_session_changes unstages these and the receipt
9220
+ # (autonomy/lib/workspace_diff.py) does not claim them. --ignored=matching lists
9221
+ # a directory only when an ignore pattern matches it, so node_modules/ is one
9222
+ # "dir/" entry covering its subtree, while logs/ holding only *.log files is
9223
+ # listed file by file (a new logs/app.json is still the agent's). "union" keeps
9224
+ # the paths already recorded. The sibling .sha.z (content hash per file entry)
9225
+ # lets the receipt list a pre-existing file the run changed; it is removed
9226
+ # first, so a failed hash step means no detection, never stale hashes. Returns
9227
+ # non-zero, leaving any earlier list in place, when git cannot list.
9228
+ _loki_snapshot_preexisting() {
9229
+ local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude=""
9230
+ rm -f "${snap%.z}.sha.z" 2>/dev/null
9231
+ top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
9232
+ mkdir -p .loki/state 2>/dev/null || return 1
9233
+ # "union" never adds a path listed in session-created.z: those files were
9234
+ # made by a session that did not finish (interrupt, pod loss), so the
9235
+ # session that finishes the work commits them. If the user edits one of
9236
+ # them between sessions it is still the session's file and is committed
9237
+ # with the edit. Recorded entries only block additions; they never remove
9238
+ # a path already in the list.
9239
+ if [ "${1:-}" = union ]; then
9240
+ base="$snap"
9241
+ exclude=".loki/state/session-created.z"
9242
+ fi
9243
+ if ! _loki_untracked_status "$snap.status" \
9244
+ || ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap"; then
9245
+ rm -f "$snap.status" "$snap.tmp"
9246
+ return 1
9247
+ fi
9248
+ rm -f "$snap.status"
9249
+ python3 -E "$SCRIPT_DIR/lib/workspace_diff.py" hash-snapshot "$top" "$snap" >/dev/null 2>&1 \
9250
+ || log_warn "Could not hash your pre-existing untracked files; the receipt cannot list the ones this run changes"
9251
+ return 0
9252
+ }
9253
+
9254
+ # _loki_untracked_status <out>: the one enumeration behind both the snapshot
9255
+ # and the session-created record, so the two always agree: raw
9256
+ # `git status --porcelain -z` records for the whole repo, .loki excluded.
9257
+ # Non-zero, writing nothing, when git cannot list.
9258
+ _loki_untracked_status() {
9259
+ local top="" prefix=""
9260
+ top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
9261
+ prefix="$(git rev-parse --show-prefix 2>/dev/null)" || return 1
9262
+ git -C "$top" --no-optional-locks status --porcelain -z --no-renames -uall \
9263
+ --ignored=matching --ignore-submodules=all -- ":(exclude,literal)${prefix}.loki" \
9264
+ > "$1" 2>/dev/null && return 0
9265
+ rm -f "$1"
9266
+ return 1
9267
+ }
9268
+
9269
+ # _loki_untracked_merge <status> <base> <exclude> exact|cover <out>
9270
+ # Atomically write <out>: the NUL-delimited paths of <base>, plus every
9271
+ # untracked ("??") or ignored ("!!") path in <status> that <exclude> does not
9272
+ # hold. With "cover", an <exclude> entry ending in "/" also holds every path
9273
+ # below it (workspace_diff._covered). An empty or missing <base> or <exclude>
9274
+ # is an empty list; any other read or write failure is non-zero and leaves
9275
+ # <out> as it was. Sorted bytewise, like `LC_ALL=C sort -z -u`. One python
9276
+ # process: bash 3.2 has no associative arrays, and a bash loop cost 300ms per
9277
+ # 6,000 entries on /bin/bash. -E and no cwd on sys.path (D7): the cwd is the
9278
+ # agent's repo.
9279
+ _loki_untracked_merge() {
9280
+ python3 -E -c 'import sys
9281
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9282
+ import os
9283
+ status, base, exclude, mode, out = sys.argv[1:6]
9284
+ def entries(path, missing_ok=True):
9285
+ try:
9286
+ with open(path, "rb") as fh:
9287
+ return [p for p in fh.read().split(b"\0") if p]
9288
+ except FileNotFoundError:
9289
+ if missing_ok:
9290
+ return []
9291
+ raise
9292
+ held = set(entries(exclude)) if exclude else set()
9293
+ def covered(path):
9294
+ if path in held:
9295
+ return True
9296
+ cut = path.find(b"/") if mode == "cover" else -1
9297
+ while cut != -1:
9298
+ if path[:cut + 1] in held:
9299
+ return True
9300
+ cut = path.find(b"/", cut + 1)
9301
+ return False
9302
+ paths = set(entries(base)) if base else set()
9303
+ for rec in entries(status, missing_ok=False):
9304
+ # The session-created record ("cover" mode) never holds a directory entry:
9305
+ # a whole-directory entry (logs/, out/) would stop the resume union from
9306
+ # adopting a file the user later creates inside it, and a .gitignore
9307
+ # rewrite would then sweep that file. Adopting such a directory as
9308
+ # pre-existing fails safe (the agent files inside stay uncommitted).
9309
+ if mode == "cover" and rec.endswith(b"/"):
9310
+ continue
9311
+ if rec[:3] in (b"?? ", b"!! ") and not covered(rec[3:]):
9312
+ paths.add(rec[3:])
9313
+ with open(out + ".tmp", "wb") as fh:
9314
+ fh.write(b"".join(p + b"\0" for p in sorted(paths)))
9315
+ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9316
+ }
9317
+
9318
+ # _loki_record_session_created
9319
+ # Union into .loki/state/session-created.z (NUL-delimited, repo-top-relative,
9320
+ # atomic) the paths git does not track now that the pre-existing snapshot does
9321
+ # not cover: the files this session created. The resume union never adds these
9322
+ # to the snapshot, so a session that is interrupted (cleanup) or killed (pod
9323
+ # loss: no trap runs, hence the call after every provider turn) still has its
9324
+ # own files committed by the session that finishes. commit_session_changes
9325
+ # clears the record after a normal commit; a new session branch starts empty.
9326
+ # Records nothing unless this run took a current snapshot (a leftover or stale
9327
+ # one would make it claim a user's file). On a failure the record is left as it
9328
+ # was, never partial, and preexisting-untracked.failed is never touched; the
9329
+ # next successful call recomputes everything, so only a failure on the final
9330
+ # call before a kill loses paths (those are then treated as the user's: kept on
9331
+ # disk, not committed).
9332
+ _loki_record_session_created() {
9333
+ local snap=".loki/state/preexisting-untracked.z" out=".loki/state/session-created.z"
9334
+ [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ] || return 0
9335
+ if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
9336
+ && _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
9337
+ rm -f "$out.status"
9338
+ return 0
9339
+ fi
9340
+ rm -f "$out.status" "$out.tmp" 2>/dev/null
9341
+ log_warn "Could not record the files this session created; if the run stops before the next record, they are treated as yours and left uncommitted"
9342
+ return 1
9343
+ }
9344
+
9345
+ # _loki_resume_snapshot: the resume paths' union, naming the files carried over
9346
+ # from an unfinished previous session (this session's commit includes them).
9347
+ _loki_resume_snapshot() {
9348
+ local carried=""
9349
+ if ! _loki_snapshot_or_fail_closed union; then
9350
+ log_warn "Could not add your current untracked files to the pre-existing list; this session will commit nothing (review and commit manually)"
9351
+ return 0
9352
+ fi
9353
+ _LOKI_SNAPSHOT_THIS_RUN=1
9354
+ carried="$(_loki_nul_names .loki/state/session-created.z)"
9355
+ if [ -n "$carried" ]; then
9356
+ log_info "Carried over as this session's work, not adopted as your files (created by the unfinished previous session): $carried"
9357
+ fi
9358
+ }
9359
+
9360
+ # _loki_nul_names <file>: the entries of a NUL-delimited repo-top-relative list
9361
+ # that exist now, comma-separated, the first 20 then "(and N more)".
9362
+ _loki_nul_names() {
9363
+ local p top="" n=0 out=""
9364
+ [ -s "$1" ] || return 0
9365
+ top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
9366
+ while IFS= read -r -d '' p; do
9367
+ if [ -e "$top/$p" ] || [ -L "$top/$p" ]; then
9368
+ n=$((n + 1))
9369
+ if [ "$n" -le 20 ]; then out="${out}${out:+, }${p}"; fi
9370
+ fi
9371
+ done < "$1"
9372
+ if [ "$n" -gt 20 ]; then out="${out} (and $((n - 20)) more)"; fi
9373
+ printf '%s' "$out"
9374
+ }
9375
+
9165
9376
  # RUN-25 iter 21 (Wave D #2): the two secret matchers now live in one sourceable
9166
9377
  # lib so the commit gate (here) and the completion evidence gate (completion-
9167
9378
  # council.sh) share a single implementation. Sourced with a guard so a re-source
@@ -9175,6 +9386,101 @@ fi
9175
9386
  if ! type _commit_scan_secret_file >/dev/null 2>&1; then _commit_scan_secret_file() { return 1; }; fi
9176
9387
  if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_secret() { return 1; }; fi
9177
9388
 
9389
+ # _loki_covered_paths <paths> <snapshot> <top> <out>
9390
+ # Atomically write <out>: the NUL-delimited entries of <paths> that <snapshot>
9391
+ # covers (the entry itself or a parent "dir/" entry, as workspace_diff._covered)
9392
+ # and that still exist under <top>. Non-zero on a read or write failure. -E and
9393
+ # no cwd on sys.path (D7): the cwd is the agent's repo.
9394
+ _loki_covered_paths() {
9395
+ python3 -E -c 'import sys
9396
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9397
+ import os
9398
+ paths, snap, top, out = sys.argv[1:5]
9399
+ def entries(path):
9400
+ with open(path, "rb") as fh:
9401
+ return [p for p in fh.read().split(b"\0") if p]
9402
+ held = set(entries(snap))
9403
+ def covered(path):
9404
+ if path in held:
9405
+ return True
9406
+ cut = path.find(b"/")
9407
+ while cut != -1:
9408
+ if path[:cut + 1] in held:
9409
+ return True
9410
+ cut = path.find(b"/", cut + 1)
9411
+ return False
9412
+ # A file gone from disk stays tracked: untracking it would leave its bytes
9413
+ # only in history.
9414
+ top = os.fsencode(top)
9415
+ hits = [p for p in entries(paths) if covered(p) and os.path.lexists(os.path.join(top, p))]
9416
+ with open(out + ".tmp", "wb") as fh:
9417
+ fh.write(b"".join(p + b"\0" for p in hits))
9418
+ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9419
+ }
9420
+
9421
+ # _loki_untrack_agent_committed_user_files <branch> (BACKLOG 74)
9422
+ # An agent that rewrites .gitignore and runs `git add -A && git commit` itself
9423
+ # puts the user's untracked or ignored files on the session branch, and a
9424
+ # checkout of the base then deletes them from disk. Files ADDED on the branch
9425
+ # since it forked from the recorded base that the pre-existing snapshot covers
9426
+ # are removed from the index (they stay on disk) and that removal is committed
9427
+ # on its own, before the session commit: it adds no content, and no later exit
9428
+ # (a secret abort, a failed unstage) can put the files back on the branch tip.
9429
+ # Records this session's removals in .loki/state/agent-committed-user-files.z
9430
+ # and warns that the branch history still holds them. The fork is the merge
9431
+ # base with the recorded base branch, else the session's recorded start commit
9432
+ # (.loki/state/session-start-sha). Non-zero when the removal could not be
9433
+ # committed, or when neither fork resolves or the check fails (the tip may hold
9434
+ # user files): the caller then makes no session commit, whose `git add -A`
9435
+ # would stage the files again.
9436
+ _loki_untrack_agent_committed_user_files() {
9437
+ local snap=".loki/state/preexisting-untracked.z" rec=".loki/state/agent-committed-user-files.z"
9438
+ local base="" fork="" start="" top="" names=""
9439
+ local unchecked="Before switching branches, run git rm --cached on each of your files the agent committed and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
9440
+ [ -s "$snap" ] || return 0
9441
+ # No commit on the branch yet: the agent committed nothing.
9442
+ git rev-parse --verify -q HEAD >/dev/null 2>&1 || return 0
9443
+ [ -s .loki/state/base-branch.txt ] && base="$(cat .loki/state/base-branch.txt 2>/dev/null)"
9444
+ if [ -n "$base" ]; then fork="$(git merge-base HEAD "$base" 2>/dev/null)" || fork=""; fi
9445
+ if [ -z "$fork" ] && [ -s .loki/state/session-start-sha ]; then
9446
+ start="$(cat .loki/state/session-start-sha 2>/dev/null)"
9447
+ # A commit on this branch, or the empty tree of an unborn mint.
9448
+ if git merge-base --is-ancestor "$start" HEAD 2>/dev/null \
9449
+ || [ "$(git cat-file -t "$start" 2>/dev/null)" = tree ]; then
9450
+ fork="$start"
9451
+ fi
9452
+ fi
9453
+ if [ -z "$fork" ]; then
9454
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1: base branch '${base:-unknown}' not found and the recorded session start commit is missing or not on this branch. ${unchecked}"
9455
+ return 1
9456
+ fi
9457
+ # --no-renames: a user file swept in beside a deleted tracked file must
9458
+ # read as added, not as a rename of that file.
9459
+ if ! top="$(git rev-parse --show-toplevel 2>/dev/null)" \
9460
+ || ! git -C "$top" diff --name-only -z --no-renames --diff-filter=A "$fork" HEAD > "$rec.added" 2>/dev/null \
9461
+ || ! _loki_covered_paths "$rec.added" "$snap" "$top" "$rec.new"; then
9462
+ rm -f "$rec.added" "$rec.new" "$rec.new.tmp"
9463
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1. ${unchecked}"
9464
+ return 1
9465
+ fi
9466
+ rm -f "$rec.added"
9467
+ if [ ! -s "$rec.new" ]; then
9468
+ rm -f "$rec.new"
9469
+ return 0
9470
+ fi
9471
+ mv -f "$rec.new" "$rec"
9472
+ names="$(_loki_nul_names "$rec")"
9473
+ git reset -q >/dev/null 2>&1 || true
9474
+ if ! git -C "$top" update-index -z --force-remove --stdin < "$rec" >/dev/null 2>&1 \
9475
+ || ! git commit -q -m "Loki Mode: untrack pre-existing user files the agent committed" >/dev/null 2>&1; then
9476
+ 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."
9477
+ return 1
9478
+ fi
9479
+ 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"
9480
+ audit_agent_action "git_untrack_user_files" "Removed pre-existing user files the agent committed" "files=${names}" || true
9481
+ return 0
9482
+ }
9483
+
9178
9484
  commit_session_changes() {
9179
9485
  # Squash the session's work into one honest session-end commit on the agent
9180
9486
  # branch (LOCK A3/A4/A8). Commit-always (incl. failed runs) so the user is
@@ -9209,6 +9515,25 @@ commit_session_changes() {
9209
9515
  ;;
9210
9516
  esac
9211
9517
 
9518
+ # Commit nothing unless THIS run took a current snapshot (BACKLOG 90). The
9519
+ # LOCK A1 opt-out takes none, so it never commits, even on a leftover
9520
+ # loki/session-* branch whose agent-branch.txt and snapshot belong to an
9521
+ # earlier session: that stale snapshot would sweep in a file the user made
9522
+ # since, and a checkout of the base would delete it. Checked before any
9523
+ # staging, so the opt-out leaves the user's index alone.
9524
+ if [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" != 1 ]; then
9525
+ if [ "${LOKI_BRANCH_PROTECTION:-true}" != "true" ]; then
9526
+ log_info "Branch protection off (LOKI_BRANCH_PROTECTION=${LOKI_BRANCH_PROTECTION}): no session commit; the work stays uncommitted on ${cur}"
9527
+ elif [ -f "$PWD/.loki/state/preexisting-untracked.failed" ]; then
9528
+ log_warn "Left uncommitted: could not record your pre-existing untracked files at session start (the snapshot needs git 2.18+, excluding them needs 2.25+). Review and commit manually."
9529
+ else
9530
+ log_warn "Left uncommitted: this run recorded no snapshot of your pre-existing untracked files, so it makes no session commit. Review and commit manually."
9531
+ fi
9532
+ return 0
9533
+ fi
9534
+
9535
+ _loki_untrack_agent_committed_user_files "$cur" || return 0
9536
+
9212
9537
  # Stage everything except .loki/ runtime state and a secret-path denylist.
9213
9538
  # These excludes are defense-in-depth ONLY for the top-level cases git
9214
9539
  # pathspec handles cleanly. We deliberately do NOT add nested globs like
@@ -9226,13 +9551,16 @@ commit_session_changes() {
9226
9551
  ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
9227
9552
  ':!id_rsa*' ':!*.token' ':!credentials*' 2>/dev/null || true
9228
9553
 
9229
- # Unstage exactly the files that were untracked when the branch was minted
9230
- # (setup_agent_branch): they are the user's, not this session's work, and
9231
- # committing them here would delete them from disk on a later checkout of
9232
- # the base. Agent-created files stay staged. Skip an empty snapshot: an
9554
+ # Unstage exactly the paths recorded as untracked or gitignored when the
9555
+ # session started (setup_agent_branch; a "dir/" entry covers its subtree):
9556
+ # they are the user's, not this session's work, and committing them here
9557
+ # would delete them from disk on a later checkout of the base (an agent
9558
+ # rewrite of .gitignore can expose ignored files to `git add -A`).
9559
+ # Agent-created files stay staged. Skip an empty snapshot: an
9233
9560
  # empty --pathspec-from-file resets the WHOLE index. No snapshot (older
9234
9561
  # session): behave as before. If the unstage fails (git < 2.25), commit
9235
- # nothing rather than sweep the user's files in.
9562
+ # nothing rather than sweep the user's files in. A failed snapshot never
9563
+ # gets here (the snapshot guard above returns first).
9236
9564
  local preexisting="$PWD/.loki/state/preexisting-untracked.z" top=""
9237
9565
  if [ -s "$preexisting" ]; then
9238
9566
  if ! { top="$(git rev-parse --show-toplevel 2>/dev/null)" \
@@ -9244,8 +9572,12 @@ commit_session_changes() {
9244
9572
  fi
9245
9573
  fi
9246
9574
 
9247
- # Nothing staged = clean no-op, never an error.
9575
+ # Nothing staged = clean no-op, never an error. A session that ends here or
9576
+ # commits below ended normally: its session-created record is spent (what
9577
+ # it listed is committed, or was never committable), so the next session
9578
+ # starts clean.
9248
9579
  if git diff --cached --quiet 2>/dev/null; then
9580
+ rm -f .loki/state/session-created.z 2>/dev/null
9249
9581
  return 0
9250
9582
  fi
9251
9583
 
@@ -9287,7 +9619,9 @@ commit_session_changes() {
9287
9619
  return 0
9288
9620
  fi
9289
9621
 
9290
- git commit -m "Loki Mode session changes (${ITERATION_COUNT:-0} iterations, result=${result:-0})" 2>/dev/null || true
9622
+ if git commit -m "Loki Mode session changes (${ITERATION_COUNT:-0} iterations, result=${result:-0})" 2>/dev/null; then
9623
+ rm -f .loki/state/session-created.z 2>/dev/null
9624
+ fi
9291
9625
  audit_agent_action "git_commit" "Committed session changes" "iterations=${ITERATION_COUNT:-0},result=${result:-0}" || true
9292
9626
  return 0
9293
9627
  }
@@ -11884,6 +12218,12 @@ ZT_MATCHES_EOF
11884
12218
  }
11885
12219
 
11886
12220
  enforce_test_coverage() {
12221
+ # Every suite this gate launches (pytest, unittest, the coverage pass, a
12222
+ # package script that runs Python) runs inside the user's repo: no
12223
+ # __pycache__/*.pyc there, so none can land in the session commit when the
12224
+ # repo does not ignore them (BACKLOG 85). Bytecode caching only; the same
12225
+ # tests run. Scoped to this call.
12226
+ local -x PYTHONDONTWRITEBYTECODE=1
11887
12227
  local loki_dir="${TARGET_DIR:-.}/.loki"
11888
12228
  local quality_dir="$loki_dir/quality"
11889
12229
  mkdir -p "$quality_dir" "$loki_dir/signals"
@@ -11913,7 +12253,9 @@ enforce_test_coverage() {
11913
12253
  # that is the legitimate case they were written for (a package that ships
11914
12254
  # a runner but no npm script).
11915
12255
  local _declared_test_script=""
11916
- _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
12256
+ # BACKLOG 81 (D7): -E plus the sys.path filter, so the agent's repo (the
12257
+ # cwd) cannot supply json or sitecustomize to this reader.
12258
+ _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
11917
12259
  import json, os, sys
11918
12260
  try:
11919
12261
  with open(os.environ['_LOKI_PKG']) as f:
@@ -12033,7 +12375,8 @@ sys.stdout.write(t.strip())
12033
12375
  # npm test), so the grep only ever picked the LABEL, never
12034
12376
  # what ran. This corrects the evidence, not the execution.
12035
12377
  local _ws_script
12036
- _ws_script=$(_LOKI_PKG="$pkg_json" python3 -c "
12378
+ # BACKLOG 81 (D7): same guard as the root reader above.
12379
+ _ws_script=$(_LOKI_PKG="$pkg_json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
12037
12380
  import json, os, sys
12038
12381
  try:
12039
12382
  with open(os.environ['_LOKI_PKG']) as f:
@@ -12353,6 +12696,9 @@ TREOF
12353
12696
  # run instead of re-running the suite. Single source of truth for "tests
12354
12697
  # ran this iteration", set on every return path that writes results.
12355
12698
  printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
12699
+ # BACKLOG 91: non-blocking, but nothing was measured: the loop's
12700
+ # stage_complete event reads this and says not_run, not pass.
12701
+ _LOKI_TEST_SUITE_STATUS=not_run
12356
12702
  return 0
12357
12703
  fi
12358
12704
 
@@ -12364,6 +12710,49 @@ TREOF
12364
12710
  # first, then delete every remaining control char (\000-\037, incl. ESC).
12365
12711
  details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | tr -d '\000-\037' | head -c 500)
12366
12712
 
12713
+ # BACKLOG 73: a runner that exits 0 while its own summary reports failures
12714
+ # (`jest || true`, a script that swallows the status) failed. The failure
12715
+ # count comes from the runner's summary lines in the FULL output (the details
12716
+ # tail can drop them: the jest branch keeps 3 lines). The highest count
12717
+ # across summaries wins, so output that concatenates several runs (npm test
12718
+ # --workspaces, `jest; vitest`) cannot hide a failed run behind a later
12719
+ # green one. Shapes: jest "Tests: 1 failed, ...", vitest "Tests 1 failed |
12720
+ # ...", pytest "=== 1 failed, 2 passed in 0.1s ===" (or its -q line), node
12721
+ # TAP "# fail 1" and the spec reporter's "<info sign> fail 1", counted only
12722
+ # right after the matching pass line (node TAP echoes a test's own stdout
12723
+ # as "# ..." lines, which must not count), mocha "1 failing", go "--- FAIL:"
12724
+ # lines. A read summary with no failures is a measured 0. With no recognised
12725
+ # summary the old best-effort parse of the tail still applies (null when it
12726
+ # finds nothing). Either way a recorded count above zero is a failure: the
12727
+ # council evidence gate and the Bun gate both read failed_count > 0 as one.
12728
+ # LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
12729
+ # old mawk.
12730
+ local _tr_passed_n _tr_failed_n _tr_summary_red=false
12731
+ _tr_failed_n=$(printf '%s\n' "${output:-}" | LC_ALL=C sed "s/$(printf '\033')\[[0-9;]*[A-Za-z]//g" | LC_ALL=C awk '
12732
+ function upd(n) { if (n > f) f = n; seen = 1 }
12733
+ /^Tests:[ \t]/ || /^[ \t]*Tests[ \t]+[0-9]/ ||
12734
+ /^=+ .*[0-9]+ (passed|failed)/ || /^[0-9]+ (passed|failed).* in [0-9.]+s/ {
12735
+ n = 0; if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0; upd(n) }
12736
+ /^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
12737
+ /^[ \t]*[0-9]+ passing \(/ { upd(0) }
12738
+ /^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
12739
+ /^--- FAIL: / { go++ }
12740
+ { prev = $0 }
12741
+ END { if (go > f) { f = go; seen = 1 } if (seen) print f + 0 }')
12742
+ _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
12743
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
12744
+ # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
12745
+ # "N passed"/"N failed" pattern above does not match. Fall back to those.
12746
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
12747
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
12748
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=null
12749
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=null
12750
+ if [ "$test_passed" = "true" ] && [ "$_tr_failed_n" != "null" ] && [ "$_tr_failed_n" -gt 0 ] 2>/dev/null; then
12751
+ test_passed=false
12752
+ _tr_summary_red=true
12753
+ log_warn "Test suite gate: $test_runner exited 0 but its summary reports $_tr_failed_n failed -- recording a failure"
12754
+ fi
12755
+
12367
12756
  # Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
12368
12757
  # non-forgeable receipt needs -- the command that ran, its exit code, and a
12369
12758
  # status enum -- alongside the legacy pass/runner/min_coverage keys the
@@ -12381,6 +12770,8 @@ TREOF
12381
12770
  *) _tr_cmd="$test_runner" ;;
12382
12771
  esac
12383
12772
  if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
12773
+ # BACKLOG 73: the runner itself exited 0; record that fact, not a made-up 1.
12774
+ if [ "$_tr_summary_red" = "true" ]; then _tr_exit=0; fi
12384
12775
 
12385
12776
  # #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
12386
12777
  # real tests is a mini fake-green -- it records "verified" while proving
@@ -12402,17 +12793,6 @@ TREOF
12402
12793
  log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
12403
12794
  fi
12404
12795
 
12405
- # Best-effort pass/fail counts from the summary text (null when not found).
12406
- local _tr_passed_n _tr_failed_n
12407
- _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
12408
- _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
12409
- # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
12410
- # "N passed"/"N failed" pattern above does not match. Fall back to those.
12411
- [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
12412
- [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
12413
- [ -n "$_tr_passed_n" ] || _tr_passed_n=null
12414
- [ -n "$_tr_failed_n" ] || _tr_failed_n=null
12415
-
12416
12796
  # verification_gap is "none" whenever a real runner executed AND ran tests:
12417
12797
  # the suite ran, so there is no docs-without-execution / source-without-tests
12418
12798
  # gap. The #82 zero-test case is the exception -- a runner ran but executed
@@ -12573,9 +12953,21 @@ os.replace(tmp, out)
12573
12953
  fi
12574
12954
 
12575
12955
  if [ "$test_passed" = "true" ]; then
12576
- touch "$quality_dir/unit-tests.pass"
12577
12956
  rm -f "$loki_dir/signals/TESTS_FAILED" 2>/dev/null || true
12578
- log_info "Test suite gate: $test_runner passed"
12957
+ # BACKLOG 55: the #82 zero-test run exited 0 but proved nothing. It is
12958
+ # inconclusive, so it must not leave the pass marker the receipt reads
12959
+ # as "unit_tests passed" (proof-generator.py _collect_quality_gates).
12960
+ # Still non-blocking (return 0 below) and still not a failure (no
12961
+ # TESTS_FAILED), so the council decides it, per the #82 design.
12962
+ if [ "$_tr_zero_tests" = "true" ]; then
12963
+ rm -f "$quality_dir/unit-tests.pass" 2>/dev/null || true
12964
+ log_warn "Test suite gate: $test_runner ran zero tests -- inconclusive (not passed, not failed)"
12965
+ _LOKI_TEST_SUITE_STATUS=not_run # BACKLOG 91, see the no-runner return
12966
+ else
12967
+ touch "$quality_dir/unit-tests.pass"
12968
+ log_info "Test suite gate: $test_runner passed"
12969
+ _LOKI_TEST_SUITE_STATUS=pass
12970
+ fi
12579
12971
  # Coverage block is distinct from tests-red: tests passed, but enforced
12580
12972
  # coverage is below threshold. Return nonzero to gate WITHOUT writing the
12581
12973
  # TESTS_FAILED signal or removing unit-tests.pass.
@@ -19583,7 +19975,11 @@ load_state() {
19583
19975
  if command -v python3 &> /dev/null; then
19584
19976
  # BUG-ST-006: Validate checkpoint integrity before loading state
19585
19977
  local state_valid
19586
- state_valid=$(LOKI_STATE_FILE="$state_file" python3 -c "
19978
+ # -E and the sys.path filter (D7): the cwd is the target repo, and a
19979
+ # committed json.py must not decide the loaded iteration count
19980
+ # (a fake non-zero count would skip the iteration-0 evidence drop).
19981
+ state_valid=$(LOKI_STATE_FILE="$state_file" python3 -E -c "
19982
+ import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
19587
19983
  import json, os, sys
19588
19984
  try:
19589
19985
  with open(os.environ['LOKI_STATE_FILE']) as f:
@@ -19609,15 +20005,20 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
19609
20005
  ITERATION_COUNT=0
19610
20006
  # Back up corrupted state file for diagnosis
19611
20007
  mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
20008
+ # Starts at iteration 0: drop the previous session's test
20009
+ # evidence, as the iteration-0 block at the end does.
20010
+ rm -f "${TARGET_DIR:-.}/.loki/quality/.test-results.iter" \
20011
+ "${TARGET_DIR:-.}/.loki/quality/unit-tests.pass" \
20012
+ "${TARGET_DIR:-.}/.loki/quality/test-results.json" 2>/dev/null || true
19612
20013
  return
19613
20014
  fi
19614
20015
 
19615
20016
  # Load retry count, iteration count, and status from previous session
19616
20017
  local prev_status
19617
- prev_status=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
19618
- RETRY_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('retryCount', 0))" 2>/dev/null || echo "0")
20018
+ prev_status=$(LOKI_STATE_FILE="$state_file" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]; import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
20019
+ RETRY_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]; import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('retryCount', 0))" 2>/dev/null || echo "0")
19619
20020
  # BUG-RUN-003: Restore ITERATION_COUNT from persisted state
19620
- ITERATION_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('iterationCount', 0))" 2>/dev/null || echo "0")
20021
+ ITERATION_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]; import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('iterationCount', 0))" 2>/dev/null || echo "0")
19621
20022
 
19622
20023
  # Reset retry count + iteration count if previous session ended in a
19623
20024
  # terminal state. A fresh `loki start` after a terminal run is a NEW
@@ -19678,6 +20079,18 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
19678
20079
  else
19679
20080
  RETRY_COUNT=0
19680
20081
  fi
20082
+ # A session that starts at iteration 0 has run nothing yet, so a leftover
20083
+ # freshness marker or unit-tests.pass is a previous session's evidence.
20084
+ # Iterations restart at 0, so keeping them would let an old pass:true read
20085
+ # as this iteration's result.
20086
+ if [ "${ITERATION_COUNT:-0}" = "0" ]; then
20087
+ # Same path the writer (enforce_test_coverage) and the freshness
20088
+ # readers use.
20089
+ local _q="${TARGET_DIR:-.}/.loki/quality"
20090
+ # test-results.json too: with the marker gone the receipt's quality
20091
+ # gates fall back to its status, reporting a previous session's run.
20092
+ rm -f "$_q/.test-results.iter" "$_q/unit-tests.pass" "$_q/test-results.json" 2>/dev/null || true
20093
+ fi
19681
20094
  }
19682
20095
 
19683
20096
  # Load tasks from queue files for prompt injection
@@ -23908,6 +24321,12 @@ if __name__ == "__main__":
23908
24321
  # v7.5.12: Provider invocation finished (or was killed by trap).
23909
24322
  LOKI_PROVIDER_ACTIVE=0
23910
24323
 
24324
+ # Record the files this session has created so far. A pod loss is a
24325
+ # SIGKILL (no trap runs), so this per-turn record is what lets the
24326
+ # resumed session commit them. One git status; no-op without a
24327
+ # session snapshot.
24328
+ _loki_record_session_created || true
24329
+
23911
24330
  echo ""
23912
24331
  echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
23913
24332
  echo ""
@@ -24345,8 +24764,12 @@ EOF
24345
24764
  local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
24346
24765
  # F49: isolate HOME so the project's suite cannot pollute the
24347
24766
  # user's real home when it execs the generated app.
24767
+ _LOKI_TEST_SUITE_STATUS=""
24348
24768
  if _loki_with_app_sandbox enforce_test_coverage; then
24349
24769
  clear_gate_failure "test_coverage"
24770
+ # BACKLOG 91: a zero-test or no-runner run returns 0 without
24771
+ # measuring anything; its stage status is not_run, not pass.
24772
+ if [ "$_LOKI_TEST_SUITE_STATUS" = "not_run" ]; then _stg_ok=not_run; fi
24350
24773
  else
24351
24774
  _stg_ok=fail
24352
24775
  local tc_count
@@ -25446,11 +25869,33 @@ kill_provider_child() {
25446
25869
  fi
25447
25870
  kill -TERM "$child_pid" 2>/dev/null && killed=1
25448
25871
  done
25449
- # Also kill provider leaf processes by name in case they were reparented.
25450
- local proc
25451
- for proc in claude codex aider cline; do
25452
- pkill -TERM -f "^${proc}( |$)" 2>/dev/null && killed=1
25453
- done
25872
+ # Also kill provider leaf processes in case they were reparented (to init,
25873
+ # once their immediate parent -- a shell or the provider CLI itself --
25874
+ # exited). A reparented process changes PARENT, not PROCESS GROUP: unless
25875
+ # a process explicitly calls setpgid/setsid, it keeps the pgid it was
25876
+ # launched into, which for every invoke_* call above is this shell's own
25877
+ # pgid ($$). Scope the sweep to that pgid, matched by process name, rather
25878
+ # than a bare `pkill -f "^claude( |$)"` -- that matched the FULL COMMAND
25879
+ # LINE of every "claude", "codex", "aider" or "cline" process on the whole
25880
+ # machine, with no scoping at all, and killed unrelated Claude Code
25881
+ # sessions in other terminals the moment ANY loki-mode run finished,
25882
+ # double-Ctrl-C'd, or Ctrl-C'd in perpetual mode (build prompt D14 and
25883
+ # `feedback-pkill-f-substring-killed-the-session`). Reproduced: a decoy
25884
+ # `claude` process in a DIFFERENT process group survived; one launched
25885
+ # inside this shell's job (matching how a real reparented leaf would sit)
25886
+ # was still caught.
25887
+ local proc my_pgid
25888
+ my_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
25889
+ if [ -n "$my_pgid" ]; then
25890
+ for proc in claude codex aider cline; do
25891
+ local leaf_pid leaf_pgid
25892
+ for leaf_pid in $(pgrep -f "^${proc}( |$)" 2>/dev/null); do
25893
+ leaf_pgid="$(ps -o pgid= -p "$leaf_pid" 2>/dev/null | tr -d ' ')"
25894
+ [ -n "$leaf_pgid" ] && [ "$leaf_pgid" = "$my_pgid" ] || continue
25895
+ kill -TERM "$leaf_pid" 2>/dev/null && killed=1
25896
+ done
25897
+ done
25898
+ fi
25454
25899
 
25455
25900
  # Brief wait for graceful exit (max ~2s).
25456
25901
  local i=0
@@ -26208,6 +26653,10 @@ cleanup() {
26208
26653
  fi
26209
26654
  # v7.5.12: Kill any running provider pipeline first, before slow cleanup.
26210
26655
  kill_provider_child 2>/dev/null || true
26656
+ # This exit makes no session commit: record the files this session
26657
+ # created (after the provider is gone, so none are added later), so a
26658
+ # resume commits them instead of adopting them as the user's.
26659
+ _loki_record_session_created || true
26211
26660
  rm -f "$loki_dir/STOP" "$loki_dir/PAUSE" "$loki_dir/PAUSED.md" 2>/dev/null
26212
26661
  # UT2-13: Clear cli-provider marker on session end.
26213
26662
  rm -f "$loki_dir/state/cli-provider" 2>/dev/null || true
@@ -26282,6 +26731,8 @@ except (json.JSONDecodeError, OSError): pass
26282
26731
  log_warn "Loki Mode interrupted -- shutting down (double Ctrl+C)"
26283
26732
  # v7.5.12: Kill provider pipeline immediately so we don't wait on it.
26284
26733
  kill_provider_child 2>/dev/null || true
26734
+ # No session commit on this exit either (see the branch above).
26735
+ _loki_record_session_created || true
26285
26736
  # Write STOP signal so any peer processes (dashboard, etc.) also stop.
26286
26737
  mkdir -p "$loki_dir" 2>/dev/null && touch "$loki_dir/STOP" 2>/dev/null || true
26287
26738
  if type app_runner_cleanup &>/dev/null; then