loki-mode 11.12.1 → 11.17.1

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 (114) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/grill.sh +3 -1
  5. package/autonomy/hooks/validate-bash.sh +51 -13
  6. package/autonomy/lib/backlog.py +18 -3
  7. package/autonomy/lib/chat.sh +117 -7
  8. package/autonomy/lib/claude-flags.sh +20 -0
  9. package/autonomy/lib/claude_permissions.py +40 -0
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/engine10-push.sh +56 -7
  12. package/autonomy/lib/prd-enrich.sh +4 -2
  13. package/autonomy/lib/proof-generator.py +21 -0
  14. package/autonomy/lib/proof-verify.py +109 -1
  15. package/autonomy/lib/trust_trajectory.py +139 -0
  16. package/autonomy/lib/voter-agents.sh +3 -1
  17. package/autonomy/lib/workspace.py +2 -3
  18. package/autonomy/loki +427 -74
  19. package/autonomy/quickstart.sh +21 -2
  20. package/autonomy/run.sh +55 -21
  21. package/autonomy/trigger-server.py +461 -32
  22. package/bin/loki +4 -1
  23. package/completions/_loki +1 -1
  24. package/completions/loki.bash +10 -2
  25. package/dashboard/__init__.py +1 -1
  26. package/dashboard/api_start.py +2 -2
  27. package/dashboard/build_supervisor.py +2 -0
  28. package/docs/CLI-REFERENCE.md +6 -4
  29. package/docs/DEPLOYMENT.md +12 -5
  30. package/docs/EVALUATING.md +13 -0
  31. package/docs/INSTALLATION.md +2 -2
  32. package/docs/SIGNED-RECEIPTS.md +7 -0
  33. package/docs/WORKSPACES.md +2 -2
  34. package/docs/cost-controls.md +26 -0
  35. package/docs/merge-queue.md +19 -0
  36. package/docs/sentry-intake.md +4 -0
  37. package/docs/slack.md +2 -1
  38. package/docs/trackers.md +12 -1
  39. package/docs/v10/BOARD.md +148 -123
  40. package/docs/v10/CLI-MODERN.md +5 -5
  41. package/docs/v10/DECISIONS.md +2 -0
  42. package/docs/v10/ENGINE.md +2 -0
  43. package/docs/v10/FAILURE-CLASSES.md +18 -0
  44. package/docs/v10/MODERNIZE.md +32 -1
  45. package/docs/v10/PROGRESS.md +7 -0
  46. package/loki-ts/data/finding-schema.json +2 -1
  47. package/loki-ts/dist/loki.js +1222 -1128
  48. package/magic/core/debate.py +27 -1
  49. package/mcp/__init__.py +1 -1
  50. package/package.json +2 -2
  51. package/packages/control-plane/dist/server.js +5895 -5292
  52. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  53. package/providers/claude.sh +6 -3
  54. package/providers/model_catalog.json +6 -0
  55. package/schemas/loki-yaml.schema.json +5 -0
  56. package/src/policies/claude_permissions.js +114 -0
  57. package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-OVzN6eLm.js} +1 -1
  58. package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-hCCrxrCZ.js} +1 -1
  59. package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-CDcpkhbJ.js} +1 -1
  60. package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-8mwWlhEu.js} +1 -1
  61. package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-D8cf3u22.js} +1 -1
  62. package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CIgkbDRY.js} +1 -1
  63. package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-99kJH9g2.js} +1 -1
  64. package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
  65. package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
  66. package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
  67. package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CcNA24ny.js} +2 -2
  68. package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-BYjlb-IK.js} +1 -1
  69. package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-C0Dwt9Na.js} +1 -1
  70. package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-jDGmdcgg.js} +1 -1
  71. package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-C_ZO8tLE.js} +1 -1
  72. package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-B5wnurfQ.js} +3 -3
  73. package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage--EyKt5-D.js} +1 -1
  74. package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-cB0lnfti.js} +1 -1
  75. package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-Dl16FDkK.js} +1 -1
  76. package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
  77. package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-BFkWVOZe.js} +1 -1
  78. package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-Bf2kmEUM.js} +1 -1
  79. package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-BItv9Wa6.js} +1 -1
  80. package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-Df2Awg6I.js} +1 -1
  81. package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-D-1ztTsJ.js} +1 -1
  82. package/web-app/dist/assets/{bot-DOGeYewg.js → bot-A4qgZsKi.js} +1 -1
  83. package/web-app/dist/assets/{check-zbjPpb3E.js → check-BPfFpF0c.js} +1 -1
  84. package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CfSJvmDi.js} +1 -1
  85. package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-wx2d1dzb.js} +1 -1
  86. package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-pQMai6l-.js} +1 -1
  87. package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-DWL470r6.js} +1 -1
  88. package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-BaEQpQLZ.js} +1 -1
  89. package/web-app/dist/assets/{database-_a22uh7l.js → database-h-N7WkQN.js} +1 -1
  90. package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-B5oYiD27.js} +1 -1
  91. package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-fbTtTy0o.js} +1 -1
  92. package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-PaF6HMo8.js} +1 -1
  93. package/web-app/dist/assets/{globe-2TveFE5l.js → globe-Ct-GlWID.js} +1 -1
  94. package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-C1oZFX51.js} +1 -1
  95. package/web-app/dist/assets/{index-DGnZXUYk.js → index-C2egrRhz.js} +3 -3
  96. package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-Dt5ZK4v2.js} +1 -1
  97. package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DrpMziic.js} +1 -1
  98. package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-DOez8MmK.js} +1 -1
  99. package/web-app/dist/assets/{package-BZtqTIqH.js → package-ClGIkerr.js} +1 -1
  100. package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-C004i9ua.js} +1 -1
  101. package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-Ddfk-7Sy.js} +1 -1
  102. package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-Bu1KgDjS.js} +1 -1
  103. package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-3q1lW5pm.js} +1 -1
  104. package/web-app/dist/assets/{server-DbG8MDv6.js → server-pd6x4HW1.js} +1 -1
  105. package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-B9jGJepT.js} +1 -1
  106. package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-Cdcc9nQf.js} +1 -1
  107. package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-foSojXHK.js} +1 -1
  108. package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-CTTiqlJ1.js} +1 -1
  109. package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-B_MgmRAx.js} +1 -1
  110. package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-g6BfALzG.js} +1 -1
  111. package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-DrHwvzuh.js} +1 -1
  112. package/web-app/dist/assets/{user-CK5-EXIj.js → user-COp75m7l.js} +1 -1
  113. package/web-app/dist/index.html +1 -1
  114. package/autonomy/lib/doctor-fix.py +0 -279
package/autonomy/loki CHANGED
@@ -132,6 +132,34 @@ _loki_caveman_narration_level() {
132
132
  fi
133
133
  }
134
134
 
135
+ # P12-26b: permission flags for the migrate/doc/heal claude launches. Lazily sources
136
+ # claude-flags.sh (like the caveman wrapper above) and fills _LOKI_PERM_ARGV; unset
137
+ # LOKI_PERMISSION_POLICY keeps --dangerously-skip-permissions byte-identically.
138
+ _loki_perm_argv_for() {
139
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
140
+ # Unset policy: return before sourcing anything (claude-flags.sh exports caveman vars).
141
+ [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] || return 0
142
+ _LOKI_PERM_ARGV=(--permission-mode plan)
143
+ local _pa_lib="${_LOKI_SCRIPT_DIR}/lib/claude-flags.sh" _pa_out _pa_a
144
+ if [ ! -f "$_pa_lib" ]; then
145
+ echo "[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: helper missing; using most restrictive mode (plan)" >&2
146
+ return 0
147
+ fi
148
+ # Subshell so the helper's top-level exports never leak into this shell.
149
+ _pa_out="$(
150
+ LOKI_PROJECT_DIR="${1:-${LOKI_PROJECT_DIR:-$PWD}}"
151
+ # shellcheck source=autonomy/lib/claude-flags.sh
152
+ . "$_pa_lib" >/dev/null 2>&1 || exit 1
153
+ declare -F _loki_claude_permission_argv >/dev/null 2>&1 || exit 1
154
+ _loki_claude_permission_argv
155
+ printf '%s\n' "${_LOKI_PERM_ARGV[@]}"
156
+ )" || return 0
157
+ [ -n "$_pa_out" ] || return 0
158
+ _LOKI_PERM_ARGV=()
159
+ while IFS= read -r _pa_a; do _LOKI_PERM_ARGV+=("$_pa_a"); done <<< "$_pa_out"
160
+ return 0
161
+ }
162
+
135
163
  # loki edit: one-pass small change fast path (functions-only, self-guarded).
136
164
  if [ -f "$_LOKI_SCRIPT_DIR/edit.sh" ]; then
137
165
  # shellcheck source=edit.sh
@@ -1682,7 +1710,7 @@ PYEOF
1682
1710
  # `loki help answer`: `loki answer` is a v10 (loki-ts) command with no bash
1683
1711
  # implementation, so its help lives here rather than behind delegation.
1684
1712
  _help_answer() {
1685
- echo -e "${BOLD}loki answer${NC} - Resume a BLOCKED run with an answer"
1713
+ echo -e "${BOLD}loki answer${NC} - Start a follow-up run from a BLOCKED run with your answer"
1686
1714
  echo ""
1687
1715
  echo "Usage: loki answer [<run-id>] [--text \"...\"]"
1688
1716
  echo ""
@@ -1711,6 +1739,8 @@ _help_verify_pr() {
1711
1739
  echo -e "${BOLD}loki verify-pr${NC} - Verify a PR against its linked issue (experimental, LOKI_VERIFY_PR=1)"
1712
1740
  echo ""
1713
1741
  echo "Usage: loki verify-pr <https://github.com/o/r/pull/N | o/r#N> [--out DIR]"
1742
+ echo " loki verify-pr respond <o/r#N> fix open review comments (one commit each, on the PR"
1743
+ echo " branch), push, reply with the sha; never resolves threads"
1714
1744
  echo ""
1715
1745
  echo "Derives a check from the PR's linked issue (Check: \`cmd\` or a loki-check fence)."
1716
1746
  echo "VERIFIED only if the check fails on the PR base, passes on its head, and the"
@@ -1795,11 +1825,12 @@ show_help_all() {
1795
1825
  echo " stop Stop execution immediately"
1796
1826
  echo " pause Pause after current session"
1797
1827
  echo " resume Resume a paused or interrupted run (after Ctrl-C or a crash)"
1798
- echo " steer \"<note>\" Nudge a running build (needs LOKI_PROMPT_INJECTION=1)"
1828
+ echo " steer \"<note>\" Nudge a running build (Loki 10 engine; legacy loop needs LOKI_PROMPT_INJECTION=true)"
1799
1829
  echo ""
1800
1830
  echo "Verify / trust:"
1801
1831
  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"
1832
+ echo " verify-pr <pr> Verify a PR against its linked issue; 'verify-pr respond <pr>' fixes review comments"
1833
+ echo " keys export Print the public receipt-signing key (JWK) for third-party verify --pubkey"
1803
1834
  echo " review [opts] Standalone code review with quality gates"
1804
1835
  echo " ultracode \"task\" Run a task as a native Claude Code Dynamic Workflow (opt-in, Claude-only)"
1805
1836
  echo " trust [cmd] Visible trust trajectory; 'trust detail' for metrics"
@@ -1839,7 +1870,7 @@ show_help_all() {
1839
1870
  echo ""
1840
1871
  echo "Options for 'start':"
1841
1872
  echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
1842
- echo " --model ID Model tier or route (e.g. ollama/qwen3-coder)"
1873
+ echo " (model route: set LOKI_MODEL_ROUTE, e.g. ollama/qwen3-coder; there is no --model flag)"
1843
1874
  echo " --simple Force simple complexity tier (3 phases)"
1844
1875
  echo " --complex Force complex complexity tier (8 phases)"
1845
1876
  echo " --no-dashboard Disable web dashboard"
@@ -2516,6 +2547,37 @@ _loki_start_handoff() {
2516
2547
  }
2517
2548
 
2518
2549
  # Start Loki Mode
2550
+ # Every value-taking cmd_start flag (its value is never brief text or a run id).
2551
+ # The one list: the --workspace brief scan and the --resume scan both use it.
2552
+ # It also names value flags that are not cmd_start's own, so a scan never
2553
+ # mistakes their value for a brief or run id. --model is honored nowhere (model
2554
+ # routing is LOKI_MODEL_ROUTE); --max-cost only via the engine10 rewrite.
2555
+ _loki_start_flag_takes_value() {
2556
+ case "$1" in
2557
+ --provider|--brief|--budget|--prd|--issue|--isolation|--aider-model|--aider-flags|--cline-model|--compliance|--max-duration|--log-level|--bmad-project|--openspec|--mirofish-rounds|--mirofish-timeout|--output|--config|--vars|--env-file|--remote|--attempts|--mirofish-docker|--spec|--model|--max-cost|--max-wait|--base-wait|--max-iterations|--max-retries|--budget-limit|--session-model|--completion-promise) return 0 ;;
2558
+ esac
2559
+ return 1
2560
+ }
2561
+
2562
+ # Optional-value cmd_start flags (--mirofish [URL]). The one decision on whether
2563
+ # the NEXT token is the flag's value: non-empty and not another --flag. The
2564
+ # cmd_start --mirofish arm and both scans use it, so they cannot disagree.
2565
+ _loki_start_flag_optional_value() { [ "$1" = "--mirofish" ]; }
2566
+ _loki_start_optval_consumes() { [[ -n "${1:-}" && "${1:-}" != --* ]]; }
2567
+
2568
+ # One exit-code mapping for an engine10 resume, shared by cmd_resume and
2569
+ # `loki start --resume`. 0 and 4 (phase reported) -> 0, 5 (publish failed) -> 1.
2570
+ # Sets _LOKI_E10_RC and returns 0 when handled; any other code returns 1 with
2571
+ # _LOKI_E10_RC set to the raw code (cmd_resume falls through, start returns it).
2572
+ _loki_e10_resume_map() {
2573
+ case "$1" in
2574
+ 0 | 4) _LOKI_E10_RC=0 ;;
2575
+ 5) _LOKI_E10_RC=1 ;;
2576
+ *) _LOKI_E10_RC="$1"; return 1 ;;
2577
+ esac
2578
+ return 0
2579
+ }
2580
+
2519
2581
  cmd_start() {
2520
2582
  # First-run welcome (once): open the branded welcome page on the very
2521
2583
  # first start. Writes ~/.loki/.welcomed and never repeats; non-blocking,
@@ -2549,7 +2611,7 @@ cmd_start() {
2549
2611
  # [--auto-rollback] chains the gated `loki deploy --execute` after a start
2550
2612
  # that ended 0. cmd_deploy owns every gate: no VERIFIED HEAD-anchored
2551
2613
  # 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
2614
+ local _ps_ws="" _ps_deploy=0 _ps_health="" _ps_hmatch="" _ps_rb=0 _ps_rest=() _ps_brief=() _ps_a
2553
2615
  while [ $# -gt 0 ]; do
2554
2616
  case "$1" in
2555
2617
  --workspace)
@@ -2561,12 +2623,16 @@ cmd_start() {
2561
2623
  [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo -e "${RED}--health-url requires a URL${NC}" >&2; exit 2; }
2562
2624
  _ps_health="$2"; shift 2 ;;
2563
2625
  --health-url=*) _ps_health="${1#*=}"; shift ;;
2626
+ --health-match)
2627
+ [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo -e "${RED}--health-match requires a string${NC}" >&2; exit 2; }
2628
+ _ps_hmatch="$2"; shift 2 ;;
2629
+ --health-match=*) _ps_hmatch="${1#*=}"; shift ;;
2564
2630
  --auto-rollback) _ps_rb=1; shift ;;
2565
2631
  *) _ps_rest+=("$1"); shift ;;
2566
2632
  esac
2567
2633
  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
2634
+ if { [ -n "$_ps_health" ] || [ -n "$_ps_hmatch" ] || [ "$_ps_rb" = 1 ]; } && [ "$_ps_deploy" != 1 ]; then
2635
+ echo -e "${RED}--health-url, --health-match and --auto-rollback apply only with --deploy${NC}" >&2
2570
2636
  exit 2
2571
2637
  fi
2572
2638
  if [ -n "$_ps_ws" ]; then
@@ -2577,13 +2643,13 @@ cmd_start() {
2577
2643
  local _ps_skip=0
2578
2644
  for _ps_a in ${_ps_rest[@]+"${_ps_rest[@]}"}; do
2579
2645
  if [ "$_ps_skip" = 1 ]; then _ps_skip=0; continue; fi
2646
+ if [ "$_ps_skip" = 3 ]; then _ps_skip=0; if _loki_start_optval_consumes "$_ps_a"; then continue; fi; fi
2580
2647
  if [ "$_ps_skip" = 2 ]; then _ps_skip=0; _ps_brief+=("$_ps_a"); continue; fi
2581
2648
  case "$_ps_a" in
2582
2649
  --brief=*) _ps_brief+=("${_ps_a#*=}") ;;
2583
2650
  --brief) _ps_skip=2 ;;
2584
2651
  # Every value-taking cmd_start flag: its value is never brief text.
2585
- --provider|--budget|--prd|--issue|--model|--max-cost|--isolation|--aider-model|--aider-flags|--cline-model|--compliance|--max-duration|--log-level|--bmad-project|--openspec|--mirofish-rounds|--mirofish-timeout|--output|--config|--vars|--env-file|--remote|--attempts|--max-wait) _ps_skip=1 ;;
2586
- -*) ;;
2652
+ -*) if _loki_start_flag_takes_value "$_ps_a"; then _ps_skip=1; elif _loki_start_flag_optional_value "$_ps_a"; then _ps_skip=3; fi ;;
2587
2653
  *) _ps_brief+=("$_ps_a") ;;
2588
2654
  esac
2589
2655
  done
@@ -2603,10 +2669,39 @@ cmd_start() {
2603
2669
  fi
2604
2670
  local _ps_dargs=(--execute)
2605
2671
  [ -n "$_ps_health" ] && _ps_dargs+=(--health-url "$_ps_health")
2672
+ [ -n "$_ps_hmatch" ] && _ps_dargs+=(--health-match "$_ps_hmatch")
2606
2673
  [ "$_ps_rb" = 1 ] && _ps_dargs+=(--auto-rollback)
2607
2674
  cmd_deploy "${_ps_dargs[@]}"
2608
2675
  return $?
2609
2676
  fi
2677
+ # P12-07b: a bare `loki start --resume [run-id]` goes to the same engine10
2678
+ # resume path as `loki resume` (util/run_outputs.ts resumeMain), never into a
2679
+ # new build. Exit codes go through _loki_e10_resume_map (0 and 4 -> 0, 5 -> 1); any other code, such as 3 for nothing to resume, is returned as is.
2680
+ local _ps_resume=0 _ps_resume_args=() _ps_rskip=0
2681
+ for _ps_a in ${_ps_rest[@]+"${_ps_rest[@]}"}; do
2682
+ if [ "$_ps_rskip" = 1 ]; then _ps_rskip=0; continue; fi
2683
+ if [ "$_ps_rskip" = 3 ]; then _ps_rskip=0; if _loki_start_optval_consumes "$_ps_a"; then continue; fi; fi
2684
+ case "$_ps_a" in
2685
+ --resume) _ps_resume=1 ;;
2686
+ --*=*) ;;
2687
+ -*) if _loki_start_flag_takes_value "$_ps_a"; then _ps_rskip=1; elif _loki_start_flag_optional_value "$_ps_a"; then _ps_rskip=3; fi ;;
2688
+ *) _ps_resume_args+=("$_ps_a") ;;
2689
+ esac
2690
+ done
2691
+ if [ "$_ps_resume" = 1 ]; then
2692
+ local _ps_rrc=0
2693
+ for _ps_a in ${_ps_resume_args[@]+"${_ps_resume_args[@]}"}; do
2694
+ case "$_ps_a" in
2695
+ e10-[A-Za-z0-9-]*) ;;
2696
+ *)
2697
+ echo -e "${RED}--resume resumes a sealed run; it takes an optional run id, not a task. Run \`loki start <task>\` for a new run.${NC}" >&2
2698
+ return 2 ;;
2699
+ esac
2700
+ done
2701
+ ( _loki_bun_delegate engine10 resume ${_ps_resume_args[@]+"${_ps_resume_args[@]}"} ) || _ps_rrc=$?
2702
+ _loki_e10_resume_map "$_ps_rrc" || true
2703
+ return "$_LOKI_E10_RC"
2704
+ fi
2610
2705
  set -- ${_ps_rest[@]+"${_ps_rest[@]}"}
2611
2706
 
2612
2707
  local args=()
@@ -2667,7 +2762,7 @@ cmd_start() {
2667
2762
  echo " Token from LOKI_REMOTE_TOKEN / LOKI_REMOTE_TOKEN_FILE"
2668
2763
  echo " (never an argument). Env: LOKI_REMOTE_URL."
2669
2764
  echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
2670
- echo " --model NAME Model or route override (for example ollama/qwen3-coder)"
2765
+ echo " (model route: set LOKI_MODEL_ROUTE, for example ollama/qwen3-coder; no --model flag)"
2671
2766
  echo " --allow-haiku Enable Haiku model for the fast tier (default: disabled)"
2672
2767
  echo " --simple Force simple complexity tier (3 phases)"
2673
2768
  echo " --complex Force complex complexity tier (8 phases)"
@@ -2712,6 +2807,7 @@ cmd_start() {
2712
2807
  echo " --pr Worktree + auto-create PR"
2713
2808
  echo " --ship Worktree + PR + auto-merge"
2714
2809
  echo " --detach, -d Run in background (implies --worktree)"
2810
+ echo " --resume [RUN_ID] Resume an interrupted run at the stage it stopped in"
2715
2811
  echo ""
2716
2812
  echo "Preview without spending (any input mode):"
2717
2813
  echo " loki plan <PRD> Analyze a spec WITHOUT executing: complexity,"
@@ -3016,7 +3112,7 @@ cmd_start() {
3016
3112
  shift
3017
3113
  ;;
3018
3114
  --mirofish)
3019
- if [[ -n "${2:-}" ]] && [[ "${2:-}" != --* ]]; then
3115
+ if _loki_start_optval_consumes "${2:-}"; then
3020
3116
  mirofish_url="$2"
3021
3117
  shift 2
3022
3118
  else
@@ -5061,25 +5157,35 @@ cmd_pause() {
5061
5157
  }
5062
5158
 
5063
5159
  # 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.
5160
+ # to .loki/HUMAN_INPUT.md. engine10 reads it before the next plan or implement
5161
+ # session (default on; only LOKI_PROMPT_INJECTION=0/false opts out, steer.ts).
5162
+ # The legacy loop injects it only when LOKI_PROMPT_INJECTION=true.
5163
+ # Each steer is a numbered revision: `## Operator directive rev N (ts) actor=A source=S`.
5164
+ # engine10 (util/steer.ts) archives the file and appends the revisions to
5165
+ # .loki/intent-revisions.jsonl. Actor and source come from LOKI_STEER_ACTOR
5166
+ # (default: git user.name, then $USER) and LOKI_STEER_SOURCE (cli, chat or an
5167
+ # https comment URL; default cli). Both are reduced to a safe character set.
5069
5168
  cmd_steer() {
5070
- case "${1:-}" in
5071
- --help|-h|help|"")
5169
+ # "--" ends options so guidance beginning with "-" is data, not a flag
5170
+ local _eoo=0
5171
+ if [ "${1:-}" = "--" ]; then _eoo=1; shift; fi
5172
+ case "$_eoo:${1:-}" in
5173
+ 1:?*) ;;
5174
+ *:--help|*:-h|*:help|*:"")
5072
5175
  echo -e "${BOLD}Loki Mode -- steer the running build${NC}"
5073
5176
  echo ""
5074
5177
  echo "Usage: loki steer \"<guidance>\""
5075
5178
  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)."
5179
+ echo "Writes your guidance to .loki/HUMAN_INPUT.md as a numbered revision"
5180
+ echo "(rev N, actor, source). The Loki 10 engine applies it once, before the"
5181
+ echo "next plan or implement session, and records it in .loki/intent-revisions.jsonl."
5182
+ echo "A steer that arrives after the Wall (the frozen acceptance tests) was authored"
5183
+ echo "makes Seal refuse the run; rerun the task with the new intent included."
5078
5184
  echo ""
5079
5185
  echo " loki steer \"focus on the auth flow, skip the docs for now\""
5080
5186
  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)"
5187
+ echo "Opt out with LOKI_PROMPT_INJECTION=0. The legacy loop reads the note"
5188
+ echo "only when LOKI_PROMPT_INJECTION=true."
5083
5189
  [ -z "${1:-}" ] && return 2
5084
5190
  return 0
5085
5191
  ;;
@@ -5089,20 +5195,35 @@ cmd_steer() {
5089
5195
  return 1
5090
5196
  fi
5091
5197
  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.
5198
+ local actor source rev pending archived
5199
+ actor="${LOKI_STEER_ACTOR:-$(git config user.name 2>/dev/null || true)}"
5200
+ actor="${actor:-${USER:-unknown}}"
5201
+ actor="$(printf '%s' "$actor" | tr -c 'A-Za-z0-9._@-' '-' | cut -c1-64)"
5202
+ source="$(printf '%s' "${LOKI_STEER_SOURCE:-cli}" | tr -c 'A-Za-z0-9:/._#?=&%-' '-' | cut -c1-300)"
5203
+ # rev = consumed revisions + steers still pending in the buffer + 1
5204
+ archived=0; pending=0
5205
+ [ -f "$LOKI_DIR/intent-revisions.jsonl" ] && archived="$(wc -l < "$LOKI_DIR/intent-revisions.jsonl" | tr -d ' ')"
5206
+ [ -f "$LOKI_DIR/HUMAN_INPUT.md" ] && pending="$(grep -c '^## Operator directive rev ' "$LOKI_DIR/HUMAN_INPUT.md" 2>/dev/null || true)"
5207
+ rev=$(( ${archived:-0} + ${pending:-0} + 1 ))
5208
+ # Append so multiple steers accumulate; the reader consumes and clears the
5209
+ # file, so this is the pending-directive buffer.
5094
5210
  {
5095
- printf '## Operator directive (%s)\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
5211
+ printf '## Operator directive rev %s (%s) actor=%s source=%s\n' "$rev" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$actor" "$source"
5096
5212
  printf '%s\n\n' "$note"
5097
5213
  } >> "$LOKI_DIR/HUMAN_INPUT.md"
5098
5214
  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
5215
+ echo -e "${GREEN}Steering note (rev $rev) written to .loki/HUMAN_INPUT.md${NC}"
5216
+ case "${LOKI_PROMPT_INJECTION:-}" in
5217
+ 0|false|FALSE|False)
5218
+ echo -e "${YELLOW}Note: LOKI_PROMPT_INJECTION is off, so the run will NOT read this.${NC}"
5219
+ ;;
5220
+ *)
5221
+ echo -e "${DIM}The Loki 10 engine applies it before the next plan or implement session.${NC}"
5222
+ # Same text the issue comment ack posts (trigger-server.py steer_ack_text).
5223
+ echo "Steer rev $rev received: applies at next plan/implement stage."
5224
+ echo "If the Wall is already frozen, Seal will refuse this run: rerun with the new intent."
5225
+ ;;
5226
+ esac
5106
5227
  return 0
5107
5228
  }
5108
5229
 
@@ -5263,10 +5384,7 @@ cmd_resume() {
5263
5384
  if ls "$LOKI_DIR"/runs/e10-*/events.jsonl >/dev/null 2>&1; then
5264
5385
  local _e10_rc=0
5265
5386
  ( _loki_bun_delegate engine10 resume "$@" ) || _e10_rc=$?
5266
- case "$_e10_rc" in
5267
- 0 | 4) return 0 ;;
5268
- 5) return 1 ;;
5269
- esac
5387
+ if _loki_e10_resume_map "$_e10_rc"; then return "$_LOKI_E10_RC"; fi
5270
5388
  fi
5271
5389
 
5272
5390
  # Check if there is anything to resume before touching signal files
@@ -6264,7 +6382,7 @@ if _unc and os.path.exists(_unc):
6264
6382
  except Exception:
6265
6383
  pass
6266
6384
  print(" Unstick it : give the loop a concrete next step:")
6267
- print(" loki steer \"<what to try next>\" (needs LOKI_PROMPT_INJECTION=1)")
6385
+ print(" loki steer \"<what to try next>\" (Loki 10 engine; legacy loop needs LOKI_PROMPT_INJECTION=true)")
6268
6386
  print(" or narrow the spec / fix the blocker, then loki resume.")
6269
6387
 
6270
6388
  # Surface the structured per-iteration error record, if one exists at
@@ -9771,7 +9889,7 @@ _deploy_latest_receipt() {
9771
9889
  return 0
9772
9890
  }
9773
9891
 
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]
9892
+ # _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
9893
  # Writes the executed deploy's own record, then signs it (loki.deploy/1, Ed25519 via the
9776
9894
  # receipt signer in loki-ts/src/util/receipt_signer.ts) so it is bound to the receipt that
9777
9895
  # authorized it; `loki verify <record>` re-checks it offline. An unsigned record is reported
@@ -9794,6 +9912,7 @@ _deploy_record() {
9794
9912
  local health_status="${7:-unchecked}" health_url="${8:-}"
9795
9913
  local rb_attempted="${9:-false}" rb_exit="${10:-}"
9796
9914
  local receipt_sha="${11:-}" started_at="${12:-}" finished_at="${13:-}" target="${14:-}" canary="${15:-false}"
9915
+ local post_rb="${16:-unchecked}" hcode_rec="${17:-}" hmatch="${18:-}" tree_sha="${19:-}"
9797
9916
  local ddir="$dir/.loki/deploys"
9798
9917
  [ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
9799
9918
  mkdir -p "$ddir" 2>/dev/null || return 0
@@ -9801,7 +9920,7 @@ _deploy_record() {
9801
9920
  DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
9802
9921
  DR_STATE="$anchor_state" DR_HS="$health_status" DR_HU="$health_url" \
9803
9922
  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" \
9923
+ 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
9924
  python3 -c '
9806
9925
  import json, os, datetime
9807
9926
  state = os.environ.get("DR_STATE") or "anchored"
@@ -9827,6 +9946,10 @@ json.dump({
9827
9946
  "health_url": os.environ.get("DR_HU") or "",
9828
9947
  "rollback_attempted": os.environ.get("DR_RBA") == "true",
9829
9948
  "rollback_exit": int(os.environ["DR_RBX"]) if os.environ.get("DR_RBX", "") != "" else None,
9949
+ "post_rollback_health": os.environ.get("DR_PRB") or "unchecked",
9950
+ "health_match": os.environ.get("DR_HM", ""),
9951
+ "tree_sha": os.environ.get("DR_TREE", ""),
9952
+ "health_http_code": int(os.environ["DR_HC"]) if os.environ.get("DR_HC", "").isdigit() else None,
9830
9953
  }, open(os.environ["DR_OUT"], "w"), indent=2)
9831
9954
  ' 2>/dev/null || return 0
9832
9955
  # Sign through the one Bun implementation. A subshell, because the delegate execs.
@@ -9895,20 +10018,31 @@ except Exception:
9895
10018
  return 0
9896
10019
  }
9897
10020
 
9898
- # _deploy_probe_health <url>
9899
- # Bounded poll: echoes the last HTTP status, returns 0 on any 2xx. Attempts and
10021
+ # _deploy_probe_health <url> [match]
10022
+ # Bounded poll: echoes the last HTTP status, returns 0 on any 2xx. With a match
10023
+ # string the 2xx body must also contain it (fixed-string), else the attempt fails
10024
+ # and the status is still echoed. Attempts and
9900
10025
  # spacing are env-tunable (LOKI_DEPLOY_HEALTH_ATTEMPTS / _INTERVAL) for tests.
9901
10026
  _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"
10027
+ local url="${1:-}" match="${2:-}" attempts="${LOKI_DEPLOY_HEALTH_ATTEMPTS:-5}" interval="${LOKI_DEPLOY_HEALTH_INTERVAL:-3}"
10028
+ local i=0 code="000" body="/dev/null"
10029
+ if [ -n "$match" ]; then body="$(mktemp 2>/dev/null)" || body="/dev/null"; fi
9904
10030
  case "$attempts" in ''|*[!0-9]*) attempts=5 ;; esac
9905
10031
  case "$interval" in ''|*[!0-9]*) interval=3 ;; esac
9906
10032
  while [ "$i" -lt "$attempts" ]; do
9907
10033
  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
10034
+ code="$(curl -s -o "$body" -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)" || code="000"
10035
+ case "$code" in
10036
+ 2??)
10037
+ if [ -z "$match" ] || grep -qF -- "$match" "$body" 2>/dev/null; then
10038
+ [ "$body" = /dev/null ] || rm -f "$body"
10039
+ printf '%s' "$code"; return 0
10040
+ fi
10041
+ ;;
10042
+ esac
9910
10043
  [ "$i" -lt "$attempts" ] && [ "$interval" -gt 0 ] && sleep "$interval"
9911
10044
  done
10045
+ [ "$body" = /dev/null ] || rm -f "$body"
9912
10046
  printf '%s' "$code"
9913
10047
  return 1
9914
10048
  }
@@ -9974,6 +10108,7 @@ _deploy_try_execute() {
9974
10108
  local dir="${1:-.}" type="${2:-}"
9975
10109
  # Set by cmd_deploy (dynamic scope); empty/false when called any other way.
9976
10110
  local health_url="${_LOKI_DEPLOY_HEALTH_URL:-}" auto_rollback="${_LOKI_DEPLOY_AUTO_ROLLBACK:-false}"
10111
+ local health_match="${_LOKI_DEPLOY_HEALTH_MATCH:-}"
9977
10112
 
9978
10113
  local command
9979
10114
  command="$(_deploy_first_installed_command "$dir" "$type")"
@@ -10086,7 +10221,7 @@ _deploy_try_execute() {
10086
10221
 
10087
10222
  local rc=0 started_at finished_at purl="" canary_out=""
10088
10223
  started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
10089
- local health_status="unchecked" rb_attempted=false rb_exit="" hcode=""
10224
+ local health_status="unchecked" rb_attempted=false rb_exit="" hcode="" rb_hcode="" post_rb="unchecked"
10090
10225
  if [ "$canary_kind" = vercel ]; then
10091
10226
  # Staged: a preview deploy (no --prod), probe the preview URL, promote only if healthy.
10092
10227
  # Nothing reaches production before the probe, so there is nothing to roll back.
@@ -10098,7 +10233,7 @@ _deploy_try_execute() {
10098
10233
  if [ "$rc" -eq 0 ] && [ -n "$purl" ]; then
10099
10234
  health_url="$purl"
10100
10235
  echo "Probing canary health: ${health_url}"
10101
- if hcode="$(_deploy_probe_health "$health_url")"; then
10236
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10102
10237
  health_status="healthy"
10103
10238
  echo "Promoting: vercel promote ${purl}"
10104
10239
  exec_cmd="vercel; vercel promote ${purl}"
@@ -10119,7 +10254,7 @@ _deploy_try_execute() {
10119
10254
  # failed deploy, status is "unchecked": never a claim the service is healthy.
10120
10255
  if [ "$canary_kind" != vercel ] && [ "$rc" -eq 0 ] && [ -n "$health_url" ]; then
10121
10256
  echo "Probing health: ${health_url}"
10122
- if hcode="$(_deploy_probe_health "$health_url")"; then
10257
+ if hcode="$(_deploy_probe_health "$health_url" "$health_match")"; then
10123
10258
  health_status="healthy"
10124
10259
  else
10125
10260
  health_status="FAILED_HEALTH"
@@ -10127,14 +10262,21 @@ _deploy_try_execute() {
10127
10262
  fi
10128
10263
 
10129
10264
  if [ "$health_status" = "FAILED_HEALTH" ]; then
10130
- echo -e "${RED}FAILED_HEALTH: ${health_url} did not return 2xx (last status ${hcode:-000}).${NC}" >&2
10265
+ echo -e "${RED}FAILED_HEALTH: ${health_url} did not return 2xx${health_match:+ containing \"${health_match}\"} (last status ${hcode:-000}).${NC}" >&2
10131
10266
  if [ "$auto_rollback" = true ] && [ -n "$rb_cmd" ] && ! _deploy_is_destructive "$rb_cmd"; then
10132
10267
  echo "Rolling back: ${rb_cmd}" >&2
10133
10268
  rb_attempted=true
10134
10269
  rb_exit=0
10135
10270
  ( cd "$dir" && eval "$rb_cmd" ) || rb_exit=$?
10136
10271
  if [ "$rb_exit" -eq 0 ]; then
10137
- echo "Rollback command exited 0. Health of the rolled-back service was NOT re-checked." >&2
10272
+ echo "Rollback command exited 0. Re-checking health: ${health_url}" >&2
10273
+ if rb_hcode="$(_deploy_probe_health "$health_url")"; then
10274
+ post_rb="healthy"
10275
+ echo -e "${GREEN}Rolled-back service is healthy${NC} (HTTP ${rb_hcode})." >&2
10276
+ else
10277
+ post_rb="FAILED_HEALTH"
10278
+ echo -e "${RED}Rolled-back service is STILL unhealthy (last status ${rb_hcode:-000}).${NC}" >&2
10279
+ fi
10138
10280
  else
10139
10281
  echo -e "${RED}Rollback command exited ${rb_exit}.${NC}" >&2
10140
10282
  fi
@@ -10150,7 +10292,7 @@ _deploy_try_execute() {
10150
10292
  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
10293
  rec="$(_deploy_record "$dir" "$exec_cmd" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
10152
10294
  "$anchor_state" "$health_status" "$health_url" "$rb_attempted" "$rb_exit" \
10153
- "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary")"
10295
+ "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary" "$post_rb" "$hcode" "$health_match" "$(git -C "$dir" rev-parse 'HEAD^{tree}' 2>/dev/null || true)")"
10154
10296
  echo ""
10155
10297
  if [ "$rc" -eq 0 ]; then
10156
10298
  echo -e "${GREEN}Deploy command exited 0.${NC}"
@@ -10177,6 +10319,8 @@ _deploy_try_execute() {
10177
10319
  fi
10178
10320
  # A deploy that exited 0 but failed its health probe is a failed deploy.
10179
10321
  if [ "$rc" -eq 0 ] && [ "$health_status" = "FAILED_HEALTH" ]; then
10322
+ # 5: the rollback ran and the service is still down.
10323
+ [ "$post_rb" = "FAILED_HEALTH" ] && return 5
10180
10324
  return 4
10181
10325
  fi
10182
10326
  return "$rc"
@@ -10290,7 +10434,7 @@ cmd_deploy() {
10290
10434
  # file alone weakens this gate.
10291
10435
  local LOKI_DEPLOY_ALLOW_UNANCHORED=false
10292
10436
  # 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
10437
+ local _LOKI_DEPLOY_HEALTH_URL="" _LOKI_DEPLOY_HEALTH_MATCH="" _LOKI_DEPLOY_AUTO_ROLLBACK=false _LOKI_DEPLOY_CANARY=false
10294
10438
 
10295
10439
  # Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
10296
10440
  while [ $# -gt 0 ]; do
@@ -10298,7 +10442,7 @@ cmd_deploy() {
10298
10442
  --help|-h|help)
10299
10443
  echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
10300
10444
  echo ""
10301
- echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--health-url URL [--auto-rollback]]"
10445
+ echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--health-url URL [--health-match STR] [--auto-rollback]]"
10302
10446
  echo ""
10303
10447
  echo "Detects your project type and your installed cloud CLI (and any"
10304
10448
  echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
@@ -10359,11 +10503,14 @@ cmd_deploy() {
10359
10503
  echo " non-2xx marks the deploy FAILED_HEALTH and exits 4."
10360
10504
  echo " Without a URL, health is recorded as 'unchecked',"
10361
10505
  echo " never as healthy."
10506
+ echo " --health-match S The 2xx body must also contain S (for example the HEAD sha"
10507
+ echo " or version), else FAILED_HEALTH. Needs --health-url."
10362
10508
  echo " --auto-rollback On FAILED_HEALTH run the provider's documented rollback:"
10363
10509
  echo " 'vercel rollback' or 'fly releases rollback <version"
10364
10510
  echo " captured before the deploy>'. Netlify and Cloudflare"
10365
10511
  echo " print a manual note. Needs --health-url. The rolled-back"
10366
- echo " service is not re-probed."
10512
+ echo " service is re-probed and recorded as post_rollback_health;"
10513
+ echo " still unhealthy exits 5."
10367
10514
  echo ""
10368
10515
  echo "Staged rollout (only with --execute):"
10369
10516
  echo " --canary Vercel: deploy without --prod, health-check the preview"
@@ -10402,6 +10549,10 @@ cmd_deploy() {
10402
10549
  *) echo "loki deploy --health-url requires an http(s) URL" >&2; return 1 ;;
10403
10550
  esac
10404
10551
  ;;
10552
+ --health-match)
10553
+ [ -n "${2:-}" ] && [ "${2#-}" = "$2" ] || { echo "loki deploy --health-match requires a string" >&2; return 1; }
10554
+ _LOKI_DEPLOY_HEALTH_MATCH="$2"; shift
10555
+ ;;
10405
10556
  --auto-rollback)
10406
10557
  _LOKI_DEPLOY_AUTO_ROLLBACK=true
10407
10558
  ;;
@@ -10429,6 +10580,10 @@ cmd_deploy() {
10429
10580
  echo "loki deploy --auto-rollback requires --health-url (no health signal, no rollback)" >&2
10430
10581
  return 1
10431
10582
  fi
10583
+ if [ -n "$_LOKI_DEPLOY_HEALTH_MATCH" ] && [ -z "$_LOKI_DEPLOY_HEALTH_URL" ]; then
10584
+ echo "loki deploy --health-match requires --health-url" >&2
10585
+ return 1
10586
+ fi
10432
10587
  if [ "$_LOKI_DEPLOY_CANARY" = true ] && [ "$do_execute" != true ]; then
10433
10588
  echo "loki deploy --canary requires --execute" >&2
10434
10589
  return 1
@@ -11492,7 +11647,7 @@ fi
11492
11647
  # Post-completion: auto-merge if requested
11493
11648
  if [[ "$LOKI_AUTO_MERGE" == "true" ]]; then
11494
11649
  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
11650
+ if "$LOKI_CMD" merge watch "$branch_current" --require-verified --delete-branch; then
11496
11651
  # A brief run carries a synthetic ref (b<epoch>), not a real issue: close only numeric refs.
11497
11652
  if [[ "${LOKI_ISSUE_NUMBER:-}" =~ ^[0-9]+$ ]]; then
11498
11653
  gh issue close "$LOKI_ISSUE_NUMBER" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -11710,7 +11865,8 @@ ${_gh_receipt}"
11710
11865
  if command -v jq >/dev/null 2>&1; then
11711
11866
  jq -n --arg url "$(printf '%s' "$_pr_out" | grep -Eo 'https://[^[:space:]]+' | head -1)" \
11712
11867
  --arg branch "$branch_current" \
11713
- '{state:"created", url:$url, branch:$branch}' \
11868
+ '{state:"created", url:$url, branch:$branch,
11869
+ merge:{outcome:"waiting", gate:"human_review", checks:"not recorded"}}' \
11714
11870
  > .loki/state/pr.json 2>/dev/null || true
11715
11871
  fi
11716
11872
  else
@@ -11744,7 +11900,7 @@ ${_gh_receipt}"
11744
11900
  github|brief)
11745
11901
  local branch_current
11746
11902
  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
11903
+ if _merge_pr_watch "$branch_current" --require-verified --delete-branch; then
11748
11904
  echo -e "${GREEN}PR merged and branch deleted${NC}"
11749
11905
  if [[ "${number:-}" =~ ^[0-9]+$ ]]; then
11750
11906
  gh issue close "$number" --comment "Resolved by Loki Mode" 2>/dev/null || true
@@ -17950,9 +18106,10 @@ Tasks:
17950
18106
  # pre-caveman. No-op when caveman is absent.
17951
18107
  local _loki_mig_cm=""
17952
18108
  _loki_mig_cm="$(_loki_caveman_narration_level)"
18109
+ _loki_perm_argv_for "$codebase_path"
17953
18110
  case "$provider_name" in
17954
18111
  claude)
17955
- { (cd "$codebase_path" && if [ -n "$_loki_mig_cm" ]; then CAVEMAN_DEFAULT_MODE="$_loki_mig_cm" claude --dangerously-skip-permissions -p "$phase_prompt" --output-format stream-json --verbose 2>&1; else claude --dangerously-skip-permissions -p "$phase_prompt" --output-format stream-json --verbose 2>&1; fi) | \
18112
+ { (cd "$codebase_path" && if [ -n "$_loki_mig_cm" ]; then CAVEMAN_DEFAULT_MODE="$_loki_mig_cm" claude "${_LOKI_PERM_ARGV[@]}" -p "$phase_prompt" --output-format stream-json --verbose 2>&1; else claude "${_LOKI_PERM_ARGV[@]}" -p "$phase_prompt" --output-format stream-json --verbose 2>&1; fi) | \
17956
18113
  while IFS= read -r line; do
17957
18114
  # Extract text from stream-json
17958
18115
  if echo "$line" | python3 -c "
@@ -18127,9 +18284,10 @@ IMPORTANT RULES:
18127
18284
  # branch, byte-identical to pre-caveman (empty mode is NOT inert). No-op absent.
18128
18285
  local _loki_doc_cm=""
18129
18286
  _loki_doc_cm="$(_loki_caveman_narration_level)"
18287
+ _loki_perm_argv_for "$codebase_path"
18130
18288
  case "$provider_name" in
18131
18289
  claude)
18132
- { (cd "$codebase_path" && if [ -n "$_loki_doc_cm" ]; then CAVEMAN_DEFAULT_MODE="$_loki_doc_cm" claude --dangerously-skip-permissions -p "$doc_prompt" --output-format stream-json --verbose 2>&1; else claude --dangerously-skip-permissions -p "$doc_prompt" --output-format stream-json --verbose 2>&1; fi) | \
18290
+ { (cd "$codebase_path" && if [ -n "$_loki_doc_cm" ]; then CAVEMAN_DEFAULT_MODE="$_loki_doc_cm" claude "${_LOKI_PERM_ARGV[@]}" -p "$doc_prompt" --output-format stream-json --verbose 2>&1; else claude "${_LOKI_PERM_ARGV[@]}" -p "$doc_prompt" --output-format stream-json --verbose 2>&1; fi) | \
18133
18291
  while IFS= read -r line; do
18134
18292
  if echo "$line" | python3 -c "
18135
18293
  import sys, json
@@ -18473,6 +18631,7 @@ cmd_modernize() {
18473
18631
  echo "Subcommands (grouped modernization surface):"
18474
18632
  echo " heal <path> Legacy system healing in phases (was: loki heal)"
18475
18633
  echo " migrate <path> Codebase migration in phases (was: loki migrate)"
18634
+ echo " unit --repo <owner/name> --ref <ref> --unit <id> Run one unit (remote unit job worker)"
18476
18635
  echo ""
18477
18636
  echo "Run 'loki modernize <subcommand> --help' for each. The old"
18478
18637
  echo "top-level names still work; see 'loki help aliases'."
@@ -19191,7 +19350,8 @@ Begin healing now. Follow the RARV cycle for each action."
19191
19350
  _loki_heal_cm="$(_loki_caveman_narration_level)"
19192
19351
  case "$provider" in
19193
19352
  claude)
19194
- local run_args=(--dangerously-skip-permissions -p "$heal_prompt" --output-format stream-json --verbose)
19353
+ _loki_perm_argv_for "$codebase_path"
19354
+ local run_args=("${_LOKI_PERM_ARGV[@]}" -p "$heal_prompt" --output-format stream-json --verbose)
19195
19355
  (cd "$codebase_path" && if [ -n "$_loki_heal_cm" ]; then CAVEMAN_DEFAULT_MODE="$_loki_heal_cm" claude "${run_args[@]}" 2>&1; else claude "${run_args[@]}" 2>&1; fi) | \
19196
19356
  while IFS= read -r line; do
19197
19357
  if echo "$line" | python3 -c "
@@ -25882,6 +26042,196 @@ SHOW_EOF
25882
26042
  esac
25883
26043
  }
25884
26044
 
26045
+ # Poll `gh pr checks` while pending (rc=8); returns the final rc (0 = green).
26046
+ _pr_checks_wait() {
26047
+ local pr="$1" poll="${LOKI_MERGE_POLL_S:-20}" tries="${LOKI_MERGE_MAX_POLLS:-90}" rc=8 i=0
26048
+ while [ "$rc" = "8" ] && [ "$i" -lt "$tries" ]; do
26049
+ rc=0; gh pr checks "$pr" >/dev/null 2>&1 || rc=$?
26050
+ [ "$rc" = "8" ] && { sleep "$poll"; i=$((i+1)); }
26051
+ done
26052
+ return "$rc"
26053
+ }
26054
+
26055
+ # CI repair loop. Waits for checks; on red, hands the failed-run log tail to one
26056
+ # bounded repair session on the PR branch (LOKI_PR_REPAIR_CMD, default: a
26057
+ # `loki start` of a generated brief; it receives the evidence file path as its last argument), pushes,
26058
+ # and re-checks, up to LOKI_PR_REPAIR_MAX attempts (default 2). Green goes through
26059
+ # _merge_pr_gated (extra args pass through). Exhausted: the PR is returned to
26060
+ # draft, nothing merges, and the last failure evidence is printed.
26061
+ # Usage: _merge_pr_watch <pr> [--require-verified] [--delete-branch]
26062
+ _merge_pr_watch() {
26063
+ local pr="$1" max="${LOKI_PR_REPAIR_MAX:-2}" attempt=0 rc branch cur rid evid tmp
26064
+ shift
26065
+ case "$max" in ''|*[!0-9]*) max=2 ;; esac
26066
+ command -v gh >/dev/null 2>&1 || { echo "$pr: not merged, gate: gh (gh CLI is required)" >&2; return 1; }
26067
+ branch="$(gh pr view "$pr" --json headRefName -q .headRefName 2>/dev/null)" || branch=""
26068
+ tmp="$(mktemp -d)" || return 1
26069
+ evid="$tmp/ci-failure.md"
26070
+ while true; do
26071
+ rc=0; _pr_checks_wait "$pr" || rc=$?
26072
+ if [ "$rc" = "0" ]; then
26073
+ rm -rf "$tmp"
26074
+ _merge_pr_gated "$pr" "$@"
26075
+ return $?
26076
+ fi
26077
+ rid="$(gh run list --branch "$branch" --status failure --limit 1 --json databaseId -q '.[0].databaseId' 2>/dev/null)" || rid=""
26078
+ {
26079
+ echo "# CI failed for PR $pr (branch ${branch:-unknown}); fix the failing checks, keep the change minimal"
26080
+ [ -n "$rid" ] && gh run view "$rid" --log-failed 2>/dev/null | tail -n 120
26081
+ } > "$evid"
26082
+ if [ "$attempt" -ge "$max" ]; then
26083
+ gh pr ready "$pr" --undo >/dev/null 2>&1 || true
26084
+ echo "$pr: not merged, gate: checks (gh pr checks rc=$rc after $attempt repair attempts); PR left as draft" >&2
26085
+ _pr_record_merge "$pr" "blocked:checks" "checks" "failing after $attempt repair attempts"
26086
+ echo "Last failure evidence:" >&2; sed 's/^/ /' "$evid" >&2
26087
+ rm -rf "$tmp"
26088
+ return 1
26089
+ fi
26090
+ attempt=$((attempt+1))
26091
+ cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
26092
+ if [ -z "$branch" ] || [ "$cur" != "$branch" ]; then
26093
+ echo "$pr: not merged, gate: repair (checkout is '${cur:-none}', repair must run on PR branch '${branch:-unknown}'); PR left open" >&2
26094
+ rm -rf "$tmp"; return 1
26095
+ fi
26096
+ echo "$pr: checks red, repair attempt $attempt of $max"
26097
+ if [ -n "${LOKI_PR_REPAIR_CMD:-}" ]; then
26098
+ bash -c "$LOKI_PR_REPAIR_CMD \"\$1\"" _ "$evid" || { echo "$pr: repair attempt $attempt failed, not pushing"; continue; }
26099
+ else
26100
+ "$(command -v loki || echo "$0")" start "$evid" || { echo "$pr: repair attempt $attempt failed, not pushing"; continue; }
26101
+ fi
26102
+ git push origin "HEAD:refs/heads/$branch" >/dev/null 2>&1 || echo "$pr: push of repair failed" >&2
26103
+ done
26104
+ }
26105
+
26106
+ # Record the merge gate's own result in .loki/state/pr.json (merged,
26107
+ # blocked:<gate> or waiting) so the receipt and handoff read the same outcome
26108
+ # the gate produced. Only updates an existing "created" record that describes
26109
+ # the same PR (pr arg matches .branch or the number at the end of .url); any
26110
+ # other record is left untouched. Never invents one. File mode is preserved.
26111
+ _pr_record_merge() {
26112
+ local pr="$1" outcome="$2" gate="${3:-}" checks="${4:-not recorded}" f=".loki/state/pr.json" t n
26113
+ n="${pr#\#}"
26114
+ case "$n" in */pull/*) n="${n%/}"; n="${n##*/}" ;; esac
26115
+ [ -f "$f" ] && command -v jq >/dev/null 2>&1 || return 0
26116
+ t="$(mktemp "$f.XXXXXX" 2>/dev/null)" || return 0
26117
+ cp -p "$f" "$t" 2>/dev/null || { rm -f "$t"; return 0; }
26118
+ if jq --arg p "$pr" --arg n "$n" --arg o "$outcome" --arg g "$gate" --arg c "$checks" \
26119
+ '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
26120
+ mv "$t" "$f"
26121
+ else
26122
+ rm -f "$t"
26123
+ fi
26124
+ return 0
26125
+ }
26126
+
26127
+ # Shared merge gate. One PR (number or branch) merges only when it is not a
26128
+ # draft, `gh pr checks` exits 0 after polling pending (rc=8), and, with
26129
+ # --require-verified, every clean proof recorded for the PR's head commit
26130
+ # (facts.git.head_sha == headRefOid, dirty-tree proofs ignored) says VERIFIED; a stale or missing proof
26131
+ # refuses ("gate: head"). The merge passes --match-head-commit so a push after
26132
+ # the gate cannot land unjudged. On refusal it prints the exact blocking gate
26133
+ # ("gate: draft|head|verdict|checks") and leaves the PR open.
26134
+ # Usage: _merge_pr_gated <pr> [--require-verified] [--delete-branch]
26135
+ _merge_pr_gated() {
26136
+ local pr="$1" need_verified=false margs=(--squash) rc d
26137
+ shift
26138
+ while [ $# -gt 0 ]; do
26139
+ case "$1" in
26140
+ --require-verified) need_verified=true ;;
26141
+ --delete-branch) margs+=(--delete-branch) ;;
26142
+ esac
26143
+ shift
26144
+ done
26145
+ 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; }
26146
+ d="$(gh pr view "$pr" --json isDraft -q .isDraft 2>/dev/null)" || d=""
26147
+ if [ "$d" != "false" ]; then
26148
+ echo "$pr: not merged, gate: draft (PR is a draft or its state is unreadable); PR left open" >&2
26149
+ _pr_record_merge "$pr" "blocked:draft" "draft"
26150
+ return 1
26151
+ fi
26152
+ # Pin the merge to the exact head the checks and proof are about to judge.
26153
+ local head=""
26154
+ head="$(gh pr view "$pr" --json headRefOid -q .headRefOid 2>/dev/null)" || head=""
26155
+ case "$head" in
26156
+ *[!0-9a-f]*) head="" ;;
26157
+ esac
26158
+ if [ "${#head}" != "40" ] && [ "${#head}" != "64" ]; then
26159
+ echo "$pr: not merged, gate: head (PR head commit is unreadable); PR left open" >&2
26160
+ _pr_record_merge "$pr" "blocked:head" "head"
26161
+ return 1
26162
+ fi
26163
+ if $need_verified; then
26164
+ local pid="" headline="" pj="" ph="" cands=() dirty_n=0 unknown_n=0
26165
+ # Resolve the proof recorded for THIS head: the run pointer first, then
26166
+ # any proof in .loki/proofs whose facts.git.head_sha equals the PR head.
26167
+ [ -f .loki/state/last-proof-id.txt ] && pid="$(cat .loki/state/last-proof-id.txt 2>/dev/null)"
26168
+ [ -z "$pid" ] || cands+=(".loki/proofs/${pid}/proof.json")
26169
+ cands+=(.loki/proofs/*/proof.json)
26170
+ if command -v jq >/dev/null 2>&1; then
26171
+ # Every clean proof for this head must agree: an older VERIFIED
26172
+ # never overrides a newer NOT_PROVEN. A proof taken on a dirty tree
26173
+ # (facts.git.dirty anything but literal false, incl. missing/null; .loki/ is excluded from that flag) judged
26174
+ # bytes other than the commit and is ignored.
26175
+ local hl="" disagree=false
26176
+ for pj in "${cands[@]}"; do
26177
+ [ -f "$pj" ] || continue
26178
+ ph="$(jq -r '.facts.git.head_sha // empty' "$pj" 2>/dev/null)"
26179
+ [ "$ph" = "$head" ] || continue
26180
+ case "$(jq -r 'if .facts.git.dirty == false then "clean" elif .facts.git.dirty == true then "dirty" else "unknown" end' "$pj" 2>/dev/null)" in
26181
+ clean) ;;
26182
+ dirty) dirty_n=$((dirty_n + 1)); continue ;;
26183
+ *) unknown_n=$((unknown_n + 1)); continue ;;
26184
+ esac
26185
+ hl="$(jq -r '.honesty.headline // empty' "$pj" 2>/dev/null)"
26186
+ [ -n "$hl" ] || hl="UNREADABLE"
26187
+ if [ -z "$headline" ]; then headline="$hl"; elif [ "$headline" != "$hl" ]; then disagree=true; fi
26188
+ done
26189
+ if $disagree; then
26190
+ echo "$pr: not merged, gate: verdict (proofs for PR head ${head:0:12} disagree); PR left open" >&2
26191
+ _pr_record_merge "$pr" "blocked:verdict" "verdict"
26192
+ return 1
26193
+ fi
26194
+ fi
26195
+ if [ -z "$headline" ]; then
26196
+ echo "$pr: not merged, gate: head (no clean proof recorded for PR head ${head:0:12}; proof is stale, dirty ($dirty_n), dirty-unknown ($unknown_n) or missing); PR left open" >&2
26197
+ if [ "$unknown_n" -gt 0 ]; then
26198
+ echo "$pr: $unknown_n proof(s) for this head have an unknown dirty state (the proof predates the dirty flag, or git could not read the tree). Commit or stash local changes, then re-run 'loki start ...' on a clean tree for this head to record a clean proof." >&2
26199
+ fi
26200
+ _pr_record_merge "$pr" "blocked:head" "head"
26201
+ return 1
26202
+ fi
26203
+ if [ "$headline" != "VERIFIED" ]; then
26204
+ echo "$pr: not merged, gate: verdict (run verdict is '${headline}', need VERIFIED); PR left open" >&2
26205
+ _pr_record_merge "$pr" "blocked:verdict" "verdict"
26206
+ return 1
26207
+ fi
26208
+ fi
26209
+ rc=0; _pr_checks_wait "$pr" || rc=$?
26210
+ if [ "$rc" != "0" ]; then
26211
+ echo "$pr: not merged, gate: checks (gh pr checks rc=$rc); PR left open" >&2
26212
+ case "$rc" in
26213
+ 8) _pr_record_merge "$pr" "waiting" "checks" "pending" ;;
26214
+ 1) _pr_record_merge "$pr" "blocked:checks" "checks" "failing" ;;
26215
+ *) _pr_record_merge "$pr" "blocked:checks" "checks" "unknown" ;;
26216
+ esac
26217
+ return 1
26218
+ fi
26219
+ if ! gh pr merge "$pr" "${margs[@]}" --match-head-commit "$head" >/dev/null 2>&1; then
26220
+ local head_now=""
26221
+ head_now="$(gh pr view "$pr" --json headRefOid -q .headRefOid 2>/dev/null)" || head_now=""
26222
+ if [ -n "$head_now" ] && [ "$head_now" != "$head" ]; then
26223
+ echo "$pr: not merged, gate: head (PR head moved from ${head:0:12} to ${head_now:0:12} during checks); PR left open" >&2
26224
+ _pr_record_merge "$pr" "blocked:head" "head" "passing"
26225
+ return 1
26226
+ fi
26227
+ echo "$pr: not merged, gate: merge (gh pr merge failed); PR left open" >&2
26228
+ _pr_record_merge "$pr" "blocked:merge" "merge" "passing"
26229
+ return 1
26230
+ fi
26231
+ _pr_record_merge "$pr" "merged" "" "all passing"
26232
+ return 0
26233
+ }
26234
+
25885
26235
  # loki merge: serial PR merge queue. Queue lives in .loki/merge-queue (one PR
25886
26236
  # number per line). A PR merges only when `gh pr checks` exits 0 (all green);
25887
26237
  # after each merge the next PR is rebased onto the new base (gh pr update-branch
@@ -25916,8 +26266,7 @@ cmd_merge() {
25916
26266
  [ "${1:-}" = "--dry-run" ] && dry=true
25917
26267
  if [ ! -s "$qfile" ]; then echo "Merge queue is empty"; return 0; fi
25918
26268
  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
26269
+ local first=true n rc remaining="" merged=0 blocked=0
25921
26270
  while IFS= read -r n; do
25922
26271
  [ -n "$n" ] || continue
25923
26272
  if $dry; then
@@ -25936,29 +26285,33 @@ cmd_merge() {
25936
26285
  || { echo "#$n: rebase onto new base failed, left in queue"; remaining="$remaining$n"$'\n'; blocked=$((blocked+1)); continue; }
25937
26286
  echo "#$n: rebased onto new base"
25938
26287
  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
26288
+ if _merge_pr_gated "$n" 2>"$qfile.err"; then
25949
26289
  echo "#$n: merged"; merged=$((merged+1)); first=false
25950
26290
  else
25951
- echo "#$n: merge failed, left in queue"
26291
+ sed 's/^/#/' "$qfile.err"; echo "#$n: left in queue"
25952
26292
  remaining="$remaining$n"$'\n'; blocked=$((blocked+1))
25953
26293
  fi
25954
26294
  done < "$qfile"
25955
26295
  if $dry; then echo "Dry run: nothing changed"; return 0; fi
26296
+ rm -f "$qfile.err"
25956
26297
  printf '%s' "$remaining" > "$qfile"
25957
26298
  echo "Merged: $merged, left in queue: $blocked"
25958
26299
  [ "$blocked" -eq 0 ]
25959
26300
  ;;
26301
+ watch)
26302
+ [ $# -gt 0 ] || { echo "Usage: loki merge watch <pr> [--require-verified] [--delete-branch]" >&2; return 2; }
26303
+ _merge_pr_watch "$@" && echo "$1: merged"
26304
+ ;;
26305
+ pr)
26306
+ [ $# -gt 0 ] || { echo "Usage: loki merge pr <pr-or-branch> [--require-verified] [--delete-branch]" >&2; return 2; }
26307
+ _merge_pr_gated "$@" && echo "$1: merged"
26308
+ ;;
25960
26309
  help|--help|-h|*)
25961
- echo "Usage: loki merge <add|list|run|clear>"
26310
+ echo "Usage: loki merge <add|list|run|pr|watch|clear>"
26311
+ echo " watch <pr> Wait for checks; on red run up to LOKI_PR_REPAIR_MAX (2) bounded"
26312
+ echo " repair sessions on the PR branch, push, re-check, merge when green."
26313
+ echo " pr <pr|branch> Merge one PR through the same gate (not draft, checks green;"
26314
+ echo " --require-verified also needs a VERIFIED run verdict)."
25962
26315
  echo " add <pr>... Queue pull requests"
25963
26316
  echo " list Show the queue"
25964
26317
  echo " run [--dry-run] Merge one at a time, only when checks are green;"