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,41 +1,42 @@
1
- /** The CLI layer of `tumwater bug` and `tumwater plan` — the operator-authored backlog
2
- * write half, mirroring the read half in backlog.ts: file a bug into BUGS.md's `## Open`
3
- * or a plan request into PLANS.md's `## Planned`, stamped the way loops write those
4
- * entries, and render the confirmation. Split out of cli.ts because the markdown surgery
5
- * it performs is more than a dispatch case: the entry must land after the section's last
6
- * entry and before the next `## ` heading, fenced-safe (a `## ` line quoted inside an
7
- * entry's code fence is body text, never a boundary — the same fenceTracker every backlog
8
- * reader consults), and a file tumwater init never seeded is created with init's template
9
- * rather than failing. The wake of the loop that should act stays in cli.ts beside the
10
- * other command bodies. */
1
+ // vocabulary: plain
2
+ /** The entry-writing half of `tumwater bug` and `tumwater plan` — the operator-authored backlog
3
+ * writes, mirroring the read half in backlog.ts: file a bug into BUGS.md's `## Open` or a plan
4
+ * request into PLANS.md's `## Planned`, stamped the way loops write those entries, and cancel,
5
+ * edit, release, or show an entry by position. The markdown surgery is more than a dispatch case:
6
+ * the entry must be placed after the section's last entry and before the next `## ` heading,
7
+ * fenced-safe (a `## ` line quoted inside an entry's code fence is body text, never a boundary —
8
+ * the same fenceTracker every backlog reader consults), and a file tumwater init never seeded is
9
+ * created with init's template rather than failing. Flag classification, preflights, and the
10
+ * printed confirmations live in backlog-cli.ts; the wake of the loop that should act stays in
11
+ * cli.ts beside the other command bodies. */
11
12
  import path from "node:path";
12
13
  import { fencedHeadingTitle, fenceTracker, headingMetadata, nextSectionHeading } from "./backlog-md.js";
13
14
  import { actionableEntryRanges, stripEntryStamp } from "./backlog-structure.js";
14
15
  import { readTextIfExists, writeTextAtomic } from "../files/files.js";
15
16
  import { formatDate } from "../text/datetime.js";
16
- import { fail, say, sayJson } from "../cli/cli-output.js";
17
- import { parsePositionFlag, rejectRepeatFlag } from "../cli/cli-args.js";
18
- import { promptFileValue, readPromptFileArg } from "../cli/cli-command-args.js";
19
- import { plannedPlanEntries, openBugEntries } from "./backlog.js";
20
- import { backlogHolds, holdMark } from "./backlog-eligibility.js";
21
- import { numberedList } from "../text/markdown.js";
17
+ import { fail } from "../cli/cli-output.js";
18
+ import { backlogHolds, withoutRefusalNotes, withoutWaitingNotes } from "./backlog-eligibility.js";
22
19
  import { collapseWhitespace, trimLeadingBlankLines, trimTrailingBlankLines } from "../text/text.js";
23
20
  import { BUGS_TEMPLATE, PLANS_TEMPLATE } from "../init/init.js";
24
- import { JSON_FLAG, rejectUnknownArgs } from "../cli/cli-flag-specs.js";
25
- import { requireReadyRepo } from "../cli/cli-repo-gate.js";
26
- import { submitRolePromptAndWake } from "../operator/operator-intent.js";
27
- import { promptLengthProblem } from "../inbox/inbox-submit.js";
28
21
  import { withStateLock } from "../concurrency/lock.js";
29
22
  import { primaryWorktreeLockPath } from "../paths.js";
30
23
  /** Today's stamp body every operator-filed entry carries, parenthesized by the caller that
31
24
  * builds the heading. Computed once per call (not module load) so a long-lived process
32
25
  * stamps with the date it filed on. */
33
- const operatorStamp = () => `reported by the operator ${formatDate(new Date())}`;
26
+ const operatorStamp = () => `reported by the user ${formatDate(new Date())}`;
27
+ /** The kind's open-section markdown file and its `## ` heading: bugs live in BUGS.md's `Open`
28
+ * section, plan requests in PLANS.md's `Planned`. Every position-based writer (cancel, edit,
29
+ * release, show) resolves the same pair. */
30
+ function backlogTarget(kind) {
31
+ return kind === "bug"
32
+ ? { fileName: "BUGS.md", sectionTitle: "Open" }
33
+ : { fileName: "PLANS.md", sectionTitle: "Planned" };
34
+ }
34
35
  /** The section-append entry point: run the whole read-modify-write under the cross-process
35
- * primary-worktree lock (paths.ts), which a landing's working-tree fast-forward (`ffMainTo`)
36
- * takes too, so a filed entry and a landed change to the same backlog file cannot each clobber
37
- * the other. The section is one read plus one atomic rename — short, never held across a build
38
- * check — and withStateLock creates the state dir the lock lives under on first use. */
36
+ * primary-worktree lock (paths.ts), which a merge's working-tree fast-forward (`ffMainTo`) takes
37
+ * too, so a filed entry and a merged change to the same backlog file cannot each clobber the other.
38
+ * The section is one read plus one atomic rename — short, never held across a build check — and
39
+ * withStateLock creates the state dir the lock lives under on first use. */
39
40
  function appendEntry(root, fileName, template, sectionTitle, heading, body) {
40
41
  withStateLock(primaryWorktreeLockPath(root), () => appendEntryLocked(root, fileName, template, sectionTitle, heading, body));
41
42
  }
@@ -102,7 +103,7 @@ function appendEntryLocked(root, fileName, template, sectionTitle, heading, body
102
103
  }
103
104
  /** The noun a cancel failure names the section by: the plan example's `no open bug at
104
105
  * position N`, and the parallel planned-feature wording for PLANS.md. */
105
- function sectionNoun(sectionTitle) {
106
+ export function sectionNoun(sectionTitle) {
106
107
  if (sectionTitle === "Open")
107
108
  return "open bug";
108
109
  if (sectionTitle === "Planned")
@@ -116,7 +117,10 @@ function locateEntry(md, fileName, sectionTitle, n) {
116
117
  const entry = findEntry(md, sectionTitle, n);
117
118
  if (entry === undefined) {
118
119
  const count = actionableEntryRanges(md, sectionTitle).length;
119
- fail(`no ${sectionNoun(sectionTitle)} at position ${n} (${count} in ${fileName})`);
120
+ // Name the valid range when the section is non-empty, so the operator sees the fix in the
121
+ // same line instead of deriving `1..N` from the count; an empty section needs no range.
122
+ const range = count > 0 ? ` — valid: 1-${count}` : "";
123
+ fail(`no ${sectionNoun(sectionTitle)} at position ${n} (${count} in ${fileName})${range}`);
120
124
  }
121
125
  return entry;
122
126
  }
@@ -136,7 +140,7 @@ function readEntryLines(root, fileName, sectionTitle, n) {
136
140
  return { file, lines: md.split("\n"), entry };
137
141
  }
138
142
  /** The section-remove entry point, the appendEntry analogue: the same cross-process lock, so a
139
- * cancel and a landing's working-tree fast-forward cannot each clobber the other's
143
+ * cancel and a merge's working-tree fast-forward cannot each clobber the other's
140
144
  * read-modify-write. */
141
145
  function removeEntry(root, fileName, sectionTitle, n) {
142
146
  return withStateLock(primaryWorktreeLockPath(root), () => removeEntryLocked(root, fileName, sectionTitle, n));
@@ -166,8 +170,7 @@ function spliceOutEntry(file, lines, entry) {
166
170
  * decided inside the lock. `expectedTitle` is the heading the caller saw (the status payload's
167
171
  * `plans`/`bugs` strings, the same `### ` text as ActionableEntryRange.title). */
168
172
  export function tryCancelEntry(root, kind, n, expectedTitle) {
169
- const fileName = kind === "bug" ? "BUGS.md" : "PLANS.md";
170
- const sectionTitle = kind === "bug" ? "Open" : "Planned";
173
+ const { fileName, sectionTitle } = backlogTarget(kind);
171
174
  return withStateLock(primaryWorktreeLockPath(root), () => {
172
175
  const file = path.join(root, fileName);
173
176
  const md = readTextIfExists(file) ?? "";
@@ -180,9 +183,9 @@ export function tryCancelEntry(root, kind, n, expectedTitle) {
180
183
  return { status: "cancelled", file: fileName, title: entry.title };
181
184
  });
182
185
  }
183
- /** The section-edit entry point, the appendEntry/removeEntry analogue: the same
184
- * cross-process lock, so an edit and a landing's working-tree fast-forward cannot each clobber
185
- * the other's read-modify-write. */
186
+ /** The section-edit entry point, the appendEntry/removeEntry analogue: the same cross-process lock,
187
+ * so an edit and a merge's working-tree fast-forward cannot each clobber the other's
188
+ * read-modify-write. */
186
189
  function editEntry(root, fileName, sectionTitle, n, title, body) {
187
190
  return withStateLock(primaryWorktreeLockPath(root), () => editEntryLocked(root, fileName, sectionTitle, n, title, body));
188
191
  }
@@ -237,8 +240,7 @@ export function bodyStructureProblem(body) {
237
240
  * body's line structure kept (no whitespace fold). The caller validates (bodyStructureProblem,
238
241
  * non-empty title) first, since fail() exits the process. */
239
242
  export function tryEditEntry(root, kind, n, expectedTitle, rawTitle, rawBody) {
240
- const fileName = kind === "bug" ? "BUGS.md" : "PLANS.md";
241
- const sectionTitle = kind === "bug" ? "Open" : "Planned";
243
+ const { fileName, sectionTitle } = backlogTarget(kind);
242
244
  const title = stripEntryStamp(collapseWhitespace(rawTitle));
243
245
  const body = rawBody.replace(/\r\n?/g, "\n").trim();
244
246
  return withStateLock(primaryWorktreeLockPath(root), () => {
@@ -253,113 +255,6 @@ export function tryEditEntry(root, kind, n, expectedTitle, rawTitle, rawBody) {
253
255
  return { status: "edited", file: fileName, title };
254
256
  });
255
257
  }
256
- /** The title each file command derives from the CLI positionals, exported so the CLI case
257
- * can hand fileAndAnnounce the same derivation the write will carry: fileAndAnnounce
258
- * preflights the composed wake prompt (wakeText(title)) against the submission cap BEFORE
259
- * the write, and a titleOf that drifted from file()'s own derivation would preflight the
260
- * wrong text. bugTitleOf(positionals) is exactly fileBug's symptom when the CLI case passes
261
- * positionals.join(" ") as its raw text; planTitleOf(positionals) is exactly filePlan's
262
- * title when it passes positionals[0]. */
263
- export function bugTitleOf(positionals) {
264
- return collapseWhitespace(positionals.join(" "));
265
- }
266
- export function planTitleOf(positionals) {
267
- return collapseWhitespace(positionals[0] ?? "");
268
- }
269
- /** The one home of the prose-carrying file commands' flag classification (the `bug`/`plan`
270
- * file forms and their `--edit` forms): `--json` is the one boolean, refused on a repeat
271
- * (the "each flag at most once" rule every spec-parsed command and the questions parser
272
- * apply); when a position flag is supplied it is the one valued flag, likewise refused on a
273
- * repeat and parsed through the shared plain-decimal rule (parsePositionFlag); `--help`/`-h`
274
- * rides into the prose positionals rather than being skipped, because the dispatcher's help
275
- * interception stands down for text-carrying invocations (cli.ts) so a help token here is
276
- * always content; every other flag-shaped token is named by rejectUnknownArgs, and every
277
- * bare token is prose. Tokens keep their original order. */
278
- function scanProseFlags(args, command, positionFlag) {
279
- let json = false;
280
- let position = null;
281
- const positionals = [];
282
- for (let i = 0; i < args.length; i++) {
283
- const arg = args[i] ?? "";
284
- if (arg === "--json") {
285
- rejectRepeatFlag(json, "--json");
286
- json = true;
287
- continue;
288
- }
289
- if (arg === positionFlag?.name) {
290
- rejectRepeatFlag(position !== null, positionFlag.name);
291
- position = parsePositionFlag(positionFlag.name, args[i + 1], positionFlag.example);
292
- i++;
293
- continue;
294
- }
295
- if (arg === "--help" || arg === "-h") {
296
- positionals.push(arg);
297
- continue;
298
- }
299
- if (arg.startsWith("-"))
300
- rejectUnknownArgs(command, [arg], [JSON_FLAG]);
301
- else
302
- positionals.push(arg);
303
- }
304
- return { json, positionals, position };
305
- }
306
- /** The shared shell of the `bug` and `plan` CLI cases — the one home of the five-step
307
- * sequence both commands run in cli.ts: peel the free-form positionals (the questions
308
- * command's prose-token pattern), reject anything flag-shaped that is not `--json`, run the
309
- * ready-repo gate the other backlog commands run, write the entry via `file`, wake the
310
- * `role` loop that should act so it picks the entry up within one poll, then announce — the
311
- * wake always fires, its confirmation line rides the prose output only, in `--json` mode
312
- * stdout is the payload document alone (the prompt --json precedent) so a script can parse
313
- * it. The command name and usage string ride in for the reject/confirmation/empty-arg
314
- * wordings (fileBug/filePlan's empty-arg usage lines); `wakeText` builds the loop prompt
315
- * from the filed entry's title and `titleOf` derives that title from the positionals (the
316
- * exported bugTitleOf/planTitleOf, so the preflight and the write see one derivation).
317
- * The wake prompt rides into the target tick's prefill under the same submission cap every
318
- * prompt answers to, so fileAndAnnounce checks it (promptLengthProblem) BEFORE calling
319
- * `file` — otherwise an over-long operator text would write its entry and only then throw
320
- * inside submitRolePromptAndWake, half-applying the command with the backlog file already
321
- * grown. With `--file <path>` (`-` reads stdin) the entry comes from a markdown file instead
322
- * of positionals: `fileText` receives its text (splitEntryFile: first line the title, the rest
323
- * the body kept line for line), the same preflight runs on that title, and any positional
324
- * alongside --file is refused. */
325
- export async function fileAndAnnounce(root, args, command, file, role, wakeText, titleOf, fileText) {
326
- const fileFlag = args.indexOf("--file");
327
- if (fileFlag >= 0) {
328
- const file = promptFileValue(args[fileFlag + 1]);
329
- const rest = args.filter((_, i) => i !== fileFlag && i !== fileFlag + 1);
330
- rejectRepeatFlag(rest.includes("--file"), "--file");
331
- const { json, positionals } = scanProseFlags(rest, command);
332
- const stray = positionals[0];
333
- if (stray !== undefined)
334
- fail(`unexpected argument ${JSON.stringify(stray)} — with --file the entry comes from the file`);
335
- await requireReadyRepo(root);
336
- const text = readPromptFileArg(file);
337
- const wakeProblem = promptLengthProblem(wakeText(splitEntryFile(text).title), role);
338
- if (wakeProblem)
339
- fail(wakeProblem);
340
- announceFiled(root, command, fileText(text), role, wakeText, json);
341
- return;
342
- }
343
- // peelPositionals cannot serve this command: bug/plan's text is operator prose, and a
344
- // standalone --help/-h token inside it ("the TUI mishandles --help output") is content —
345
- // the dispatcher's help interception stands down for text-carrying invocations (cli.ts),
346
- // so a help token that reaches here is always prose, and scanProseFlags keeps it in the
347
- // positionals rather than flag-shaping it into rest for rejectUnknownArgs to refuse.
348
- const { json, positionals } = scanProseFlags(args, command);
349
- await requireReadyRepo(root);
350
- const wakeProblem = promptLengthProblem(wakeText(titleOf(positionals)), role);
351
- if (wakeProblem)
352
- fail(wakeProblem);
353
- announceFiled(root, command, file(positionals), role, wakeText, json);
354
- }
355
- /** The tail both fileAndAnnounce forms share: wake the `role` loop with the filed entry's
356
- * title, then print the confirmation (the wake line rides the prose output only). */
357
- function announceFiled(root, command, filed, role, wakeText, json) {
358
- const wake = submitRolePromptAndWake(root, role, wakeText(filed.title));
359
- sayFiled(command, filed, json);
360
- if (!json)
361
- say(wake);
362
- }
363
258
  /** Split a markdown entry file into its title and body: the title is the first non-blank line
364
259
  * (a leading `#`-marker stripped, folded through collapseWhitespace), the body every later
365
260
  * line kept as written with trailing whitespace stripped and edge blank lines dropped. */
@@ -379,6 +274,21 @@ export function normalizeEntryBody(body) {
379
274
  trimTrailingBlankLines(lines);
380
275
  return lines.join("\n");
381
276
  }
277
+ /** Fail with the kind's empty-field usage line when the title/symptom is empty: the plan
278
+ * form names its missing field a "title", the bug form a "symptom". The single home of those
279
+ * two strings, shared by the inline, `--file`, and `--edit` forms so the CLI and dashboard
280
+ * can't drift. */
281
+ function requireEntryTitle(kind, title, usage) {
282
+ if (title === "")
283
+ fail(kind === "plan" ? `plan needs a title: ${usage}` : `bug needs a symptom: ${usage}`);
284
+ }
285
+ /** Fail when a split `--file` body would grow a phantom section (bodyStructureProblem). verb is
286
+ * the CLI verb in the message: "filed" for `--file`, "written" for `--edit <n> --file`. */
287
+ function requireBodyStructurallySound(body, verb) {
288
+ const problem = bodyStructureProblem(body);
289
+ if (problem)
290
+ fail(`the --file body cannot be ${verb}: ${problem}`);
291
+ }
382
292
  /** File an already-validated title and body under PLANS.md's `## Planned` (plan) or BUGS.md's
383
293
  * `## Open` (bug). Shared by the `--file` forms and the dashboard file endpoint; the caller has
384
294
  * checked the title is non-empty and the body passes bodyStructureProblem. */
@@ -388,35 +298,32 @@ export function fileEntryWithBody(root, kind, title, body) {
388
298
  ? appendOperatorEntry(root, "PLANS.md", PLANS_TEMPLATE, "Planned", t, body)
389
299
  : appendOperatorEntry(root, "BUGS.md", BUGS_TEMPLATE, "Open", t, body);
390
300
  }
301
+ /** The `--file` filing form shared by plan and bug: split the file's title and body, require a
302
+ * title and a structurally-sound body, and file under the kind's section (fileEntryWithBody).
303
+ * filePlanFromText and fileBugFromText are the public plan/bug twins routing through it. */
304
+ function fileFromText(root, kind, text, usage) {
305
+ const { title, body } = splitEntryFile(text);
306
+ requireEntryTitle(kind, title, usage);
307
+ requireBodyStructurallySound(body, "filed");
308
+ return fileEntryWithBody(root, kind, title, body);
309
+ }
391
310
  /** `tumwater plan --file`: file the markdown file's title and line-for-line body under
392
311
  * PLANS.md's `## Planned`. A body that would grow a phantom section writes nothing. */
393
312
  export function filePlanFromText(root, text, usage) {
394
- const { title, body } = splitEntryFile(text);
395
- if (title === "")
396
- fail(`plan needs a title: ${usage}`);
397
- const problem = bodyStructureProblem(body);
398
- if (problem)
399
- fail(`the --file body cannot be filed: ${problem}`);
400
- return fileEntryWithBody(root, "plan", title, body);
313
+ return fileFromText(root, "plan", text, usage);
401
314
  }
402
315
  /** `tumwater bug --file`: the bug twin of filePlanFromText, under BUGS.md's `## Open`. */
403
316
  export function fileBugFromText(root, text, usage) {
404
- const { title, body } = splitEntryFile(text);
405
- if (title === "")
406
- fail(`bug needs a symptom: ${usage}`);
407
- const problem = bodyStructureProblem(body);
408
- if (problem)
409
- fail(`the --file body cannot be filed: ${problem}`);
410
- return fileEntryWithBody(root, "bug", title, body);
317
+ return fileFromText(root, "bug", text, usage);
411
318
  }
412
319
  /** The loop prompt a filed bug wakes the `bugfix` loop with — one wording for the CLI's
413
320
  * `tumwater bug` and the dashboard's backlog-file endpoint. */
414
321
  export function bugWakeText(title) {
415
- return `Operator filed a new bug with \`tumwater bug\`: "${title}" — it is under BUGS.md ## Open; fix it.`;
322
+ return `The user filed a new bug with \`tumwater bug\`: "${title}" — it is under BUGS.md ## Open; fix it.`;
416
323
  }
417
324
  /** The loop prompt a requested plan wakes the `feature` loop with, shared like bugWakeText. */
418
325
  export function planWakeText(title) {
419
- return `Operator requested a plan with \`tumwater plan\`: "${title}" — it is under PLANS.md ## Planned; flesh out the plan stub.`;
326
+ return `The user requested a plan with \`tumwater plan\`: "${title}" — it is under PLANS.md ## Planned; flesh out the plan stub.`;
420
327
  }
421
328
  /** The loop each operator-filed kind wakes and the wording it wakes it with, keyed by the
422
329
  * kind the dashboard posts. */
@@ -434,26 +341,24 @@ function appendOperatorEntry(root, fileName, template, sectionTitle, title, body
434
341
  appendEntry(root, fileName, template, sectionTitle, `### ${title} (${stamp})`, body);
435
342
  return { file: fileName, title, stamp };
436
343
  }
437
- /** `tumwater bug "<symptom>"`: append one `### <symptom> (reported by the operator <date>)`
344
+ /** `tumwater bug "<symptom>"`: append one `### <symptom> (reported by the user <date>)`
438
345
  * entry to BUGS.md's `## Open`. The symptom is operator prose, so it folds to one line
439
346
  * (collapseWhitespace, the questions answer precedent) — a multi-line raw text could carry
440
347
  * a `## `/`### ` line and grow a phantom section no reader wrote. An empty symptom fails
441
348
  * with the usage line and writes nothing. */
442
349
  export function fileBug(root, rawText, usage) {
443
350
  const symptom = collapseWhitespace(rawText);
444
- if (symptom === "")
445
- fail(`bug needs a symptom: ${usage}`);
351
+ requireEntryTitle("bug", symptom, usage);
446
352
  return appendOperatorEntry(root, "BUGS.md", BUGS_TEMPLATE, "Open", symptom, "");
447
353
  }
448
- /** `tumwater plan "<title>" [body...]`: append one `### <title> (reported by the operator
354
+ /** `tumwater plan "<title>" [body...]`: append one `### <title> (reported by the user
449
355
  * <date>)` entry to PLANS.md's `## Planned`, with the optional body as the entry text. No
450
356
  * goal/approach/acceptance-criteria scaffolding is invented — the feature loop's
451
357
  * plan-refinement pass fleshes the stub out on pickup. An empty title fails with the usage
452
358
  * line and writes nothing. */
453
359
  export function filePlan(root, rawTitle, rawBody, usage) {
454
360
  const title = collapseWhitespace(rawTitle);
455
- if (title === "")
456
- fail(`plan needs a title: ${usage}`);
361
+ requireEntryTitle("plan", title, usage);
457
362
  return appendOperatorEntry(root, "PLANS.md", PLANS_TEMPLATE, "Planned", title, collapseWhitespace(rawBody));
458
363
  }
459
364
  /** `tumwater bug --cancel <n>`: remove the nth entry from BUGS.md's `## Open`, as
@@ -466,28 +371,29 @@ export function cancelBug(root, n) {
466
371
  export function cancelPlan(root, n) {
467
372
  return removeEntry(root, "PLANS.md", "Planned", n);
468
373
  }
374
+ /** The `--edit <n> --file` form shared by plan and bug: split the file, strip a pasted-back
375
+ * heading's own stamp, and require a title and structurally-sound body before splicing into the
376
+ * kind's section (editEntry). editPlanFromText and editBugFromText are the public plan/bug twins
377
+ * routing through it, so the two cannot drift. */
378
+ function editFromText(root, kind, n, text, usage) {
379
+ const { title: rawTitle, body } = splitEntryFile(text);
380
+ // A first line pasted back as the full heading carries the entry's stamp; the splice re-adds it.
381
+ const title = stripEntryStamp(rawTitle);
382
+ requireEntryTitle(kind, title, usage);
383
+ requireBodyStructurallySound(body, "written");
384
+ const target = backlogTarget(kind);
385
+ return editEntry(root, target.fileName, target.sectionTitle, n, title, body);
386
+ }
469
387
  /** `tumwater plan --edit <n> --file`: rewrite the nth `## Planned` entry from a markdown file,
470
388
  * title and body kept line for line (splitEntryFile), position and original stamp kept. A body
471
389
  * that would grow a phantom section writes nothing. */
472
390
  export function editPlanFromText(root, n, text, usage) {
473
- const { title, body } = splitEntryFile(text);
474
- if (title === "")
475
- fail(`plan needs a title: ${usage}`);
476
- const problem = bodyStructureProblem(body);
477
- if (problem)
478
- fail(`the --file body cannot be written: ${problem}`);
479
- return editEntry(root, "PLANS.md", "Planned", n, title, body);
391
+ return editFromText(root, "plan", n, text, usage);
480
392
  }
481
393
  /** `tumwater bug --edit <n> --file`: the bug twin of editPlanFromText over `## Open`. The body is
482
394
  * written as the file gives it, so a bug filed with `--file` can be revised with its body. */
483
395
  export function editBugFromText(root, n, text, usage) {
484
- const { title, body } = splitEntryFile(text);
485
- if (title === "")
486
- fail(`bug needs a symptom: ${usage}`);
487
- const problem = bodyStructureProblem(body);
488
- if (problem)
489
- fail(`the --file body cannot be written: ${problem}`);
490
- return editEntry(root, "BUGS.md", "Open", n, title, body);
396
+ return editFromText(root, "bug", n, text, usage);
491
397
  }
492
398
  /** `tumwater bug --edit <n> "<symptom>"`: replace the nth BUGS.md `## Open` entry's heading
493
399
  * text with the new symptom, keeping its position and its original `(reported by …)` stamp.
@@ -496,8 +402,7 @@ export function editBugFromText(root, n, text, usage) {
496
402
  * body, so the rebuilt entry is its heading alone. */
497
403
  export function editBug(root, n, rawText, usage) {
498
404
  const symptom = collapseWhitespace(rawText);
499
- if (symptom === "")
500
- fail(`bug needs a symptom: ${usage}`);
405
+ requireEntryTitle("bug", symptom, usage);
501
406
  return editEntry(root, "BUGS.md", "Open", n, symptom, "");
502
407
  }
503
408
  /** `tumwater plan --edit <n> "<title>" [body...]`: replace the nth PLANS.md `## Planned`
@@ -506,148 +411,121 @@ export function editBug(root, n, rawText, usage) {
506
411
  * phantom section; an empty title fails with the usage line and writes nothing. */
507
412
  export function editPlan(root, n, rawTitle, rawBody, usage) {
508
413
  const title = collapseWhitespace(rawTitle);
509
- if (title === "")
510
- fail(`plan needs a title: ${usage}`);
414
+ requireEntryTitle("plan", title, usage);
511
415
  const body = collapseWhitespace(rawBody);
512
416
  return editEntry(root, "PLANS.md", "Planned", n, title, body);
513
417
  }
514
- /** The shared shell of the `bug --cancel` and `plan --cancel` CLI cases, mirroring
515
- * fileAndAnnounce's flag handling: `--cancel <n>` is the one valued flag, `--json` the one
516
- * boolean (at most once), `--help`/`-h` tolerated; any positional or other flag is refused
517
- * through rejectUnknownArgs. The position parses through the plain-decimal rule every position
518
- * flag shares (parsePositionFlag, cli-args.ts), not Number(). After requireReadyRepo it prints
519
- * `cancelled <command> in <file>: <title>` or the `--json` payload {file, title}. Unlike
520
- * fileAndAnnounce there is no wake: removing work must not wake a loop. */
521
- export async function cancelAndAnnounce(root, args, command, cancel) {
522
- let position = null;
523
- let json = false;
524
- const rest = [];
525
- for (let i = 0; i < args.length; i++) {
526
- const arg = args[i];
527
- if (arg === "--json") {
528
- rejectRepeatFlag(json, "--json");
529
- json = true;
530
- continue;
531
- }
532
- if (arg === "--cancel") {
533
- rejectRepeatFlag(position !== null, "--cancel");
534
- position = parsePositionFlag("--cancel", args[i + 1], "--cancel 2");
535
- i++;
536
- continue;
537
- }
538
- if (arg === "--help" || arg === "-h")
539
- continue;
540
- rest.push(arg);
541
- }
542
- // rejectUnknownArgs names both a stray positional and an unknown flag, with this command's
543
- // (empty) valid-flag list — the vocabulary a cancel invocation accepts is exactly --cancel
544
- // and --json, both consumed above.
545
- rejectUnknownArgs(command, rest, []);
546
- if (position === null)
547
- fail(`--cancel needs a position (usage: tumwater ${command} --cancel <n>)`);
548
- await requireReadyRepo(root);
549
- const removed = cancel(position);
550
- if (json)
551
- sayJson({ file: removed.file, title: removed.title });
552
- else
553
- say(`cancelled ${command} in ${removed.file}: ${removed.title}`);
554
- }
555
- /** The shared shell of the `bug --list` and `plan --list` CLI cases — the read half of the
556
- * operator queue whose `--edit`/`--cancel` flags address entries by position. Reads the same
557
- * entries, in the same file order, that locateEntry numbers for `--edit`/`--cancel`, pairs
558
- * each with its eligibility hold (backlogHolds), and prints them numbered from 1, or the
559
- * command's `no …` line on an empty or missing file. Like `tumwater backlog` and `tumwater
560
- * questions` it is read-only: no requireReadyRepo gate and no wake. `--list` and `--json` are
561
- * the two booleans this shell consumes (repeats refused), `--help`/`-h` is tolerated, and any
562
- * positional or other flag is refused through rejectUnknownArgs. */
563
- export function listAndAnnounce(root, args, command, fileName, sectionTitle) {
564
- let json = false;
565
- let list = false;
566
- const rest = [];
567
- for (const arg of args) {
568
- if (arg === "--list") {
569
- rejectRepeatFlag(list, "--list");
570
- list = true;
571
- continue;
572
- }
573
- if (arg === "--json") {
574
- rejectRepeatFlag(json, "--json");
575
- json = true;
576
- continue;
577
- }
578
- if (arg === "--help" || arg === "-h")
579
- continue;
580
- rest.push(arg);
581
- }
582
- // rejectUnknownArgs names a stray positional and an unknown flag with this command's (empty)
583
- // valid-flag list: the vocabulary a list invocation accepts is exactly --list and --json,
584
- // both consumed above.
585
- rejectUnknownArgs(command, rest, []);
586
- // BUGS.md's `## Open` is the one section whose noun is "bug"; every other caller is a plan.
587
- const bugSection = sectionTitle === "Open";
588
- const entries = bugSection ? openBugEntries(root) : plannedPlanEntries(root);
589
- const holds = backlogHolds(root);
590
- const entryHolds = bugSection ? holds.bugs : holds.plans;
591
- if (json) {
592
- sayJson({
593
- entries: entries.map((entry, i) => ({
594
- position: i + 1,
595
- title: entry.title,
596
- body: entry.body,
597
- hold: entryHolds[i] ?? null,
598
- })),
599
- });
600
- return;
601
- }
602
- const noun = sectionNoun(sectionTitle);
603
- if (entries.length === 0) {
604
- say(`no ${noun}s`);
605
- return;
606
- }
607
- const numbered = entries.map((entry, i) => `${entry.title}${holdMark(entryHolds[i] ?? null)}`);
608
- say(`${noun}s in ${fileName}:\n${numberedList(numbered)}`);
609
- }
610
- /** The shared shell of the `bug --edit` and `plan --edit` CLI cases, mirroring
611
- * fileAndAnnounce's prose classification and cancelAndAnnounce's flag handling: `--edit <n>` is
612
- * the one valued flag, `--json` the one boolean (at most once), `--help`/`-h` ride into the new
613
- * text, and any other flag-shaped token is refused through rejectUnknownArgs. Unlike cancel,
614
- * what remains after the flags is the new text the callback folds to one line (so a `## `/`### `
615
- * line quoted in it can never grow a phantom section). After requireReadyRepo it prints
616
- * `edited <command> in <file>: <title>` or the `--json` payload {file, title}. Unlike
617
- * fileAndAnnounce there is no wake: an edit adds no work for a loop to pick up. With
618
- * `--file <path>` (`-` reads stdin) the new entry comes from a markdown file instead of
619
- * positionals: `editText` receives its text, and any positional alongside --file is refused. */
620
- export async function editAndAnnounce(root, args, command, edit, editText) {
621
- const fileFlag = args.indexOf("--file");
622
- const path = fileFlag >= 0 ? promptFileValue(args[fileFlag + 1]) : null;
623
- const rest = path === null ? args : args.filter((_, i) => i !== fileFlag && i !== fileFlag + 1);
624
- if (path !== null)
625
- rejectRepeatFlag(rest.includes("--file"), "--file");
626
- const { json, positionals, position } = scanProseFlags(rest, command, {
627
- name: "--edit",
628
- example: "--edit 2",
418
+ /** Drop the Refused note from `entry`'s slice of `lines`, in place; an entry with none loses its
419
+ * Waiting-for-the-next-failure note instead. False (and `lines` untouched) when the entry
420
+ * carries neither; shared by releaseEntry and tryReleaseEntry so they cannot
421
+ * drift. */
422
+ function liftRefusal(lines, entry) {
423
+ const slice = lines.slice(entry.start - 1, entry.end);
424
+ let kept = withoutRefusalNotes(slice);
425
+ if (kept.length === slice.length)
426
+ kept = withoutWaitingNotes(slice);
427
+ if (kept.length === slice.length)
428
+ return false;
429
+ lines.splice(entry.start - 1, slice.length, ...kept);
430
+ return true;
431
+ }
432
+ /** The non-exiting release the dashboard calls: releaseEntry's splice with tryCancelEntry's
433
+ * title check, all decided inside the lock. */
434
+ export function tryReleaseEntry(root, kind, n, expectedTitle) {
435
+ const { fileName, sectionTitle } = backlogTarget(kind);
436
+ return withStateLock(primaryWorktreeLockPath(root), () => {
437
+ const file = path.join(root, fileName);
438
+ const md = readTextIfExists(file) ?? "";
439
+ const entry = findEntry(md, sectionTitle, n);
440
+ if (entry === undefined)
441
+ return { status: "gone" };
442
+ if (entry.title !== expectedTitle)
443
+ return { status: "moved", title: entry.title };
444
+ const lines = md.split("\n");
445
+ if (!liftRefusal(lines, entry))
446
+ return { status: "not_refused", title: entry.title };
447
+ writeTextAtomic(file, lines.join("\n"));
448
+ return { status: "released", file: fileName, title: entry.title };
629
449
  });
630
- if (path !== null) {
631
- const stray = positionals[0];
632
- if (stray !== undefined)
633
- fail(`unexpected argument ${JSON.stringify(stray)} — with --file the entry comes from the file`);
450
+ }
451
+ /** Lift the Refused or Waiting for the next failure note from the
452
+ * `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
453
+ * file, under the same lock the cancel and edit writers hold.
454
+ * An out-of-range position fails with locateEntry's wording; an entry with neither note
455
+ * fails too, writing nothing. */
456
+ export function releaseEntry(root, kind, n) {
457
+ const { fileName, sectionTitle } = backlogTarget(kind);
458
+ return withStateLock(primaryWorktreeLockPath(root), () => {
459
+ const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
460
+ if (!liftRefusal(lines, entry))
461
+ fail(`${kind} #${n} "${entry.title}" carries no Refused or Waiting for the next failure note`);
462
+ writeTextAtomic(file, lines.join("\n"));
463
+ return { file: fileName, title: entry.title };
464
+ });
465
+ }
466
+ /** Move the `n`th entry (1-based, as `--list` numbers them) of the kind's open section to the
467
+ * head of that section and write the file, under the same lock as the other writers. Position
468
+ * 1 fails ("is already first") and an out-of-range one fails with locateEntry's wording, both
469
+ * writing nothing. The entry's lines move byte for byte; only the blank lines around the cut
470
+ * and the splice are normalized, and an entry that was the file's last content leaves the file
471
+ * ending as it did before the move (a trailing newline iff it had one). */
472
+ export function moveEntryToTop(root, kind, n) {
473
+ const { fileName, sectionTitle } = backlogTarget(kind);
474
+ return withStateLock(primaryWorktreeLockPath(root), () => {
475
+ const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
476
+ if (n === 1)
477
+ fail(`${kind} #1 "${entry.title}" is already first`);
478
+ spliceEntryToTop(lines, sectionTitle, entry);
479
+ writeTextAtomic(file, lines.join("\n"));
480
+ return { file: fileName, title: entry.title };
481
+ });
482
+ }
483
+ /** Cut `entry` out of `lines` and put it at the head of the section, in place; shared by
484
+ * moveEntryToTop and tryMoveEntryToTop so their bytes cannot drift. */
485
+ function spliceEntryToTop(lines, sectionTitle, entry) {
486
+ const first = findEntry(lines.join("\n"), sectionTitle, 1);
487
+ const endsWithNewline = lines[lines.length - 1] === "";
488
+ const atEof = lines.slice(entry.end).every((l) => l.trim() === "");
489
+ const cut = lines.slice(entry.start - 1, entry.end);
490
+ trimTrailingBlankLines(cut);
491
+ lines.splice(entry.start - 1, entry.end - entry.start + 1);
492
+ if (atEof) {
493
+ trimTrailingBlankLines(lines);
494
+ if (endsWithNewline)
495
+ lines.push("");
634
496
  }
635
- if (position === null)
636
- fail(`--edit needs a position (usage: tumwater ${command} --edit <n>)`);
637
- await requireReadyRepo(root);
638
- const edited = path === null ? edit(position, positionals) : editText(position, readPromptFileArg(path));
639
- if (json)
640
- sayJson({ file: edited.file, title: edited.title });
641
- else
642
- say(`edited ${command} in ${edited.file}: ${edited.title}`);
643
- }
644
- /** One confirmation for both file commands, prose or `--json`: the JSON branch prints the
645
- * {file, title, stamp} payload as data, the prose branch one line naming the command, the
646
- * file, and the entry title. Private to this module — cli.ts reaches it through
647
- * fileAndAnnounce, the shared shell both file commands' CLI cases run through. */
648
- function sayFiled(command, filed, json) {
649
- if (json)
650
- sayJson({ file: filed.file, title: filed.title, stamp: filed.stamp });
651
- else
652
- say(`filed ${command} in ${filed.file}: ${filed.title}`);
497
+ lines.splice(first.start - 1, 0, ...cut, "");
498
+ }
499
+ /** The non-exiting reorder the dashboard calls: moveEntryToTop's splice with tryReleaseEntry's
500
+ * title check, all decided inside the lock. */
501
+ export function tryMoveEntryToTop(root, kind, n, expectedTitle) {
502
+ const { fileName, sectionTitle } = backlogTarget(kind);
503
+ return withStateLock(primaryWorktreeLockPath(root), () => {
504
+ const file = path.join(root, fileName);
505
+ const md = readTextIfExists(file) ?? "";
506
+ const entry = findEntry(md, sectionTitle, n);
507
+ if (entry === undefined)
508
+ return { status: "gone" };
509
+ if (entry.title !== expectedTitle)
510
+ return { status: "moved", title: entry.title };
511
+ if (n === 1)
512
+ return { status: "already_first", title: entry.title };
513
+ const lines = md.split("\n");
514
+ spliceEntryToTop(lines, sectionTitle, entry);
515
+ writeTextAtomic(file, lines.join("\n"));
516
+ return { status: "moved_to_top", file: fileName, title: entry.title };
517
+ });
518
+ }
519
+ /** The nth entry as the markdown file `--edit <n> --file` reads back: the stamp-stripped title
520
+ * and the body with its indentation kept (read from the raw lines, not the trimmed
521
+ * BacklogEntry.body), plus the entry's eligibility hold. Read-only. */
522
+ export function showEntry(root, kind, n) {
523
+ const { fileName, sectionTitle } = backlogTarget(kind);
524
+ const { lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
525
+ const meta = headingMetadata(lines, entry.start - 1);
526
+ const title = collapseWhitespace(stripEntryStamp(meta.text.replace(/^### /, "")));
527
+ const body = normalizeEntryBody(lines.slice(meta.next, entry.end).join("\n"));
528
+ const holds = backlogHolds(root);
529
+ const hold = (kind === "bug" ? holds.bugs : holds.plans)[n - 1] ?? null;
530
+ return { position: n, title, body, hold };
653
531
  }