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,16 +1,21 @@
1
+ // vocabulary: plain
1
2
  import { fail, say, sayJsonLine } from "./cli-output.js";
2
- import { durationLabel, failRivalShapes, flagValue, parseCountFlag, parseGrepFlag, parseRoleScope, parseSinceFlag } from "./cli-args.js";
3
+ import { durationLabel, failRivalShapes, flagValue, parseCountFlag, parseGrepFlag, parseRoleScope, parseSinceFlag, parseTypeFlag } from "./cli-args.js";
3
4
  import { LOGS_SINCE_MAX_MS, readEventsSince, SPARSE_WINDOW_NOTE, } from "../events/event-window.js";
5
+ import { compareCodeUnits, increment, sortByValueDesc } from "../collections.js";
4
6
  import { parseEventLine, readEventsTailWithEnd } from "../events/event-read.js";
5
7
  import { eventMatchesGrep, formatEvent } from "../events/event-format.js";
8
+ import { canonicalEventType } from "../events/event-legacy.js";
9
+ import { EVENT_TYPE_LABELS, eventTypeLabel } from "../events/event-labels.js";
6
10
  import { followFile } from "../files/tail.js";
11
+ import { typoSuffix } from "../text/suggest.js";
7
12
  import { createTranscriptRenderer } from "../ui/transcript.js";
8
13
  import { readTranscriptTail } from "../ui/transcript-tail.js";
9
14
  import { eventsLogPath, piLogPath } from "../paths.js";
10
15
  /** The read-only observing half of the CLI's non-dispatch commands: `tumwater logs` and its
11
16
  * `--role` transcript view, split out of cli.ts so the entry point stays a dispatch table. Unlike
12
17
  * operator/operator-commands.ts these write nothing but stdout — they only read the event log and
13
- * each loop's pi transcript. It lives beside the other CLI command bodies (history.ts,
18
+ * each loop's agent transcript. It lives beside the other CLI command bodies (history.ts,
14
19
  * cli/cli-query-commands.ts, operator/operator-commands.ts), which may import the ui/ rendering
15
20
  * layer they drive — here the event formatter and the transcript renderer — while src/ui/
16
21
  * itself stays off src/ module boundaries it does not own. */
@@ -25,36 +30,90 @@ function sayEventLine(e, json) {
25
30
  else
26
31
  say(formatEvent(e));
27
32
  }
33
+ /** Render the `--count` tally: one `N × <type>` line per present event type, most-frequent
34
+ * first with ties broken alphabetically by type — the single shape shared by both window
35
+ * sources (-n and --since), so the print and the count cannot drift on which events count.
36
+ * In `--json` mode it emits one compact object mapping `type → count` with keys sorted, the
37
+ * machine-readable twin of the prose tally. */
38
+ function sayTally(events, json) {
39
+ const counts = new Map();
40
+ for (const e of events)
41
+ increment(counts, e.type);
42
+ if (json) {
43
+ const obj = {};
44
+ for (const [type] of [...counts.entries()].sort((a, b) => compareCodeUnits(a[0], b[0])))
45
+ obj[type] = counts.get(type);
46
+ sayJsonLine(obj);
47
+ return;
48
+ }
49
+ for (const [type, n] of sortByValueDesc([...counts.entries()]))
50
+ say(`${n} × ${eventTypeLabel(type)} (${type})`);
51
+ }
28
52
  /** The shared rival-shape guard for logs flags that only apply to the event-log feed:
29
- * `--role` swaps in a pi transcript rather than the event log (--prompt requires --role, so
53
+ * `--role` swaps in a transcript rather than the event log (--prompt requires --role, so
30
54
  * it is excluded with it), so each such flag fails naming both. One wording for all of them —
31
55
  * the --json, --grep, and --since checks cannot drift apart in what they say or why. `active`
32
56
  * is the flag's own presence — --json alone composes with nothing here but --role, while
33
57
  * --grep and --since call from inside their own presence guards. */
34
58
  function rejectRoleViewRival(rest, flag, active) {
35
59
  if (active && (rest.includes("--role") || rest.includes("--prompt")))
36
- failRivalShapes(`logs ${flag}`, "--role", "the --role view is a pi transcript, not the event log; --prompt requires --role");
60
+ failRivalShapes(`logs ${flag}`, "--role", "the --role view is the role's agent transcript, not the event log; --prompt requires --role");
37
61
  }
38
62
  /** The missing-pattern error cmdLogs prints for a valueless `--grep`, exported so cli.ts's
39
63
  * rejectUnknownArgs spec for --grep can fail a trailing `logs --grep` with the same wording
40
64
  * (the gate runs before the ready-repo gate and this parser, so the wordings must not drift). */
41
65
  export const GREP_VALUE_ERROR = "logs --grep needs a pattern";
66
+ /** The missing-type error cmdLogs prints for a valueless `--type`, exported so cli.ts's
67
+ * rejectUnknownArgs spec for --type can fail a trailing `logs --type` with the same wording
68
+ * (the gate runs before the ready-repo gate and this parser, so the wordings must not drift). */
69
+ export const TYPE_VALUE_ERROR = "logs --type needs an event type";
70
+ /** The exact-type empty-match prose: the same no-match shape `--grep` uses, naming the type
71
+ * whose value matched nothing, so the two filters' empty replies cannot drift in form. Past the
72
+ * names, a value that is not itself a real event type picks up a did-you-mean hint (the shell
73
+ * completion already offers the valid codes, so a bare no-match for a near-miss reads as a silent
74
+ * mystery) — while a VALID type that merely has no matching events stays hint-free, a legitimate
75
+ * empty result rather than a typo. A valid type or a legacy alias (canonicalEventType maps the
76
+ * alias onto the current code, which is a label key) never hints. */
77
+ function typeEmpty(type) {
78
+ const canonical = canonicalEventType(type);
79
+ const hint = Object.hasOwn(EVENT_TYPE_LABELS, canonical)
80
+ ? ""
81
+ : typoSuffix(type, Object.keys(EVENT_TYPE_LABELS));
82
+ return `no events matching "${type}"${hint}`;
83
+ }
42
84
  /** `tumwater logs [-f] [-n <count>] [--since <duration>] [--role <id>] [--prompt]`: follow or
43
85
  * dump the harness event log, with `--since` a bounded past window of it, or with `--role` one
44
- * loop's pi transcript (see cmdLogsTranscript). */
86
+ * loop's agent transcript (see cmdLogsTranscript). */
45
87
  export async function cmdLogs(root, args) {
46
88
  // `--grep <text>` filters the event feed (the -n view and its follow): case-insensitive
47
89
  // substring against `${e.type} ${formatEvent(e)}` — the rendered line is what the operator
48
90
  // would otherwise read (WYSIWYG), and prefixing the raw type id lets stable ids
49
- // (review_rejected, land_failed) be filtered even where the rendering paraphrases them.
50
- // Rival shapes stay exclusive — --role swaps in a pi transcript rather than this event log
91
+ // (review_rejected, merge_failed) be filtered even where the rendering paraphrases them.
92
+ // Rival shapes stay exclusive — --role swaps in a transcript rather than this event log
51
93
  // (--prompt requires --role, so it is excluded with it), and --since is a filter of its own
52
94
  // rather than a window to filter — and each failure names both flags. The flag scan itself
53
95
  // (the value lookup, the `rest` construction that keeps a flag-shaped pattern from
54
96
  // impersonating a rival flag, and the empty-value fail) is cli/cli-args.ts's parseGrepFlag,
55
97
  // the one home shared with history's identical preamble.
56
- const { rest, pattern: grepPattern } = parseGrepFlag(args, GREP_VALUE_ERROR);
98
+ const { rest: grepped, pattern: grepPattern } = parseGrepFlag(args, GREP_VALUE_ERROR);
99
+ // `--type <eventType>` filters the event feed by EXACT event type (e.type === value):
100
+ // deterministic where --grep substrings — a merge_failed whose rendered line quotes
101
+ // "merge_failed" matches --grep but not --type. Unlike --grep it composes with --since
102
+ // (the window, not a filter of its own) as well as -n, -f, --json, and --grep; like --json
103
+ // it is a rival only of the --role/--prompt transcript view. The value scan (keeping a
104
+ // flag-shaped value like --since as text, the empty-value fail) mirrors parseGrepFlag via
105
+ // parseTypeFlag, so the two cannot drift; `rest` here has both flags' value tokens removed.
106
+ const { rest, type } = parseTypeFlag(grepped, TYPE_VALUE_ERROR);
57
107
  const grepLower = grepPattern === null ? null : grepPattern.toLowerCase();
108
+ // An older event name matches its current one; the empty-match prose still names what was typed.
109
+ const wantedType = type === null ? null : canonicalEventType(type);
110
+ const typeFilter = type === null ? null : (e) => e.type === wantedType;
111
+ // The composed feed rule every emit site threads today's grep-only predicate through:
112
+ // an event passes when it is absent-or-matches the --grep substring AND absent-or-equals
113
+ // the --type id, held in one place so the --since window, the -n tally, and the follow
114
+ // cannot drift on what an event must satisfy.
115
+ const feedFilter = (e) => (grepLower === null || eventMatchesGrep(e, grepLower)) &&
116
+ (typeFilter === null || typeFilter(e));
58
117
  const follow = rest.includes("-f") || rest.includes("--follow");
59
118
  // `--json` switches the feed from formatEvent rendering to one JSON.stringify(e) per line —
60
119
  // the raw HarnessEvent objects exactly as stored in the log — so scripts read the canonical
@@ -65,6 +124,15 @@ export async function cmdLogs(root, args) {
65
124
  // excluded with it).
66
125
  const json = rest.includes("--json");
67
126
  rejectRoleViewRival(rest, "--json", json);
127
+ // `--count` tallies the window's present event types instead of printing the rows. Rival
128
+ // shapes stay exclusive: counting implies a bounded window (so -f/--follow is rejected)
129
+ // and the --role view is the role's agent transcript rather than the event log.
130
+ const count = rest.includes("--count");
131
+ rejectRoleViewRival(rest, "--count", count);
132
+ if (count && follow)
133
+ failRivalShapes("logs --count", "-f/--follow", "counting implies a bounded window; -f follows from now on");
134
+ if (type !== null)
135
+ rejectRoleViewRival(rest, "--type", true);
68
136
  if (grepPattern !== null) {
69
137
  rejectRoleViewRival(rest, "--grep", true);
70
138
  if (rest.includes("--since"))
@@ -74,7 +142,7 @@ export async function cmdLogs(root, args) {
74
142
  // it reads a bounded past window (event-window.ts's day-keyed backwards scan) and prints the
75
143
  // survivors oldest-first, so an operator can ask for "the hour around that 429 storm" without
76
144
  // guessing a count. Rival shapes stay exclusive — follow means "from now on", -n means "last
77
- // N", and --role swaps in a pi transcript rather than this event log — and each failure names
145
+ // N", and --role swaps in a transcript rather than this log — and each failure names
78
146
  // both flags. (--prompt requires --role, so it is excluded with it.)
79
147
  const ms = parseSinceFlag(rest, "logs --since", LOGS_SINCE_MAX_MS);
80
148
  if (ms !== null) {
@@ -86,16 +154,29 @@ export async function cmdLogs(root, args) {
86
154
  // covered is only consulted below when the window has rows (an empty window returns
87
155
  // earlier), so the helper's vacuous empty-log branch never reaches the note here.
88
156
  const { events, covered } = readEventsSince(root, ms);
89
- if (events.length === 0) {
90
- // In JSON mode an empty window answers silently — an empty output IS the machine-readable
157
+ // `--type` composes with the window (it is NOT the --since rival --grep is): only the
158
+ // window events of the exact type are printed, and an all-filtered window answers with
159
+ // the no-match prose (the type's own line) instead of the generic empty-window one —
160
+ // "which merge_failed in the last hour" filters the same as it would the -n view.
161
+ const filtered = type === null ? events : events.filter((e) => e.type === wantedType);
162
+ if (filtered.length === 0) {
163
+ // In JSON mode an empty result answers silently — an empty output IS the machine-readable
91
164
  // answer, and prose would corrupt a consumer's NDJSON stream.
92
165
  if (!json)
93
- say(`no events in ${durationLabel(ms)}`);
166
+ say(type === null ? `no events in ${durationLabel(ms)}` : typeEmpty(type));
94
167
  return;
95
168
  }
96
- for (const e of events)
169
+ // `--count` composes with the window: the tally covers exactly the filtered window rows
170
+ // the plain view would print (and answers the empty window with the plain view's own
171
+ // prose above), so the two cannot disagree on which events count. Never printed with the
172
+ // sparse-window note below: the note rides rows, and a count has none.
173
+ if (count) {
174
+ sayTally(filtered, json);
175
+ return;
176
+ }
177
+ for (const e of filtered)
97
178
  sayEventLine(e, json);
98
- // A sparse window is never mistaken for a quiet fleet — but the note only ever rides rows:
179
+ // A sparse window is never mistaken for a quiet team — but the note only ever rides rows:
99
180
  // with no rows at all (a fresh install's missing log among them) there is nothing sparse to
100
181
  // explain, and a flat "rotated out" claim would be false for a log that never had events.
101
182
  // The hedged phrasing stays true whenever it prints: the oldest retained event being inside
@@ -117,17 +198,42 @@ export async function cmdLogs(root, args) {
117
198
  return;
118
199
  }
119
200
  // -n bounds the scan, not the print: the matched subset of the last `limit` events is what
120
- // appears, so `-n 200 --grep land_failed` may print 3 rows from 200 scanned. The read is
201
+ // appears, so `-n 200 --grep merge_failed` may print 3 rows from 200 scanned. The read is
121
202
  // the tail-with-end shape (not the per-poll cached readEvents) so the follow below can seed
122
203
  // from the byte end this very read covered instead of a later stat of the file.
123
204
  const tail = readEventsTailWithEnd(root, limit);
124
- const shown = grepLower ? tail.events.filter((e) => eventMatchesGrep(e, grepLower)) : tail.events;
205
+ const shown = grepLower !== null || type !== null ? tail.events.filter(feedFilter) : tail.events;
206
+ // `--count` over the -n view answers with the tally of exactly the filtered rows the plain
207
+ // view would print, and carries over the plain view's empty-match prose below (a `--type`
208
+ // or `--grep` that matches nothing names its value; an empty unfiltered -n view prints
209
+ // nothing, as the plain view does). In JSON mode the empty output is the answer.
210
+ if (count) {
211
+ if (shown.length !== 0)
212
+ sayTally(shown, json);
213
+ else if (!json && (grepPattern !== null || type !== null)) {
214
+ if (grepPattern !== null && type !== null)
215
+ say(`no events matching "${grepPattern}" or ${typeEmpty(type)}`);
216
+ // Type-empty gets the hint (only the type branch); a grep-only empty stays the bare
217
+ // no-match line, because a free-text pattern is not an event type to suggest against.
218
+ else
219
+ say(type !== null ? typeEmpty(type) : `no events matching "${grepPattern ?? ""}"`);
220
+ }
221
+ return;
222
+ }
125
223
  for (const e of shown)
126
224
  sayEventLine(e, json);
127
225
  if (!follow) {
128
- // The empty-match line is prose: in JSON mode the empty output is the answer.
129
- if (grepPattern !== null && shown.length === 0 && !json)
130
- say(`no events matching "${grepPattern}"`);
226
+ // The empty-match line is prose: in JSON mode the empty output is the answer. It fires
227
+ // for either filter, naming the flag's value(s) whose match came up empty so the operator
228
+ // knows what to loosen.
229
+ if (shown.length === 0 && !json && (grepPattern !== null || type !== null)) {
230
+ if (grepPattern !== null && type !== null)
231
+ say(`no events matching "${grepPattern}" or ${typeEmpty(type)}`);
232
+ // Type-empty gets the hint (only the type branch); a grep-only empty stays the bare
233
+ // no-match line, because a free-text pattern is not an event type to suggest against.
234
+ else
235
+ say(type !== null ? typeEmpty(type) : `no events matching "${grepPattern ?? ""}"`);
236
+ }
131
237
  return;
132
238
  }
133
239
  const file = eventsLogPath(root);
@@ -144,13 +250,13 @@ export async function cmdLogs(root, args) {
144
250
  const e = parseEventLine(line);
145
251
  // The filter holds across rotation: every event the follow callback sees goes through
146
252
  // the same match rule as the seeded window.
147
- if (e && (grepLower === null || eventMatchesGrep(e, grepLower)))
253
+ if (e && feedFilter(e))
148
254
  sayEventLine(e, json);
149
255
  }
150
256
  });
151
257
  await new Promise(() => { }); // Follow until Ctrl+C.
152
258
  }
153
- /** `tumwater logs --role <id>`: print (and optionally follow) one loop's pi transcript —
259
+ /** `tumwater logs --role <id>`: print (and optionally follow) one loop's agent transcript —
154
260
  * run separators, abbreviated thinking, assistant text, and tool calls; with `--prompt` also
155
261
  * each run's exact prompt text. Read-only; the raw log is pi's streaming event stream, so only
156
262
  * complete renderable events are shown. */
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fail, say } from "./cli-output.js";
3
3
  import { loadConfig } from "../config/config.js";
4
- import { notifyCommandEnv } from "../events/notify.js";
5
- /** The one representative allowlisted event `tumwater notify --test` fires: a failed landing
6
- * with loop `harness`. Fixed and opinionated — the goal is to prove the hook runs, not to
4
+ import { TUMWATER_SOURCE } from "../events/events.js";
5
+ import { notifyCommandEnv, notifyExitGloss } from "../events/notify.js";
6
+ /** The one representative allowlisted event `tumwater notify --test` fires: a failed merge
7
+ * with source `tumwater`. Fixed and opinionated — the goal is to prove the hook runs, not to
7
8
  * replay a specific page, so there are no per-type or per-message flags. */
8
- const NOTIFY_TEST_TYPE = "land_failed";
9
- const NOTIFY_TEST_LOOP = "harness";
9
+ const NOTIFY_TEST_TYPE = "merge_failed";
10
+ const NOTIFY_TEST_SOURCE = TUMWATER_SOURCE;
10
11
  const NOTIFY_TEST_MESSAGE = "tumwater notify --test";
11
12
  /** How long `tumwater notify --test` waits for the hook before giving up. A hook still running
12
13
  * after this long cannot usefully page anyone — the notify channel is meant to be a quick
@@ -15,15 +16,16 @@ const NOTIFY_TEST_MESSAGE = "tumwater notify --test";
15
16
  * Generous enough not to misclassify a hook that is merely slow. */
16
17
  export const NOTIFY_TEST_TIMEOUT_MS = 10_000;
17
18
  /** Run one notify hook to completion, returning its exit code. Unlike the notifier's detached
18
- * fire-and-forget spawn (src/events/notify.ts), this waits with inherited stdio, so the
19
+ * fire-and-forget spawn (src/events/notify.ts), this waits with inherited stdio by
20
+ * default (the dashboard passes "ignore"), so the
19
21
  * operator sees the hook's own output and its exit. A shell that cannot start resolves 127 —
20
22
  * the conventional "command not found" code. A hook that does not finish within `timeoutMs`
21
23
  * is SIGKILLed and reported as `{ timedOut: true }` rather than awaited forever; the timer is
22
24
  * cleared on every outcome so a fast hook never keeps the CLI alive. `timeoutMs` is a test
23
25
  * seam; production callers leave it at NOTIFY_TEST_TIMEOUT_MS. */
24
- export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS) {
26
+ export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS, stdio = "inherit") {
25
27
  return new Promise((resolve) => {
26
- const child = spawn(command, [], { shell: true, stdio: "inherit", env });
28
+ const child = spawn(command, [], { shell: true, stdio, env });
27
29
  let settled = false;
28
30
  let timer;
29
31
  const finish = (result) => {
@@ -43,6 +45,23 @@ export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS)
43
45
  child.on("error", () => finish({ code: 127 }));
44
46
  });
45
47
  }
48
+ /** Run the saved notify hook once with the synthetic test event, under the NOTIFY_TEST_TIMEOUT_MS
49
+ * bound. Shared by `tumwater notify --test` (inherited stdio) and the dashboard's "Send test"
50
+ * (`"ignore"`, so the hook's output never lands in the server's terminal). */
51
+ export async function testNotifyHook(root, stdio = "inherit") {
52
+ const command = loadConfig(root).notify;
53
+ if (typeof command !== "string" || command === "")
54
+ return { status: "not_configured" };
55
+ const result = await runNotifyHook(command, notifyCommandEnv(NOTIFY_TEST_TYPE, NOTIFY_TEST_SOURCE, NOTIFY_TEST_MESSAGE), NOTIFY_TEST_TIMEOUT_MS, stdio);
56
+ if ("timedOut" in result)
57
+ return { status: "ran", passed: false, message: `notify hook did not finish within ${NOTIFY_TEST_TIMEOUT_MS / 1000}s and was killed` };
58
+ if (result.code === 0)
59
+ return { status: "ran", passed: true, message: "notify hook exited 0" };
60
+ // The sh launch-failure gloss (same wording the real notifier warns with) names the fix
61
+ // for a hook that could not start: a typo'd `notify` command reads as a bare `code 127`
62
+ // without it.
63
+ return { status: "ran", passed: false, message: `notify hook exited with code ${result.code}${notifyExitGloss(result.code)}` };
64
+ }
46
65
  /** `tumwater notify --test`: run the configured notify hook once with a synthetic event so an
47
66
  * operator can check the push channel before a real page. Read-only toward harness state — no
48
67
  * event is logged — and the exit code reflects the hook's own (0 passes, anything else fails). */
@@ -53,16 +72,12 @@ export async function cmdNotify(root, args) {
53
72
  if (typeof command !== "string" || command === "")
54
73
  fail("notify is not configured (set `notify` in tumwater.json)");
55
74
  say(`running the notify hook (TUMWATER_EVENT_TYPE=${NOTIFY_TEST_TYPE}) …`);
56
- const result = await runNotifyHook(command, notifyCommandEnv(NOTIFY_TEST_TYPE, NOTIFY_TEST_LOOP, NOTIFY_TEST_MESSAGE));
57
- if ("timedOut" in result) {
58
- say(`notify hook did not finish within ${NOTIFY_TEST_TIMEOUT_MS / 1000}s and was killed`);
59
- process.exitCode = 1;
60
- }
61
- else if (result.code === 0) {
62
- say("notify hook exited 0");
63
- }
75
+ const outcome = await testNotifyHook(root);
76
+ if (outcome.status === "not_configured")
77
+ fail("notify is not configured (set `notify` in tumwater.json)");
64
78
  else {
65
- say(`notify hook exited with code ${result.code}`);
66
- process.exitCode = 1;
79
+ say(outcome.message);
80
+ if (!outcome.passed)
81
+ process.exitCode = 1;
67
82
  }
68
83
  }
@@ -1,110 +1,14 @@
1
- /** The CLI layer of `tumwater questions` — the open-question outbox command: list the open
2
- * questions in QUESTIONS.md numbered, or `answer <n> <decision>` one, which moves its entry
3
- * from `## Open` to `## Answered` with a dated operator answer. Split out of cli.ts because
4
- * the markdown surgery it performs is more than a dispatch case: the answer path must cut
5
- * exactly one `### ` block (fence-aware, like every backlog reader in backlog-md.ts) and extend
6
- * a missing `## Answered` section with the file's documented skeleton, so the loop→human→loop
7
- * round trip never corrupts the file the loops read at their next tick. */
8
- import path from "node:path";
1
+ /** The CLI layer of `tumwater questions` — the `tumwater questions` command body: list the open
2
+ * questions in QUESTIONS.md numbered, or `answer <n> <decision>` one, which moves its entry from
3
+ * `## Open` to `## Answered` with a dated operator answer. The markdown surgery itself — the
4
+ * fence-aware `### ` block cut and the `## Answered` skeleton — lives in src/backlog/
5
+ * question-answer.ts (the shared QUESTIONS.md core the TUI and dashboard also call); this module
6
+ * is only the command's say/fail/editor jacket over that core, imported by src/cli/
7
+ * cli-query-commands.ts.
8
+ */
9
9
  import { fail, say, sayJson } from "./cli-output.js";
10
- import { fencedHeadingTitle, fenceTracker, nextSectionHeading } from "../backlog/backlog-md.js";
11
- import { openQuestionEntries } from "../backlog/backlog.js";
10
+ import { openQuestionList, questionListPayload, UNCLOSED_FENCE_MESSAGE, tryAnswerQuestion } from "../backlog/question-answer.js";
12
11
  import { bodyStructureProblem, normalizeEntryBody } from "../backlog/backlog-write.js";
13
- import { collapseWhitespace, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
14
- import { readTextOrNull, writeTextAtomic } from "../files/files.js";
15
- import { formatDate } from "../text/datetime.js";
16
- import { numberedList } from "../text/markdown.js";
17
- import { withStateLock } from "../concurrency/lock.js";
18
- import { primaryWorktreeLockPath } from "../paths.js";
19
- /** Cap on a question's one-line body preview, so one very long first line cannot dominate
20
- * the `questions` list. */
21
- const BODY_PREVIEW_MAX = 100;
22
- /** The ` -- <ellipsized body>` suffix one prose list line carries for its question's first
23
- * body line: the `prompt --list` numbered shape, so an operator can tell two open questions
24
- * apart without opening the file. Truncated through text.ts's truncate — the one ellipsis
25
- * rule — so the preview is never longer than BODY_PREVIEW_MAX and never splits a surrogate
26
- * pair. Omitted for a heading-only entry. */
27
- function firstBodySuffix(body) {
28
- const line = body
29
- .split("\n")
30
- .map((l) => l.trim())
31
- .find((l) => l !== "");
32
- if (line === undefined)
33
- return "";
34
- return ` — ${truncate(line, BODY_PREVIEW_MAX)}`;
35
- }
36
- /** `tumwater questions` with no arguments: the open questions numbered from 1 in file order,
37
- * one line each (`prompt --list`'s shape), or one line when none are open. Reads through
38
- * openQuestionEntries, so a missing or unreadable QUESTIONS.md degrades to "no open
39
- * questions" like every backlog reader. */
40
- export function openQuestionList(root) {
41
- const questions = openQuestionEntries(root);
42
- if (questions.length === 0)
43
- return "no open questions";
44
- return numberedList(questions.map((q) => `${q.title}${firstBodySuffix(q.body)}`));
45
- }
46
- /** The same list as data for `--json` (the backlog --json pattern: a JSON document in every
47
- * exit-0 case, never prose): each entry's 1-based position (the number `answer` consumes),
48
- * its verbatim heading text, and its full body. */
49
- export function questionListPayload(root) {
50
- return {
51
- questions: openQuestionEntries(root).map((q, i) => ({
52
- position: i + 1,
53
- title: q.title,
54
- body: q.body,
55
- })),
56
- };
57
- }
58
- /** Locate QUESTIONS.md's `## Open` and `## Answered` section heading lines (fence-aware) and
59
- * every open entry's block between them. Returns null when the file has no `## Open` section
60
- * at all — then there is nothing to answer, whatever the position. */
61
- function scanQuestions(lines) {
62
- const fenced = fenceTracker();
63
- let openIdx = -1;
64
- let answeredIdx = -1;
65
- for (let i = 0; i < lines.length; i++) {
66
- const line = lines[i] ?? "";
67
- const title = fencedHeadingTitle(line, fenced, "## ");
68
- if (title !== null) {
69
- if (title === "Open" && openIdx === -1)
70
- openIdx = i;
71
- else if (title === "Answered" && answeredIdx === -1)
72
- answeredIdx = i;
73
- }
74
- }
75
- if (openIdx === -1)
76
- return null;
77
- // A fresh tracker for this walk: the first loop consumed every line and left the tracker in
78
- // its end-of-file state, so reusing it here would quote all Open headings whenever a fence
79
- // after the Open section runs unclosed to EOF — and `answer` would refuse a question the
80
- // list just showed.
81
- const fencedBlocks = fenceTracker();
82
- // The section's end is the next `## ` heading of ANY title, fence-aware — the same boundary
83
- // rule sectionLines applies, so the entries `--list` numbers (sectionEntries →
84
- // parseEntryDetails) and the blocks `answer` moves can never disagree about where Open
85
- // stops. Ending at the first `## Answered` instead would run the walk through any
86
- // intermediate section and let one answer move another section's entries.
87
- const openEnd = nextSectionHeading(lines, openIdx + 1, fencedBlocks);
88
- const blocks = [];
89
- // A fresh tracker for this walk too: the openEnd walk consumed the lines up to the section's
90
- // end, so a fence in Open left unclosed at that boundary (an unclosed fence runs openEnd to
91
- // EOF) would leave the shared tracker inside and quote every `### ` heading — answer would
92
- // then see no blocks, or answer a later entry, on a file `--list` just numbered.
93
- const fencedEntries = fenceTracker();
94
- let current = null;
95
- for (let i = openIdx + 1; i < openEnd; i++) {
96
- const line = lines[i] ?? "";
97
- const entryTitle = fencedHeadingTitle(line, fencedEntries, "### ");
98
- if (entryTitle !== null) {
99
- if (current !== null)
100
- current.end = i;
101
- current = { start: i, end: openEnd, title: entryTitle };
102
- blocks.push(current);
103
- }
104
- }
105
- return { openIdx, answeredIdx, openEnd, fenceOpen: fencedEntries.open(), blocks };
106
- }
107
- 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";
108
12
  /** The `tumwater questions answer` entry point: `tryAnswerQuestion` without a title check, its
109
13
  * refusals mapped to `fail` (exit 1) with the CLI's wording. Returns the answered question's
110
14
  * title plus the collapsed decision that landed in the file. */
@@ -113,10 +17,14 @@ export function answerQuestion(root, n, rawDecision) {
113
17
  }
114
18
  /** Map a non-answered outcome to the CLI's `fail` wording; shared by both CLI entry points. */
115
19
  function answeredOrFail(n, outcome) {
116
- if (outcome.status === "missing")
117
- fail(`no question at position ${n} (${outcome.open} open)`);
20
+ if (outcome.status === "missing") {
21
+ const range = outcome.open > 0 ? ` — valid: 1-${outcome.open}` : "";
22
+ fail(`no question at position ${n} (${outcome.open} open)${range}`);
23
+ }
118
24
  if (outcome.status === "fenced")
119
25
  fail(UNCLOSED_FENCE_MESSAGE);
26
+ if (outcome.status === "moved")
27
+ fail(`question ${n} is now "${outcome.title}" — the list changed while you edited; nothing changed`);
120
28
  if (outcome.status !== "answered")
121
29
  fail(`no question at position ${n}`);
122
30
  return { title: outcome.title, decision: outcome.decision };
@@ -124,131 +32,28 @@ function answeredOrFail(n, outcome) {
124
32
  /** `tumwater questions answer <n> --file <path>`: the decision's lines kept (normalizeEntryBody),
125
33
  * behind the same structural guard `plan --file` uses, so a stray `## ` line cannot grow a
126
34
  * phantom section. Refusals exit 1 through `fail`. */
127
- export function answerQuestionFromText(root, n, text) {
35
+ export function answerQuestionFromText(root, n, text, expectedTitle) {
128
36
  const body = normalizeEntryBody(text);
129
37
  if (body === "")
130
38
  fail("questions answer needs a decision: the file is blank");
131
39
  const problem = bodyStructureProblem(body);
132
40
  if (problem !== null)
133
41
  fail(`questions answer --file: ${problem}`);
134
- return answeredOrFail(n, tryAnswerQuestion(root, n, body, undefined, true));
42
+ return answeredOrFail(n, tryAnswerQuestion(root, n, body, expectedTitle, true));
135
43
  }
136
- /** The non-exiting answer core, for callers (the dashboard server) that must not end the
137
- * process: runs the whole read-modify-write under the cross-process primary-worktree lock
138
- * (paths.ts), shared with `tumwater bug`/`plan` and a landing's working-tree fast-forward, so an
139
- * answer and a landed change to QUESTIONS.md cannot each overwrite the other's whole-file write.
140
- * With `expectedTitle`, a block at position `n` titled otherwise is `moved` and nothing is
141
- * written; the comparison runs inside the lock, after the scan. */
142
- export function tryAnswerQuestion(root, n, rawDecision, expectedTitle, keepLines = false) {
143
- return withStateLock(primaryWorktreeLockPath(root), () => answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines));
44
+ /** The line that ends the decision area of the `questions answer <n>` editor buffer. */
45
+ export const ANSWER_EDITOR_CUT = "<!-- tumwater: write your decision above this line; this line and everything below it are dropped -->";
46
+ /** The editor buffer for bare `questions answer <n>`: an empty decision area, the cut line, then
47
+ * the question's heading and body for reference. */
48
+ export function answerEditorBuffer(title, body) {
49
+ return ["", "", ANSWER_EDITOR_CUT, `### ${title}`, body].join("\n");
144
50
  }
145
- /** `tumwater questions answer <n> <decision>`: move the Nth open question's full block —
146
- * heading plus body, verbatim — from `## Open` to the end of `## Answered`, followed by a
147
- * `**Answered <today> by operator:** <decision>` paragraph stamped with the local date — the
148
- * positional decision is free-form operator prose, so it folds to one line (collapseWhitespace,
149
- * the pause `--reason` precedent): verbatim multi-line text could carry a line that starts with
150
- * `## ` or `### ` and grow a phantom section no reader wrote. The `--file` form (`keepLines`)
151
- * keeps the lines, behind bodyStructureProblem — the guard `plan --file` uses — applied by its
152
- * caller; a multi-line decision follows the stamp after a blank line. The Open section that is left empty regains
153
- * its `_None yet._` placeholder; a file with no
154
- * `## Answered` section gains one. Returns the outcome (see `AnswerOutcome`); on `answered`
155
- * the title for the confirmation line, plus the collapsed decision that actually landed in the file so callers
156
- * report what was written rather than the caller's raw whitespace. */
157
- function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
158
- const decision = keepLines ? normalizeEntryBody(rawDecision) : collapseWhitespace(rawDecision);
159
- const file = path.join(root, "QUESTIONS.md");
160
- const md = readTextOrNull(file);
161
- const lines = md === null ? null : md.split("\n");
162
- const scan = lines === null ? null : scanQuestions(lines);
163
- const openCount = scan === null ? 0 : scan.blocks.length;
164
- if (scan === null || n < 1 || n > scan.blocks.length) {
165
- return { status: "missing", open: openCount };
166
- }
167
- const scanned = scan;
168
- // A fence inside ## Open left unclosed runs to EOF (CommonMark), so the whole tail — the
169
- // real ## Answered section included — reads as quoted body of the still-open entry. Acting
170
- // on that degraded read moves Answered content inside the moved block's fence and grows a
171
- // second ## Answered at the file's end, while the question still lists as open: refuse
172
- // instead, and tell the operator the one local repair (close or remove the fence).
173
- if (scanned.fenceOpen) {
174
- return { status: "fenced" };
175
- }
176
- const block = scanned.blocks[n - 1];
177
- if (expectedTitle !== undefined && block.title !== expectedTitle)
178
- return { status: "moved", title: block.title };
179
- const moved = lines.slice(block.start, block.end);
180
- trimTrailingBlankLines(moved);
181
- // Cut the block out of ## Open: everything before it, everything from its end on. When the
182
- // section is left with no content at all, restore the `_None yet._` placeholder the skeleton
183
- // carries, so an empty outbox still reads as an intentionally empty section.
184
- // Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
185
- // intermediate section between Open and Answered, judging against answeredIdx would keep
186
- // the placeholder from returning and, worse, the empty rebuild would drop that section.
187
- const openContent = lines.slice(scanned.openIdx + 1, scanned.openEnd);
188
- const remaining = openContent.filter((_, i) => i < block.start - scanned.openIdx - 1 || i >= block.end - scanned.openIdx - 1);
189
- const openEmpty = remaining.every((l) => l.trim() === "");
190
- const rebuilt = [];
191
- if (openEmpty) {
192
- rebuilt.push(...lines.slice(0, scanned.openIdx + 1), "", "_None yet._",
193
- // Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
194
- // section between the two must survive) with the blank separator line the dropped Open
195
- // content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
196
- "", ...lines.slice(scanned.openEnd));
197
- }
198
- else {
199
- rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
200
- }
201
- // Append the block at the end of ## Answered: before the next `## ` heading when one
202
- // follows, at the file's end otherwise — the section is last in the documented skeleton.
203
- const stamp = `**Answered ${formatDate(new Date())} by operator:**`;
204
- const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
205
- const answerLines = moved.concat("", ...stamped, "");
206
- if (scanned.answeredIdx === -1) {
207
- // No ## Answered section: grow the file's end with the skeleton section plus the block.
208
- let out = rebuilt.join("\n");
209
- if (out !== "" && !out.endsWith("\n"))
210
- out += "\n";
211
- out += `\n## Answered\n\n${answerLines.join("\n")}`;
212
- writeTextAtomic(file, out);
213
- return { status: "answered", title: block.title, decision };
214
- }
215
- const fenced2 = fenceTracker();
216
- let answeredAt = -1;
217
- const placeholderAt = [];
218
- for (let i = 0; i < rebuilt.length; i++) {
219
- const line = rebuilt[i] ?? "";
220
- if (fenced2.inside(line))
221
- continue;
222
- if (answeredAt === -1) {
223
- if (fencedHeadingTitle(line, fenced2, "## ") === "Answered")
224
- answeredAt = i;
225
- }
226
- else if (fencedHeadingTitle(line, fenced2, "## ") !== null) {
227
- break; // The Answered section's end: a placeholder beyond it is another section's.
228
- }
229
- else if (line.trim() === "_None yet._") {
230
- placeholderAt.push(i);
231
- }
232
- }
233
- // A section holding only its `_None yet._` skeleton placeholder must lose the placeholder
234
- // when a real entry lands there: the placeholder means "no entries", so leaving it above
235
- // the moved block makes ## Answered claim to be empty while holding an answered question.
236
- // Fence-aware: a `_None yet._` line quoted inside a fenced block is quoted content, never
237
- // the placeholder.
238
- if (placeholderAt.length > 0) {
239
- const dropped = new Set(placeholderAt);
240
- const stripped = rebuilt.filter((_, i) => !dropped.has(i));
241
- rebuilt.length = 0;
242
- rebuilt.push(...stripped);
243
- }
244
- const insertAt = nextSectionHeading(rebuilt, answeredAt + 1, fenced2);
245
- const head = rebuilt.slice(0, insertAt);
246
- const tail = rebuilt.slice(insertAt);
247
- trimTrailingBlankLines(head);
248
- trimLeadingBlankLines(tail);
249
- const result = head.concat("", answerLines, tail);
250
- writeTextAtomic(file, result.join("\n"));
251
- return { status: "answered", title: block.title, decision };
51
+ /** The decision an edited `answerEditorBuffer` holds: the text before the first cut line (all of
52
+ * it when the operator deleted the line). */
53
+ export function decisionFromEditorBuffer(text) {
54
+ const lines = text.split("\n");
55
+ const cut = lines.findIndex((l) => l.trim() === ANSWER_EDITOR_CUT);
56
+ return (cut === -1 ? lines : lines.slice(0, cut)).join("\n");
252
57
  }
253
58
  /** The one confirmation line `questions answer` prints in prose mode: which position was
254
59
  * answered and which entry moved — `--json` swaps it for the answer result payload. */