tumwater 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -238,8 +238,35 @@ export async function topAndAnnounce(root, args, command, top) {
238
238
  * $VISUAL/$EDITOR and the saved buffer goes through `editText`. A saved buffer, positional or
239
239
  * `--file` text that rebuilds the entry byte-for-byte writes and commits nothing (`unchanged
240
240
  * <command> <n>: <title>`), and an out-of-range position fails
241
- * before the editor launches. */
241
+ * before the editor launches. The flag vocabulary, editor round-trip, unchanged report and
242
+ * commit-plus-hold announcement each live in their own named helper below. */
242
243
  export async function editAndAnnounce(root, args, command, edit, editText) {
244
+ const { path, json, positionals, position } = parseEditArgs(args, command);
245
+ await requireReadyRepo(root);
246
+ let text = null;
247
+ if (path === null && positionals.length === 0) {
248
+ const opened = openEditInEditor(root, command, position);
249
+ if (!opened.changed) {
250
+ sayUnchangedEdit(command, position, json, opened.file, opened.title);
251
+ return;
252
+ }
253
+ text = opened.text;
254
+ }
255
+ else if (path !== null)
256
+ text = readPromptFileArg(path);
257
+ const edited = text === null ? edit(position, positionals) : editText(position, text);
258
+ if (edited.unchanged) {
259
+ sayUnchangedEdit(command, position, json, edited.file, edited.title);
260
+ return;
261
+ }
262
+ await announceEdited(root, command, position, json, edited);
263
+ }
264
+ /** The flag vocabulary of the `--edit` shell: `--file <path>` is extracted and the repeat
265
+ * of the flag refused; the remaining tokens go through scanProseFlags (the `--json` boolean
266
+ * at most once, an `--edit <n>` position required here through scanProseFlags' one valued
267
+ * flag), and a stray positional alongside `--file` is refused. `fail` returns never, so on
268
+ * return `position` is a `number`. */
269
+ function parseEditArgs(args, command) {
243
270
  const fileFlag = args.indexOf("--file");
244
271
  const path = fileFlag >= 0 ? promptFileValue(args[fileFlag + 1]) : null;
245
272
  const rest = path === null ? args : args.filter((_, i) => i !== fileFlag && i !== fileFlag + 1);
@@ -253,32 +280,36 @@ export async function editAndAnnounce(root, args, command, edit, editText) {
253
280
  rejectStrayWithFile(positionals[0]);
254
281
  if (position === null)
255
282
  fail(`--edit needs a position (usage: tumwater ${command} --edit <n>)`);
256
- await requireReadyRepo(root);
257
- const sayUnchanged = (file, title) => {
258
- if (json)
259
- sayJson({ file, title, changed: false });
260
- else
261
- say(`unchanged ${command} ${position}: ${title}`);
262
- };
263
- let text = null;
264
- if (path === null && positionals.length === 0) {
265
- const shown = showEntry(root, command, position);
266
- const before = renderShownEntry(shown);
267
- const after = editInEditor(before, `${command}-${position}.md`);
268
- if (after.trim() === before.trim()) {
269
- const file = command === "bug" ? "BUGS.md" : "PLANS.md";
270
- sayUnchanged(file, shown.title);
271
- return;
272
- }
273
- text = after;
274
- }
275
- else if (path !== null)
276
- text = readPromptFileArg(path);
277
- const edited = text === null ? edit(position, positionals) : editText(position, text);
278
- if (edited.unchanged) {
279
- sayUnchanged(edited.file, edited.title);
280
- return;
283
+ return { path, json, positionals, position };
284
+ }
285
+ /** The no-op wording when an edit changes nothing: a `--json` `{ file, title, changed: false }`
286
+ * payload, else a single `unchanged <command> <n>: <title>` line. */
287
+ function sayUnchangedEdit(command, position, json, file, title) {
288
+ if (json)
289
+ sayJson({ file, title, changed: false });
290
+ else
291
+ say(`unchanged ${command} ${position}: ${title}`);
292
+ }
293
+ /** The editor round-trip taken when the `--edit` shell has neither `--file` nor positionals:
294
+ * the entry rendered as `--show` prints it (showEntry/renderShownEntry) opens in
295
+ * $VISUAL/$EDITOR, and a saved buffer that trims to the same text reports `{ changed: false }`
296
+ * with the backing file's name and the shown title; otherwise the saved text is returned as
297
+ * `{ changed: true, text }`. */
298
+ function openEditInEditor(root, command, position) {
299
+ const shown = showEntry(root, command, position);
300
+ const before = renderShownEntry(shown);
301
+ const after = editInEditor(before, `${command}-${position}.md`);
302
+ if (after.trim() === before.trim()) {
303
+ return { changed: false, file: command === "bug" ? "BUGS.md" : "PLANS.md", title: shown.title };
281
304
  }
305
+ return { changed: true, text: after };
306
+ }
307
+ /** The commit-and-announce tail of the `--edit` shell: the changed entry is committed through
308
+ * commitOperatorBacklogEdit with the `Edit <command>: <title>` subject (warned on an
309
+ * uncommitted worktree), the still-held role from the one before `position` is picked for the
310
+ * hold report, and the `--json` payload (with `hold` when held) or the prose `edited …` line
311
+ * plus stillHeldNote is printed. */
312
+ async function announceEdited(root, command, position, json, edited) {
282
313
  warnIfUncommitted(await commitOperatorBacklogEdit(root, edited.file, stampedSubject("operator", `Edit ${command}: ${edited.title}`)), edited.file); // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
283
314
  const holds = backlogHolds(root);
284
315
  const hold = (command === "bug" ? holds.bugs : holds.plans)[position - 1] ?? null;
@@ -10,53 +10,21 @@
10
10
  import path from "node:path";
11
11
  import { statOrNull } from "../files/files.js";
12
12
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
- import { escapeRegExp, firstTrimmedLineStartingWith } from "../text/text.js";
14
- import { HELD_TAG_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX } from "../roles/role-guidance.js";
13
+ import { firstTrimmedLineStartingWith } from "../text/text.js";
14
+ import { NEEDS_REVIEW_PREFIX } from "../roles/role-guidance.js";
15
15
  import { baseRoleOf } from "../roles/runner-ids.js";
16
16
  import { roleLabel } from "../roles/role-labels.js";
17
17
  import { actionableEntryRangesFor } from "./backlog-index-data.js";
18
18
  import { entryKey, partKey, plannedPartKeys, plansWaitingOn, requiredParts, seriesPart } from "./backlog-parts.js";
19
19
  import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
20
- /** The number of holds an entry's count must reach before a Needs-review note
21
- * becomes a `held-for-operator` hold (the operator's decision, surfaced by parts 2/3 and 3/3). */
22
- export const OPERATOR_HOLD_THRESHOLD = 3;
23
- /** The held count a body records: the integer in the first Needs-review note
24
- * line's `(held N)` tag, else 0. A present but non-numeric `(held …)` tag is 0 (and stops the
25
- * scan), so a malformed tag never throws. Pure over the body text — the data-layer read half of
26
- * the convergence backstop. No writer emits a `(held N)` tag anymore and the `**Previously held
27
- * N**` markers left on disk are never read, so a freshly written flat note always reads 0 and a
28
- * new hold never escalates to the operator's desk. */
29
- export function heldCount(body) {
30
- const tagRe = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`);
31
- for (const raw of body.split("\n")) {
32
- const line = raw.trimStart();
33
- const note = line.startsWith(NEEDS_REVIEW_PREFIX);
34
- if (!note)
35
- continue;
36
- const tag = tagRe.exec(line);
37
- if (tag !== null)
38
- return Number(tag[1]);
39
- if (line.includes(HELD_TAG_PREFIX))
40
- return 0;
41
- }
42
- return 0;
43
- }
44
- /** The `(held N)` count on a single Needs-review note line, or undefined when the
45
- * line is not such a note or carries no numeric tag. The per-line form of heldCount, so a writer
46
- * that drops notes (withoutHeldForOperatorNotes) can gate on the same threshold without scanning
47
- * the whole body. */
48
- function lineHeldCount(line) {
49
- const trimmed = line.trimStart();
50
- const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX);
51
- if (!isNote)
52
- return undefined;
53
- const tag = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`).exec(trimmed);
54
- return tag === null ? undefined : Number(tag[1]);
55
- }
56
20
  /** The `**Refused …` note prefix a refusing run writes. Every hold note — this one and the
57
21
  * imported Needs-review prefix — is matched as a line prefix (hasNoteLine)
58
22
  * so a mention in prose never holds an entry. */
59
23
  const REFUSED_PREFIX = "**Refused ";
24
+ /** The legacy marker the old increment rule left beside a Needs-review note's prior held count
25
+ * (`**Previously held N−1**` beside the live `(held N)`), dropped by withoutNeedsReviewNotes so
26
+ * a release leaves no orphan. No writer emits it anymore; only survives on live notes. */
27
+ const PRIOR_HELD_PREFIX = "**Previously held ";
60
28
  /** True when `body` carries a line (ignoring leading indentation) that begins with `prefix`.
61
29
  * The one home of the hold-note rule: a note holds its entry only when written as its own
62
30
  * line, never when its prefix is quoted inside prose. */
@@ -80,6 +48,25 @@ export function refusalNote(body) {
80
48
  export function needsReviewNote(body) {
81
49
  return noteText(body, NEEDS_REVIEW_PREFIX);
82
50
  }
51
+ /** True when `entry` is parked with the plan role — its Needs-review note is the feature
52
+ * role's too-large note, the automatic convergence hold, or a final-round rejection, all of
53
+ * which the plan role clears by splitting or replanning (role-catalog.ts `plan` find). False
54
+ * for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
55
+ * entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
56
+ * role can act on until then. entryHold returns "needs-review" for every such note (all of
57
+ * them park the entry), so the stalled-with-the-plan-role surfaces — the stalled-entries team
58
+ * alert (team-alert-backlog.ts) and the doctor's `stalled entries` row
59
+ * (checkStalledEntries) — classify by this agreed subset, keeping the bugfix parks out of the
60
+ * plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once, so the alert and
61
+ * the doctor row cannot disagree on which notes the plan role clears; the eligibility mark
62
+ * (holdMark) continues to hold every needs-review note, a by-bugfix park included. */
63
+ export function isPlanStalled(entry) {
64
+ const note = needsReviewNote(entry.body);
65
+ if (note === null)
66
+ return false;
67
+ // A "by bugfix:" note is a wait for evidence that selfcheck clears, not plan-role work.
68
+ return !note.includes("by bugfix");
69
+ }
83
70
  /** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
84
71
  * applies). When a dropped note sat between two blank lines, the blank line after it goes too,
85
72
  * so releasing leaves one blank line where the note was. Every other line is kept. */
@@ -101,33 +88,25 @@ function withoutNoteLines(lines, prefix) {
101
88
  export function withoutRefusalNotes(lines) {
102
89
  return withoutNoteLines(lines, REFUSED_PREFIX);
103
90
  }
104
- /** `lines` with every Needs-review note whose `(held N)` count reached
105
- * OPERATOR_HOLD_THRESHOLD removed, together with its `**Previously held N−1**` line (the prior
106
- * count the increment rule recorded beside the live `(held N)`), so a `--release` lift leaves
107
- * neither behind and the entry becomes eligible. Drops nothing when no note is at or past the
108
- * threshold, so release still cannot lift a live first/second Needs-review hold. Consecutive
109
- * dropped lines are collapsed to one blank line, the same rule withoutNoteLines applies to a
110
- * single dropped note. */
111
- export function withoutHeldForOperatorNotes(lines) {
112
- const drop = new Set();
113
- const priorHeld = new Set();
114
- lines.forEach((raw, i) => {
115
- const c = lineHeldCount(raw);
116
- if (c !== undefined && c >= OPERATOR_HOLD_THRESHOLD) {
117
- priorHeld.add(c - 1);
118
- drop.add(i);
119
- }
120
- });
121
- if (priorHeld.size === 0)
91
+ /** `lines` without its Needs-review note lines and the legacy `**Previously held N**`
92
+ * markers the old increment rule left beside them, the release's lift when the entry has no
93
+ * Refused note: a `--release` that finds no Refused note drops every Needs-review note line so
94
+ * the entry becomes eligible, and takes each `**Previously held N**` marker in the slice with
95
+ * it, so the lift leaves no orphan whether or not the note still carries a `(held N)` tag.
96
+ * Nothing changes when the slice carries no Needs-review note, so a marker that sits on an
97
+ * eligible entry stays put. Every other line, Refused notes included, is kept, so the
98
+ * Needs-review lift never lifts a refusal. */
99
+ export function withoutNeedsReviewNotes(lines) {
100
+ // A lift only happens when the slice carries a Needs-review note; without one, the slice
101
+ // (and any legacy prior-held marker on it) is left byte for byte.
102
+ if (!lines.some((raw) => raw.trimStart().startsWith(NEEDS_REVIEW_PREFIX)))
122
103
  return lines;
104
+ const drop = new Set();
123
105
  lines.forEach((raw, i) => {
124
- if (drop.has(i))
125
- return;
126
106
  const trimmed = raw.trimStart();
127
- if (!trimmed.startsWith(PRIOR_HELD_PREFIX))
128
- return;
129
- const count = /\d+/.exec(trimmed.slice(PRIOR_HELD_PREFIX.length));
130
- if (count !== null && priorHeld.has(Number(count[0])))
107
+ // Every Needs-review note goes, and so does each `**Previously held N**` marker the old
108
+ // increment rule wrote with it.
109
+ if (trimmed.startsWith(NEEDS_REVIEW_PREFIX) || trimmed.startsWith(PRIOR_HELD_PREFIX))
131
110
  drop.add(i);
132
111
  });
133
112
  const out = [];
@@ -140,7 +119,7 @@ export function withoutHeldForOperatorNotes(lines) {
140
119
  while (j < lines.length && drop.has(j))
141
120
  j++;
142
121
  // A dropped run between two blank lines drops the blank after it too, so the lift leaves
143
- // one blank line where the note was (the same rule withoutNoteLines applies to one).
122
+ // one blank line where the note was (the same rule withoutNoteLines applies to one note).
144
123
  if (j < lines.length && (lines[i - 1] ?? "x").trim() === "" && (lines[j] ?? "x").trim() === "")
145
124
  j++;
146
125
  i = j - 1;
@@ -209,13 +188,10 @@ export function entryHold(entry, planned) {
209
188
  export function entryHoldAgainst(entry, plannedParts) {
210
189
  if (hasNoteLine(entry.body, REFUSED_PREFIX))
211
190
  return "refused";
212
- if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)) {
213
- // A note whose held count reached OPERATOR_HOLD_THRESHOLD stops out of the role loop:
214
- // neither the plan role (planBacklogFull) nor this role takes it.
215
- return heldCount(entry.body) >= OPERATOR_HOLD_THRESHOLD
216
- ? "held-for-operator" // vocabulary-ok: the operator-held string names the operator hold decision
217
- : "needs-review";
218
- }
191
+ // A Needs-review note — the feature role's too-large note or the convergence hold's — parks the
192
+ // entry for the plan role to split or replan, at any count: nothing escalates to the operator.
193
+ if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX))
194
+ return "needs-review";
219
195
  const refs = requiredParts(entry.title);
220
196
  if (refs.length === 0)
221
197
  return null;
@@ -235,8 +211,6 @@ export function holdMark(hold) {
235
211
  return " [blocked: refused]";
236
212
  if (hold === "needs-review")
237
213
  return " [blocked: needs review]";
238
- if (hold === "held-for-operator")
239
- return " [blocked: held for operator]"; // vocabulary-ok: this hold names the operator-held wording for the operator decision
240
214
  return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
241
215
  }
242
216
  /** The line `plan|bug --release` and `--top` print when the entry they touched is still held:
@@ -315,42 +289,22 @@ export function backlogMarks(root) {
315
289
  bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
316
290
  };
317
291
  }
318
- /** A planned plan or open bug whose hold is `"held-for-operator"`, in file order: the
319
- * entries parked until the operator lifts them with `tumwater plan --release` or `tumwater
320
- * bug --release` (the `operator-held` team alert's input), each carrying the backlog file
321
- * and its 0-based position so a surface opening the row can land on it, the same RefusedEntry
322
- * shape refusedEntries returns. The convergence hold writes only on plans, but a role's own
323
- * too-large note can sit on a bug and reach this threshold, so a bug can be held this way too.
324
- * A missing file yields `[]`. */
325
- export function operatorHeldEntries(root) {
326
- const holds = backlogHolds(root);
327
- const out = [];
328
- const entriesOf = (entries, held, file) => {
329
- entries.forEach((e, i) => {
330
- if (held[i] === "held-for-operator")
331
- out.push({ file, index: i, title: e.title }); // vocabulary-ok: the held-for-operator hold's key
332
- });
333
- };
334
- entriesOf(plannedPlanEntries(root), holds.plans, "plans");
335
- entriesOf(openBugEntries(root), holds.bugs, "bugs");
336
- return out;
337
- }
338
292
  /** The display noun for a backlog-file discriminator — the `"plans" | "bugs"` `file` on
339
293
  * RefusedEntry and StalledEntry (and the same discriminator the prompt's named-hold wording
340
294
  * and the doctor's Stalled row take): BUGS.md is a "Bug" and PLANS.md a "Plan", and the
341
295
  * lowercase form is the `Refused plan|bug:` label and the `tumwater plan|bug --release`
342
- * command noun the operator-held alert body interpolates. */
296
+ * command noun. */
343
297
  export function entryKindLabel(file) {
344
298
  return file === "bugs" ? "Bug" : "Plan";
345
299
  }
346
- /** The planned plans and open bugs whose hold is exactly `"needs-review"`, in file order: the
347
- * entries parked by a Needs-review note until the plan role splits or replans them (the
348
- * `stalled-entries` team alert's input), each carrying its backlog file, 0-based index and the
349
- * hold. Same `backlogHolds(root)` + the planned/`openBugEntries` readers `operatorHeldEntries`
350
- * uses, so the alert and the doctor's `stalled entries` row classify through the same
351
- * `entryHold` and cannot disagree on which holds are role-resolvable. A held-for-operator
352
- * entry (a note at the held threshold) is
353
- * excluded — it feeds `operatorHeldEntries` and the `operator-held` alert instead. A missing
300
+ /** The planned plans and open bugs parked with the plan role — whose Needs-review hold the plan
301
+ * role clears by splitting or replanning (the feature role's too-large note, the convergence hold,
302
+ * a final-round rejection), in file order: the `stalled-entries` team alert's input, each
303
+ * carrying its backlog file, 0-based index and the hold. Same `backlogHolds(root)` + the
304
+ * planned/`openBugEntries` readers `refusedEntries` uses, so the alert and the doctor's
305
+ * `stalled entries` row classify through the same `entryHold` + `isPlanStalled` and cannot
306
+ * disagree on which holds are role-resolvable. The bugfix role's wait-for-the-next-failure
307
+ * parks ("by bugfix:") are excluded: selfcheck lifts them, not the plan role. A missing
354
308
  * file yields `[]`. */
355
309
  export function stalledEntries(root) {
356
310
  const holds = backlogHolds(root);
@@ -358,7 +312,9 @@ export function stalledEntries(root) {
358
312
  const entriesOf = (entries, held, file) => {
359
313
  entries.forEach((e, i) => {
360
314
  const hold = held[i];
361
- if (hold === "needs-review")
315
+ // Only holds the plan role can clear surface as stalled: the bugfix role's
316
+ // wait-for-the-next-failure parks stay out of the plan-role blame.
317
+ if (hold === "needs-review" && isPlanStalled(e))
362
318
  out.push({ file, index: i, title: e.title, hold });
363
319
  });
364
320
  };
@@ -3,8 +3,8 @@ import { daysBetween } from "../text/datetime.js";
3
3
  * QUESTIONS.md text, with no filesystem access — every function here takes the markdown text as
4
4
  * an argument. Split from src/backlog/backlog.ts, which keeps the stat-cached file readers
5
5
  * (sectionEntries, the root-based plannedPlans/openBugs/openQuestions family, and the
6
- * Done/Fixed date scan): consumers who only parse markdown (question-answer.ts's QUESTIONS.md
7
- * walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
6
+ * Done/Fixed date scan): consumers who only parse markdown (backlog-question-answer.ts's
7
+ * QUESTIONS.md walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
8
8
  * import from here without reaching the file/cache layer, and a caller who parses a string never
9
9
  * pays for a stat. Both layers share one fenceTracker state machine, so independent readers can
10
10
  * never disagree about what is body content. */
@@ -87,7 +87,7 @@ export function headingTitle(line, prefix) {
87
87
  * code block. The single home of the fence-aware heading guard (`!fenced.inside(line) &&
88
88
  * line.startsWith(prefix)` plus headingTitle) that every line-level reader of the backlog
89
89
  * docs repeats: sectionLines' section boundaries and parseEntryDetails' entry boundaries here,
90
- * and question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
90
+ * and backlog-question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
91
91
  * split, and the Answered section's start); that file's two pure next-`## ` boundary walks
92
92
  * go through nextSectionHeading below instead. Readers that walk
93
93
  * whole documents collecting heading lines go through fenceAwareHeadingLines instead; callers
@@ -120,7 +120,7 @@ export function sectionLines(md, sectionTitle) {
120
120
  /** The index of the next `## ` section heading at or after `from`, fence-aware through the
121
121
  * caller's `fenced` tracker — or `lines.length` when none follows. The section-end boundary
122
122
  * rule as an index, for readers that must cut or splice at the boundary rather than collect
123
- * its content (question-answer.ts cuts the Open section at its end and inserts a moved
123
+ * its content (backlog-question-answer.ts cuts the Open section at its end and inserts a moved
124
124
  * block before Answered's next `## `; backlog-write.ts cuts the section a filed entry is
125
125
  * appended to). Shares fencedHeadingTitle's guard with sectionLines, so
126
126
  * a boundary found here is the same boundary sectionLines would stop at. */
@@ -418,7 +418,7 @@ export function lastDate(text, dateRe) {
418
418
  * about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
419
419
  * text with no date yields null. Exported so the heading stamp parsers that apply the same
420
420
  * filing rule to a single title line — `filingDate` (backlog.ts) and `questionFilingDate`
421
- * (question-answer.ts) — share the one first-match mechanism instead of re-stating it;
421
+ * (backlog-question-answer.ts) — share the one first-match mechanism instead of re-stating it;
422
422
  * entryDates selects it with `first`, so the walker stays single-homed like lastDate's
423
423
  * completion rule. */
424
424
  export function firstDate(text, dateRe) {
@@ -16,25 +16,37 @@ function parseRef(chunk, fallbackSeries) {
16
16
  const m = /^(?:([\s\S]+?)\s+)?([0-9]+[a-z]?)\/(\d+)(?:\s*[–-]\s*(\d+)\/(\d+))?$/i.exec(text);
17
17
  if (m === null)
18
18
  return { refs: [], named: null };
19
- let named = (m[1]?.trim() ?? "").replace(/\s+parts?$/i, "");
20
- const series = named || fallbackSeries;
21
- if (series === null || series === "")
22
- return { refs: [], named: named || null };
19
+ const { named, series } = resolveRefSeries(m[1], fallbackSeries);
23
20
  const of = Number(m[3]);
24
- const start = m[2].toLowerCase();
25
- if (!Number.isInteger(of) || of < 1)
26
- return { refs: [], named: named || null };
27
- if (m[4] === undefined)
28
- return { refs: [{ series, part: start, of }], named: named || null };
21
+ if (series === null || !Number.isInteger(of) || of < 1)
22
+ return { refs: [], named };
23
+ return { refs: refsFromMatch(series, of, m[2], m[4] ?? null), named };
24
+ }
25
+ /** Whichever series a ref chunk resolves to: a captured name with any trailing `parts`/`part`
26
+ * keyword dropped, or — for a bare `parts a/n` — the clause's last-named series (null when it
27
+ * named none, so a bare ref there resolves to nothing). */
28
+ function resolveRefSeries(matchName, fallbackSeries) {
29
+ const named = (matchName?.trim() ?? "").replace(/\s+parts?$/i, "");
30
+ if (named !== "")
31
+ return { named, series: named };
32
+ return { named: null, series: fallbackSeries === "" ? null : fallbackSeries };
33
+ }
34
+ /** Build the resolved refs for a chunk whose regex matched: the one part, or an inclusive
35
+ * numeric range. The part token arrives verbatim (so `2a` survives); a range is numeric on
36
+ * both ends (the lettered parts never range) and expands to that many single refs. */
37
+ function refsFromMatch(series, of, startText, toText) {
38
+ const start = startText.toLowerCase();
39
+ if (toText === null)
40
+ return [{ series, part: start, of }];
29
41
  // A range is numeric on both ends (the lettered parts never range); expand it inclusively.
30
42
  const from = Number(start);
31
- const to = Number(m[4]);
43
+ const to = Number(toText);
32
44
  if (!Number.isInteger(from) || !Number.isInteger(to) || to < from)
33
- return { refs: [], named: named || null };
45
+ return [];
34
46
  const parts = [];
35
47
  for (let p = from; p <= to; p++)
36
48
  parts.push({ series, part: String(p), of });
37
- return { refs: parts, named: named || null };
49
+ return parts;
38
50
  }
39
51
  /** The stable identity of an entry heading: its `(planned …)`/`(done …)` stamp suffix
40
52
  * removed through the shared stripEntryStamp, whitespace collapsed, lowercased. Adding a
@@ -181,12 +193,45 @@ const MISWORDED_DEPENDENCY_RE = /\b(?:blocked by|blocked on|after|follows|depend
181
193
  * Refused and needs-review entries count like the rest, since their clauses still decide when
182
194
  * they unblock. */
183
195
  export function planDependencyProblems(planned, doneTitles) {
196
+ const known = dependencyKnownParts(planned, doneTitles);
197
+ const { nodes, dangling, unparsed, misworded } = dependencyEntryScan(planned, known);
198
+ return { cycle: firstDependencyCycle(nodes), dangling, unparsed, misworded };
199
+ }
200
+ /** The part keys that count as known for dangling checks: every still-planned entry's part plus
201
+ * every done title's part — a dangling ref is one no planned or done heading carries. */
202
+ function dependencyKnownParts(planned, doneTitles) {
184
203
  const known = plannedPartKeys(planned);
185
204
  for (const t of doneTitles) {
186
205
  const part = seriesPart(t);
187
206
  if (part !== null)
188
207
  known.add(partKey(part.series, part.part));
189
208
  }
209
+ return known;
210
+ }
211
+ /** The misworded refs a still-planned `Series, part i/n:` entry contributes: the series-parts its
212
+ * trailing-or-open parenthetical names through a dependency verb with no requires clause holding
213
+ * them, each `title`+`ref` deduped against the refs already collected across the whole planned
214
+ * list (a duplicate title contributes one ref, not one per occurrence). */
215
+ function miswordedDependencyRefs(entry, own, refs, plannedKeys, misworded) {
216
+ let meta = trailingParenthetical(entry.title);
217
+ if (meta === "")
218
+ meta = openParenthetical(entry.title);
219
+ const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
220
+ for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
221
+ for (const ref of parseRef(m[1], own.series).refs) {
222
+ const key = partKey(ref.series, ref.part);
223
+ if (!plannedKeys.has(key) || held.has(key))
224
+ continue;
225
+ const name = `${ref.series} ${ref.part}/${ref.of}`;
226
+ if (!misworded.some((w) => w.title === entry.title && w.ref === name))
227
+ misworded.push({ title: entry.title, ref: name });
228
+ }
229
+ }
230
+ }
231
+ /** The one-pass scan over the planned entries: a clause that parses to no refs pushes its title to
232
+ * `unparsed`, a ref `known` does not carry lands in `dangling`, and each entry with a series part
233
+ * contributes its misworded refs and a graph node whose deps are its required parts' keys. */
234
+ function dependencyEntryScan(planned, known) {
190
235
  const dangling = [];
191
236
  const unparsed = [];
192
237
  const misworded = [];
@@ -203,25 +248,18 @@ export function planDependencyProblems(planned, doneTitles) {
203
248
  const own = seriesPart(entry.title);
204
249
  if (own === null)
205
250
  continue;
206
- let meta = trailingParenthetical(entry.title);
207
- if (meta === "")
208
- meta = openParenthetical(entry.title);
209
- const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
210
- for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
211
- for (const ref of parseRef(m[1], own.series).refs) {
212
- const key = partKey(ref.series, ref.part);
213
- if (!plannedKeys.has(key) || held.has(key))
214
- continue;
215
- const name = `${ref.series} ${ref.part}/${ref.of}`;
216
- if (!misworded.some((w) => w.title === entry.title && w.ref === name))
217
- misworded.push({ title: entry.title, ref: name });
218
- }
219
- }
251
+ miswordedDependencyRefs(entry, own, refs, plannedKeys, misworded);
220
252
  nodes.set(partKey(own.series, own.part), {
221
253
  name: `${own.series} ${own.part}/${own.of}`,
222
254
  deps: refs.map((ref) => partKey(ref.series, ref.part)),
223
255
  });
224
256
  }
257
+ return { nodes, dangling, unparsed, misworded };
258
+ }
259
+ /** The first dependency cycle the depth-first walk finds, as `Series i/n` names in walk order with
260
+ * the first name repeated last (a self-reference is a one-hop cycle), or null when the graph is
261
+ * acyclic. The `open`/`done` state map and the walk order decide which cycle reports first. */
262
+ function firstDependencyCycle(nodes) {
225
263
  const state = new Map();
226
264
  const stack = [];
227
265
  const visit = (key) => {
@@ -252,7 +290,7 @@ export function planDependencyProblems(planned, doneTitles) {
252
290
  if (cycle !== null)
253
291
  break;
254
292
  }
255
- return { cycle, dangling, unparsed, misworded };
293
+ return cycle;
256
294
  }
257
295
  /** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
258
296
  * `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
@@ -1,35 +1,35 @@
1
1
  /** The hold-lifting and reordering writes of `tumwater bug|plan --release <n>` and `--top <n>`:
2
- * lift an entry's Refused or held-for-operator note, or move
2
+ * lift an entry's Refused or Needs-review note, or move
3
3
  * it to the head of its open
4
4
  * section. Each comes as an exiting writer (the CLI) and a non-exiting `try…` core that refuses as
5
5
  * data (the TUI and dashboard); entry lookup and the section's file target are
6
6
  * backlog-write.ts's. */
7
7
  import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
8
- import { withoutHeldForOperatorNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
8
+ import { withoutNeedsReviewNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
9
9
  import { writeTextAtomic } from "../files/files.js";
10
10
  import { fail } from "../cli/cli-output.js";
11
11
  import { trimTrailingBlankLines } from "../text/text.js";
12
12
  import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
13
- /** The prose naming the note `lifted` kind a release removed, shared by the CLI, TUI and the
14
- * exit-announcers so their released lines cannot drift. */
13
+ /** The prose naming the note kind a release removed — plural, since a release lifts every line
14
+ * of the chosen kind — shared by the CLI, TUI and the exit-announcers so their released lines
15
+ * cannot drift. */
15
16
  export function liftedNotePhrase(lifted) {
16
17
  return lifted === "refused"
17
- ? "the Refused note"
18
- : "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
18
+ ? "the Refused notes"
19
+ : "the Needs review notes";
19
20
  }
20
21
  /** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
21
- * entry with none loses its held-for-operator Needs-review note (the `(held N)` count at/above
22
- * threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
22
+ * entry with none loses its Needs-review note. Null (and `lines` untouched) when the entry
23
23
  * carries neither, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
24
24
  function liftRefusal(lines, entry) {
25
25
  const slice = lines.slice(entry.start - 1, entry.end);
26
26
  let kept = withoutRefusalNotes(slice);
27
27
  if (kept.length === slice.length) {
28
- kept = withoutHeldForOperatorNotes(slice);
28
+ kept = withoutNeedsReviewNotes(slice);
29
29
  if (kept.length === slice.length)
30
30
  return null;
31
31
  lines.splice(entry.start - 1, slice.length, ...kept);
32
- return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
32
+ return "needs-review";
33
33
  }
34
34
  lines.splice(entry.start - 1, slice.length, ...kept);
35
35
  return "refused";
@@ -51,7 +51,7 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
51
51
  return { status: "released", file: fileName, title: entry.title, lifted };
52
52
  });
53
53
  }
54
- /** Lift the Refused or held-for-operator note from the
54
+ /** Lift the Refused or Needs-review note from the
55
55
  * `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
56
56
  * file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
57
57
  * An out-of-range position fails with locateEntry's wording; an entry with neither note
@@ -62,7 +62,7 @@ export function releaseEntry(root, kind, n) {
62
62
  const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
63
63
  const lifted = liftRefusal(lines, entry);
64
64
  if (lifted === null)
65
- fail(`${kind} #${n} "${entry.title}" carries no Refused or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
65
+ fail(`${kind} #${n} "${entry.title}" carries no Refused or Needs review note`);
66
66
  writeTextAtomic(file, lines.join("\n"));
67
67
  return { file: fileName, title: entry.title, lifted };
68
68
  });