tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** The review gate's deterministic build pre-check — split out of review.ts so the gate keeps
2
3
  * one responsibility (running the model reviewer and acting on its verdict) while this module
3
4
  * owns the pre-review verification: the one scoped build-check run, its flake re-run, the
@@ -13,7 +14,7 @@ import { sampleSleepClock } from "../build/host-sleep.js";
13
14
  import { recordReview } from "../tick/tick-apply.js";
14
15
  import { mainRedNotMine } from "../text/phrases.js";
15
16
  import { shortSha } from "../text/format.js";
16
- import { checkWaitStage, setLandingStage } from "../landing/landing-slot.js";
17
+ import { checkWaitStage, setMergeStage } from "../merge/merge-slot.js";
17
18
  import { mainTipVerdict } from "../baseline/main-red.js";
18
19
  import { FLAKY_WARNING_PREFIX, isKnownFlake } from "./known-flakes.js";
19
20
  /** Run the gate's deterministic build pre-check. `reject` is the gate's one shared rejection
@@ -22,10 +23,10 @@ import { FLAKY_WARNING_PREFIX, isKnownFlake } from "./known-flakes.js";
22
23
  * model-verdict path. */
23
24
  export async function gateBuildPrecheck(ctx, state, head, reject) {
24
25
  const { root, role, wt, mainBranch, config } = ctx;
25
- // The landing cell's stage (a no-op outside a queued landing — see setLandingStage): the
26
+ // The merge cell's stage (a no-op outside a queued merge — see setMergeStage): the
26
27
  // pre-check, its one re-run, and any attribution check behind them are the gate's first
27
28
  // long phase.
28
- setLandingStage(root, role, "build-check");
29
+ setMergeStage(root, role, "build-check");
29
30
  // The gate's one check invocation — the pre-check and its one flake re-run below are the
30
31
  // same run in the same scope, worktree, and config with the same resolved timeout, so the
31
32
  // scope and timeout resolution live here once and cannot drift between the two calls.
@@ -65,9 +66,9 @@ export async function gateBuildPrecheck(ctx, state, head, reject) {
65
66
  // The run spanned a host sleep past the tolerance and no clean attempt judged the tree
66
67
  // (runScopedBuildCheck already retried once): nothing here is the change's failure, so
67
68
  // the gate attributes nothing — it keeps the commit and reports the sleep, and the
68
- // landing path keeps the pin for a re-land (BUGS.md 2026-09-30).
69
+ // merge path keeps the pin for a re-merge (BUGS.md 2026-09-30).
69
70
  const detail = sleptPhrase("gate check", outcome.run.sleptMs, UNVERIFIED_TREE);
70
- warnEventBestEffort(root, role, `${detail}; keeping the commit for a re-land`);
71
+ warnEventBestEffort(root, role, `${detail}; keeping the commit for another merge attempt`);
71
72
  return { resolved: { decision: "failed", detail, unverified: true } };
72
73
  }
73
74
  if (outcome.status === "failed") {
@@ -75,23 +76,23 @@ export async function gateBuildPrecheck(ctx, state, head, reject) {
75
76
  // re-run passed on this exact headline) says nothing about this change: attribute nothing
76
77
  // — keep the commit and the pin, count no strike — and name the flake, exactly as the
77
78
  // host-sleep path above does. Before this, the repeat was attributed through main's
78
- // per-SHA baseline cache (usually a green seeded by an earlier landing), so a load- or
79
+ // per-SHA baseline cache (usually a green seeded by an earlier merge), so a load- or
79
80
  // clock-sensitive test rejected whichever unrelated change hit it (BUGS.md 2026-10-06).
80
81
  const flaky = failureHeadline(outcome.outputTail) ?? describeCheck(check);
81
82
  if (isKnownFlake(root, flaky)) {
82
- const detail = `known-flaky failure, landing kept: ${flaky}`;
83
+ const detail = `known-flaky failure, merge kept: ${flaky}`;
83
84
  recordReview(state, "failed", [detail], head);
84
85
  warnEventBestEffort(root, role, detail);
85
86
  return { resolved: { decision: "failed", detail, unverified: true } };
86
87
  }
87
88
  const reasons = checkFailureReasons(check, outcome);
88
- // A failure that reproduced is attributed, never fixed here: the one landing slot the
89
+ // A failure that reproduced is attributed, never fixed here: the one merge slot the
89
90
  // whole queue waits on is no place for a model run (the in-slot build-fix run this
90
- // replaces held it for hours and never once led to a landing — PLANS.md "Land-queue
91
+ // replaces held it for hours and never once led to a merge — PLANS.md "Land-queue
91
92
  // speed 1/3"). The question is whose failure it is, and main's own verdict at its tip
92
93
  // answers it. A failure matching no recorded flake bypasses the per-SHA cache and runs
93
94
  // main's check now (forceFresh): a clock- or load-sensitive test can pass on an immutable
94
- // SHA early and fail later, so a cached green — usually seeded by an earlier landing —
95
+ // SHA early and fail later, so a cached green — usually seeded by an earlier merge —
95
96
  // may no longer be true, and trusting it rejected an unrelated change (BUGS.md
96
97
  // 2026-10-06). A shutdown already under way skips the question and fails closed like any
97
98
  // abort.
@@ -105,10 +106,10 @@ export async function gateBuildPrecheck(ctx, state, head, reject) {
105
106
  // Main fails too: not this change's failure. Leave the commit (and with it the pin) and
106
107
  // do not advance the discard counter — the transport-failure rule (BUGS.md 2026-09-20):
107
108
  // nothing judged this diff. main-red.ts's gate and the bugfix handoff own the repair,
108
- // and the author's next tick re-lands the change once main is green again.
109
+ // and the author's next tick re-merges the change once main is green again.
109
110
  const detail = mainRedNotMine(main.sha);
110
111
  recordReview(state, "failed", [detail], head);
111
- warnEventBestEffort(root, role, `gate check failed on ${shortSha(head)}, but ${detail}; landing kept`);
112
+ warnEventBestEffort(root, role, `gate check failed on ${shortSha(head)}, but ${detail}; merge kept`);
112
113
  return { resolved: { decision: "failed", detail, mainRed: true } };
113
114
  }
114
115
  // Main green: the change broke the check — rejected deterministically through the shared
@@ -123,7 +124,7 @@ export async function gateBuildPrecheck(ctx, state, head, reject) {
123
124
  }
124
125
  if (outcome.status === "passed") {
125
126
  // Passed: this exact tree just went green under the project's own declared check. Hand
126
- // the verdict to the landing path via verifiedHead — it seeds the red-main baseline with
127
+ // the verdict to the merge path via verifiedHead — it seeds the red-main baseline with
127
128
  // the SHA that actually becomes main (the rebased head, which may differ from `head`),
128
129
  // so every role's next fresh tick is a cache hit instead of one redundant full-suite
129
130
  // re-run on an already-verified tree.
@@ -7,6 +7,20 @@ import { verdictLines } from "../verdict/reply-contract.js";
7
7
  import { clipReason } from "../build/build-check-report.js";
8
8
  /** Cap on recorded reasons, so a chatty reviewer cannot bloat persisted state. */
9
9
  const MAX_REASONS = 10;
10
+ /** The prior-objection number a reviewer reason is tagged with at its start — `(prior N)`, N
11
+ * the objection's 1-based number in the prior list the re-review prompt showed — or null when
12
+ * the reason is a newly raised (untagged) objection. The repeat-finality policy (BUGS.md
13
+ * 2026-10-14) asks a re-review to restate a still-open prior objection with this tag so the
14
+ * harness can tell a repeat from a new finding and end a chain whose objection has stopped
15
+ * converging. Anything else that merely begins with the word "prior" (e.g. "prior to the
16
+ * change…") is not a tag and reads as an untagged reason. */
17
+ export function priorObjectionNumber(reason) {
18
+ const m = /^\(prior\s+(\d+)\)/i.exec(reason.trim());
19
+ if (!m)
20
+ return null;
21
+ const n = Number(m[1]);
22
+ return Number.isInteger(n) && n >= 1 ? n : null;
23
+ }
10
24
  /** Parse the reviewer's reply: the LAST VERDICT line wins (the prompt asks for exactly one),
11
25
  * followed by its reasons — numbered/bulleted lines first, any other non-empty prose as a
12
26
  * fallback. Null when no parseable verdict exists: that is a FAILED review, never an approval
@@ -1,5 +1,5 @@
1
1
  import { REVIEW_RESUME_LIMIT, saveLoopState } from "../loop/loop-state.js";
2
- import { modelSelectorField, reviewRunConfig } from "../config/config-views.js";
2
+ import { modelSelectorField, resolvedAgentConfig, reviewRunConfig } from "../config/config-views.js";
3
3
  import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
4
4
  import { git } from "../git/git-run.js";
5
5
  import { changeBaseRev, headOf, patchId } from "../git/git.js";
@@ -16,16 +16,18 @@ import { shortSha } from "../text/format.js";
16
16
  import { firstReason } from "../text/phrases.js";
17
17
  import { isExemptDiff } from "./exemptions.js";
18
18
  import { falseFixReason } from "../verdict/fix-claim.js";
19
- import { conflictMarkerReason } from "../landing/landing-git.js";
19
+ import { conflictMarkerReason } from "../merge/merge-git.js";
20
+ import { protectedEditReason } from "./protected-edits.js";
21
+ import { secretLeakReason } from "./secret-leaks.js";
20
22
  import { backlogStructureReason } from "../backlog/backlog-structure.js";
21
23
  import { suiteRerunWarning } from "./suite-rerun.js";
22
- import { setLandingStage } from "../landing/landing-slot.js";
24
+ import { setMergeStage } from "../merge/merge-slot.js";
23
25
  import { gateBuildPrecheck } from "./review-precheck.js";
24
26
  /** Attach the review gate's pi runs onto a partial GateResult — `run` plus the optional
25
27
  * verdict-recovery follow-up (`followUpRun`) and no-re-run nudge (`nudgeRun`) fields, each
26
28
  * present only when its turn actually ran. Spelled once so every return site of
27
29
  * reviewAheadOfMain that carries runs shares one shape for the usage-folding caller
28
- * (landing-core.ts folds run, then followUpRun, then nudgeRun), instead of one conditional
30
+ * (merge-core.ts folds run, then followUpRun, then nudgeRun), instead of one conditional
29
31
  * spread per site that a new run kind would have to update everywhere. */
30
32
  function withRuns(r, run, followUp, nudge) {
31
33
  return {
@@ -35,6 +37,15 @@ function withRuns(r, run, followUp, nudge) {
35
37
  ...(nudge ? { nudgeRun: nudge } : {}),
36
38
  };
37
39
  }
40
+ /** Put the worktree back on the exact head the gate reviewed: the reviewer's only output is its
41
+ * verdict, so a commit it made must never reach the merge pin or the next re-review. */
42
+ async function restoreReviewedHead(wt, head, root, role) {
43
+ const now = await headOf(wt, "HEAD").catch(() => null);
44
+ if (now && now !== head) {
45
+ warnEventBestEffort(root, role, `the reviewer moved HEAD to ${shortSha(now)} during review; restored the reviewed head ${shortSha(head)}`);
46
+ }
47
+ await git(wt, "reset", "--hard", head);
48
+ }
38
49
  /** Consecutive failed reviews of one branch HEAD after which the leftover is discarded with
39
50
  * a warning: a misconfigured reviewer model must not be able to wedge a loop into re-reviewing
40
51
  * the same commit forever. */
@@ -84,7 +95,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
84
95
  state.unreviewFailures = 0;
85
96
  await resetWorktreeToMain(wt, mainBranch);
86
97
  logEventBestEffort(root, {
87
- loop: role,
98
+ source: role,
88
99
  type: "review_rejected",
89
100
  head,
90
101
  reasons,
@@ -99,23 +110,32 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
99
110
  }
100
111
  const files = await aheadOfMainFiles(wt, mainBranch);
101
112
  // A change handed back to its author with the conflict markers left in place (PLANS.md
102
- // "Robust conflict landing, part 2/2") must not land raw `<<<<<<<` lines. The stage check
113
+ // "Robust conflict landing, part 2/2") must not merge raw `<<<<<<<` lines. The stage check
103
114
  // flags them for one fix-up turn, but only this deterministic gate rejection makes it a
104
115
  // hard stop — an md-only diff skips the reviewer entirely, so nothing else would catch it.
105
116
  const markerReason = conflictMarkerReason(wt, files);
106
117
  if (markerReason !== undefined)
107
118
  return reject([markerReason]);
119
+ // The prompt block and PRINCIPLES.md are markdown, so the exempt fast path below would merge
120
+ // an edit to either unreviewed; reject it here, for md-only and code diffs alike.
121
+ const protectedReason = await protectedEditReason(wt, mainBranch, files, role);
122
+ if (protectedReason !== undefined)
123
+ return reject([protectedReason]);
124
+ // A leaked credential is just as invisible to the exempt fast path: reject it here too.
125
+ const secretReason = await secretLeakReason(wt, mainBranch, files);
126
+ if (secretReason !== undefined)
127
+ return reject([secretReason]);
108
128
  if (isExemptDiff(files, config.review.exemptPaths)) {
109
129
  // The exemption is a fast path, not a blind eye: an md-only diff that moves a bug to
110
130
  // Fixed must have its fix's symbols on this tree, or it records code that does not
111
- // exist (BUGS.md 2026-09-22 — 9cea8c3 landed a Fix paragraph naming runScriptGroup
131
+ // exist (BUGS.md 2026-09-22 — 9cea8c3 merged a Fix paragraph naming runScriptGroup
112
132
  // and signalTree with no source behind it). A deterministic rejection, like the
113
133
  // pre-check below: no pi run consumed, reasons injected into the author's next tick.
114
134
  const falseFix = await falseFixReason(wt, mainBranch, files);
115
135
  if (falseFix)
116
136
  return reject([falseFix]);
117
- // Same cross-check as the landing path (backlog-structure.ts): an md-only diff that
118
- // duplicates or drops a `## ` section heading would land unreviewed — md-only edits skip
137
+ // Same cross-check as the merge path (backlog-structure.ts): an md-only diff that
138
+ // duplicates or drops a `## ` section heading would merge unreviewed — md-only edits skip
119
139
  // the reviewer by design, and a malformed backlog stays invisible until an entry ends up
120
140
  // on the wrong side of a heading (PLANS.md 2026-09-25, 52cbadd1).
121
141
  const structure = await backlogStructureReason(wt, mainBranch, files);
@@ -126,7 +146,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
126
146
  }
127
147
  // The backlog-structure check for code diffs too, before the build pre-check: a change that
128
148
  // duplicates or drops a `## ` section heading in PLANS.md, BUGS.md, or QUESTIONS.md cannot
129
- // land, and the rejection is deterministic — no pi run and no check run spent on a tree the
149
+ // merge, and the rejection is deterministic — no pi run and no check run spent on a tree the
130
150
  // heading set already condemns.
131
151
  const structure = await backlogStructureReason(wt, mainBranch, files);
132
152
  if (structure)
@@ -136,9 +156,9 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
136
156
  // "reviewing": a deterministic rejection never shows as reviewing on the dashboards. Both
137
157
  // gate callers (the tick path and leftover.ts's recoverLeftover) get it for free. Its run,
138
158
  // flake re-run, and attribution live in review-precheck.ts (the check run itself lives in
139
- // runScopedBuildCheck, shared with the landing path's in-lock re-check); a resolved result
140
- // here is returned verbatim and a pass hands the verified head to the landing path below.
141
- // The pre-check sets the landing cell's stage (a no-op outside a queued landing) —
159
+ // runScopedBuildCheck, shared with the merge path's in-lock re-check); a resolved result
160
+ // here is returned verbatim and a pass hands the verified head to the merge path below.
161
+ // The pre-check sets the merge cell's stage (a no-op outside a queued merge) —
142
162
  // "build-check" while it runs, "reviewing" when the reviewer run starts below.
143
163
  const pre = await gateBuildPrecheck(ctx, state, head, reject);
144
164
  if (pre.resolved) {
@@ -163,6 +183,10 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
163
183
  // same config the run below gets, so the event names what the reviewer actually runs on —
164
184
  // the budget fallback included. Omitted when no model is configured (pi's own default).
165
185
  const reviewCfg = reviewRunConfig(config);
186
+ // The team claude config dir (plans/notes-command.md "Keeping funes out of team runs"), so
187
+ // a claude review's resume guard looks under the same dir the review run writes its transcript
188
+ // to — a team that set one must resume under it, not the default ~/.claude.
189
+ const claudeConfigDir = resolvedAgentConfig(config, "claude").configDir;
166
190
  // A progressing timeout left this run's session for the next attempt (LoopState.reviewResume):
167
191
  // resume it only while the patch is unchanged (the same diff), the resume budget lasts, and
168
192
  // the directory still holds a gate session to `--continue`. Otherwise the stale marker is
@@ -174,7 +198,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
174
198
  agentSessionResumable(priorResume.sessionDir, {
175
199
  kind: "gate",
176
200
  agent: reviewCfg.agent ?? "pi",
177
- });
201
+ }, claudeConfigDir);
178
202
  if (resuming) {
179
203
  // Count the resume before the run: a crash must not let the same session be resumed
180
204
  // forever without ever spending the budget.
@@ -194,7 +218,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
194
218
  // needs the override.
195
219
  const followupCtx = reviewSessionDir === ctx.sessionDir ? ctx : { ...ctx, sessionDir: reviewSessionDir };
196
220
  logEventBestEffort(root, {
197
- loop: role,
221
+ source: role,
198
222
  type: "review_start",
199
223
  head,
200
224
  ...(ctx.revisionRound !== undefined ? { revision: ctx.revisionRound } : {}),
@@ -207,8 +231,8 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
207
231
  state.phase = "review";
208
232
  saveLoopState(root, state);
209
233
  // Freeze the revision the diff is measured from once, before the run: main moves while a
210
- // landing is vetted, and a reviewer comparing against the live `main` ref reads another loop's
211
- // just-landed work as this change reverting it (BUGS.md 2026-10-06). The prompt names this SHA
234
+ // merge is approved, and a reviewer comparing against the live `main` ref reads another loop's
235
+ // just-merged work as this change reverting it (BUGS.md 2026-10-06). The prompt names this SHA
212
236
  // and tells the reviewer to compare against it instead.
213
237
  const base = await changeBaseRev(wt, mainBranch);
214
238
  const diff = await aheadOfMainDiff(wt, mainBranch);
@@ -224,17 +248,17 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
224
248
  }
225
249
  : undefined;
226
250
  // The reviewer run's wall time rides on its verdict event: a reviewer that takes an hour per
227
- // merge on local hardware is a fleet-level cost an operator must be able to see.
251
+ // merge on local hardware is a team-level cost an operator must be able to see.
228
252
  const reviewStartedAt = Date.now();
229
253
  // The reviewer's started tool calls, collected only when the no-re-run rule stands (a verified
230
254
  // pre-check) — the one case the suite-rerun tripwire below can fire.
231
255
  const reviewerCalls = [];
232
- // The landing cell switches to the reviewer run's live detail (no-op outside a queued
233
- // landing). runPi writes the run's `tumwater_run` label line before spawning pi, and that
256
+ // The merge cell switches to the reviewer run's live detail (no-op outside a queued
257
+ // merge). runPi writes the run's `tumwater_run` label line before spawning pi, and that
234
258
  // line resets the gate progress accumulator, so the cell never shows a previous run's counts.
235
- setLandingStage(root, role, "reviewing");
259
+ setMergeStage(root, role, "reviewing");
236
260
  // The author's claimed WHY/RISK/VERIFIED ride along when present — checking those claims
237
- // against the actual diff is exactly the adversarial angle (recovery landings reconstruct
261
+ // against the actual diff is exactly the adversarial angle (recovery merges reconstruct
238
262
  // them from the pinned commit's message).
239
263
  const pi = await ctx.runGatePi({
240
264
  cwd: wt,
@@ -244,8 +268,8 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
244
268
  : buildReviewPrompt(diff, summary, commitBody, readPrinciples(root), highFriction, verifiedByHarness, base, undefined, priorReview),
245
269
  // The reviewer runs on its own time budget (review.timeoutSeconds), never longer than a
246
270
  // tick's: a timed-out review is a FAILED run (pi.ok false), so it takes the dead-backend
247
- // path below — commit kept, no strike — and re-lands through the author's next tick
248
- // instead of holding the land queue for a whole authoring tick.
271
+ // path below — commit kept, no strike — and re-merges through the author's next tick
272
+ // instead of holding the merge queue for a whole authoring tick.
249
273
  config: reviewCfg,
250
274
  // This run's own session directory (per-run, keyed by tick and pinned sha): a follow-up
251
275
  // turn, or a later attempt that resumes this review, continues exactly this run instead
@@ -269,21 +293,38 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
269
293
  reviewerCalls.push({ toolName, args });
270
294
  }
271
295
  : undefined,
296
+ // A gate run that read an image its model cannot see is logged, not killed (plans/
297
+ // image-input.md "Detecting a blind read"): the reviewer keeps working from what it CAN
298
+ // see, and the image_blind line names the file, the model, and this tick so the operator
299
+ // knows the diff was judged under a blind read.
300
+ onBlindRead: (path, action) => logEventBestEffort(root, {
301
+ source: role,
302
+ type: "image_blind",
303
+ run: ctx.tick,
304
+ path,
305
+ model: reviewCfg.model,
306
+ action,
307
+ }),
272
308
  });
273
- // A reviewer told the pre-check passed that re-ran the full suite anyway held the landing
309
+ // A reviewer told the pre-check passed that re-ran the full suite anyway held the merge
274
310
  // slot and loaded the shared host for a result the harness already had (BUGS.md 2026-09-23:
275
311
  // scratch copies under /tmp, `npm ci` and `npm test` there). Warned on every outcome, abort
276
312
  // included — the run happened either way; the verdict itself is not touched.
277
313
  const rerun = suiteRerunWarning(reviewerCalls);
278
314
  if (rerun)
279
315
  warnEventBestEffort(root, role, rerun);
316
+ // Every exit below that keeps the change leaves the worktree at the head this gate reviewed.
317
+ const keep = async (r) => {
318
+ await restoreReviewedHead(wt, head, root, role);
319
+ return r;
320
+ };
280
321
  if (pi.aborted) {
281
322
  // Shutdown mid-review: fail closed — the commit stays on the branch and the
282
323
  // resumed/following tick re-reviews it via the combined ahead-of-main diff. Any resume
283
324
  // marker from an earlier attempt is dropped: an abort says nothing about the diff, and the
284
325
  // next attempt starts clean rather than inheriting a half-counted session.
285
326
  state.reviewResume = undefined;
286
- return withRuns({ decision: "failed", aborted: true }, pi, null, null);
327
+ return keep(withRuns({ decision: "failed", aborted: true }, pi, null, null));
287
328
  }
288
329
  // The bounded nudge (BUGS.md 2026-10-02): the reviewer broke the no-re-run rule despite a
289
330
  // green pre-check, so one turn on its own session names the call and asks it to finish
@@ -295,11 +336,11 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
295
336
  const nudgeCalls = [];
296
337
  nudge = await requestNoRerun(followupCtx, rerun, nudgeCalls);
297
338
  if (nudge?.aborted) {
298
- return withRuns({ decision: "failed", aborted: true }, pi, null, nudge);
339
+ return keep(withRuns({ decision: "failed", aborted: true }, pi, null, nudge));
299
340
  }
300
341
  nudgeRepeat = nudge ? (suiteRerunWarning(nudgeCalls) ?? null) : null;
301
342
  if (nudgeRepeat) {
302
- warnEventBestEffort(root, role, `${nudgeRepeat} the repeat fails this review: the commit stays, and the landing re-reviews it without a third suite run`);
343
+ warnEventBestEffort(root, role, `${nudgeRepeat} the repeat fails this review: the commit stays, and the merge re-reviews it without a third suite run`);
303
344
  }
304
345
  }
305
346
  let verdict = parseVerdict(pi.verdictText ?? "");
@@ -324,7 +365,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
324
365
  if (!verdict && pi.ok && !nudge) {
325
366
  followUp = await requestVerdict(followupCtx);
326
367
  if (followUp?.aborted)
327
- return withRuns({ decision: "failed", aborted: true }, pi, followUp, nudge);
368
+ return keep(withRuns({ decision: "failed", aborted: true }, pi, followUp, nudge));
328
369
  const recovered = followUp ? parseVerdict(followUp.verdictText ?? "") : null;
329
370
  if (recovered) {
330
371
  verdict = recovered;
@@ -333,14 +374,14 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
333
374
  }
334
375
  // A repeat after the nudge (BUGS.md 2026-10-02): the reviewer re-ran the suite on the nudge
335
376
  // turn too. Fail the review — strike-free like a dead backend (BUGS.md 2026-09-20): the
336
- // commit stays for the next landing's re-review, the discard counter never advances, and no
377
+ // commit stays for the next merge's re-review, the discard counter never advances, and no
337
378
  // rejection is recorded against the author's diff.
338
379
  if (nudgeRepeat) {
339
- const message = `${nudgeRepeat} The review is failed on this repeat: the commit is kept, and the landing re-reviews it under the normal path.`;
340
- logEventBestEffort(root, { loop: role, type: "review_failed", head, message, durationMs: Date.now() - reviewStartedAt });
380
+ const message = `${nudgeRepeat} The review is failed on this repeat: the commit is kept, and the merge re-reviews it under the normal path.`;
381
+ logEventBestEffort(root, { source: role, type: "review_failed", head, message, durationMs: Date.now() - reviewStartedAt });
341
382
  state.reviewResume = undefined;
342
383
  recordReview(state, "failed", [message], head);
343
- return withRuns({ decision: "failed", detail: message }, pi, followUp, nudge);
384
+ return keep(withRuns({ decision: "failed", detail: message }, pi, followUp, nudge));
344
385
  }
345
386
  if (!verdict) {
346
387
  const followUpError = followUp && !followUp.ok ? followUp.errorMessage : undefined;
@@ -369,7 +410,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
369
410
  agentSessionResumable(reviewSessionDir, {
370
411
  kind: "gate",
371
412
  agent: reviewCfg.agent ?? "pi",
372
- });
413
+ }, claudeConfigDir);
373
414
  const reviewTimeoutRewrite = (message) => message.replace(" — session and worktree edits preserved for resume", resumeRemains
374
415
  ? " — the commit is kept; the next attempt continues this review"
375
416
  : " — the commit is kept; the next attempt reviews it from scratch");
@@ -381,7 +422,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
381
422
  : followUp
382
423
  ? ", even after a follow-up turn on its session"
383
424
  : ""}`);
384
- logEventBestEffort(root, { loop: role, type: "review_failed", head, message, durationMs: Date.now() - reviewStartedAt });
425
+ logEventBestEffort(root, { source: role, type: "review_failed", head, message, durationMs: Date.now() - reviewStartedAt });
385
426
  // A dead reviewer must never destroy committed work (BUGS.md 2026-09-20): leave the commit
386
427
  // for the next tick's re-review and do not advance the per-HEAD discard counter. That
387
428
  // holds for the review run itself (BUGS.md 2026-09-20), for the verdict-recovery
@@ -405,7 +446,7 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
405
446
  }
406
447
  recordReview(state, "failed", [message], head);
407
448
  const configError = pi.configError || Boolean(followUp?.configError) || Boolean(nudge?.configError);
408
- return withRuns({ decision: "failed", detail: message, ...(configError ? { configError: true } : {}) }, pi, followUp, nudge);
449
+ return keep(withRuns({ decision: "failed", detail: message, ...(configError ? { configError: true } : {}) }, pi, followUp, nudge));
409
450
  }
410
451
  // Consecutive failures of THIS HEAD only: a new commit (new HEAD) starts fresh. Read
411
452
  // *before* overwriting lastReview with this failure.
@@ -421,7 +462,8 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
421
462
  discarded = true;
422
463
  warnEventBestEffort(root, role, `discarding unreviewed leftover after ${REVIEW_FAILURE_LIMIT} failed reviews (${shortSha(head)})`);
423
464
  }
424
- return { ...withRuns({ decision: "failed", detail: message }, pi, followUp, nudge), ...(discarded ? { discarded: true } : {}) };
465
+ const failed = { ...withRuns({ decision: "failed", detail: message }, pi, followUp, nudge), ...(discarded ? { discarded: true } : {}) };
466
+ return discarded ? failed : keep(failed);
425
467
  }
426
468
  if (verdict.verdict === "reject") {
427
469
  const rejected = await reject(verdict.reasons, Date.now() - reviewStartedAt);
@@ -435,9 +477,9 @@ export async function reviewAheadOfMain(ctx, state, summary, commitBody, highFri
435
477
  state.reviewResume = undefined;
436
478
  recordReview(state, "approve", verdict.reasons, head);
437
479
  state.unreviewFailures = 0;
438
- await git(wt, "reset", "--hard", "HEAD");
480
+ await restoreReviewedHead(wt, head, root, role);
439
481
  logEventBestEffort(root, {
440
- loop: role,
482
+ source: role,
441
483
  type: "review_verdict",
442
484
  head,
443
485
  reason: verdict.reasons[0],
@@ -0,0 +1,127 @@
1
+ // vocabulary: plain
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { readTextOrNull } from "../files/files.js";
5
+ import { readJsonFile } from "../files/json-files.js";
6
+ import { changeBaseRev, fileContentAt } from "../git/git.js";
7
+ import { piModelsPath } from "../pi/pi-models.js";
8
+ import { moreSuffix } from "../text/phrases.js";
9
+ /** Environment variable names that hold credentials. */
10
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)/i;
11
+ /** A value shorter than this is a setting (a token limit), not a credential. */
12
+ const MIN_SECRET_LENGTH = 16;
13
+ /** Files larger than this are not scanned. */
14
+ const MAX_SCAN_BYTES = 8 * 1024 * 1024;
15
+ /** Most files named in one reason. */
16
+ const MAX_NAMED = 10;
17
+ /** A credential-shaped value: at least 16 characters, no whitespace, not a path. */
18
+ function looksLikeSecret(value) {
19
+ return value.length >= MIN_SECRET_LENGTH && !/\s/.test(value) && !value.startsWith("/");
20
+ }
21
+ /** The environment entries that look like credentials: a secret-like name and a value of at
22
+ * least 16 characters with no whitespace that is not a path (so `GOOGLE_APPLICATION_CREDENTIALS`
23
+ * pointing at a key file is not a secret). */
24
+ export function secretEnvValues(env) {
25
+ const out = [];
26
+ for (const [name, value] of Object.entries(env)) {
27
+ if (value === undefined || !SECRET_NAME.test(name))
28
+ continue;
29
+ if (!looksLikeSecret(value))
30
+ continue;
31
+ out.push({ name, value });
32
+ }
33
+ return out;
34
+ }
35
+ function isRecord(v) {
36
+ return typeof v === "object" && v !== null && !Array.isArray(v);
37
+ }
38
+ /** The credentials pi stores: literal `apiKey`s and header values in `models.json`, and the
39
+ * `key`/`access`/`refresh` fields (plus secret-named `env` values) of the sibling `auth.json`.
40
+ * `$NAME` interpolations (secretEnvValues covers those) and `!command` values are skipped. A
41
+ * missing or torn file yields nothing; never throws. */
42
+ export function piCredentialValues(modelsPath = piModelsPath()) {
43
+ const out = [];
44
+ const add = (name, value) => {
45
+ if (typeof value !== "string" || value.startsWith("$") || value.startsWith("!"))
46
+ return;
47
+ if (looksLikeSecret(value))
48
+ out.push({ name, value });
49
+ };
50
+ const models = readJsonFile(modelsPath);
51
+ if (models && isRecord(models.providers)) {
52
+ for (const [p, cfg] of Object.entries(models.providers)) {
53
+ if (!isRecord(cfg))
54
+ continue;
55
+ add(`models.json ${p}.apiKey`, cfg.apiKey);
56
+ if (isRecord(cfg.headers)) {
57
+ for (const [h, v] of Object.entries(cfg.headers))
58
+ add(`models.json ${p}.headers.${h}`, v);
59
+ }
60
+ }
61
+ }
62
+ const auth = readJsonFile(path.join(path.dirname(modelsPath), "auth.json"));
63
+ if (auth) {
64
+ for (const [p, cred] of Object.entries(auth)) {
65
+ if (!isRecord(cred))
66
+ continue;
67
+ for (const f of ["key", "access", "refresh"])
68
+ add(`auth.json ${p}.${f}`, cred[f]);
69
+ if (isRecord(cred.env)) {
70
+ for (const [n, v] of Object.entries(cred.env)) {
71
+ if (SECRET_NAME.test(n))
72
+ add(`auth.json ${p}.env.${n}`, v);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ /** Every secret the gate knows: secret-named environment values (named `$NAME`) then pi's stored
80
+ * credentials (named `pi <file> <path>`), the first entry winning when two share a value. */
81
+ export function knownSecrets(env = process.env, modelsPath = piModelsPath()) {
82
+ const all = [
83
+ ...secretEnvValues(env).map((s) => ({ name: `$${s.name}`, value: s.value })),
84
+ ...piCredentialValues(modelsPath).map((s) => ({ name: `pi ${s.name}`, value: s.value })),
85
+ ];
86
+ return all.filter((s, i) => all.findIndex((o) => o.value === s.value) === i);
87
+ }
88
+ /** The deterministic gate/stage finding for a change that writes the value of a secret
89
+ * environment variable or stored pi credential into a file (a Node diagnostic report dumps
90
+ * the whole environment). A value already present in the file at the merge-base is not this
91
+ * change's leak. The reason
92
+ * names files and variables, never the value. undefined when nothing leaked; never throws on
93
+ * an unreadable file. */
94
+ export async function secretLeakReason(wt, mainBranch, files, secrets = knownSecrets()) {
95
+ if (secrets.length === 0)
96
+ return undefined;
97
+ let baseRev;
98
+ const leaks = [];
99
+ for (const file of files) {
100
+ const full = path.join(wt, file);
101
+ let stat;
102
+ try {
103
+ stat = fs.statSync(full);
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
109
+ continue;
110
+ const head = readTextOrNull(full);
111
+ if (head === null)
112
+ continue;
113
+ const hits = secrets.filter((s) => head.includes(s.value));
114
+ if (hits.length === 0)
115
+ continue;
116
+ baseRev ??= await changeBaseRev(wt, mainBranch);
117
+ const base = await fileContentAt(wt, baseRev, file);
118
+ for (const s of hits) {
119
+ if (!base.includes(s.value))
120
+ leaks.push(`\`${file}\` contains the value of ${s.name}`);
121
+ }
122
+ }
123
+ if (leaks.length === 0)
124
+ return undefined;
125
+ const shown = leaks.slice(0, MAX_NAMED).join("; ");
126
+ return `${shown}${moreSuffix(leaks.length - MAX_NAMED)} — delete the file or remove the value; never commit credentials (a Node diagnostic report, report.*.json, dumps the whole environment)`;
127
+ }
@@ -1,8 +1,9 @@
1
+ // vocabulary: plain
1
2
  /** Detecting a reviewer run that re-ran the full suite on a tree the gate's pre-check already
2
3
  * verified — pure string logic over the bash command lines a pi run started, no subprocess or
3
4
  * file I/O. The review prompt tells such a reviewer not to (src/prompt/gate-prompts.ts), but on
4
- * 2026-09-23 two reviewers that got the instruction copied their lander worktree to /tmp and ran
5
- * `npm ci` and `npm test` there anyway, holding the landing slot and loading the shared host;
5
+ * 2026-09-23 two reviewers that got the instruction copied their merger worktree to /tmp and ran
6
+ * `npm ci` and `npm test` there anyway, holding the merge slot and loading the shared host;
6
7
  * 102 of 429 retained review sessions ran a suite or `npm ci` themselves (BUGS.md 2026-09-23).
7
8
  * The prompt is the fix, this is the tripwire: review.ts feeds the reviewer's tool calls through
8
9
  * suiteRerunWarning and logs a warning when the rule was broken, so the digest can count it.
@@ -174,5 +175,5 @@ export function suiteRerunWarning(calls) {
174
175
  if (first === undefined)
175
176
  return undefined;
176
177
  const more = moreSuffix(reruns.length - 1);
177
- return `reviewer re-ran the suite the harness's pre-check already verified: ${squash(first, 160)}${more}`;
178
+ return `reviewer re-ran the suite tumwater's pre-check already verified: ${squash(first, 160)}${more}`;
178
179
  }
@@ -0,0 +1,21 @@
1
+ /** The one table of retired role ids, old id → new id. The switch parts of the plain-terms role
2
+ * rename add a row each; a row is never removed, because old events, state files and typed
3
+ * flags keep naming the old id. */
4
+ export const LEGACY_ROLE_IDS = {
5
+ dry: "dedupe",
6
+ telemetry: "selfcheck",
7
+ };
8
+ /** Today's id for a role id a person typed or an old file holds; any other id comes back
9
+ * unchanged. `legacy` is a test seam. */
10
+ export function canonicalRoleId(id, legacy = LEGACY_ROLE_IDS) {
11
+ return Object.hasOwn(legacy, id) ? legacy[id] : id;
12
+ }
13
+ /** The retired ids that now map to `id`. */
14
+ export function legacyRoleIdsOf(id, legacy = LEGACY_ROLE_IDS) {
15
+ return Object.entries(legacy).filter(([, next]) => next === id).map(([old]) => old);
16
+ }
17
+ /** `--grep` arguments matching a role's commit subjects under its current id and every retired
18
+ * one. git ORs repeated `--grep`s, so a history search sees both. */
19
+ export function roleCommitGrep(id, legacy = LEGACY_ROLE_IDS) {
20
+ return [id, ...legacyRoleIdsOf(id, legacy)].map((r) => `--grep="tumwater(${r})"`).join(" ");
21
+ }