loki-mode 11.12.1 → 11.15.0

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 (97) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +18 -3
  5. package/autonomy/lib/chat.sh +6 -0
  6. package/autonomy/lib/engine10-push.sh +26 -2
  7. package/autonomy/lib/proof-generator.py +16 -0
  8. package/autonomy/lib/trust_trajectory.py +130 -0
  9. package/autonomy/lib/workspace.py +2 -3
  10. package/autonomy/loki +262 -58
  11. package/autonomy/run.sh +39 -15
  12. package/autonomy/trigger-server.py +452 -29
  13. package/bin/loki +4 -1
  14. package/completions/_loki +1 -1
  15. package/completions/loki.bash +8 -0
  16. package/dashboard/__init__.py +1 -1
  17. package/docs/CLI-REFERENCE.md +5 -4
  18. package/docs/DEPLOYMENT.md +12 -5
  19. package/docs/EVALUATING.md +13 -0
  20. package/docs/INSTALLATION.md +2 -2
  21. package/docs/SIGNED-RECEIPTS.md +7 -0
  22. package/docs/WORKSPACES.md +2 -2
  23. package/docs/cost-controls.md +26 -0
  24. package/docs/merge-queue.md +19 -0
  25. package/docs/sentry-intake.md +4 -0
  26. package/docs/slack.md +2 -1
  27. package/docs/trackers.md +12 -1
  28. package/docs/v10/BOARD.md +123 -123
  29. package/docs/v10/CLI-MODERN.md +2 -2
  30. package/docs/v10/DECISIONS.md +1 -0
  31. package/docs/v10/ENGINE.md +2 -0
  32. package/docs/v10/MODERNIZE.md +32 -1
  33. package/docs/v10/PROGRESS.md +3 -0
  34. package/loki-ts/dist/loki.js +1221 -1148
  35. package/mcp/__init__.py +1 -1
  36. package/package.json +2 -2
  37. package/packages/control-plane/dist/server.js +2486 -2190
  38. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  39. package/schemas/loki-yaml.schema.json +5 -0
  40. package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-bZlvH4TL.js} +1 -1
  41. package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-BZrBstQe.js} +1 -1
  42. package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-Q8l8r4Vq.js} +1 -1
  43. package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-B8Bt0A3S.js} +1 -1
  44. package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-CIYExrXa.js} +1 -1
  45. package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CBOmHdam.js} +1 -1
  46. package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-BKwDPSnK.js} +1 -1
  47. package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-KqzjphWl.js} +1 -1
  48. package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-lF3GkHRv.js} +1 -1
  49. package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-BCzipPWD.js} +1 -1
  50. package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CIdnkzaN.js} +2 -2
  51. package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-D-178nTT.js} +1 -1
  52. package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-BmHuzMT8.js} +1 -1
  53. package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-CPCiyDFn.js} +1 -1
  54. package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-B2T6n1gx.js} +1 -1
  55. package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-DQmpFhb0.js} +3 -3
  56. package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage-BmugzFEd.js} +1 -1
  57. package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-ZB-RrmgM.js} +1 -1
  58. package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-BAhwHt4_.js} +1 -1
  59. package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-Bt7sf2Vu.js} +1 -1
  60. package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-B-v5j_8q.js} +1 -1
  61. package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-bdqUL-LG.js} +1 -1
  62. package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-CRHEXBwS.js} +1 -1
  63. package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-C-ROkmXt.js} +1 -1
  64. package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-DbiCBxQA.js} +1 -1
  65. package/web-app/dist/assets/{bot-DOGeYewg.js → bot-DsIgE_qr.js} +1 -1
  66. package/web-app/dist/assets/{check-zbjPpb3E.js → check-CkAdmHWP.js} +1 -1
  67. package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CYTLivAo.js} +1 -1
  68. package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-BVIcAsFJ.js} +1 -1
  69. package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-Dg3Rq0go.js} +1 -1
  70. package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-CenrqHFl.js} +1 -1
  71. package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-C2mvfoug.js} +1 -1
  72. package/web-app/dist/assets/{database-_a22uh7l.js → database-BKa0_eCb.js} +1 -1
  73. package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-DfelUK1S.js} +1 -1
  74. package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-CZKWoHVg.js} +1 -1
  75. package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-CZ-m1zMX.js} +1 -1
  76. package/web-app/dist/assets/{globe-2TveFE5l.js → globe-S0kv_GwC.js} +1 -1
  77. package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-BW1MDdu1.js} +1 -1
  78. package/web-app/dist/assets/{index-DGnZXUYk.js → index-ACEYsTc2.js} +3 -3
  79. package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-BrXHYZ-L.js} +1 -1
  80. package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DOdhKFx0.js} +1 -1
  81. package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-BcFTyg8J.js} +1 -1
  82. package/web-app/dist/assets/{package-BZtqTIqH.js → package-D34cMVQG.js} +1 -1
  83. package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-CEaMeBxL.js} +1 -1
  84. package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-D2Rzxr5U.js} +1 -1
  85. package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-C_-EjRLh.js} +1 -1
  86. package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-BSSCMI_c.js} +1 -1
  87. package/web-app/dist/assets/{server-DbG8MDv6.js → server-BKvLonWt.js} +1 -1
  88. package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-DbbQbPdW.js} +1 -1
  89. package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-NoBD1fUR.js} +1 -1
  90. package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-BXz8rAyd.js} +1 -1
  91. package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-D8u535Re.js} +1 -1
  92. package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-q8k3kgiy.js} +1 -1
  93. package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-Cp7_zeBI.js} +1 -1
  94. package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-CTHVdcuX.js} +1 -1
  95. package/web-app/dist/assets/{user-CK5-EXIj.js → user-DF7bfSf_.js} +1 -1
  96. package/web-app/dist/index.html +1 -1
  97. package/autonomy/lib/doctor-fix.py +0 -279
package/autonomy/loki CHANGED
@@ -1682,7 +1682,7 @@ PYEOF
1682
1682
  # `loki help answer`: `loki answer` is a v10 (loki-ts) command with no bash
1683
1683
  # implementation, so its help lives here rather than behind delegation.
1684
1684
  _help_answer() {
1685
- echo -e "${BOLD}loki answer${NC} - Resume a BLOCKED run with an answer"
1685
+ echo -e "${BOLD}loki answer${NC} - Start a follow-up run from a BLOCKED run with your answer"
1686
1686
  echo ""
1687
1687
  echo "Usage: loki answer [<run-id>] [--text \"...\"]"
1688
1688
  echo ""
@@ -1711,6 +1711,8 @@ _help_verify_pr() {
1711
1711
  echo -e "${BOLD}loki verify-pr${NC} - Verify a PR against its linked issue (experimental, LOKI_VERIFY_PR=1)"
1712
1712
  echo ""
1713
1713
  echo "Usage: loki verify-pr <https://github.com/o/r/pull/N | o/r#N> [--out DIR]"
1714
+ echo " loki verify-pr respond <o/r#N> fix open review comments (one commit each, on the PR"
1715
+ echo " branch), push, reply with the sha; never resolves threads"
1714
1716
  echo ""
1715
1717
  echo "Derives a check from the PR's linked issue (Check: \`cmd\` or a loki-check fence)."
1716
1718
  echo "VERIFIED only if the check fails on the PR base, passes on its head, and the"
@@ -1799,7 +1801,8 @@ show_help_all() {
1799
1801
  echo ""
1800
1802
  echo "Verify / trust:"
1801
1803
  echo " verify [base] Deterministic PR verification (CI-gate exit codes)"
1802
- echo " keys export Print the public receipt-signing key (JWK) for third-party verify --pubkey"
1804
+ echo " verify-pr <pr> Verify a PR against its linked issue; 'verify-pr respond <pr>' fixes review comments"
1805
+ echo " keys export Print the public receipt-signing key (JWK) for third-party verify --pubkey"
1803
1806
  echo " review [opts] Standalone code review with quality gates"
1804
1807
  echo " ultracode \"task\" Run a task as a native Claude Code Dynamic Workflow (opt-in, Claude-only)"
1805
1808
  echo " trust [cmd] Visible trust trajectory; 'trust detail' for metrics"
@@ -2549,7 +2552,7 @@ cmd_start() {
2549
2552
  # [--auto-rollback] chains the gated `loki deploy --execute` after a start
2550
2553
  # that ended 0. cmd_deploy owns every gate: no VERIFIED HEAD-anchored
2551
2554
  # receipt means a refusal, never a deploy.
2552
- local _ps_ws="" _ps_deploy=0 _ps_health="" _ps_rb=0 _ps_rest=() _ps_brief=() _ps_a
2555
+ local _ps_ws="" _ps_deploy=0 _ps_health="" _ps_hmatch="" _ps_rb=0 _ps_rest=() _ps_brief=() _ps_a
2553
2556
  while [ $# -gt 0 ]; do
2554
2557
  case "$1" in
2555
2558
  --workspace)
@@ -2561,12 +2564,16 @@ cmd_start() {
2561
2564
  [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo -e "${RED}--health-url requires a URL${NC}" >&2; exit 2; }
2562
2565
  _ps_health="$2"; shift 2 ;;
2563
2566
  --health-url=*) _ps_health="${1#*=}"; shift ;;
2567
+ --health-match)
2568
+ [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo -e "${RED}--health-match requires a string${NC}" >&2; exit 2; }
2569
+ _ps_hmatch="$2"; shift 2 ;;
2570
+ --health-match=*) _ps_hmatch="${1#*=}"; shift ;;
2564
2571
  --auto-rollback) _ps_rb=1; shift ;;
2565
2572
  *) _ps_rest+=("$1"); shift ;;
2566
2573
  esac
2567
2574
  done
2568
- if { [ -n "$_ps_health" ] || [ "$_ps_rb" = 1 ]; } && [ "$_ps_deploy" != 1 ]; then
2569
- echo -e "${RED}--health-url and --auto-rollback apply only with --deploy${NC}" >&2
2575
+ if { [ -n "$_ps_health" ] || [ -n "$_ps_hmatch" ] || [ "$_ps_rb" = 1 ]; } && [ "$_ps_deploy" != 1 ]; then
2576
+ echo -e "${RED}--health-url, --health-match and --auto-rollback apply only with --deploy${NC}" >&2
2570
2577
  exit 2
2571
2578
  fi
2572
2579
  if [ -n "$_ps_ws" ]; then
@@ -2603,6 +2610,7 @@ cmd_start() {
2603
2610
  fi
2604
2611
  local _ps_dargs=(--execute)
2605
2612
  [ -n "$_ps_health" ] && _ps_dargs+=(--health-url "$_ps_health")
2613
+ [ -n "$_ps_hmatch" ] && _ps_dargs+=(--health-match "$_ps_hmatch")
2606
2614
  [ "$_ps_rb" = 1 ] && _ps_dargs+=(--auto-rollback)
2607
2615
  cmd_deploy "${_ps_dargs[@]}"
2608
2616
  return $?
@@ -5061,11 +5069,14 @@ cmd_pause() {
5061
5069
  }
5062
5070
 
5063
5071
  # RUN-25 iter 22 (Wave D #3): `loki steer "<note>"` writes an operator directive
5064
- # the running loop actually reads (.loki/HUMAN_INPUT.md), replacing the dead
5065
- # steering.md hint the cockpit used to advertise (nothing read it). The loop only
5066
- # INJECTS the note when LOKI_PROMPT_INJECTION=1 (default off, for safety), so we
5067
- # write the note and tell the operator the exact flag -- honest: the control now
5068
- # genuinely persists the directive instead of silently no-op'ing.
5072
+ # to .loki/HUMAN_INPUT.md. engine10 reads it before the next plan or implement
5073
+ # session (default on; only LOKI_PROMPT_INJECTION=0/false opts out, steer.ts).
5074
+ # The legacy loop injects it only when LOKI_PROMPT_INJECTION=1.
5075
+ # Each steer is a numbered revision: `## Operator directive rev N (ts) actor=A source=S`.
5076
+ # engine10 (util/steer.ts) archives the file and appends the revisions to
5077
+ # .loki/intent-revisions.jsonl. Actor and source come from LOKI_STEER_ACTOR
5078
+ # (default: git user.name, then $USER) and LOKI_STEER_SOURCE (cli, chat or an
5079
+ # https comment URL; default cli). Both are reduced to a safe character set.
5069
5080
  cmd_steer() {
5070
5081
  case "${1:-}" in
5071
5082
  --help|-h|help|"")
@@ -5073,13 +5084,14 @@ cmd_steer() {
5073
5084
  echo ""
5074
5085
  echo "Usage: loki steer \"<guidance>\""
5075
5086
  echo ""
5076
- echo "Writes your guidance to .loki/HUMAN_INPUT.md, which the running"
5077
- echo "loop reads at the next iteration boundary (when LOKI_PROMPT_INJECTION=1)."
5087
+ echo "Writes your guidance to .loki/HUMAN_INPUT.md as a numbered revision"
5088
+ echo "(rev N, actor, source). The Loki 10 engine applies it once, before the"
5089
+ echo "next plan or implement session, and records it in .loki/intent-revisions.jsonl."
5078
5090
  echo ""
5079
5091
  echo " loki steer \"focus on the auth flow, skip the docs for now\""
5080
5092
  echo ""
5081
- echo "The loop injects the note only when prompt injection is enabled:"
5082
- echo " LOKI_PROMPT_INJECTION=1 loki start ... (or export it before starting)"
5093
+ echo "Opt out with LOKI_PROMPT_INJECTION=0. The legacy loop reads the note"
5094
+ echo "only when LOKI_PROMPT_INJECTION=1."
5083
5095
  [ -z "${1:-}" ] && return 2
5084
5096
  return 0
5085
5097
  ;;
@@ -5089,20 +5101,34 @@ cmd_steer() {
5089
5101
  return 1
5090
5102
  fi
5091
5103
  local note="$*"
5092
- # Append with a timestamp so multiple steers accumulate; the reader consumes
5093
- # and clears the file each iteration, so this is the pending-directive buffer.
5104
+ local actor source rev pending archived
5105
+ actor="${LOKI_STEER_ACTOR:-$(git config user.name 2>/dev/null || true)}"
5106
+ actor="${actor:-${USER:-unknown}}"
5107
+ actor="$(printf '%s' "$actor" | tr -c 'A-Za-z0-9._@-' '-' | cut -c1-64)"
5108
+ source="$(printf '%s' "${LOKI_STEER_SOURCE:-cli}" | tr -c 'A-Za-z0-9:/._#?=&%-' '-' | cut -c1-300)"
5109
+ # rev = consumed revisions + steers still pending in the buffer + 1
5110
+ archived=0; pending=0
5111
+ [ -f "$LOKI_DIR/intent-revisions.jsonl" ] && archived="$(wc -l < "$LOKI_DIR/intent-revisions.jsonl" | tr -d ' ')"
5112
+ [ -f "$LOKI_DIR/HUMAN_INPUT.md" ] && pending="$(grep -c '^## Operator directive rev ' "$LOKI_DIR/HUMAN_INPUT.md" 2>/dev/null || true)"
5113
+ rev=$(( ${archived:-0} + ${pending:-0} + 1 ))
5114
+ # Append so multiple steers accumulate; the reader consumes and clears the
5115
+ # file, so this is the pending-directive buffer.
5094
5116
  {
5095
- printf '## Operator directive (%s)\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
5117
+ printf '## Operator directive rev %s (%s) actor=%s source=%s\n' "$rev" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$actor" "$source"
5096
5118
  printf '%s\n\n' "$note"
5097
5119
  } >> "$LOKI_DIR/HUMAN_INPUT.md"
5098
5120
  emit_event session cli steer "chars=${#note}" 2>/dev/null || true
5099
- echo -e "${GREEN}Steering note written to .loki/HUMAN_INPUT.md${NC}"
5100
- if [ "${LOKI_PROMPT_INJECTION:-false}" != "1" ] && [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ]; then
5101
- echo -e "${YELLOW}Note: prompt injection is OFF, so the loop will NOT read this yet.${NC}"
5102
- echo -e "${DIM}Enable it: export LOKI_PROMPT_INJECTION=1 (then the next iteration ingests the note).${NC}"
5103
- else
5104
- echo -e "${DIM}The next iteration will read and apply it.${NC}"
5105
- fi
5121
+ echo -e "${GREEN}Steering note (rev $rev) written to .loki/HUMAN_INPUT.md${NC}"
5122
+ case "${LOKI_PROMPT_INJECTION:-}" in
5123
+ 0|false|FALSE|False)
5124
+ echo -e "${YELLOW}Note: LOKI_PROMPT_INJECTION is off, so the run will NOT read this.${NC}"
5125
+ ;;
5126
+ *)
5127
+ echo -e "${DIM}The Loki 10 engine applies it before the next plan or implement session.${NC}"
5128
+ # Same text the issue comment ack posts (trigger-server.py steer_ack_text).
5129
+ echo "Steer rev $rev received: applies at next plan/implement stage."
5130
+ ;;
5131
+ esac
5106
5132
  return 0
5107
5133
  }
5108
5134
 
@@ -9771,7 +9797,7 @@ _deploy_latest_receipt() {
9771
9797
  return 0
9772
9798
  }
9773
9799
 
9774
- # _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha> [state] [health_status] [health_url] [rb_attempted] [rb_exit] [receipt_sha256] [started_at] [finished_at] [target] [canary]
9800
+ # _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha> [state] [health_status] [health_url] [rb_attempted] [rb_exit] [receipt_sha256] [started_at] [finished_at] [target] [canary] [post_rollback_health] [health_http_code] [health_match] [tree_sha]
9775
9801
  # Writes the executed deploy's own record, then signs it (loki.deploy/1, Ed25519 via the
9776
9802
  # receipt signer in loki-ts/src/util/receipt_signer.ts) so it is bound to the receipt that
9777
9803
  # authorized it; `loki verify <record>` re-checks it offline. An unsigned record is reported
@@ -9794,6 +9820,7 @@ _deploy_record() {
9794
9820
  local health_status="${7:-unchecked}" health_url="${8:-}"
9795
9821
  local rb_attempted="${9:-false}" rb_exit="${10:-}"
9796
9822
  local receipt_sha="${11:-}" started_at="${12:-}" finished_at="${13:-}" target="${14:-}" canary="${15:-false}"
9823
+ local post_rb="${16:-unchecked}" hcode_rec="${17:-}" hmatch="${18:-}" tree_sha="${19:-}"
9797
9824
  local ddir="$dir/.loki/deploys"
9798
9825
  [ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
9799
9826
  mkdir -p "$ddir" 2>/dev/null || return 0
@@ -9801,7 +9828,7 @@ _deploy_record() {
9801
9828
  DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
9802
9829
  DR_STATE="$anchor_state" DR_HS="$health_status" DR_HU="$health_url" \
9803
9830
  DR_RBA="$rb_attempted" DR_RBX="$rb_exit" DR_RSHA="$receipt_sha" DR_T0="$started_at" \
9804
- DR_T1="$finished_at" DR_TGT="$target" DR_CAN="$canary" \
9831
+ DR_T1="$finished_at" DR_TGT="$target" DR_CAN="$canary" DR_PRB="$post_rb" DR_HC="$hcode_rec" DR_HM="$hmatch" DR_TREE="$tree_sha" \
9805
9832
  python3 -c '
9806
9833
  import json, os, datetime
9807
9834
  state = os.environ.get("DR_STATE") or "anchored"
@@ -9827,6 +9854,10 @@ json.dump({
9827
9854
  "health_url": os.environ.get("DR_HU") or "",
9828
9855
  "rollback_attempted": os.environ.get("DR_RBA") == "true",
9829
9856
  "rollback_exit": int(os.environ["DR_RBX"]) if os.environ.get("DR_RBX", "") != "" else None,
9857
+ "post_rollback_health": os.environ.get("DR_PRB") or "unchecked",
9858
+ "health_match": os.environ.get("DR_HM", ""),
9859
+ "tree_sha": os.environ.get("DR_TREE", ""),
9860
+ "health_http_code": int(os.environ["DR_HC"]) if os.environ.get("DR_HC", "").isdigit() else None,
9830
9861
  }, open(os.environ["DR_OUT"], "w"), indent=2)
9831
9862
  ' 2>/dev/null || return 0
9832
9863
  # Sign through the one Bun implementation. A subshell, because the delegate execs.
@@ -9895,20 +9926,31 @@ except Exception:
9895
9926
  return 0
9896
9927
  }
9897
9928
 
9898
- # _deploy_probe_health <url>
9899
- # Bounded poll: echoes the last HTTP status, returns 0 on any 2xx. Attempts and
9929
+ # _deploy_probe_health <url> [match]
9930
+ # Bounded poll: echoes the last HTTP status, returns 0 on any 2xx. With a match
9931
+ # string the 2xx body must also contain it (fixed-string), else the attempt fails
9932
+ # and the status is still echoed. Attempts and
9900
9933
  # spacing are env-tunable (LOKI_DEPLOY_HEALTH_ATTEMPTS / _INTERVAL) for tests.
9901
9934
  _deploy_probe_health() {
9902
- local url="${1:-}" attempts="${LOKI_DEPLOY_HEALTH_ATTEMPTS:-5}" interval="${LOKI_DEPLOY_HEALTH_INTERVAL:-3}"
9903
- local i=0 code="000"
9935
+ local url="${1:-}" match="${2:-}" attempts="${LOKI_DEPLOY_HEALTH_ATTEMPTS:-5}" interval="${LOKI_DEPLOY_HEALTH_INTERVAL:-3}"
9936
+ local i=0 code="000" body="/dev/null"
9937
+ if [ -n "$match" ]; then body="$(mktemp 2>/dev/null)" || body="/dev/null"; fi
9904
9938
  case "$attempts" in ''|*[!0-9]*) attempts=5 ;; esac
9905
9939
  case "$interval" in ''|*[!0-9]*) interval=3 ;; esac
9906
9940
  while [ "$i" -lt "$attempts" ]; do
9907
9941
  i=$((i + 1))
9908
- code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)" || code="000"
9909
- case "$code" in 2??) printf '%s' "$code"; return 0 ;; esac
9942
+ code="$(curl -s -o "$body" -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)" || code="000"
9943
+ case "$code" in
9944
+ 2??)
9945
+ if [ -z "$match" ] || grep -qF -- "$match" "$body" 2>/dev/null; then
9946
+ [ "$body" = /dev/null ] || rm -f "$body"
9947
+ printf '%s' "$code"; return 0
9948
+ fi
9949
+ ;;
9950
+ esac
9910
9951
  [ "$i" -lt "$attempts" ] && [ "$interval" -gt 0 ] && sleep "$interval"
9911
9952
  done
9953
+ [ "$body" = /dev/null ] || rm -f "$body"
9912
9954
  printf '%s' "$code"
9913
9955
  return 1
9914
9956
  }
@@ -9974,6 +10016,7 @@ _deploy_try_execute() {
9974
10016
  local dir="${1:-.}" type="${2:-}"
9975
10017
  # Set by cmd_deploy (dynamic scope); empty/false when called any other way.
9976
10018
  local health_url="${_LOKI_DEPLOY_HEALTH_URL:-}" auto_rollback="${_LOKI_DEPLOY_AUTO_ROLLBACK:-false}"
10019
+ local health_match="${_LOKI_DEPLOY_HEALTH_MATCH:-}"
9977
10020
 
9978
10021
  local command
9979
10022
  command="$(_deploy_first_installed_command "$dir" "$type")"
@@ -10086,7 +10129,7 @@ _deploy_try_execute() {
10086
10129
 
10087
10130
  local rc=0 started_at finished_at purl="" canary_out=""
10088
10131
  started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
10089
- local health_status="unchecked" rb_attempted=false rb_exit="" hcode=""
10132
+ local health_status="unchecked" rb_attempted=false rb_exit="" hcode="" rb_hcode="" post_rb="unchecked"
10090
10133
  if [ "$canary_kind" = vercel ]; then
10091
10134
  # Staged: a preview deploy (no --prod), probe the preview URL, promote only if healthy.
10092
10135
  # Nothing reaches production before the probe, so there is nothing to roll back.
@@ -10098,7 +10141,7 @@ _deploy_try_execute() {
10098
10141
  if [ "$rc" -eq 0 ] && [ -n "$purl" ]; then
10099
10142
  health_url="$purl"
10100
10143
  echo "Probing canary health: ${health_url}"
10101
- if hcode="$(_deploy_probe_health "$health_url")"; then
10144
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10102
10145
  health_status="healthy"
10103
10146
  echo "Promoting: vercel promote ${purl}"
10104
10147
  exec_cmd="vercel; vercel promote ${purl}"
@@ -10119,7 +10162,7 @@ _deploy_try_execute() {
10119
10162
  # failed deploy, status is "unchecked": never a claim the service is healthy.
10120
10163
  if [ "$canary_kind" != vercel ] && [ "$rc" -eq 0 ] && [ -n "$health_url" ]; then
10121
10164
  echo "Probing health: ${health_url}"
10122
- if hcode="$(_deploy_probe_health "$health_url")"; then
10165
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10123
10166
  health_status="healthy"
10124
10167
  else
10125
10168
  health_status="FAILED_HEALTH"
@@ -10127,14 +10170,21 @@ _deploy_try_execute() {
10127
10170
  fi
10128
10171
 
10129
10172
  if [ "$health_status" = "FAILED_HEALTH" ]; then
10130
- echo -e "${RED}FAILED_HEALTH: ${health_url} did not return 2xx (last status ${hcode:-000}).${NC}" >&2
10173
+ echo -e "${RED}FAILED_HEALTH: ${health_url} did not return 2xx${health_match:+ containing \"${health_match}\"} (last status ${hcode:-000}).${NC}" >&2
10131
10174
  if [ "$auto_rollback" = true ] && [ -n "$rb_cmd" ] && ! _deploy_is_destructive "$rb_cmd"; then
10132
10175
  echo "Rolling back: ${rb_cmd}" >&2
10133
10176
  rb_attempted=true
10134
10177
  rb_exit=0
10135
10178
  ( cd "$dir" && eval "$rb_cmd" ) || rb_exit=$?
10136
10179
  if [ "$rb_exit" -eq 0 ]; then
10137
- echo "Rollback command exited 0. Health of the rolled-back service was NOT re-checked." >&2
10180
+ echo "Rollback command exited 0. Re-checking health: ${health_url}" >&2
10181
+ if rb_hcode="$(_deploy_probe_health "$health_url")"; then
10182
+ post_rb="healthy"
10183
+ echo -e "${GREEN}Rolled-back service is healthy${NC} (HTTP ${rb_hcode})." >&2
10184
+ else
10185
+ post_rb="FAILED_HEALTH"
10186
+ echo -e "${RED}Rolled-back service is STILL unhealthy (last status ${rb_hcode:-000}).${NC}" >&2
10187
+ fi
10138
10188
  else
10139
10189
  echo -e "${RED}Rollback command exited ${rb_exit}.${NC}" >&2
10140
10190
  fi
@@ -10150,7 +10200,7 @@ _deploy_try_execute() {
10150
10200
  rsha="$(python3 -c 'import json,sys;print((json.load(open(sys.argv[1])).get("verification") or {}).get("hash") or "")' "$pj" 2>/dev/null || printf '')"
10151
10201
  rec="$(_deploy_record "$dir" "$exec_cmd" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
10152
10202
  "$anchor_state" "$health_status" "$health_url" "$rb_attempted" "$rb_exit" \
10153
- "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary")"
10203
+ "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary" "$post_rb" "$hcode" "$health_match" "$(git -C "$dir" rev-parse 'HEAD^{tree}' 2>/dev/null || true)")"
10154
10204
  echo ""
10155
10205
  if [ "$rc" -eq 0 ]; then
10156
10206
  echo -e "${GREEN}Deploy command exited 0.${NC}"
@@ -10177,6 +10227,8 @@ _deploy_try_execute() {
10177
10227
  fi
10178
10228
  # A deploy that exited 0 but failed its health probe is a failed deploy.
10179
10229
  if [ "$rc" -eq 0 ] && [ "$health_status" = "FAILED_HEALTH" ]; then
10230
+ # 5: the rollback ran and the service is still down.
10231
+ [ "$post_rb" = "FAILED_HEALTH" ] && return 5
10180
10232
  return 4
10181
10233
  fi
10182
10234
  return "$rc"
@@ -10290,7 +10342,7 @@ cmd_deploy() {
10290
10342
  # file alone weakens this gate.
10291
10343
  local LOKI_DEPLOY_ALLOW_UNANCHORED=false
10292
10344
  # Per-invocation like --execute; local so an ambient env var cannot set them.
10293
- local _LOKI_DEPLOY_HEALTH_URL="" _LOKI_DEPLOY_AUTO_ROLLBACK=false _LOKI_DEPLOY_CANARY=false
10345
+ local _LOKI_DEPLOY_HEALTH_URL="" _LOKI_DEPLOY_HEALTH_MATCH="" _LOKI_DEPLOY_AUTO_ROLLBACK=false _LOKI_DEPLOY_CANARY=false
10294
10346
 
10295
10347
  # Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
10296
10348
  while [ $# -gt 0 ]; do
@@ -10298,7 +10350,7 @@ cmd_deploy() {
10298
10350
  --help|-h|help)
10299
10351
  echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
10300
10352
  echo ""
10301
- echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--health-url URL [--auto-rollback]]"
10353
+ echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--health-url URL [--health-match STR] [--auto-rollback]]"
10302
10354
  echo ""
10303
10355
  echo "Detects your project type and your installed cloud CLI (and any"
10304
10356
  echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
@@ -10359,11 +10411,14 @@ cmd_deploy() {
10359
10411
  echo " non-2xx marks the deploy FAILED_HEALTH and exits 4."
10360
10412
  echo " Without a URL, health is recorded as 'unchecked',"
10361
10413
  echo " never as healthy."
10414
+ echo " --health-match S The 2xx body must also contain S (for example the HEAD sha"
10415
+ echo " or version), else FAILED_HEALTH. Needs --health-url."
10362
10416
  echo " --auto-rollback On FAILED_HEALTH run the provider's documented rollback:"
10363
10417
  echo " 'vercel rollback' or 'fly releases rollback <version"
10364
10418
  echo " captured before the deploy>'. Netlify and Cloudflare"
10365
10419
  echo " print a manual note. Needs --health-url. The rolled-back"
10366
- echo " service is not re-probed."
10420
+ echo " service is re-probed and recorded as post_rollback_health;"
10421
+ echo " still unhealthy exits 5."
10367
10422
  echo ""
10368
10423
  echo "Staged rollout (only with --execute):"
10369
10424
  echo " --canary Vercel: deploy without --prod, health-check the preview"
@@ -10402,6 +10457,10 @@ cmd_deploy() {
10402
10457
  *) echo "loki deploy --health-url requires an http(s) URL" >&2; return 1 ;;
10403
10458
  esac
10404
10459
  ;;
10460
+ --health-match)
10461
+ [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo "loki deploy --health-match requires a string" >&2; return 1; }
10462
+ _LOKI_DEPLOY_HEALTH_MATCH="$2"; shift
10463
+ ;;
10405
10464
  --auto-rollback)
10406
10465
  _LOKI_DEPLOY_AUTO_ROLLBACK=true
10407
10466
  ;;
@@ -10429,6 +10488,10 @@ cmd_deploy() {
10429
10488
  echo "loki deploy --auto-rollback requires --health-url (no health signal, no rollback)" >&2
10430
10489
  return 1
10431
10490
  fi
10491
+ if [ -n "$_LOKI_DEPLOY_HEALTH_MATCH" ] && [ -z "$_LOKI_DEPLOY_HEALTH_URL" ]; then
10492
+ echo "loki deploy --health-match requires --health-url" >&2
10493
+ return 1
10494
+ fi
10432
10495
  if [ "$_LOKI_DEPLOY_CANARY" = true ] && [ "$do_execute" != true ]; then
10433
10496
  echo "loki deploy --canary requires --execute" >&2
10434
10497
  return 1
@@ -11492,7 +11555,7 @@ fi
11492
11555
  # Post-completion: auto-merge if requested
11493
11556
  if [[ "$LOKI_AUTO_MERGE" == "true" ]]; then
11494
11557
  branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
11495
- if gh pr merge "$branch_current" --squash --delete-branch 2>/dev/null; then
11558
+ if "$LOKI_CMD" merge watch "$branch_current" --require-verified --delete-branch; then
11496
11559
  # A brief run carries a synthetic ref (b<epoch>), not a real issue: close only numeric refs.
11497
11560
  if [[ "${LOKI_ISSUE_NUMBER:-}" =~ ^[0-9]+$ ]]; then
11498
11561
  gh issue close "$LOKI_ISSUE_NUMBER" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -11710,7 +11773,8 @@ ${_gh_receipt}"
11710
11773
  if command -v jq >/dev/null 2>&1; then
11711
11774
  jq -n --arg url "$(printf '%s' "$_pr_out" | grep -Eo 'https://[^[:space:]]+' | head -1)" \
11712
11775
  --arg branch "$branch_current" \
11713
- '{state:"created", url:$url, branch:$branch}' \
11776
+ '{state:"created", url:$url, branch:$branch,
11777
+ merge:{outcome:"waiting", gate:"human_review", checks:"not recorded"}}' \
11714
11778
  > .loki/state/pr.json 2>/dev/null || true
11715
11779
  fi
11716
11780
  else
@@ -11744,7 +11808,7 @@ ${_gh_receipt}"
11744
11808
  github|brief)
11745
11809
  local branch_current
11746
11810
  branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
11747
- if gh pr merge "$branch_current" --squash --delete-branch 2>/dev/null; then
11811
+ if _merge_pr_watch "$branch_current" --require-verified --delete-branch; then
11748
11812
  echo -e "${GREEN}PR merged and branch deleted${NC}"
11749
11813
  if [[ "${number:-}" =~ ^[0-9]+$ ]]; then
11750
11814
  gh issue close "$number" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -18473,6 +18537,7 @@ cmd_modernize() {
18473
18537
  echo "Subcommands (grouped modernization surface):"
18474
18538
  echo " heal <path> Legacy system healing in phases (was: loki heal)"
18475
18539
  echo " migrate <path> Codebase migration in phases (was: loki migrate)"
18540
+ echo " unit --repo <owner/name> --ref <ref> --unit <id> Run one unit (remote unit job worker)"
18476
18541
  echo ""
18477
18542
  echo "Run 'loki modernize <subcommand> --help' for each. The old"
18478
18543
  echo "top-level names still work; see 'loki help aliases'."
@@ -25882,6 +25947,142 @@ SHOW_EOF
25882
25947
  esac
25883
25948
  }
25884
25949
 
25950
+ # Poll `gh pr checks` while pending (rc=8); returns the final rc (0 = green).
25951
+ _pr_checks_wait() {
25952
+ local pr="$1" poll="${LOKI_MERGE_POLL_S:-20}" tries="${LOKI_MERGE_MAX_POLLS:-90}" rc=8 i=0
25953
+ while [ "$rc" = "8" ] && [ "$i" -lt "$tries" ]; do
25954
+ rc=0; gh pr checks "$pr" >/dev/null 2>&1 || rc=$?
25955
+ [ "$rc" = "8" ] && { sleep "$poll"; i=$((i+1)); }
25956
+ done
25957
+ return "$rc"
25958
+ }
25959
+
25960
+ # CI repair loop. Waits for checks; on red, hands the failed-run log tail to one
25961
+ # bounded repair session on the PR branch (LOKI_PR_REPAIR_CMD, default: a
25962
+ # `loki start` of a generated brief; it receives the evidence file path as its last argument), pushes,
25963
+ # and re-checks, up to LOKI_PR_REPAIR_MAX attempts (default 2). Green goes through
25964
+ # _merge_pr_gated (extra args pass through). Exhausted: the PR is returned to
25965
+ # draft, nothing merges, and the last failure evidence is printed.
25966
+ # Usage: _merge_pr_watch <pr> [--require-verified] [--delete-branch]
25967
+ _merge_pr_watch() {
25968
+ local pr="$1" max="${LOKI_PR_REPAIR_MAX:-2}" attempt=0 rc branch cur rid evid tmp
25969
+ shift
25970
+ case "$max" in ''|*[!0-9]*) max=2 ;; esac
25971
+ command -v gh >/dev/null 2>&1 || { echo "$pr: not merged, gate: gh (gh CLI is required)" >&2; return 1; }
25972
+ branch="$(gh pr view "$pr" --json headRefName -q .headRefName 2>/dev/null)" || branch=""
25973
+ tmp="$(mktemp -d)" || return 1
25974
+ evid="$tmp/ci-failure.md"
25975
+ while true; do
25976
+ rc=0; _pr_checks_wait "$pr" || rc=$?
25977
+ if [ "$rc" = "0" ]; then
25978
+ rm -rf "$tmp"
25979
+ _merge_pr_gated "$pr" "$@"
25980
+ return $?
25981
+ fi
25982
+ rid="$(gh run list --branch "$branch" --status failure --limit 1 --json databaseId -q '.[0].databaseId' 2>/dev/null)" || rid=""
25983
+ {
25984
+ echo "# CI failed for PR $pr (branch ${branch:-unknown}); fix the failing checks, keep the change minimal"
25985
+ [ -n "$rid" ] && gh run view "$rid" --log-failed 2>/dev/null | tail -n 120
25986
+ } > "$evid"
25987
+ if [ "$attempt" -ge "$max" ]; then
25988
+ gh pr ready "$pr" --undo >/dev/null 2>&1 || true
25989
+ echo "$pr: not merged, gate: checks (gh pr checks rc=$rc after $attempt repair attempts); PR left as draft" >&2
25990
+ _pr_record_merge "$pr" "blocked:checks" "checks" "failing after $attempt repair attempts"
25991
+ echo "Last failure evidence:" >&2; sed 's/^/ /' "$evid" >&2
25992
+ rm -rf "$tmp"
25993
+ return 1
25994
+ fi
25995
+ attempt=$((attempt+1))
25996
+ cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
25997
+ if [ -z "$branch" ] || [ "$cur" != "$branch" ]; then
25998
+ echo "$pr: not merged, gate: repair (checkout is '${cur:-none}', repair must run on PR branch '${branch:-unknown}'); PR left open" >&2
25999
+ rm -rf "$tmp"; return 1
26000
+ fi
26001
+ echo "$pr: checks red, repair attempt $attempt of $max"
26002
+ if [ -n "${LOKI_PR_REPAIR_CMD:-}" ]; then
26003
+ bash -c "$LOKI_PR_REPAIR_CMD \"\$1\"" _ "$evid" || { echo "$pr: repair attempt $attempt failed, not pushing"; continue; }
26004
+ else
26005
+ "$(command -v loki || echo "$0")" start "$evid" || { echo "$pr: repair attempt $attempt failed, not pushing"; continue; }
26006
+ fi
26007
+ git push origin "HEAD:refs/heads/$branch" >/dev/null 2>&1 || echo "$pr: push of repair failed" >&2
26008
+ done
26009
+ }
26010
+
26011
+ # Record the merge gate's own result in .loki/state/pr.json (merged,
26012
+ # blocked:<gate> or waiting) so the receipt and handoff read the same outcome
26013
+ # the gate produced. Only updates an existing "created" record that describes
26014
+ # the same PR (pr arg matches .branch or the number at the end of .url); any
26015
+ # other record is left untouched. Never invents one. File mode is preserved.
26016
+ _pr_record_merge() {
26017
+ local pr="$1" outcome="$2" gate="${3:-}" checks="${4:-not recorded}" f=".loki/state/pr.json" t n
26018
+ n="${pr#\#}"
26019
+ case "$n" in */pull/*) n="${n%/}"; n="${n##*/}" ;; esac
26020
+ [ -f "$f" ] && command -v jq >/dev/null 2>&1 || return 0
26021
+ t="$(mktemp "$f.XXXXXX" 2>/dev/null)" || return 0
26022
+ cp -p "$f" "$t" 2>/dev/null || { rm -f "$t"; return 0; }
26023
+ if jq --arg p "$pr" --arg n "$n" --arg o "$outcome" --arg g "$gate" --arg c "$checks" \
26024
+ 'select(.state=="created" and (.branch==$p or ((.url // "") | (endswith("/pull/" + $n) or endswith("/pull/" + $n + "/"))))) | .merge={outcome:$o, gate:(if $g=="" then null else $g end), checks:$c}' "$f" > "$t" 2>/dev/null && [ -s "$t" ]; then
26025
+ mv "$t" "$f"
26026
+ else
26027
+ rm -f "$t"
26028
+ fi
26029
+ return 0
26030
+ }
26031
+
26032
+ # Shared merge gate. One PR (number or branch) merges only when it is not a
26033
+ # draft, `gh pr checks` exits 0 after polling pending (rc=8), and, with
26034
+ # --require-verified, this run's proof headline is VERIFIED (missing proof or
26035
+ # an unreadable headline is never VERIFIED). On refusal it prints the exact
26036
+ # blocking gate ("gate: draft|verdict|checks") and leaves the PR open.
26037
+ # Usage: _merge_pr_gated <pr> [--require-verified] [--delete-branch]
26038
+ _merge_pr_gated() {
26039
+ local pr="$1" need_verified=false margs=(--squash) rc d
26040
+ shift
26041
+ while [ $# -gt 0 ]; do
26042
+ case "$1" in
26043
+ --require-verified) need_verified=true ;;
26044
+ --delete-branch) margs+=(--delete-branch) ;;
26045
+ esac
26046
+ shift
26047
+ done
26048
+ command -v gh >/dev/null 2>&1 || { echo "$pr: not merged, gate: gh (gh CLI is required)" >&2; _pr_record_merge "$pr" "blocked:gh" "gh"; return 1; }
26049
+ d="$(gh pr view "$pr" --json isDraft -q .isDraft 2>/dev/null)" || d=""
26050
+ if [ "$d" != "false" ]; then
26051
+ echo "$pr: not merged, gate: draft (PR is a draft or its state is unreadable); PR left open" >&2
26052
+ _pr_record_merge "$pr" "blocked:draft" "draft"
26053
+ return 1
26054
+ fi
26055
+ if $need_verified; then
26056
+ local pid="" headline=""
26057
+ [ -f .loki/state/last-proof-id.txt ] && pid="$(cat .loki/state/last-proof-id.txt 2>/dev/null)"
26058
+ if [ -n "$pid" ] && [ -f ".loki/proofs/${pid}/proof.json" ] && command -v jq >/dev/null 2>&1; then
26059
+ headline="$(jq -r '.honesty.headline // empty' ".loki/proofs/${pid}/proof.json" 2>/dev/null)"
26060
+ fi
26061
+ if [ "$headline" != "VERIFIED" ]; then
26062
+ echo "$pr: not merged, gate: verdict (run verdict is '${headline:-missing}', need VERIFIED); PR left open" >&2
26063
+ _pr_record_merge "$pr" "blocked:verdict" "verdict"
26064
+ return 1
26065
+ fi
26066
+ fi
26067
+ rc=0; _pr_checks_wait "$pr" || rc=$?
26068
+ if [ "$rc" != "0" ]; then
26069
+ echo "$pr: not merged, gate: checks (gh pr checks rc=$rc); PR left open" >&2
26070
+ case "$rc" in
26071
+ 8) _pr_record_merge "$pr" "waiting" "checks" "pending" ;;
26072
+ 1) _pr_record_merge "$pr" "blocked:checks" "checks" "failing" ;;
26073
+ *) _pr_record_merge "$pr" "blocked:checks" "checks" "unknown" ;;
26074
+ esac
26075
+ return 1
26076
+ fi
26077
+ if ! gh pr merge "$pr" "${margs[@]}" >/dev/null 2>&1; then
26078
+ echo "$pr: not merged, gate: merge (gh pr merge failed); PR left open" >&2
26079
+ _pr_record_merge "$pr" "blocked:merge" "merge" "passing"
26080
+ return 1
26081
+ fi
26082
+ _pr_record_merge "$pr" "merged" "" "all passing"
26083
+ return 0
26084
+ }
26085
+
25885
26086
  # loki merge: serial PR merge queue. Queue lives in .loki/merge-queue (one PR
25886
26087
  # number per line). A PR merges only when `gh pr checks` exits 0 (all green);
25887
26088
  # after each merge the next PR is rebased onto the new base (gh pr update-branch
@@ -25916,8 +26117,7 @@ cmd_merge() {
25916
26117
  [ "${1:-}" = "--dry-run" ] && dry=true
25917
26118
  if [ ! -s "$qfile" ]; then echo "Merge queue is empty"; return 0; fi
25918
26119
  command -v gh >/dev/null 2>&1 || { echo "gh CLI is required" >&2; return 1; }
25919
- local poll="${LOKI_MERGE_POLL_S:-20}" tries="${LOKI_MERGE_MAX_POLLS:-90}"
25920
- local first=true n rc i remaining="" merged=0 blocked=0
26120
+ local first=true n rc remaining="" merged=0 blocked=0
25921
26121
  while IFS= read -r n; do
25922
26122
  [ -n "$n" ] || continue
25923
26123
  if $dry; then
@@ -25936,29 +26136,33 @@ cmd_merge() {
25936
26136
  || { echo "#$n: rebase onto new base failed, left in queue"; remaining="$remaining$n"$'\n'; blocked=$((blocked+1)); continue; }
25937
26137
  echo "#$n: rebased onto new base"
25938
26138
  fi
25939
- rc=8; i=0
25940
- while [ "$rc" = "8" ] && [ "$i" -lt "$tries" ]; do
25941
- rc=0; gh pr checks "$n" >/dev/null 2>&1 || rc=$?
25942
- [ "$rc" = "8" ] && { sleep "$poll"; i=$((i+1)); }
25943
- done
25944
- if [ "$rc" != "0" ]; then
25945
- echo "#$n: checks not green (rc=$rc), left in queue, not merged"
25946
- remaining="$remaining$n"$'\n'; blocked=$((blocked+1)); continue
25947
- fi
25948
- if gh pr merge "$n" --squash >/dev/null 2>&1; then
26139
+ if _merge_pr_gated "$n" 2>"$qfile.err"; then
25949
26140
  echo "#$n: merged"; merged=$((merged+1)); first=false
25950
26141
  else
25951
- echo "#$n: merge failed, left in queue"
26142
+ sed 's/^/#/' "$qfile.err"; echo "#$n: left in queue"
25952
26143
  remaining="$remaining$n"$'\n'; blocked=$((blocked+1))
25953
26144
  fi
25954
26145
  done < "$qfile"
25955
26146
  if $dry; then echo "Dry run: nothing changed"; return 0; fi
26147
+ rm -f "$qfile.err"
25956
26148
  printf '%s' "$remaining" > "$qfile"
25957
26149
  echo "Merged: $merged, left in queue: $blocked"
25958
26150
  [ "$blocked" -eq 0 ]
25959
26151
  ;;
26152
+ watch)
26153
+ [ $# -gt 0 ] || { echo "Usage: loki merge watch <pr> [--require-verified] [--delete-branch]" >&2; return 2; }
26154
+ _merge_pr_watch "$@" && echo "$1: merged"
26155
+ ;;
26156
+ pr)
26157
+ [ $# -gt 0 ] || { echo "Usage: loki merge pr <pr-or-branch> [--require-verified] [--delete-branch]" >&2; return 2; }
26158
+ _merge_pr_gated "$@" && echo "$1: merged"
26159
+ ;;
25960
26160
  help|--help|-h|*)
25961
- echo "Usage: loki merge <add|list|run|clear>"
26161
+ echo "Usage: loki merge <add|list|run|pr|watch|clear>"
26162
+ echo " watch <pr> Wait for checks; on red run up to LOKI_PR_REPAIR_MAX (2) bounded"
26163
+ echo " repair sessions on the PR branch, push, re-check, merge when green."
26164
+ echo " pr <pr|branch> Merge one PR through the same gate (not draft, checks green;"
26165
+ echo " --require-verified also needs a VERIFIED run verdict)."
25962
26166
  echo " add <pr>... Queue pull requests"
25963
26167
  echo " list Show the queue"
25964
26168
  echo " run [--dry-run] Merge one at a time, only when checks are green;"