tumwater 0.4.2 → 0.4.3

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 (305) 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} +25 -12
  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 +1 -8
  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 +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -93,35 +93,24 @@ const pushReason = async (findings, check) => {
93
93
  if (reason)
94
94
  findings.push(reason);
95
95
  };
96
- /** The findings likely to get this uncommitted change rejected at merge, or an empty list when
97
- * it is clean: the merge gate's own deterministic checks plus the staging-only heuristics (plan
98
- * dependencies, path drift). Checks the worktree at `wt` against
99
- * `mainBranch`'s merge-base; `exemptPaths` are the config's review-exemption globs that scope
100
- * the false-fix check.
101
- *
102
- * Never throws: a check that fails yields no finding, so a git hiccup at staging time defers to
103
- * the gate instead of failing the run. */
104
- export async function stageCheckFindings(wt, mainBranch, exemptPaths, role) {
105
- const files = await changedFiles(wt).catch(() => null);
106
- if (files === null || files.length === 0)
107
- return [];
108
- const findings = [];
109
- await pushReason(findings, backlogStructureReason(wt, mainBranch, files));
110
- if (files.includes("PLANS.md")) {
111
- const { head, base } = await headAndBase(wt, mainBranch, "PLANS.md");
112
- // Finished plans are archived in PLANS-DONE.md (plans/backlog-archive.md): a requires clause
113
- // naming a merged part resolves there, on both sides of the comparison alike.
114
- const archived = parseEntryDetails(readTextOrNull(path.join(wt, PLANS_ARCHIVE)) ?? "", "Done").map((e) => e.title);
115
- const dependency = base === undefined ? undefined : planDependencyFinding(head, base, archived);
116
- if (dependency !== undefined)
117
- findings.push(dependency);
118
- const refusal = base === undefined ? undefined : ungroundedRefusalFinding("PLANS.md", head, base);
119
- if (refusal !== undefined)
120
- findings.push(refusal);
121
- }
96
+ /** The PLANS.md record findings: a requires clause naming a part the archive lacks, and an
97
+ * ungrounded refusal that cites another part. */
98
+ async function plansFindings(wt, mainBranch) {
99
+ const { head, base } = await headAndBase(wt, mainBranch, "PLANS.md");
100
+ // Finished plans are archived in PLANS-DONE.md (plans/backlog-archive.md): a requires clause
101
+ // naming a merged part resolves there, on both sides of the comparison alike.
102
+ const archived = parseEntryDetails(readTextOrNull(path.join(wt, PLANS_ARCHIVE)) ?? "", "Done").map((e) => e.title);
103
+ const dependency = base === undefined ? undefined : planDependencyFinding(head, base, archived);
104
+ const refusal = base === undefined ? undefined : ungroundedRefusalFinding("PLANS.md", head, base);
105
+ return [dependency, refusal].filter((f) => f !== undefined);
106
+ }
107
+ /** The finished-archive findings: duplicates of entries the archive already holds, plus the
108
+ * acceptance greps of the plan entries the change finished (for a PLANS.md change). */
109
+ async function archiveFindings(wt, mainBranch, files) {
122
110
  const baseRev = FINISHED_ARCHIVES.some((s) => files.includes(s.file) || files.includes(s.archive))
123
111
  ? await changeBaseRev(wt, mainBranch).catch(() => undefined)
124
112
  : undefined;
113
+ const findings = [];
125
114
  for (const spec of FINISHED_ARCHIVES) {
126
115
  if (baseRev === undefined || !(files.includes(spec.file) || files.includes(spec.archive)))
127
116
  continue;
@@ -144,12 +133,18 @@ export async function stageCheckFindings(wt, mainBranch, exemptPaths, role) {
144
133
  })().catch(() => []);
145
134
  findings.push(...greps);
146
135
  }
147
- if (files.includes("BUGS.md")) {
148
- const { head, base } = await headAndBase(wt, mainBranch, "BUGS.md");
149
- const refusal = base === undefined ? undefined : ungroundedRefusalFinding("BUGS.md", head, base);
150
- if (refusal !== undefined)
151
- findings.push(refusal);
152
- }
136
+ return findings;
137
+ }
138
+ /** The BUGS.md record finding: an ungrounded refusal that cites another bug. */
139
+ async function bugsFindings(wt, mainBranch) {
140
+ const { head, base } = await headAndBase(wt, mainBranch, "BUGS.md");
141
+ const refusal = base === undefined ? undefined : ungroundedRefusalFinding("BUGS.md", head, base);
142
+ return refusal === undefined ? [] : [refusal];
143
+ }
144
+ /** The stray-placeholder findings: a `_None yet._` line a changed record still leaves in a
145
+ * section that holds entries. */
146
+ async function placeholderFindings(wt, mainBranch, files) {
147
+ const findings = [];
153
148
  for (const file of PLACEHOLDER_FILES) {
154
149
  if (!files.includes(file))
155
150
  continue;
@@ -158,6 +153,28 @@ export async function stageCheckFindings(wt, mainBranch, exemptPaths, role) {
158
153
  if (stray !== undefined)
159
154
  findings.push(stray);
160
155
  }
156
+ return findings;
157
+ }
158
+ /** The findings likely to get this uncommitted change rejected at merge, or an empty list when
159
+ * it is clean: the merge gate's own deterministic checks plus the staging-only heuristics (plan
160
+ * dependencies, path drift). Checks the worktree at `wt` against
161
+ * `mainBranch`'s merge-base; `exemptPaths` are the config's review-exemption globs that scope
162
+ * the false-fix check.
163
+ *
164
+ * Never throws: a check that fails yields no finding, so a git hiccup at staging time defers to
165
+ * the gate instead of failing the run. */
166
+ export async function stageCheckFindings(wt, mainBranch, exemptPaths, role) {
167
+ const files = await changedFiles(wt).catch(() => null);
168
+ if (files === null || files.length === 0)
169
+ return [];
170
+ const findings = [];
171
+ await pushReason(findings, backlogStructureReason(wt, mainBranch, files));
172
+ if (files.includes("PLANS.md"))
173
+ findings.push(...(await plansFindings(wt, mainBranch)));
174
+ findings.push(...(await archiveFindings(wt, mainBranch, files)));
175
+ if (files.includes("BUGS.md"))
176
+ findings.push(...(await bugsFindings(wt, mainBranch)));
177
+ findings.push(...(await placeholderFindings(wt, mainBranch, files)));
161
178
  await pushReason(findings, protectedEditReason(wt, mainBranch, files, role));
162
179
  await pushReason(findings, secretLeakReason(wt, mainBranch, files));
163
180
  if (isExemptDiff(files, exemptPaths))
@@ -16,6 +16,34 @@ export const MAX_STALE_PATHS = 10;
16
16
  export function diffPlusPath(line) {
17
17
  return line.slice(4).replace(/^b\//, "");
18
18
  }
19
+ /** Parse a `-U0` unified diff into each file's removed and added content lines, marker-stripped.
20
+ * The `diff --git` header names the file, and git's `---`/`+++` old/new path headers carry no
21
+ * content; `@@` hunk headers, context lines and the `\ No newline` marker match neither branch,
22
+ * so the walk records only real `-`/`+` content lines. Consumers keep only what they want — a
23
+ * JS-file filter, a trim-and-min-length rule, a compound-name extraction — from the per-file
24
+ * removed/added lists; stage-path-checks's movedSourceFindings and stage-name-checks's
25
+ * declaredNameDeltas each walked this same grammar before the helper. stage-prose-checks's
26
+ * detachedDocFindings and addedDiffLines are deliberately not folded here: both need each hunk's
27
+ * start-line count from the `@@` header, which this shape drops. */
28
+ export function diffRemovedAdded(diff) {
29
+ const files = [];
30
+ let cur;
31
+ for (const line of diff.split("\n")) {
32
+ const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
33
+ if (header) {
34
+ cur = { path: header[1], removed: [], added: [] };
35
+ files.push(cur);
36
+ continue;
37
+ }
38
+ if (!cur)
39
+ continue;
40
+ if (line.startsWith("-") && !line.startsWith("---"))
41
+ cur.removed.push(line.slice(1));
42
+ else if (line.startsWith("+") && !line.startsWith("+++"))
43
+ cur.added.push(line.slice(1));
44
+ }
45
+ return files;
46
+ }
19
47
  /** Parse `git grep -n` stage output into `file:line:text` hits. `git grep -n` prints one
20
48
  * `path:line:text` per match, and the four name/path checks (stage-name-checks.ts's
21
49
  * staleNameFindings and movedNameFindings, stage-path-checks.ts's movedSourceFindings and
@@ -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 [