loki-mode 11.10.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 (99) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  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 +263 -58
  11. package/autonomy/quickstart.sh +40 -0
  12. package/autonomy/run.sh +39 -15
  13. package/autonomy/trigger-server.py +452 -29
  14. package/bin/loki +4 -1
  15. package/completions/_loki +1 -1
  16. package/completions/loki.bash +16 -1
  17. package/dashboard/__init__.py +1 -1
  18. package/docs/CLI-REFERENCE.md +7 -4
  19. package/docs/DEPLOYMENT.md +12 -5
  20. package/docs/EVALUATING.md +13 -0
  21. package/docs/INSTALLATION.md +2 -2
  22. package/docs/SIGNED-RECEIPTS.md +7 -0
  23. package/docs/WORKSPACES.md +2 -2
  24. package/docs/cost-controls.md +26 -0
  25. package/docs/merge-queue.md +19 -0
  26. package/docs/sentry-intake.md +4 -0
  27. package/docs/slack.md +2 -1
  28. package/docs/trackers.md +12 -1
  29. package/docs/v10/BOARD.md +140 -85
  30. package/docs/v10/CLI-MODERN.md +5 -4
  31. package/docs/v10/DECISIONS.md +1 -0
  32. package/docs/v10/ENGINE.md +2 -0
  33. package/docs/v10/FAILURE-CLASSES.md +3 -3
  34. package/docs/v10/MODERNIZE.md +34 -2
  35. package/docs/v10/PROGRESS.md +15 -0
  36. package/loki-ts/dist/loki.js +1225 -1139
  37. package/mcp/__init__.py +1 -1
  38. package/package.json +2 -2
  39. package/packages/control-plane/dist/server.js +1889 -1577
  40. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  41. package/schemas/loki-yaml.schema.json +5 -0
  42. package/web-app/dist/assets/{AdminPage-D3PgG7SH.js → AdminPage-bZlvH4TL.js} +1 -1
  43. package/web-app/dist/assets/{Avatar-Ct77hvlG.js → Avatar-BZrBstQe.js} +1 -1
  44. package/web-app/dist/assets/{Badge-Cg_gZaJF.js → Badge-Q8l8r4Vq.js} +1 -1
  45. package/web-app/dist/assets/{Button-i9r2G84n.js → Button-B8Bt0A3S.js} +1 -1
  46. package/web-app/dist/assets/{CockpitPage-C3UyLXyp.js → CockpitPage-CIYExrXa.js} +1 -1
  47. package/web-app/dist/assets/{ComparePage-B1dXHga_.js → ComparePage-CBOmHdam.js} +1 -1
  48. package/web-app/dist/assets/{ErrorBoundary-CQ_xREw0.js → ErrorBoundary-BKwDPSnK.js} +1 -1
  49. package/web-app/dist/assets/{EvidenceReceiptPanel-DwUxd_Xi.js → EvidenceReceiptPanel-KqzjphWl.js} +1 -1
  50. package/web-app/dist/assets/{GitHubIssuesPanel-C0jAnuAd.js → GitHubIssuesPanel-lF3GkHRv.js} +1 -1
  51. package/web-app/dist/assets/{GitHubPRsPanel-B4pNXJMz.js → GitHubPRsPanel-BCzipPWD.js} +1 -1
  52. package/web-app/dist/assets/{HomePage-BC9UTS1l.js → HomePage-CIdnkzaN.js} +2 -2
  53. package/web-app/dist/assets/{LoginPage-CnpdwK8F.js → LoginPage-D-178nTT.js} +1 -1
  54. package/web-app/dist/assets/{MagicPage-Bj0lZuNp.js → MagicPage-BmHuzMT8.js} +1 -1
  55. package/web-app/dist/assets/{MetricsPage-yyw3VnfX.js → MetricsPage-CPCiyDFn.js} +1 -1
  56. package/web-app/dist/assets/{NotFoundPage-LMxkJP15.js → NotFoundPage-B2T6n1gx.js} +1 -1
  57. package/web-app/dist/assets/{ProjectPage-2x0MRkNu.js → ProjectPage-DQmpFhb0.js} +3 -3
  58. package/web-app/dist/assets/{ProjectsPage-A8J8o1gj.js → ProjectsPage-BmugzFEd.js} +1 -1
  59. package/web-app/dist/assets/{SettingsPage-fwvOVcRb.js → SettingsPage-ZB-RrmgM.js} +1 -1
  60. package/web-app/dist/assets/{ShowcasePage-DqI4sUwk.js → ShowcasePage-BAhwHt4_.js} +1 -1
  61. package/web-app/dist/assets/{SystemSettingsPage-CyytfNo_.js → SystemSettingsPage-Bt7sf2Vu.js} +1 -1
  62. package/web-app/dist/assets/{TeamsPage-BTTe76W1.js → TeamsPage-B-v5j_8q.js} +1 -1
  63. package/web-app/dist/assets/{TemplatesPage-CIp3OTlL.js → TemplatesPage-bdqUL-LG.js} +1 -1
  64. package/web-app/dist/assets/{TerminalOutput-dX5L4ky-.js → TerminalOutput-CRHEXBwS.js} +1 -1
  65. package/web-app/dist/assets/{activity-uHXr2AaF.js → activity-C-ROkmXt.js} +1 -1
  66. package/web-app/dist/assets/{bell-D6RbxRMo.js → bell-DbiCBxQA.js} +1 -1
  67. package/web-app/dist/assets/{bot-D31w2Mu6.js → bot-DsIgE_qr.js} +1 -1
  68. package/web-app/dist/assets/{check-CoKJksbr.js → check-CkAdmHWP.js} +1 -1
  69. package/web-app/dist/assets/{chevron-left-Bjx7oX7s.js → chevron-left-CYTLivAo.js} +1 -1
  70. package/web-app/dist/assets/{circle-alert-DOAh8Jmi.js → circle-alert-BVIcAsFJ.js} +1 -1
  71. package/web-app/dist/assets/{clock-DgEXPNWH.js → clock-Dg3Rq0go.js} +1 -1
  72. package/web-app/dist/assets/{cloud-0dXJFzBv.js → cloud-CenrqHFl.js} +1 -1
  73. package/web-app/dist/assets/{code-xml-DytQ_bq7.js → code-xml-C2mvfoug.js} +1 -1
  74. package/web-app/dist/assets/{database-Qyjryb72.js → database-BKa0_eCb.js} +1 -1
  75. package/web-app/dist/assets/{dollar-sign-BzhTVy1e.js → dollar-sign-DfelUK1S.js} +1 -1
  76. package/web-app/dist/assets/{file-code-corner-DTdSOALw.js → file-code-corner-CZKWoHVg.js} +1 -1
  77. package/web-app/dist/assets/{file-plus-VdjOG1RK.js → file-plus-CZ-m1zMX.js} +1 -1
  78. package/web-app/dist/assets/{globe-BhxNOfyM.js → globe-S0kv_GwC.js} +1 -1
  79. package/web-app/dist/assets/{hammer-MQzrw8ee.js → hammer-BW1MDdu1.js} +1 -1
  80. package/web-app/dist/assets/{index-DaxrMbHf.js → index-ACEYsTc2.js} +3 -3
  81. package/web-app/dist/assets/{layers-C7nusTP-.js → layers-BrXHYZ-L.js} +1 -1
  82. package/web-app/dist/assets/{loader-circle-B1qBkgOY.js → loader-circle-DOdhKFx0.js} +1 -1
  83. package/web-app/dist/assets/{lock-BdkOxK2O.js → lock-BcFTyg8J.js} +1 -1
  84. package/web-app/dist/assets/{package-RVyK96Zn.js → package-D34cMVQG.js} +1 -1
  85. package/web-app/dist/assets/{plus-ntmwFRrp.js → plus-CEaMeBxL.js} +1 -1
  86. package/web-app/dist/assets/{refresh-cw-BJ39b_PP.js → refresh-cw-D2Rzxr5U.js} +1 -1
  87. package/web-app/dist/assets/{rotate-ccw-uPx_-3vz.js → rotate-ccw-C_-EjRLh.js} +1 -1
  88. package/web-app/dist/assets/{scroll-text-D9qvXkj-.js → scroll-text-BSSCMI_c.js} +1 -1
  89. package/web-app/dist/assets/{server-gs4xmD8Y.js → server-BKvLonWt.js} +1 -1
  90. package/web-app/dist/assets/{shield-alert-O2_YffGI.js → shield-alert-DbbQbPdW.js} +1 -1
  91. package/web-app/dist/assets/{thumbs-up-B3RVBhQx.js → thumbs-up-NoBD1fUR.js} +1 -1
  92. package/web-app/dist/assets/{trash-2-DEUt7qhq.js → trash-2-BXz8rAyd.js} +1 -1
  93. package/web-app/dist/assets/{trending-down-oyTns4UZ.js → trending-down-D8u535Re.js} +1 -1
  94. package/web-app/dist/assets/{trending-up-CdpZK0Oc.js → trending-up-q8k3kgiy.js} +1 -1
  95. package/web-app/dist/assets/{upload-D9sbjjC8.js → upload-Cp7_zeBI.js} +1 -1
  96. package/web-app/dist/assets/{usePolling-6ognrnyV.js → usePolling-CTHVdcuX.js} +1 -1
  97. package/web-app/dist/assets/{user-CBKc8QoI.js → user-DF7bfSf_.js} +1 -1
  98. package/web-app/dist/index.html +1 -1
  99. 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"
@@ -1905,6 +1908,7 @@ show_help_all() {
1905
1908
  echo " loki memory list # All learnings"
1906
1909
  echo " loki memory search <query> # Search across learnings"
1907
1910
  echo " loki memory consolidate # Run episodic-to-semantic pipeline"
1911
+ echo " loki memory learn --recent N # Learn from the newest N merged PRs"
1908
1912
  echo ""
1909
1913
  echo " # Config + dashboard"
1910
1914
  echo " loki config set maxTier sonnet # Cap model cost"
@@ -2548,7 +2552,7 @@ cmd_start() {
2548
2552
  # [--auto-rollback] chains the gated `loki deploy --execute` after a start
2549
2553
  # that ended 0. cmd_deploy owns every gate: no VERIFIED HEAD-anchored
2550
2554
  # receipt means a refusal, never a deploy.
2551
- 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
2552
2556
  while [ $# -gt 0 ]; do
2553
2557
  case "$1" in
2554
2558
  --workspace)
@@ -2560,12 +2564,16 @@ cmd_start() {
2560
2564
  [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo -e "${RED}--health-url requires a URL${NC}" >&2; exit 2; }
2561
2565
  _ps_health="$2"; shift 2 ;;
2562
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 ;;
2563
2571
  --auto-rollback) _ps_rb=1; shift ;;
2564
2572
  *) _ps_rest+=("$1"); shift ;;
2565
2573
  esac
2566
2574
  done
2567
- if { [ -n "$_ps_health" ] || [ "$_ps_rb" = 1 ]; } && [ "$_ps_deploy" != 1 ]; then
2568
- 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
2569
2577
  exit 2
2570
2578
  fi
2571
2579
  if [ -n "$_ps_ws" ]; then
@@ -2602,6 +2610,7 @@ cmd_start() {
2602
2610
  fi
2603
2611
  local _ps_dargs=(--execute)
2604
2612
  [ -n "$_ps_health" ] && _ps_dargs+=(--health-url "$_ps_health")
2613
+ [ -n "$_ps_hmatch" ] && _ps_dargs+=(--health-match "$_ps_hmatch")
2605
2614
  [ "$_ps_rb" = 1 ] && _ps_dargs+=(--auto-rollback)
2606
2615
  cmd_deploy "${_ps_dargs[@]}"
2607
2616
  return $?
@@ -5060,11 +5069,14 @@ cmd_pause() {
5060
5069
  }
5061
5070
 
5062
5071
  # RUN-25 iter 22 (Wave D #3): `loki steer "<note>"` writes an operator directive
5063
- # the running loop actually reads (.loki/HUMAN_INPUT.md), replacing the dead
5064
- # steering.md hint the cockpit used to advertise (nothing read it). The loop only
5065
- # INJECTS the note when LOKI_PROMPT_INJECTION=1 (default off, for safety), so we
5066
- # write the note and tell the operator the exact flag -- honest: the control now
5067
- # 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.
5068
5080
  cmd_steer() {
5069
5081
  case "${1:-}" in
5070
5082
  --help|-h|help|"")
@@ -5072,13 +5084,14 @@ cmd_steer() {
5072
5084
  echo ""
5073
5085
  echo "Usage: loki steer \"<guidance>\""
5074
5086
  echo ""
5075
- echo "Writes your guidance to .loki/HUMAN_INPUT.md, which the running"
5076
- 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."
5077
5090
  echo ""
5078
5091
  echo " loki steer \"focus on the auth flow, skip the docs for now\""
5079
5092
  echo ""
5080
- echo "The loop injects the note only when prompt injection is enabled:"
5081
- 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."
5082
5095
  [ -z "${1:-}" ] && return 2
5083
5096
  return 0
5084
5097
  ;;
@@ -5088,20 +5101,34 @@ cmd_steer() {
5088
5101
  return 1
5089
5102
  fi
5090
5103
  local note="$*"
5091
- # Append with a timestamp so multiple steers accumulate; the reader consumes
5092
- # 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.
5093
5116
  {
5094
- 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"
5095
5118
  printf '%s\n\n' "$note"
5096
5119
  } >> "$LOKI_DIR/HUMAN_INPUT.md"
5097
5120
  emit_event session cli steer "chars=${#note}" 2>/dev/null || true
5098
- echo -e "${GREEN}Steering note written to .loki/HUMAN_INPUT.md${NC}"
5099
- if [ "${LOKI_PROMPT_INJECTION:-false}" != "1" ] && [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ]; then
5100
- echo -e "${YELLOW}Note: prompt injection is OFF, so the loop will NOT read this yet.${NC}"
5101
- echo -e "${DIM}Enable it: export LOKI_PROMPT_INJECTION=1 (then the next iteration ingests the note).${NC}"
5102
- else
5103
- echo -e "${DIM}The next iteration will read and apply it.${NC}"
5104
- 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
5105
5132
  return 0
5106
5133
  }
5107
5134
 
@@ -9770,7 +9797,7 @@ _deploy_latest_receipt() {
9770
9797
  return 0
9771
9798
  }
9772
9799
 
9773
- # _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]
9774
9801
  # Writes the executed deploy's own record, then signs it (loki.deploy/1, Ed25519 via the
9775
9802
  # receipt signer in loki-ts/src/util/receipt_signer.ts) so it is bound to the receipt that
9776
9803
  # authorized it; `loki verify <record>` re-checks it offline. An unsigned record is reported
@@ -9793,6 +9820,7 @@ _deploy_record() {
9793
9820
  local health_status="${7:-unchecked}" health_url="${8:-}"
9794
9821
  local rb_attempted="${9:-false}" rb_exit="${10:-}"
9795
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:-}"
9796
9824
  local ddir="$dir/.loki/deploys"
9797
9825
  [ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
9798
9826
  mkdir -p "$ddir" 2>/dev/null || return 0
@@ -9800,7 +9828,7 @@ _deploy_record() {
9800
9828
  DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
9801
9829
  DR_STATE="$anchor_state" DR_HS="$health_status" DR_HU="$health_url" \
9802
9830
  DR_RBA="$rb_attempted" DR_RBX="$rb_exit" DR_RSHA="$receipt_sha" DR_T0="$started_at" \
9803
- 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" \
9804
9832
  python3 -c '
9805
9833
  import json, os, datetime
9806
9834
  state = os.environ.get("DR_STATE") or "anchored"
@@ -9826,6 +9854,10 @@ json.dump({
9826
9854
  "health_url": os.environ.get("DR_HU") or "",
9827
9855
  "rollback_attempted": os.environ.get("DR_RBA") == "true",
9828
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,
9829
9861
  }, open(os.environ["DR_OUT"], "w"), indent=2)
9830
9862
  ' 2>/dev/null || return 0
9831
9863
  # Sign through the one Bun implementation. A subshell, because the delegate execs.
@@ -9894,20 +9926,31 @@ except Exception:
9894
9926
  return 0
9895
9927
  }
9896
9928
 
9897
- # _deploy_probe_health <url>
9898
- # 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
9899
9933
  # spacing are env-tunable (LOKI_DEPLOY_HEALTH_ATTEMPTS / _INTERVAL) for tests.
9900
9934
  _deploy_probe_health() {
9901
- local url="${1:-}" attempts="${LOKI_DEPLOY_HEALTH_ATTEMPTS:-5}" interval="${LOKI_DEPLOY_HEALTH_INTERVAL:-3}"
9902
- 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
9903
9938
  case "$attempts" in ''|*[!0-9]*) attempts=5 ;; esac
9904
9939
  case "$interval" in ''|*[!0-9]*) interval=3 ;; esac
9905
9940
  while [ "$i" -lt "$attempts" ]; do
9906
9941
  i=$((i + 1))
9907
- code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)" || code="000"
9908
- 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
9909
9951
  [ "$i" -lt "$attempts" ] && [ "$interval" -gt 0 ] && sleep "$interval"
9910
9952
  done
9953
+ [ "$body" = /dev/null ] || rm -f "$body"
9911
9954
  printf '%s' "$code"
9912
9955
  return 1
9913
9956
  }
@@ -9973,6 +10016,7 @@ _deploy_try_execute() {
9973
10016
  local dir="${1:-.}" type="${2:-}"
9974
10017
  # Set by cmd_deploy (dynamic scope); empty/false when called any other way.
9975
10018
  local health_url="${_LOKI_DEPLOY_HEALTH_URL:-}" auto_rollback="${_LOKI_DEPLOY_AUTO_ROLLBACK:-false}"
10019
+ local health_match="${_LOKI_DEPLOY_HEALTH_MATCH:-}"
9976
10020
 
9977
10021
  local command
9978
10022
  command="$(_deploy_first_installed_command "$dir" "$type")"
@@ -10085,7 +10129,7 @@ _deploy_try_execute() {
10085
10129
 
10086
10130
  local rc=0 started_at finished_at purl="" canary_out=""
10087
10131
  started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
10088
- 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"
10089
10133
  if [ "$canary_kind" = vercel ]; then
10090
10134
  # Staged: a preview deploy (no --prod), probe the preview URL, promote only if healthy.
10091
10135
  # Nothing reaches production before the probe, so there is nothing to roll back.
@@ -10097,7 +10141,7 @@ _deploy_try_execute() {
10097
10141
  if [ "$rc" -eq 0 ] && [ -n "$purl" ]; then
10098
10142
  health_url="$purl"
10099
10143
  echo "Probing canary health: ${health_url}"
10100
- if hcode="$(_deploy_probe_health "$health_url")"; then
10144
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10101
10145
  health_status="healthy"
10102
10146
  echo "Promoting: vercel promote ${purl}"
10103
10147
  exec_cmd="vercel; vercel promote ${purl}"
@@ -10118,7 +10162,7 @@ _deploy_try_execute() {
10118
10162
  # failed deploy, status is "unchecked": never a claim the service is healthy.
10119
10163
  if [ "$canary_kind" != vercel ] && [ "$rc" -eq 0 ] && [ -n "$health_url" ]; then
10120
10164
  echo "Probing health: ${health_url}"
10121
- if hcode="$(_deploy_probe_health "$health_url")"; then
10165
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10122
10166
  health_status="healthy"
10123
10167
  else
10124
10168
  health_status="FAILED_HEALTH"
@@ -10126,14 +10170,21 @@ _deploy_try_execute() {
10126
10170
  fi
10127
10171
 
10128
10172
  if [ "$health_status" = "FAILED_HEALTH" ]; then
10129
- 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
10130
10174
  if [ "$auto_rollback" = true ] && [ -n "$rb_cmd" ] && ! _deploy_is_destructive "$rb_cmd"; then
10131
10175
  echo "Rolling back: ${rb_cmd}" >&2
10132
10176
  rb_attempted=true
10133
10177
  rb_exit=0
10134
10178
  ( cd "$dir" && eval "$rb_cmd" ) || rb_exit=$?
10135
10179
  if [ "$rb_exit" -eq 0 ]; then
10136
- 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
10137
10188
  else
10138
10189
  echo -e "${RED}Rollback command exited ${rb_exit}.${NC}" >&2
10139
10190
  fi
@@ -10149,7 +10200,7 @@ _deploy_try_execute() {
10149
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 '')"
10150
10201
  rec="$(_deploy_record "$dir" "$exec_cmd" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
10151
10202
  "$anchor_state" "$health_status" "$health_url" "$rb_attempted" "$rb_exit" \
10152
- "$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)")"
10153
10204
  echo ""
10154
10205
  if [ "$rc" -eq 0 ]; then
10155
10206
  echo -e "${GREEN}Deploy command exited 0.${NC}"
@@ -10176,6 +10227,8 @@ _deploy_try_execute() {
10176
10227
  fi
10177
10228
  # A deploy that exited 0 but failed its health probe is a failed deploy.
10178
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
10179
10232
  return 4
10180
10233
  fi
10181
10234
  return "$rc"
@@ -10289,7 +10342,7 @@ cmd_deploy() {
10289
10342
  # file alone weakens this gate.
10290
10343
  local LOKI_DEPLOY_ALLOW_UNANCHORED=false
10291
10344
  # Per-invocation like --execute; local so an ambient env var cannot set them.
10292
- 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
10293
10346
 
10294
10347
  # Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
10295
10348
  while [ $# -gt 0 ]; do
@@ -10297,7 +10350,7 @@ cmd_deploy() {
10297
10350
  --help|-h|help)
10298
10351
  echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
10299
10352
  echo ""
10300
- 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]]"
10301
10354
  echo ""
10302
10355
  echo "Detects your project type and your installed cloud CLI (and any"
10303
10356
  echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
@@ -10358,11 +10411,14 @@ cmd_deploy() {
10358
10411
  echo " non-2xx marks the deploy FAILED_HEALTH and exits 4."
10359
10412
  echo " Without a URL, health is recorded as 'unchecked',"
10360
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."
10361
10416
  echo " --auto-rollback On FAILED_HEALTH run the provider's documented rollback:"
10362
10417
  echo " 'vercel rollback' or 'fly releases rollback <version"
10363
10418
  echo " captured before the deploy>'. Netlify and Cloudflare"
10364
10419
  echo " print a manual note. Needs --health-url. The rolled-back"
10365
- echo " service is not re-probed."
10420
+ echo " service is re-probed and recorded as post_rollback_health;"
10421
+ echo " still unhealthy exits 5."
10366
10422
  echo ""
10367
10423
  echo "Staged rollout (only with --execute):"
10368
10424
  echo " --canary Vercel: deploy without --prod, health-check the preview"
@@ -10401,6 +10457,10 @@ cmd_deploy() {
10401
10457
  *) echo "loki deploy --health-url requires an http(s) URL" >&2; return 1 ;;
10402
10458
  esac
10403
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
+ ;;
10404
10464
  --auto-rollback)
10405
10465
  _LOKI_DEPLOY_AUTO_ROLLBACK=true
10406
10466
  ;;
@@ -10428,6 +10488,10 @@ cmd_deploy() {
10428
10488
  echo "loki deploy --auto-rollback requires --health-url (no health signal, no rollback)" >&2
10429
10489
  return 1
10430
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
10431
10495
  if [ "$_LOKI_DEPLOY_CANARY" = true ] && [ "$do_execute" != true ]; then
10432
10496
  echo "loki deploy --canary requires --execute" >&2
10433
10497
  return 1
@@ -11491,7 +11555,7 @@ fi
11491
11555
  # Post-completion: auto-merge if requested
11492
11556
  if [[ "$LOKI_AUTO_MERGE" == "true" ]]; then
11493
11557
  branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
11494
- 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
11495
11559
  # A brief run carries a synthetic ref (b<epoch>), not a real issue: close only numeric refs.
11496
11560
  if [[ "${LOKI_ISSUE_NUMBER:-}" =~ ^[0-9]+$ ]]; then
11497
11561
  gh issue close "$LOKI_ISSUE_NUMBER" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -11709,7 +11773,8 @@ ${_gh_receipt}"
11709
11773
  if command -v jq >/dev/null 2>&1; then
11710
11774
  jq -n --arg url "$(printf '%s' "$_pr_out" | grep -Eo 'https://[^[:space:]]+' | head -1)" \
11711
11775
  --arg branch "$branch_current" \
11712
- '{state:"created", url:$url, branch:$branch}' \
11776
+ '{state:"created", url:$url, branch:$branch,
11777
+ merge:{outcome:"waiting", gate:"human_review", checks:"not recorded"}}' \
11713
11778
  > .loki/state/pr.json 2>/dev/null || true
11714
11779
  fi
11715
11780
  else
@@ -11743,7 +11808,7 @@ ${_gh_receipt}"
11743
11808
  github|brief)
11744
11809
  local branch_current
11745
11810
  branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
11746
- 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
11747
11812
  echo -e "${GREEN}PR merged and branch deleted${NC}"
11748
11813
  if [[ "${number:-}" =~ ^[0-9]+$ ]]; then
11749
11814
  gh issue close "$number" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -18472,6 +18537,7 @@ cmd_modernize() {
18472
18537
  echo "Subcommands (grouped modernization surface):"
18473
18538
  echo " heal <path> Legacy system healing in phases (was: loki heal)"
18474
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)"
18475
18541
  echo ""
18476
18542
  echo "Run 'loki modernize <subcommand> --help' for each. The old"
18477
18543
  echo "top-level names still work; see 'loki help aliases'."
@@ -25881,6 +25947,142 @@ SHOW_EOF
25881
25947
  esac
25882
25948
  }
25883
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
+
25884
26086
  # loki merge: serial PR merge queue. Queue lives in .loki/merge-queue (one PR
25885
26087
  # number per line). A PR merges only when `gh pr checks` exits 0 (all green);
25886
26088
  # after each merge the next PR is rebased onto the new base (gh pr update-branch
@@ -25915,8 +26117,7 @@ cmd_merge() {
25915
26117
  [ "${1:-}" = "--dry-run" ] && dry=true
25916
26118
  if [ ! -s "$qfile" ]; then echo "Merge queue is empty"; return 0; fi
25917
26119
  command -v gh >/dev/null 2>&1 || { echo "gh CLI is required" >&2; return 1; }
25918
- local poll="${LOKI_MERGE_POLL_S:-20}" tries="${LOKI_MERGE_MAX_POLLS:-90}"
25919
- local first=true n rc i remaining="" merged=0 blocked=0
26120
+ local first=true n rc remaining="" merged=0 blocked=0
25920
26121
  while IFS= read -r n; do
25921
26122
  [ -n "$n" ] || continue
25922
26123
  if $dry; then
@@ -25935,29 +26136,33 @@ cmd_merge() {
25935
26136
  || { echo "#$n: rebase onto new base failed, left in queue"; remaining="$remaining$n"$'\n'; blocked=$((blocked+1)); continue; }
25936
26137
  echo "#$n: rebased onto new base"
25937
26138
  fi
25938
- rc=8; i=0
25939
- while [ "$rc" = "8" ] && [ "$i" -lt "$tries" ]; do
25940
- rc=0; gh pr checks "$n" >/dev/null 2>&1 || rc=$?
25941
- [ "$rc" = "8" ] && { sleep "$poll"; i=$((i+1)); }
25942
- done
25943
- if [ "$rc" != "0" ]; then
25944
- echo "#$n: checks not green (rc=$rc), left in queue, not merged"
25945
- remaining="$remaining$n"$'\n'; blocked=$((blocked+1)); continue
25946
- fi
25947
- if gh pr merge "$n" --squash >/dev/null 2>&1; then
26139
+ if _merge_pr_gated "$n" 2>"$qfile.err"; then
25948
26140
  echo "#$n: merged"; merged=$((merged+1)); first=false
25949
26141
  else
25950
- echo "#$n: merge failed, left in queue"
26142
+ sed 's/^/#/' "$qfile.err"; echo "#$n: left in queue"
25951
26143
  remaining="$remaining$n"$'\n'; blocked=$((blocked+1))
25952
26144
  fi
25953
26145
  done < "$qfile"
25954
26146
  if $dry; then echo "Dry run: nothing changed"; return 0; fi
26147
+ rm -f "$qfile.err"
25955
26148
  printf '%s' "$remaining" > "$qfile"
25956
26149
  echo "Merged: $merged, left in queue: $blocked"
25957
26150
  [ "$blocked" -eq 0 ]
25958
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
+ ;;
25959
26160
  help|--help|-h|*)
25960
- 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)."
25961
26166
  echo " add <pr>... Queue pull requests"
25962
26167
  echo " list Show the queue"
25963
26168
  echo " run [--dry-run] Merge one at a time, only when checks are green;"