tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -8,7 +8,7 @@
8
8
  * not run any of them. */
9
9
  import path from "node:path";
10
10
  import { gitTry } from "../git/git-run.js";
11
- import { JS_FILE, MAX_STALE_HITS, MAX_STALE_PATHS, cappedList, gitGrepHits, isRecordProse, standalonePathIndexes, } from "./stage-checks-shared.js";
11
+ import { JS_FILE, MAX_STALE_HITS, MAX_STALE_PATHS, cappedList, diffRemovedAdded, gitGrepHits, isRecordProse, standalonePathIndexes, } from "./stage-checks-shared.js";
12
12
  /** How many removed identifiers one stale-name check looks up. */
13
13
  const MAX_STALE_NAMES = 40;
14
14
  /** The most characters between a moved name and its old file's name for a line to count as
@@ -59,86 +59,114 @@ function insideStringLiteral(line, index) {
59
59
  /** (d) Compound identifiers the change removed from JS/TS source that a comment or doc still
60
60
  * names. Candidates come from removed `-U0` lines; one `git grep -w --cached` covers them all. */
61
61
  export async function staleNameFindings(wt, diff) {
62
- const removed = [];
63
- const added = new Set();
64
- let jsFile = false;
65
- for (const line of diff.split("\n")) {
66
- const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
67
- if (header) {
68
- jsFile = JS_FILE.test(header[1]);
69
- continue;
70
- }
71
- if (!jsFile)
72
- continue;
73
- if (line.startsWith("-") && !line.startsWith("---")) {
74
- removed.push(...declaredNames(line.slice(1)));
75
- }
76
- else if (line.startsWith("+") && !line.startsWith("+++")) {
77
- for (const n of declaredNames(line.slice(1)))
78
- added.add(n);
79
- }
80
- }
81
- const candidates = [...new Set(removed)]
82
- .filter((n) => isCompoundName(n) && !added.has(n))
83
- .slice(0, MAX_STALE_NAMES);
62
+ const { removedIn, addedIn } = declaredNameDeltas(diff);
63
+ const candidates = staleNameCandidates(removedIn, addedIn);
84
64
  if (candidates.length === 0)
85
65
  return [];
86
- const out = await gitTry(wt, "grep", "-n", "-w", "-I", "--cached", ...candidates.flatMap((n) => ["-e", n]));
87
- if (!out)
88
- return [];
89
- const lines = gitGrepHits(out);
66
+ const lines = await wholeWordGrep(wt, candidates);
90
67
  const findings = [];
91
68
  for (const name of candidates) {
92
69
  if (findings.length >= MAX_STALE_PATHS)
93
70
  break;
94
- const word = new RegExp(`(?<![\\w$])${name.replace(/\$/g, "\\$")}(?![\\w$])`, "g");
95
- const mine = lines.filter((l) => [...l.text.matchAll(word)].length > 0);
71
+ const word = staleNameWord(name);
72
+ const mine = staleNameMatches(word, lines);
96
73
  // Another module may still own the name.
97
- if (mine.some((l) => JS_FILE.test(l.file) && declaredNames(l.text).includes(name)))
74
+ if (ownedElsewhere(name, mine))
98
75
  continue;
99
- const hits = [];
100
- for (const l of mine) {
101
- if (isRecordProse(l.file) || l.text.includes("retired-name-ok"))
102
- continue;
103
- if ([...l.text.matchAll(word)].some((m) => !insideStringLiteral(l.text, m.index))) {
104
- hits.push(`${l.file}:${l.no}`);
105
- }
106
- }
76
+ const hits = staleNameHits(word, mine);
107
77
  if (hits.length === 0)
108
78
  continue;
109
79
  findings.push(`\`${name}\` was removed by this change but is still named at: ${cappedList(hits, MAX_STALE_HITS)}`);
110
80
  }
111
81
  return findings;
112
82
  }
83
+ /** The candidates this check looks up: compound names the change removed from JS/TS source that
84
+ * were not also added, capped. (`declaredNameDeltas` keeps only compound names, so every key is
85
+ * one.) */
86
+ function staleNameCandidates(removedIn, addedIn) {
87
+ return [...removedIn.keys()].filter((n) => !addedIn.has(n)).slice(0, MAX_STALE_NAMES);
88
+ }
89
+ /** A whole-word, case-sensitive matcher for `name`. */
90
+ function staleNameWord(name) {
91
+ return new RegExp(`(?<![\\w$])${name.replace(/\$/g, "\\$")}(?![\\w$])`, "g");
92
+ }
93
+ /** `git grep -w --cached` for every name in `names`, returning its `file:line` hit records;
94
+ * `null` (gitTry maps a clean no-match and a real failure the same way) becomes `[]`. Shared by
95
+ * the stale-name and moved-name sweeps, which both grep the staged tree for a batch of compound
96
+ * names exactly the same way before each filters its own subset of the hits. */
97
+ async function wholeWordGrep(wt, names) {
98
+ const out = await gitTry(wt, "grep", "-n", "-w", "-I", "--cached", ...names.flatMap((n) => ["-e", n]));
99
+ if (!out)
100
+ return [];
101
+ return gitGrepHits(out);
102
+ }
103
+ /** The hits whose text has at least one whole-word occurrence of `word`. */
104
+ function staleNameMatches(word, lines) {
105
+ return lines.filter((l) => [...l.text.matchAll(word)].length > 0);
106
+ }
107
+ /** True when a JS/TS hit still declares `name` — another module may still own it. */
108
+ function ownedElsewhere(name, mine) {
109
+ return mine.some((l) => JS_FILE.test(l.file) && declaredNames(l.text).includes(name));
110
+ }
111
+ /** The `file:line` hits that still name `word` outside a string literal, skipping record prose
112
+ * and retired-name-ok lines. */
113
+ function staleNameHits(word, mine) {
114
+ const hits = [];
115
+ for (const l of mine) {
116
+ if (isRecordProse(l.file) || l.text.includes("retired-name-ok"))
117
+ continue;
118
+ if ([...l.text.matchAll(word)].some((m) => !insideStringLiteral(l.text, m.index))) {
119
+ hits.push(`${l.file}:${l.no}`);
120
+ }
121
+ }
122
+ return hits;
123
+ }
113
124
  /** (g) A compound name the change moved out of one JS/TS file into another that a comment or doc
114
125
  * still places in the old file: the line has the name within MOVED_NAME_GAP_MAX characters of the
115
126
  * old file's name (full path or basename), with no `,` or `;` between them. An old file that
116
127
  * still names the moved name (a re-export or import) and record prose are skipped. */
117
128
  export async function movedNameFindings(wt, diff) {
129
+ const { removedIn, addedIn } = declaredNameDeltas(diff);
130
+ const picked = movedNames(removedIn, addedIn);
131
+ if (picked.length === 0)
132
+ return [];
133
+ const lines = await wholeWordGrep(wt, picked.map((m) => m.name));
134
+ const findings = [];
135
+ for (const move of picked) {
136
+ if (findings.length >= MAX_STALE_PATHS)
137
+ break;
138
+ const hits = movedNameHits(move, lines);
139
+ if (hits.length === 0)
140
+ continue;
141
+ findings.push(`\`${move.name}\` moved from ${move.from} to ${cappedList(move.to, MAX_STALE_PATHS)}, but ${hits.join(", ")} ` +
142
+ `still place it in ${path.basename(move.from)} — point each at its new file`);
143
+ }
144
+ return findings;
145
+ }
146
+ /** Record that `file` declares `name` into the per-name file set of `map`. */
147
+ function noteDeclaredName(map, name, f) {
148
+ const set = map.get(name) ?? new Set();
149
+ set.add(f);
150
+ map.set(name, set);
151
+ }
152
+ /** Split a `-U0` diff into the compound names each JS/TS file removed and added. */
153
+ function declaredNameDeltas(diff) {
118
154
  const removedIn = new Map();
119
155
  const addedIn = new Map();
120
- let file = null;
121
- const note = (map, name, f) => {
122
- const set = map.get(name) ?? new Set();
123
- set.add(f);
124
- map.set(name, set);
125
- };
126
- for (const line of diff.split("\n")) {
127
- const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
128
- if (header) {
129
- file = JS_FILE.test(header[1]) ? header[1] : null;
156
+ for (const f of diffRemovedAdded(diff)) {
157
+ if (!JS_FILE.test(f.path))
130
158
  continue;
159
+ for (const n of f.removed.flatMap((l) => declaredNames(l)).filter(isCompoundName)) {
160
+ noteDeclaredName(removedIn, n, f.path);
131
161
  }
132
- if (file === null)
133
- continue;
134
- const isRemoved = line.startsWith("-") && !line.startsWith("---");
135
- const isAdded = line.startsWith("+") && !line.startsWith("+++");
136
- if (!isRemoved && !isAdded)
137
- continue;
138
- for (const n of declaredNames(line.slice(1)).filter(isCompoundName)) {
139
- note(isRemoved ? removedIn : addedIn, n, file);
162
+ for (const n of f.added.flatMap((l) => declaredNames(l)).filter(isCompoundName)) {
163
+ noteDeclaredName(addedIn, n, f.path);
140
164
  }
141
165
  }
166
+ return { removedIn, addedIn };
167
+ }
168
+ /** The names removed from one JS/TS file that the change moved into another, capped. */
169
+ function movedNames(removedIn, addedIn) {
142
170
  const moves = [];
143
171
  for (const [name, froms] of removedIn) {
144
172
  const added = addedIn.get(name);
@@ -151,44 +179,35 @@ export async function movedNameFindings(wt, diff) {
151
179
  break;
152
180
  }
153
181
  }
154
- const picked = moves.slice(0, MAX_STALE_NAMES);
155
- if (picked.length === 0)
156
- return [];
157
- const out = await gitTry(wt, "grep", "-n", "-w", "-I", "--cached", ...picked.flatMap((m) => ["-e", m.name]));
158
- if (!out)
182
+ return moves.slice(0, MAX_STALE_NAMES);
183
+ }
184
+ /** True when `text` sits near a standalone occurrence of the old file's basename `base`. */
185
+ function placesMovedNameInOldFile(text, word, name, base) {
186
+ const names = [...text.matchAll(word)].map((m) => m.index);
187
+ const bases = standalonePathIndexes(text, base);
188
+ return names.some((i) => bases.some((j) => {
189
+ const gap = j >= i + name.length ? text.slice(i + name.length, j) : text.slice(j + base.length, i);
190
+ const overlaps = j < i + name.length && i < j + base.length;
191
+ return !overlaps && gap.length <= MOVED_NAME_GAP_MAX && !/[,;]/.test(gap);
192
+ }));
193
+ }
194
+ /** The staged-index hits that still place `move.name` in its old file, capped. */
195
+ function movedNameHits(move, lines) {
196
+ const word = staleNameWord(move.name);
197
+ const mine = lines.filter((l) => l.text.search(word) !== -1);
198
+ if (mine.some((l) => l.file === move.from))
159
199
  return [];
160
- const lines = gitGrepHits(out);
161
- const findings = [];
162
- for (const { name, from, to } of picked) {
163
- if (findings.length >= MAX_STALE_PATHS)
164
- break;
165
- const word = new RegExp(`(?<![\\w$])${name.replace(/\$/g, "\\$")}(?![\\w$])`, "g");
166
- const mine = lines.filter((l) => l.text.search(word) !== -1);
167
- if (mine.some((l) => l.file === from))
200
+ const base = path.basename(move.from);
201
+ const hits = [];
202
+ for (const l of mine) {
203
+ if (isRecordProse(l.file))
168
204
  continue;
169
- const base = path.basename(from);
170
- const hits = [];
171
- for (const l of mine) {
172
- if (isRecordProse(l.file))
173
- continue;
174
- const text = l.text.split(from).join(base);
175
- const names = [...text.matchAll(word)].map((m) => m.index);
176
- const bases = standalonePathIndexes(text, base);
177
- const near = names.some((i) => bases.some((j) => {
178
- const gap = j >= i + name.length ? text.slice(i + name.length, j) : text.slice(j + base.length, i);
179
- const overlaps = j < i + name.length && i < j + base.length;
180
- return !overlaps && gap.length <= MOVED_NAME_GAP_MAX && !/[,;]/.test(gap);
181
- }));
182
- if (!near)
183
- continue;
184
- hits.push(`${l.file}:${l.no}`);
185
- if (hits.length >= MAX_STALE_HITS)
186
- break;
187
- }
188
- if (hits.length === 0)
205
+ const text = l.text.split(move.from).join(base);
206
+ if (!placesMovedNameInOldFile(text, word, move.name, base))
189
207
  continue;
190
- findings.push(`\`${name}\` moved from ${from} to ${cappedList(to, MAX_STALE_PATHS)}, but ${hits.join(", ")} ` +
191
- `still place it in ${base} — point each at its new file`);
208
+ hits.push(`${l.file}:${l.no}`);
209
+ if (hits.length >= MAX_STALE_HITS)
210
+ break;
192
211
  }
193
- return findings;
212
+ return hits;
194
213
  }
@@ -12,7 +12,7 @@
12
12
  import { changeBaseRev } from "../git/git.js";
13
13
  import { gitTry } from "../git/git-run.js";
14
14
  import { pathTokens } from "../text/text.js";
15
- import { MAX_STALE_HITS, MAX_STALE_PATHS, cappedList, diffPlusPath, gitGrepHits, hasStandalonePathOccurrence, isRecordProse, } from "./stage-checks-shared.js";
15
+ import { MAX_STALE_HITS, MAX_STALE_PATHS, cappedList, diffPlusPath, diffRemovedAdded, gitGrepHits, hasStandalonePathOccurrence, isRecordProse, } from "./stage-checks-shared.js";
16
16
  import { movedNameFindings, staleNameFindings } from "./stage-name-checks.js";
17
17
  import { detachedDocFindings, garbledWordingFindings, newlineFindings } from "./stage-prose-checks.js";
18
18
  /** How many nonexistent added paths one finding names before it stops. */
@@ -94,6 +94,34 @@ async function pathGrepHits(wt, needle, excludeFile) {
94
94
  }
95
95
  return hits;
96
96
  }
97
+ /** The moved-line delta per changed file, from `diffRemovedAdded(diff)`: each file's removed
98
+ * lines and added set, trimmed and filtered at MOVED_LINE_MIN_CHARS. The single place the
99
+ * walk and its threshold live, so a reader asking what counts as a moved line finds it here. */
100
+ function movedLineDeltas(diff) {
101
+ const files = new Map();
102
+ for (const f of diffRemovedAdded(diff)) {
103
+ const removed = f.removed.map((l) => l.trim()).filter((l) => l.length >= MOVED_LINE_MIN_CHARS);
104
+ const added = new Set(f.added.map((l) => l.trim()).filter((l) => l.length >= MOVED_LINE_MIN_CHARS));
105
+ files.set(f.path, { removed, added });
106
+ }
107
+ return files;
108
+ }
109
+ /** Which files count as receivers of a move source: the other files whose added set contains
110
+ * at least one of the source's removed lines, in diff order. */
111
+ function movedLineReceivers(src, others) {
112
+ return others.filter(([, v]) => src.removed.some((l) => v.added.has(l))).map(([f]) => f);
113
+ }
114
+ /** How many of the source's removed lines another file added verbatim. */
115
+ function movedLineCount(src, others) {
116
+ return src.removed.filter((l) => others.some(([, v]) => v.added.has(l))).length;
117
+ }
118
+ /** The moved-source finding wording: the source path, its moved-line count, the receiving
119
+ * files (capped at MAX_STALE_PATHS) and the still-matching `file:line` hits to re-check. */
120
+ function movedSourceFinding(path, moved, receivers, hits) {
121
+ return (`${path} had ${moved} lines moved to ${cappedList(receivers, MAX_STALE_PATHS)}, but it is ` +
122
+ `still named at: ${hits.join(", ")} — check each still describes what stays in ${path}, ` +
123
+ `and point any that describes the moved code at its new file`);
124
+ }
97
125
  /** (f) References to a file the change moved code out of. A modified (`M`) file is a source
98
126
  * when at least MOVED_LINES_MIN of its removed `-U0` lines (trimmed, at least MOVED_LINE_MIN_CHARS
99
127
  * long) were added verbatim to another file. Each hit of the source's path outside record prose
@@ -104,29 +132,7 @@ async function pathGrepHits(wt, needle, excludeFile) {
104
132
  * and revision runs that rightly changed nothing were rejected until the review chain ran out
105
133
  * (6 of 7 exhausted chains on 2026-10-20 and -21). */
106
134
  async function movedSourceFindings(wt, diff, entries) {
107
- const files = new Map();
108
- let cur;
109
- for (const line of diff.split("\n")) {
110
- const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
111
- if (header) {
112
- cur = { removed: [], added: new Set() };
113
- files.set(header[1], cur);
114
- continue;
115
- }
116
- if (!cur)
117
- continue;
118
- const isRemoved = line.startsWith("-") && !line.startsWith("---");
119
- const isAdded = line.startsWith("+") && !line.startsWith("+++");
120
- if (!isRemoved && !isAdded)
121
- continue;
122
- const text = line.slice(1).trim();
123
- if (text.length < MOVED_LINE_MIN_CHARS)
124
- continue;
125
- if (isRemoved)
126
- cur.removed.push(text);
127
- else
128
- cur.added.add(text);
129
- }
135
+ const files = movedLineDeltas(diff);
130
136
  const findings = [];
131
137
  for (const e of entries) {
132
138
  if (e.status !== "M" || isRecordProse(e.path))
@@ -137,18 +143,13 @@ async function movedSourceFindings(wt, diff, entries) {
137
143
  if (!src)
138
144
  continue;
139
145
  const others = [...files].filter(([f]) => f !== e.path);
140
- const receivers = others
141
- .filter(([, v]) => src.removed.some((l) => v.added.has(l)))
142
- .map(([f]) => f);
143
- const moved = src.removed.filter((l) => others.some(([, v]) => v.added.has(l))).length;
146
+ const moved = movedLineCount(src, others);
144
147
  if (moved < MOVED_LINES_MIN)
145
148
  continue;
146
149
  const hits = await pathGrepHits(wt, e.path, e.path);
147
150
  if (hits.length === 0)
148
151
  continue;
149
- findings.push(`${e.path} had ${moved} lines moved to ${cappedList(receivers, MAX_STALE_PATHS)}, but it is ` +
150
- `still named at: ${hits.join(", ")} — check each still describes what stays in ${e.path}, ` +
151
- `and point any that describes the moved code at its new file`);
152
+ findings.push(movedSourceFinding(e.path, moved, movedLineReceivers(src, others), hits));
152
153
  }
153
154
  return findings;
154
155
  }
@@ -174,10 +175,9 @@ async function staleReferenceFindings(wt, entries) {
174
175
  }
175
176
  return findings;
176
177
  }
177
- /** (b) Nonexistent paths: every path-shaped token on an added diff line whose first segment is
178
- * a tracked top-level directory but which is absent from the staged tree. Rename origins and
179
- * deleted paths are excluded — "moved from X" prose names them legitimately. */
180
- async function missingPathFindings(wt, diff, entries) {
178
+ /** The staged tree as the added-path check needs it: the set of every tracked path and the set
179
+ * of its top-level directories, from `git ls-files`. */
180
+ async function readStagedIndex(wt) {
181
181
  const ls = await gitTry(wt, "ls-files");
182
182
  const staged = new Set();
183
183
  const topDirs = new Set();
@@ -189,6 +189,11 @@ async function missingPathFindings(wt, diff, entries) {
189
189
  if (slash > 0)
190
190
  topDirs.add(p.slice(0, slash));
191
191
  }
192
+ return { staged, topDirs };
193
+ }
194
+ /** The paths a change moved code away from and must not flag: every rename origin and every
195
+ * deleted path in `entries`, which "moved from X" prose names legitimately. */
196
+ function renameOriginPaths(entries) {
192
197
  const oldPaths = new Set();
193
198
  for (const e of entries) {
194
199
  if (e.oldPath)
@@ -196,6 +201,25 @@ async function missingPathFindings(wt, diff, entries) {
196
201
  if (e.status === "D")
197
202
  oldPaths.add(e.path);
198
203
  }
204
+ return oldPaths;
205
+ }
206
+ /** The path-shaped tokens an added line names that are absent from the staged tree: a token whose
207
+ * first segment is a tracked top-level directory, not a rename origin or deleted path, not
208
+ * already staged, and not already seen. Each returned token is added to `seen` as it goes. */
209
+ function missingPathTokens(line, staged, topDirs, oldPaths, seen) {
210
+ const found = [];
211
+ for (const p of pathTokens(line.slice(1))) {
212
+ const top = p.slice(0, p.indexOf("/"));
213
+ if (!topDirs.has(top) || oldPaths.has(p) || staged.has(p) || seen.has(p))
214
+ continue;
215
+ seen.add(p);
216
+ found.push(p);
217
+ }
218
+ return found;
219
+ }
220
+ /** Every added diff line's missing-path tokens, tracking the current file to spare added lines in
221
+ * test/ and in record prose (isRecordProse). */
222
+ function missingPathsInDiff(diff, staged, topDirs, oldPaths) {
199
223
  const seen = new Set();
200
224
  const missing = [];
201
225
  let file = null;
@@ -212,14 +236,17 @@ async function missingPathFindings(wt, diff, entries) {
212
236
  // test/ and in record prose (isRecordProse).
213
237
  if (file !== null && (file.startsWith("test/") || isRecordProse(file)))
214
238
  continue;
215
- for (const p of pathTokens(line.slice(1))) {
216
- const top = p.slice(0, p.indexOf("/"));
217
- if (!topDirs.has(top) || oldPaths.has(p) || staged.has(p) || seen.has(p))
218
- continue;
219
- seen.add(p);
220
- missing.push(p);
221
- }
239
+ missing.push(...missingPathTokens(line, staged, topDirs, oldPaths, seen));
222
240
  }
241
+ return missing;
242
+ }
243
+ /** (b) Nonexistent paths: every path-shaped token on an added diff line whose first segment is
244
+ * a tracked top-level directory but which is absent from the staged tree. Rename origins and
245
+ * deleted paths are excluded — "moved from X" prose names them legitimately. */
246
+ async function missingPathFindings(wt, diff, entries) {
247
+ const { staged, topDirs } = await readStagedIndex(wt);
248
+ const oldPaths = renameOriginPaths(entries);
249
+ const missing = missingPathsInDiff(diff, staged, topDirs, oldPaths);
223
250
  if (missing.length === 0)
224
251
  return [];
225
252
  return [
@@ -196,64 +196,79 @@ function spanningGarbled(joined, joinAt, patterns) {
196
196
  }
197
197
  return null;
198
198
  }
199
- /** (e) Added lines, outside record prose, that match GARBLED_WORDING: at most one hit per line,
200
- * reported as `file:line "matched text"` in a single finding. A phrase split across two
201
- * consecutive added lines is reported as `file:N-M`. */
202
- export function garbledWordingFindings(diff) {
203
- const hits = [];
199
+ /** The added lines of a diff, in order, each with the added line that precedes it. */
200
+ function addedDiffLines(diff) {
201
+ const out = [];
204
202
  let file = null;
205
203
  let lineNo = 0;
206
- let prevAdded = null;
204
+ let prev = null;
207
205
  for (const line of diff.split("\n")) {
208
206
  const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
209
207
  if (header) {
210
208
  file = header[1];
211
- prevAdded = null;
209
+ prev = null;
212
210
  continue;
213
211
  }
214
212
  const hunk = /^@@ .*\+(\d+)(?:,\d+)? @@/.exec(line);
215
213
  if (hunk) {
216
214
  lineNo = Number(hunk[1]);
217
- prevAdded = null;
215
+ prev = null;
218
216
  continue;
219
217
  }
220
218
  if (!line.startsWith("+") || line.startsWith("+++ ")) {
221
- prevAdded = null;
219
+ prev = null;
222
220
  continue;
223
221
  }
224
- const cur = line.slice(1);
225
- if (file !== null && !isRecordProse(file)) {
226
- let hit = false;
227
- for (const re of GARBLED_WORDING) {
228
- const m = re.exec(cur);
229
- if (m) {
230
- hits.push(`${file}:${lineNo} "${m[0]}"`);
231
- hit = true;
232
- break;
233
- }
234
- }
235
- const prose = proseOf(file, cur);
236
- if (!hit && prose !== null) {
237
- const m = DOUBLED_WORD.exec(prose);
238
- if (m) {
239
- hits.push(`${file}:${lineNo} "${m[0]}"`);
240
- hit = true;
241
- }
242
- }
243
- if (!hit && prevAdded !== null) {
244
- const head = prevAdded.trimEnd();
245
- const joined = head + " " + cur.replace(/^\s*(?:\/\/+|\*|#)\s*/, "");
246
- let split = spanningGarbled(joined, head.length, GARBLED_WORDING);
247
- if (!split && prose !== null && proseOf(file, prevAdded) !== null) {
248
- split = spanningGarbled(joined, head.length, [DOUBLED_WORD]);
249
- }
250
- if (split)
251
- hits.push(`${file}:${lineNo - 1}-${lineNo} "${split}"`);
252
- }
253
- }
254
- prevAdded = cur;
222
+ const text = line.slice(1);
223
+ if (file !== null)
224
+ out.push({ file, lineNo, text, prev });
225
+ prev = text;
255
226
  lineNo++;
256
227
  }
228
+ return out;
229
+ }
230
+ /** The first GARBLED_WORDING hit on a single added line, or the DOUBLED_WORD hit in its prose. */
231
+ function singleLineGarbledHit(file, lineNo, cur) {
232
+ for (const re of GARBLED_WORDING) {
233
+ const m = re.exec(cur);
234
+ if (m)
235
+ return `${file}:${lineNo} "${m[0]}"`;
236
+ }
237
+ const prose = proseOf(file, cur);
238
+ if (prose !== null) {
239
+ const m = DOUBLED_WORD.exec(prose);
240
+ if (m)
241
+ return `${file}:${lineNo} "${m[0]}"`;
242
+ }
243
+ return null;
244
+ }
245
+ /** A garbled or doubled phrase split across two consecutive added lines, joined at their seam. */
246
+ function spanningGarbledHit(file, lineNo, cur, prevAdded) {
247
+ if (prevAdded === null)
248
+ return null;
249
+ const head = prevAdded.trimEnd();
250
+ const joined = head + " " + cur.replace(/^\s*(?:\/\/+|\*|#)\s*/, "");
251
+ let split = spanningGarbled(joined, head.length, GARBLED_WORDING);
252
+ const prose = proseOf(file, cur);
253
+ if (!split && prose !== null && proseOf(file, prevAdded) !== null) {
254
+ split = spanningGarbled(joined, head.length, [DOUBLED_WORD]);
255
+ }
256
+ if (split)
257
+ return `${file}:${lineNo - 1}-${lineNo} "${split}"`;
258
+ return null;
259
+ }
260
+ /** (e) Added lines, outside record prose, that match GARBLED_WORDING: at most one hit per line,
261
+ * reported as `file:line "matched text"` in a single finding. A phrase split across two
262
+ * consecutive added lines is reported as `file:N-M`. */
263
+ export function garbledWordingFindings(diff) {
264
+ const hits = [];
265
+ for (const { file, lineNo, text, prev } of addedDiffLines(diff)) {
266
+ if (isRecordProse(file))
267
+ continue;
268
+ const hit = singleLineGarbledHit(file, lineNo, text) ?? spanningGarbledHit(file, lineNo, text, prev);
269
+ if (hit !== null)
270
+ hits.push(hit);
271
+ }
257
272
  if (hits.length === 0)
258
273
  return [];
259
274
  const lead = 'added lines carry garbled rename wording (reword them; a rote word swap such as "tick" → "run" reads wrong here or leaves a word doubled): '; // vocabulary-ok: quotes the retired word
@@ -16,8 +16,8 @@ import { shortSha } from "../text/format.js";
16
16
  * holds whether or not the pin survived. Split out of runner.ts — which keeps the run lifecycle
17
17
  * around it — because this is a self-contained concern with its own entry condition and git
18
18
  * surface; the only things it borrows from the runner are identity, the run number, the
19
- * worktree (for the no-pin fallback), and three pieces of the role's merge state: its
20
- * conflict streak, outstanding hand-back, and permanent-reviewer hold. */
19
+ * worktree (for the no-pin fallback), and two pieces of the role's merge state: its conflict
20
+ * streak and outstanding hand-back. */
21
21
  /** Consecutive merges of one pinned sha that may end `merge_conflict` before recovery stops
22
22
  * re-queuing it (RunnerState.mergeConflicts). Each attempt costs a conflict-resolution run, and
23
23
  * a role holding a pin never authors: without a cap an unmergeable change would re-queue
@@ -43,19 +43,14 @@ async function dropDanglingPin(root, role, ref, sha) {
43
43
  warnEventBestEffort(root, role, `dropped merge pin ${shortSha(sha)}: its commit is missing from the repository, so it can never merge`);
44
44
  return true;
45
45
  }
46
- /** The permanent-reviewer-config hold when it names `sha`, else undefined. */
47
- function reviewHold(held, sha) {
48
- return held?.sha === sha ? held : undefined;
49
- }
50
46
  /** Consecutive conflict-ended merges of `sha` (0 when the streak names another sha). */
51
47
  function conflictAttempts(streak, sha) {
52
48
  return streak?.sha === sha ? streak.count : 0;
53
49
  }
54
50
  /** The sha of a role's kept pin when the merge pipeline may re-queue it itself: the pin exists,
55
- * main does not already contain it (a stale pin is deleted, as recovery does), no reviewer
56
- * configuration hold names it, and its conflict streak is under MERGE_CONFLICT_LIMIT. Null
57
- * otherwise — a pin at the limit is the author's conflict hand-back, a held one waits for a
58
- * config change, and both stay with recoverLeftover. */
51
+ * main does not already contain it (a stale pin is deleted, as recovery does), and its conflict
52
+ * streak is under MERGE_CONFLICT_LIMIT. Null otherwise — a pin at the limit is the author's
53
+ * conflict hand-back, and it stays with recoverLeftover. */
59
54
  export async function pinnedRetrySha(root, role, mainBranch, state) {
60
55
  const ref = mergeRefName(role);
61
56
  const sha = await refSha(root, ref).catch(() => null);
@@ -65,8 +60,6 @@ export async function pinnedRetrySha(root, role, mainBranch, state) {
65
60
  return null;
66
61
  if (await dropStalePin(root, ref, sha, mainBranch))
67
62
  return null;
68
- if (reviewHold(state.mergeReviewError, sha))
69
- return null;
70
63
  if (conflictAttempts(state.mergeConflicts, sha) >= MERGE_CONFLICT_LIMIT)
71
64
  return null;
72
65
  return sha;
@@ -130,13 +123,10 @@ export async function recoverLeftover(ctx) {
130
123
  sha = await headOf(ctx.wt, "HEAD").catch(() => null);
131
124
  if (!sha)
132
125
  return null;
133
- // Hold checks BEFORE adopting: when a reviewer-config hold or a main_red retry names this
134
- // tip, adopting it into the merge ref would create/repoint the pin and lose the kept sha
135
- // the wait exists to preserve. Return the hold path without touching the ref (BUGS.md
136
- // 2026-10-06, the main_red wait), leaving the commit on the branch.
137
- const heldTip = reviewHold(ctx.mergeReviewError, sha);
138
- if (heldTip)
139
- return { kind: "held", sha, message: heldTip.message };
126
+ // Hold checks BEFORE adopting: when a main_red retry names this tip, adopting it into the
127
+ // merge ref would create/repoint the pin and lose the kept sha the wait exists to preserve.
128
+ // Return the wait path without touching the ref (BUGS.md 2026-10-06, the main_red wait),
129
+ // leaving the commit on the branch.
140
130
  if (ctx.mainRedRetry) {
141
131
  return mainRedWait(sha, ctx.mainRedRetry.head);
142
132
  }
@@ -150,13 +140,6 @@ export async function recoverLeftover(ctx) {
150
140
  }
151
141
  }
152
142
  const meta = await recoveredMetadata(ctx.root, sha);
153
- // A permanent reviewer configuration error holds the pin: re-queuing it would pay another
154
- // full gate check for a request that can never succeed until the model config changes. The
155
- // caller clears the hold (and re-queues) once the resolved reviewer selector differs, so a
156
- // fixed config recovers automatically (BUGS.md 2026-10-06).
157
- const held = reviewHold(ctx.mergeReviewError, sha);
158
- if (held)
159
- return { kind: "held", sha, message: held.message };
160
143
  // `requeueMainRedPin` owns the re-queue of a main_red pin, once main's head moves. Queuing it
161
144
  // here would pay a full gate check against the same red head, whose result is already known.
162
145
  if (ctx.mainRedRetry) {
@@ -10,7 +10,7 @@ import { mergeToMain } from "../merge/merge-to-main.js";
10
10
  import { MERGE_LOCK_WAIT_MS } from "../paths.js";
11
11
  import { resolveRunVerdict } from "../run/run-verdict.js";
12
12
  import { modelTrailerLine } from "../git/commit-message.js";
13
- import { seamAgent } from "../config/model-selector.js";
13
+ import { seamAgent } from "../config/config-model-selector.js";
14
14
  import { stageRunMerge } from "../run/run-stage.js";
15
15
  import { stageCheckFindings } from "../run/stage-check.js";
16
16
  /** Merge the worktree branch on main (see src/merge/merge-to-main.ts for the
@@ -62,6 +62,11 @@ export async function handlePiResultPhase(ctx, pi, userPrompt, revisionRound, wt
62
62
  flow,
63
63
  filed,
64
64
  ci,
65
+ // The image-reroute arm resolves its target from the live config and the current
66
+ // regime, and names the model this run ran on as the handoff's `from`.
67
+ config: ctx.config,
68
+ rerouteRegime: ctx.rerouteRegime(),
69
+ runModel: cfg.model,
65
70
  warn: (message) => ctx.warn(message),
66
71
  merge: (w, sum) => mergePhase(ctx, w, sum),
67
72
  finishAbortedRun: () => ctx.finishAbortedRun(userPrompt, wt),
@@ -96,7 +101,7 @@ export async function handlePiResultPhase(ctx, pi, userPrompt, revisionRound, wt
96
101
  piStartedAt,
97
102
  warn: (message) => ctx.warn(message),
98
103
  requestSummary: (w, n) => ctx.pi.requestSummary(w, cfg, n),
99
- stageCheck: (w) => stageCheckFindings(w, ctx.mainBranch, ctx.config.review.exemptPaths, ctx.role),
104
+ stageCheck: (w) => stageCheckFindings(w, ctx.mainBranch, ctx.role),
100
105
  requestStageFix: (w, findings, change) => ctx.pi.requestStageFix(w, findings, change, cfg),
101
106
  pinAndReset: (w, sha) => ctx.pinAndReset(w, sha),
102
107
  finishAbortedRun: () => ctx.finishAbortedRun(userPrompt, wt),