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,13 +1,13 @@
1
1
  import path from "node:path";
2
2
  import { cachedTextByStat } from "../files/stat-cache.js";
3
- import { entryDates, parseEntryDetails } from "./backlog-md.js";
3
+ import { entryDates, finishedArchiveOf, lastDate, parseEntryDetails } from "./backlog-md.js";
4
4
  /** The stat-cached file readers of the project backlog data shown on both dashboards: planned
5
5
  * features (PLANS.md), open bugs (BUGS.md), and open questions (QUESTIONS.md). These are tracked
6
- * markdown that loops edit, so readers must never show a stale entry — but the dashboards poll
7
- * them every second while the files change only when a loop lands an edit. Each reader therefore
8
- * serves an unchanged file from a stat-keyed cache: one syscall per file per poll instead of
9
- * re-reading and re-parsing markdown that grows without bound over the project's lifetime
10
- * (PLANS/BUGS are append-only durable memory). Any write invalidates it via dev/ino/mtime/size
6
+ * markdown that loops edit, so readers must never show a stale entry — but the dashboards poll them
7
+ * every second while the files change only when a loop merges an edit. Each reader therefore serves
8
+ * an unchanged file from a stat-keyed cache: one syscall per file per poll instead of re-reading
9
+ * and re-parsing markdown that grows without bound over the project's lifetime (PLANS/BUGS are
10
+ * append-only durable memory). Any write invalidates it via dev/ino/mtime/size
11
11
  * (stat-cache.cachedByStat, same freshness check as files/tail.ts's incremental log readers). The
12
12
  * pure markdown parsing underneath lives in src/backlog/backlog-md.ts — this module owns only
13
13
  * reading and caching. Each dashboard formats this data for its own surface (the TUI's lines live
@@ -29,26 +29,113 @@ function sectionEntries(root, fileName, sectionTitle) {
29
29
  /** Stat-keyed cache of the completion-date reads, keyed by file + section + date regex (each
30
30
  * completion section's verbs differ) and bounded inside cachedTextByStat like sectionCache. */
31
31
  const dateCache = new Map();
32
- /** Completion dates of an entry section (PLANS.md `## Done`, BUGS.md `## Fixed`), stat-cached
33
- * like sectionEntries above. The usage report scans these sections on every /api/report fetch,
34
- * but the files change only when a loop lands an edit — serving an unchanged file from the
35
- * cache makes that cost one stat instead of a full read plus an O(size) markdown walk of
36
- * append-only documents that grow without bound. The date regex is part of the cache key
37
- * (each section's completion verbs differ); a missing or unreadable file yields []. */
32
+ /** Completion dates of an entry section (PLANS.md `## Done`, BUGS.md `## Fixed`), stat-cached like
33
+ * sectionEntries above. The usage report scans these sections on every /api/report fetch, but the
34
+ * files change only when a loop merges an edit — serving an unchanged file from the cache makes
35
+ * that cost one stat instead of a full read plus an O(size) markdown walk of append-only documents
36
+ * that grow without bound. The date regex is part of the cache key (each section's completion verbs
37
+ * differ); a missing or unreadable file yields []. */
38
38
  export function sectionCompletionDates(root, fileName, sectionTitle, dateRe) {
39
39
  const file = path.join(root, fileName);
40
40
  return (cachedTextByStat(dateCache, `${file}\u0000${sectionTitle}\u0000${dateRe.source}\u0000${dateRe.flags}`, file, (md) => entryDates(md, sectionTitle, dateRe), (dates) => dates.slice()) ?? []);
41
41
  }
42
+ /** Filing dates of an entry section (PLANS.md `## Planned` and `## Done`; BUGS.md `## Open`
43
+ * and `## Fixed`), stat-cached and detected exactly like sectionCompletionDates — the per-day
44
+ * filing twin of that completion read, but reading each entry's FIRST stamp date (entryDates'
45
+ * `first` mode) rather than the completion read's last-date: the filing date is the day the
46
+ * entry first opened, matching the canonical `filingDate` first-match that `plan|bug --list`
47
+ * and the doctor's unfixed-bug age use, so a later re-filing or a cross-reference
48
+ * ("found by … <early date>; found by … <later date>") cannot land the filing on the later day.
49
+ * Where the completion read keys its cache by the section
50
+ * date regex, this
51
+ * uses the module's fixed STAMP_DATE_RE, so the cache key carries a distinct `\u0000planned` marker
52
+ * instead (Plan adds and bug filings share the stamp verb set, so the section title alone cannot
53
+ * tell this read from a completion read of the same section). Every eligible heading carries its
54
+ * filing stamp in prose (`planned DATE`, `reported DATE`, `found by … DATE`), and the Usage view
55
+ * collects them per day so an operator can read net progress (opened vs done). A missing or
56
+ * unreadable file yields []. */
57
+ export function sectionFilingDates(root, fileName, sectionTitle) {
58
+ const file = path.join(root, fileName);
59
+ return (cachedTextByStat(dateCache, `${file}\u0000${sectionTitle}\u0000planned`, file, (md) => entryDates(md, sectionTitle, STAMP_DATE_RE, true), (dates) => dates.slice()) ?? []);
60
+ }
61
+ /** The first `YYYY-MM-DD` after a `planned`/`reported`/`found by` stamp word in an entry heading:
62
+ * the filing date every eligible heading carries in prose (plans open with `(planned YYYY-MM-DD…)`,
63
+ * bugs with `(found by …)` or `(reported …)`), parsed so `plan|bug --list` shows how long an entry
64
+ * has waited, without opening the file. Read only the title — the whole heading, never the body,
65
+ * because the stamp is the entry's own and the body may name other dates.
66
+ * null when the heading carries no such clause. */
67
+ const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
68
+ export function filingDate(title) {
69
+ const m = STAMP_DATE_RE.exec(title);
70
+ return m ? m[1] : null;
71
+ }
72
+ /** The completion stamp of a `## Done` plan heading. */
73
+ export const DONE_DATE_RE = /done (\d{4}-\d{2}-\d{2})/;
74
+ // The completion verbs track the phrasing the harness's own loops actually write: the bugfix prompt
75
+ // pins the date, not the verb, and the re-merge flow's own epitaph — "re-merged <date>" on an entry
76
+ // whose first merge was rejected in review — is a completion the report must count (a rejected
77
+ // merge is not a fix, the re-merge is). Body verbs without a date ("was fixed;") match nothing, and
78
+ // a bare "merged <date>" stays out: headings mention sibling merges, and a sibling's date is not
79
+ // this entry's. The completion stamp of a `## Fixed` bug heading. */
80
+ export const FIXED_DATE_RE = /\b(?:re-landed|fixed|closed|resolved) (\d{4}-\d{2}-\d{2})/;
81
+ /** The date a finished entry's heading records for `dateRe` (DONE_DATE_RE / FIXED_DATE_RE): its
82
+ * LAST match. Reads only the heading line, like filingDate; a date on a wrapped continuation
83
+ * line or in the body yields null. */
84
+ export function completionDate(title, dateRe) {
85
+ return lastDate(title, dateRe);
86
+ }
87
+ /** Whole days between a filing stamp and `now`, floored at 0 so a future-dated entry reads as
88
+ * fresh (0d) rather than negative. Shared by the suffix and the `--list --json` `ageDays` field. */
89
+ export function ageInDays(since, now = Date.now()) {
90
+ return Math.max(0, Math.floor((now - Date.parse(since)) / 86400000));
91
+ }
92
+ /** The ` · since YYYY-MM-DD (Nd)`/`(today)` tail a listing line gains for its entry's filing
93
+ * stamp — the one place the wording and the today/`Nd` choice live, so the plans/bugs list
94
+ * (entryAgeSuffix, stamp parsed by filingDate) and the questions list (questionAgeSuffix,
95
+ * parsed by questionFilingDate) cannot drift. `date` is the already-parsed stamp; the caller
96
+ * returns `""` for an undated heading. */
97
+ export function ageSuffix(date, now = Date.now()) {
98
+ const n = ageInDays(date, now);
99
+ return ` · since ${date} (${n === 0 ? "today" : `${n}d`})`;
100
+ }
101
+ /** The ` · since YYYY-MM-DD (Nd)`/`(today)` tail an entry's listing line gains when its heading
102
+ * parses a filing stamp, else `""` — rendered through ageSuffix, the one place the age is
103
+ * worded (shared with the questions list's questionAgeSuffix). */
104
+ export function entryAgeSuffix(title, now = Date.now()) {
105
+ const since = filingDate(title);
106
+ return since === null ? "" : ageSuffix(since, now);
107
+ }
42
108
  /** Planned features: the `### ` headings under PLANS.md's `## Planned` section. Missing or
43
109
  * unreadable → []. */
44
110
  export function plannedPlans(root) {
45
111
  return sectionEntries(root, "PLANS.md", "Planned").map((e) => e.title);
46
112
  }
47
- /** Done plans: the `### ` headings under PLANS.md's `## Done` section. The bootstrap gate
48
- * (src/gates/bootstrap-gates.ts) counts these to decide when to latch bootstrap complete; the
49
- * stat-keyed cache means an unchanged PLANS.md costs one syscall per poll, not a re-read. */
113
+ /** The finished entries of a live backlog file, newest first: its own finished section's
114
+ * (normally none — tumwater moves a finished entry to the archive as the change that finished it
115
+ * commits) followed by its archive file's (plans/backlog-archive.md). Both reads are stat-cached
116
+ * like every section read here. */
117
+ function finishedEntries(root, file) {
118
+ const { section, archive } = finishedArchiveOf(file);
119
+ return [...sectionEntries(root, file, section), ...sectionEntries(root, archive, section)];
120
+ }
121
+ /** The files a live backlog file's finished entries sit in: the file itself, then its archive.
122
+ * The per-day date reads below are per file, so the report walks both. */
123
+ export function finishedFiles(file) {
124
+ return [file, finishedArchiveOf(file).archive];
125
+ }
126
+ /** Done plans: the `### ` headings under `## Done` in PLANS.md and PLANS-DONE.md. The bootstrap
127
+ * gate (src/gates/bootstrap-gates.ts) counts these to decide when to latch bootstrap complete;
128
+ * the stat-keyed cache means unchanged files cost one syscall each per poll, not a re-read. */
50
129
  export function donePlans(root) {
51
- return sectionEntries(root, "PLANS.md", "Done").map((e) => e.title);
130
+ return finishedEntries(root, "PLANS.md").map((e) => e.title);
131
+ }
132
+ /** Done plans as entries (title and body), newest first. */
133
+ export function donePlanEntries(root) {
134
+ return finishedEntries(root, "PLANS.md");
135
+ }
136
+ /** Fixed bugs as entries (title and body), newest first. */
137
+ export function fixedBugEntries(root) {
138
+ return finishedEntries(root, "BUGS.md");
52
139
  }
53
140
  /** Open bugs: the `### ` headings under BUGS.md's `## Open` section. Missing or unreadable →
54
141
  * []. */
@@ -74,6 +161,16 @@ export function openBugEntries(root) {
74
161
  export function openQuestionEntries(root) {
75
162
  return sectionEntries(root, "QUESTIONS.md", "Open");
76
163
  }
164
+ /** Answered questions as full entries (title + body), in file order — oldest first, since
165
+ * tryAnswerQuestion appends. Missing or unreadable → []. */
166
+ export function answeredQuestionEntries(root) {
167
+ return sectionEntries(root, "QUESTIONS.md", "Answered");
168
+ }
169
+ /** Whether an entry's heading or body contains `pattern`, ignoring case: the one matcher behind
170
+ * the finished-entry lists' `--grep`. */
171
+ export function entryMatches(entry, pattern) {
172
+ return `${entry.title}\n${entry.body}`.toLowerCase().includes(pattern.toLowerCase());
173
+ }
77
174
  /** The backlog as one machine-readable document: the three entry arrays the Markdown renderer
78
175
  * and the GUI's /api/backlog endpoint serve, so `tumwater backlog --json` prints the same data
79
176
  * every surface reads (status --json's "print the endpoint's payload" pattern). Each array keeps
@@ -0,0 +1,339 @@
1
+ // vocabulary: plain
2
+ /** The QUESTIONS.md reader/writer core — the shared home of the open-question outbox logic: listing
3
+ * the open questions in QUESTIONS.md and answering one, which moves its entry from `## Open` to
4
+ * `## Answered` with a dated operator answer. It does the markdown surgery — cut exactly one
5
+ * `### ` block (fence-aware, like every backlog reader in backlog-md.ts) and extend a missing
6
+ * `## Answered` section with the file's documented skeleton — so the loop→human→loop round trip
7
+ * never corrupts the file the loops read at their next tick. Pure: no subprocess and no CLI
8
+ * output helpers, so every surface can drive it without ending the process.
9
+ *
10
+ * Shared by every read/answer surface: the CLI's `tumwater questions` command body wraps it in
11
+ * src/cli/question-commands.ts (the say/fail/editor layer), while the TUI's answer box
12
+ * (src/ui/tui/tui-keys.ts) and the dashboard's POST route (src/gui/gui-endpoint-commands.ts)
13
+ * import the non-exiting answer core directly. It lives with the rest of the BUGS/PLANS/QUESTIONS
14
+ * markdown (src/backlog/), not under src/cli/, so the non-command surfaces never depend on the
15
+ * CLI command package for domain logic.
16
+ */
17
+ import path from "node:path";
18
+ import { fencedHeadingTitle, fenceTracker, nextSectionHeading } from "./backlog-md.js";
19
+ import { answeredQuestionEntries, entryMatches, openQuestionEntries, ageInDays, ageSuffix } from "./backlog.js";
20
+ import { normalizeEntryBody } from "./backlog-write.js";
21
+ import { collapseWhitespace, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
22
+ import { readTextOrNull, writeTextAtomic } from "../files/files.js";
23
+ import { formatDate } from "../text/datetime.js";
24
+ import { numberedList } from "../text/markdown.js";
25
+ import { withStateLock } from "../concurrency/lock.js";
26
+ import { primaryWorktreeLockPath } from "../paths.js";
27
+ /** Cap on a question's one-line body preview, so one very long first line cannot dominate
28
+ * the `questions` list. */
29
+ const BODY_PREVIEW_MAX = 100;
30
+ /** The ` -- <ellipsized body>` suffix one prose list line carries for its question's first
31
+ * body line: the `prompt --list` numbered shape, so an operator can tell two open questions
32
+ * apart without opening the file. Truncated through text.ts's truncate — the one ellipsis
33
+ * rule — so the preview is never longer than BODY_PREVIEW_MAX and never splits a surrogate
34
+ * pair. Omitted for a heading-only entry. */
35
+ function firstBodySuffix(body) {
36
+ const line = body
37
+ .split("\n")
38
+ .map((l) => l.trim())
39
+ .find((l) => l !== "");
40
+ if (line === undefined)
41
+ return "";
42
+ return ` — ${truncate(line, BODY_PREVIEW_MAX)}`;
43
+ }
44
+ /** The open question's filing stamp, `YYYY-MM-DD`, read from the heading's `(asked by <who>
45
+ * YYYY-MM-DD)` clause — the first `YYYY-MM-DD` after an `asked by` word in the title text — or
46
+ * null when the heading carries no such clause. Taken from the title text only, not the body:
47
+ * the stamp is the entry's own, and the body may name other dates. */
48
+ export function questionFilingDate(title) {
49
+ const m = /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/.exec(title);
50
+ return m === null ? null : m[1];
51
+ }
52
+ /** The role id named in the heading's `(asked by <role> …)` clause — the first word after
53
+ * `asked by` — or null when the heading has no such clause. Taken from the title only, like
54
+ * questionFilingDate. */
55
+ export function questionAsker(title) {
56
+ const m = /\(asked by ([A-Za-z0-9_-]+)/.exec(title);
57
+ return m === null ? null : m[1];
58
+ }
59
+ /** The ` · since <date> (…)` suffix naming how long an open question has waited for an answer,
60
+ * from its heading's stamp (see questionFilingDate), or "" when the heading is undated —
61
+ * rendered through backlog.ts's ageSuffix, the one place the wording lives (shared with the
62
+ * plans/bugs list's entryAgeSuffix), which floors at 0 through ageInDays so a future stamp
63
+ * reads `(today)`. */
64
+ export function questionAgeSuffix(title, now = Date.now()) {
65
+ const date = questionFilingDate(title);
66
+ return date === null ? "" : ageSuffix(date, now);
67
+ }
68
+ /** `tumwater questions` with no arguments: the open questions numbered from 1 in file order,
69
+ * one line each (`prompt --list`'s shape), or one line when none are open. Reads through
70
+ * openQuestionEntries, so a missing or unreadable QUESTIONS.md degrades to "no open
71
+ * questions" like every backlog reader. */
72
+ export function openQuestionList(root) {
73
+ const questions = openQuestionEntries(root);
74
+ if (questions.length === 0)
75
+ return "no open questions";
76
+ return numberedList(questions.map((q) => `${q.title}${questionAgeSuffix(q.title)}${firstBodySuffix(q.body)}`));
77
+ }
78
+ /** The Nth (1-based) open question in the shape of one `questionListPayload` entry, or null
79
+ * past the end: the reader behind `tumwater questions --show <n>`. */
80
+ export function openQuestionAt(root, n) {
81
+ return questionListPayload(root).questions[n - 1] ?? null;
82
+ }
83
+ /** One question as `questions --show` prints it: the heading, a blank line, then the body
84
+ * (the heading alone when the body is empty). */
85
+ export function renderShownQuestion(q) {
86
+ return q.body === "" ? q.title : `${q.title}\n\n${q.body}`;
87
+ }
88
+ /** The same list as data for `--json` (the backlog --json pattern: a JSON document in every
89
+ * exit-0 case, never prose): each entry's 1-based position (the number `answer` consumes),
90
+ * its verbatim heading text, and its full body. */
91
+ export function questionListPayload(root) {
92
+ return {
93
+ questions: openQuestionEntries(root).map((q, i) => {
94
+ const sent = {
95
+ position: i + 1,
96
+ title: q.title,
97
+ body: q.body,
98
+ };
99
+ const since = questionFilingDate(q.title);
100
+ if (since !== null) {
101
+ sent.since = since;
102
+ sent.ageDays = ageInDays(since);
103
+ }
104
+ return sent;
105
+ }),
106
+ };
107
+ }
108
+ /** The date in an answer stamp paragraph (`**Answered <date> by …`). */
109
+ const ANSWER_DATE_RE = /\*\*Answered (\d{4}-\d{2}-\d{2})\b/;
110
+ /** The first answer stamp's date in an answered entry's body, or null when it has none. The
111
+ * stamp is a body paragraph, so this reads the body, not the heading. */
112
+ function answerDate(body) {
113
+ const m = ANSWER_DATE_RE.exec(body);
114
+ return m === null ? null : m[1];
115
+ }
116
+ /** The first body line that starts with `**Answered `, markup stripped, whitespace collapsed
117
+ * and truncated; "" when no line matches. */
118
+ function answerPreview(body) {
119
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l.startsWith("**Answered "));
120
+ if (line === undefined)
121
+ return "";
122
+ return truncate(collapseWhitespace(line.replaceAll("**", "")), BODY_PREVIEW_MAX);
123
+ }
124
+ /** The newest `count` answered questions, newest first (the file appends, so it is reversed),
125
+ * each with its unfiltered newest-first position, and how many older matches were left out.
126
+ * A `pattern` keeps only the entries whose heading or body contains it (entryMatches). */
127
+ function answeredQuestionsNewestFirst(root, count, pattern) {
128
+ const numbered = answeredQuestionEntries(root).reverse().map((e, i) => ({ ...e, position: i + 1 }));
129
+ const all = pattern === undefined ? numbered : numbered.filter((e) => entryMatches(e, pattern));
130
+ return { entries: all.slice(0, count), older: Math.max(0, all.length - count) };
131
+ }
132
+ /** `tumwater questions --answered`: lines numbered by `--show <n> --answered` position, newest
133
+ * first — a numbering separate from the `## Open` positions `questions answer <n>` takes. */
134
+ export function answeredQuestionList(root, count, pattern) {
135
+ const { entries, older } = answeredQuestionsNewestFirst(root, count, pattern);
136
+ if (entries.length === 0)
137
+ return pattern === undefined ? "no answered questions" : `no answered questions match "${pattern}"`;
138
+ const lines = [
139
+ pattern === undefined
140
+ ? "answered questions in QUESTIONS.md, newest first:"
141
+ : `answered questions in QUESTIONS.md matching "${pattern}", newest first:`,
142
+ ];
143
+ for (const e of entries) {
144
+ const preview = answerPreview(e.body);
145
+ lines.push(`${e.position}. ${e.title}${preview === "" ? "" : ` — ${preview}`}`);
146
+ }
147
+ if (older > 0)
148
+ lines.push(`(${older} older; -n <count> shows more)`);
149
+ return lines.join("\n");
150
+ }
151
+ /** The Nth (1-based) newest answered question, or null past the end: the reader behind
152
+ * `tumwater questions --show <n> --answered`. */
153
+ export function answeredQuestionAt(root, n) {
154
+ const e = answeredQuestionEntries(root).reverse()[n - 1];
155
+ return e === undefined ? null : { position: n, title: e.title, body: e.body, answeredOn: answerDate(e.body) };
156
+ }
157
+ /** How many answered questions QUESTIONS.md holds. */
158
+ export function answeredQuestionCount(root) {
159
+ return answeredQuestionEntries(root).length;
160
+ }
161
+ /** The same list as data for `--json`. */
162
+ export function answeredQuestionPayload(root, count, pattern) {
163
+ const { entries, older } = answeredQuestionsNewestFirst(root, count, pattern);
164
+ return {
165
+ questions: entries.map((e) => ({ position: e.position, title: e.title, body: e.body, answeredOn: answerDate(e.body) })),
166
+ older,
167
+ };
168
+ }
169
+ /** The dashboard's compact form of the `--answered` list: the same titles and truncated
170
+ * `**Answered …` lines, newest first, without bodies so each poll stays small. */
171
+ export function answeredQuestionSummaries(root, count) {
172
+ return answeredQuestionsNewestFirst(root, count).entries.map((e) => ({ title: e.title, decision: answerPreview(e.body) }));
173
+ }
174
+ /** Locate QUESTIONS.md's `## Open` and `## Answered` section heading lines (fence-aware) and
175
+ * every open entry's block between them. Returns null when the file has no `## Open` section
176
+ * at all — then there is nothing to answer, whatever the position. */
177
+ function scanQuestions(lines) {
178
+ const fenced = fenceTracker();
179
+ let openIdx = -1;
180
+ let answeredIdx = -1;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const line = lines[i] ?? "";
183
+ const title = fencedHeadingTitle(line, fenced, "## ");
184
+ if (title !== null) {
185
+ if (title === "Open" && openIdx === -1)
186
+ openIdx = i;
187
+ else if (title === "Answered" && answeredIdx === -1)
188
+ answeredIdx = i;
189
+ }
190
+ }
191
+ if (openIdx === -1)
192
+ return null;
193
+ // A fresh tracker for this walk: the first loop consumed every line and left the tracker in
194
+ // its end-of-file state, so reusing it here would quote all Open headings whenever a fence
195
+ // after the Open section runs unclosed to EOF — and `answer` would refuse a question the
196
+ // list just showed.
197
+ const fencedBlocks = fenceTracker();
198
+ // The section's end is the next `## ` heading of ANY title, fence-aware — the same boundary
199
+ // rule sectionLines applies, so the entries `--list` numbers (sectionEntries →
200
+ // parseEntryDetails) and the blocks `answer` moves can never disagree about where Open
201
+ // stops. Ending at the first `## Answered` instead would run the walk through any
202
+ // intermediate section and let one answer move another section's entries.
203
+ const openEnd = nextSectionHeading(lines, openIdx + 1, fencedBlocks);
204
+ const blocks = [];
205
+ // A fresh tracker for this walk too: the openEnd walk consumed the lines up to the section's
206
+ // end, so a fence in Open left unclosed at that boundary (an unclosed fence runs openEnd to
207
+ // EOF) would leave the shared tracker inside and quote every `### ` heading — answer would
208
+ // then see no blocks, or answer a later entry, on a file `--list` just numbered.
209
+ const fencedEntries = fenceTracker();
210
+ let current = null;
211
+ for (let i = openIdx + 1; i < openEnd; i++) {
212
+ const line = lines[i] ?? "";
213
+ const entryTitle = fencedHeadingTitle(line, fencedEntries, "### ");
214
+ if (entryTitle !== null) {
215
+ if (current !== null)
216
+ current.end = i;
217
+ current = { start: i, end: openEnd, title: entryTitle };
218
+ blocks.push(current);
219
+ }
220
+ }
221
+ return { openIdx, answeredIdx, openEnd, fenceOpen: fencedEntries.open(), blocks };
222
+ }
223
+ export const UNCLOSED_FENCE_MESSAGE = "QUESTIONS.md's ## Open section holds an unclosed code fence — every reader quotes the rest of the file as that entry's body, so answering would corrupt the file; close or remove the fence and re-run";
224
+ /** The non-exiting answer core, for callers (the dashboard server) that must not end the process:
225
+ * runs the whole read-modify-write under the cross-process primary-worktree lock (paths.ts), shared
226
+ * with `tumwater bug`/`plan` and a merge's working-tree fast-forward, so an answer and a merged
227
+ * change to QUESTIONS.md cannot each overwrite the other's whole-file write. With `expectedTitle`,
228
+ * a block at position `n` titled otherwise is `moved` and nothing is written; the comparison runs
229
+ * inside the lock, after the scan. */
230
+ export function tryAnswerQuestion(root, n, rawDecision, expectedTitle, keepLines = false) {
231
+ return withStateLock(primaryWorktreeLockPath(root), () => answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines));
232
+ }
233
+ /** `tumwater questions answer <n> <decision>`: move the Nth open question's full block —
234
+ * heading plus body, verbatim — from `## Open` to the end of `## Answered`, followed by a
235
+ * `**Answered <today> by the user:** <decision>` paragraph stamped with the local date — the
236
+ * positional decision is free-form operator prose, so it folds to one line (collapseWhitespace,
237
+ * the pause `--reason` precedent): verbatim multi-line text could carry a line that starts with
238
+ * `## ` or `### ` and grow a phantom section no reader wrote. The `--file` form (`keepLines`)
239
+ * keeps the lines, behind bodyStructureProblem — the guard `plan --file` uses — applied by its
240
+ * caller; a multi-line decision follows the stamp after a blank line. The Open section that is
241
+ * left empty regains its `_None yet._` placeholder; a file with no `## Answered` section gains
242
+ * one. Returns the outcome (see `AnswerOutcome`); on `answered` the title for the confirmation
243
+ * line, plus the collapsed decision that actually went into the file so callers report what was
244
+ * written rather than the caller's raw whitespace. */
245
+ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
246
+ const decision = keepLines ? normalizeEntryBody(rawDecision) : collapseWhitespace(rawDecision);
247
+ const file = path.join(root, "QUESTIONS.md");
248
+ const md = readTextOrNull(file);
249
+ const lines = md === null ? null : md.split("\n");
250
+ const scan = lines === null ? null : scanQuestions(lines);
251
+ const openCount = scan === null ? 0 : scan.blocks.length;
252
+ if (scan === null || n < 1 || n > scan.blocks.length) {
253
+ return { status: "missing", open: openCount };
254
+ }
255
+ const scanned = scan;
256
+ // A fence inside ## Open left unclosed runs to EOF (CommonMark), so the whole tail — the
257
+ // real ## Answered section included — reads as quoted body of the still-open entry. Acting
258
+ // on that degraded read moves Answered content inside the moved block's fence and grows a
259
+ // second ## Answered at the file's end, while the question still lists as open: refuse
260
+ // instead, and tell the operator the one local repair (close or remove the fence).
261
+ if (scanned.fenceOpen) {
262
+ return { status: "fenced" };
263
+ }
264
+ const block = scanned.blocks[n - 1];
265
+ if (expectedTitle !== undefined && block.title !== expectedTitle)
266
+ return { status: "moved", title: block.title };
267
+ const moved = lines.slice(block.start, block.end);
268
+ trimTrailingBlankLines(moved);
269
+ // Cut the block out of ## Open: everything before it, everything from its end on. When the
270
+ // section is left with no content at all, restore the `_None yet._` placeholder the skeleton
271
+ // carries, so an empty outbox still reads as an intentionally empty section.
272
+ // Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
273
+ // intermediate section between Open and Answered, judging against answeredIdx would keep
274
+ // the placeholder from returning and, worse, the empty rebuild would drop that section.
275
+ const openContent = lines.slice(scanned.openIdx + 1, scanned.openEnd);
276
+ const remaining = openContent.filter((_, i) => i < block.start - scanned.openIdx - 1 || i >= block.end - scanned.openIdx - 1);
277
+ const openEmpty = remaining.every((l) => l.trim() === "");
278
+ const rebuilt = [];
279
+ if (openEmpty) {
280
+ rebuilt.push(...lines.slice(0, scanned.openIdx + 1), "", "_None yet._",
281
+ // Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
282
+ // section between the two must survive) with the blank separator line the dropped Open
283
+ // content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
284
+ "", ...lines.slice(scanned.openEnd));
285
+ }
286
+ else {
287
+ rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
288
+ }
289
+ // Append the block at the end of ## Answered: before the next `## ` heading when one
290
+ // follows, at the file's end otherwise — the section is last in the documented skeleton.
291
+ const stamp = `**Answered ${formatDate(new Date())} by the user:**`;
292
+ const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
293
+ const answerLines = moved.concat("", ...stamped, "");
294
+ if (scanned.answeredIdx === -1) {
295
+ // No ## Answered section: grow the file's end with the skeleton section plus the block.
296
+ let out = rebuilt.join("\n");
297
+ if (out !== "" && !out.endsWith("\n"))
298
+ out += "\n";
299
+ out += `\n## Answered\n\n${answerLines.join("\n")}`;
300
+ writeTextAtomic(file, out);
301
+ return { status: "answered", title: block.title, decision };
302
+ }
303
+ const fenced2 = fenceTracker();
304
+ let answeredAt = -1;
305
+ const placeholderAt = [];
306
+ for (let i = 0; i < rebuilt.length; i++) {
307
+ const line = rebuilt[i] ?? "";
308
+ if (fenced2.inside(line))
309
+ continue;
310
+ if (answeredAt === -1) {
311
+ if (fencedHeadingTitle(line, fenced2, "## ") === "Answered")
312
+ answeredAt = i;
313
+ }
314
+ else if (fencedHeadingTitle(line, fenced2, "## ") !== null) {
315
+ break; // The Answered section's end: a placeholder beyond it is another section's.
316
+ }
317
+ else if (line.trim() === "_None yet._") {
318
+ placeholderAt.push(i);
319
+ }
320
+ }
321
+ // A section holding only its `_None yet._` skeleton placeholder must lose the placeholder when a
322
+ // real entry goes there: the placeholder means "no entries", so leaving it above the moved
323
+ // block makes ## Answered claim to be empty while holding an answered question. Fence-aware: a
324
+ // `_None yet._` line quoted inside a fenced block is quoted content, never the placeholder.
325
+ if (placeholderAt.length > 0) {
326
+ const dropped = new Set(placeholderAt);
327
+ const stripped = rebuilt.filter((_, i) => !dropped.has(i));
328
+ rebuilt.length = 0;
329
+ rebuilt.push(...stripped);
330
+ }
331
+ const insertAt = nextSectionHeading(rebuilt, answeredAt + 1, fenced2);
332
+ const head = rebuilt.slice(0, insertAt);
333
+ const tail = rebuilt.slice(insertAt);
334
+ trimTrailingBlankLines(head);
335
+ trimLeadingBlankLines(tail);
336
+ const result = head.concat("", answerLines, tail);
337
+ writeTextAtomic(file, result.join("\n"));
338
+ return { status: "answered", title: block.title, decision };
339
+ }