tumwater 0.0.0-stage → 0.1.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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -2
  3. package/dist/build-info.json +5 -0
  4. package/dist/src/backlog-structure.js +166 -0
  5. package/dist/src/backlog.js +285 -0
  6. package/dist/src/backoff.js +113 -0
  7. package/dist/src/budget-gates.js +128 -0
  8. package/dist/src/budget.js +118 -0
  9. package/dist/src/build-check-counts.js +36 -0
  10. package/dist/src/build-check-detect.js +133 -0
  11. package/dist/src/build-check-events.js +164 -0
  12. package/dist/src/build-check-report.js +120 -0
  13. package/dist/src/build-check-scoped.js +129 -0
  14. package/dist/src/build-check.js +173 -0
  15. package/dist/src/build-info.js +66 -0
  16. package/dist/src/build-stage.js +215 -0
  17. package/dist/src/change-data.js +90 -0
  18. package/dist/src/check-permit.js +56 -0
  19. package/dist/src/cli-args.js +187 -0
  20. package/dist/src/cli-command-args.js +276 -0
  21. package/dist/src/cli-flag-specs.js +185 -0
  22. package/dist/src/cli-output.js +44 -0
  23. package/dist/src/cli-run.js +243 -0
  24. package/dist/src/cli.js +472 -0
  25. package/dist/src/command-shape.js +40 -0
  26. package/dist/src/commit-message.js +121 -0
  27. package/dist/src/config-commands.js +58 -0
  28. package/dist/src/config-example.js +68 -0
  29. package/dist/src/config-field-checks.js +132 -0
  30. package/dist/src/config-live.js +87 -0
  31. package/dist/src/config-schema.js +103 -0
  32. package/dist/src/config-validation.js +307 -0
  33. package/dist/src/config-views.js +106 -0
  34. package/dist/src/config-write.js +217 -0
  35. package/dist/src/config.js +301 -0
  36. package/dist/src/datetime.js +92 -0
  37. package/dist/src/dep-install.js +111 -0
  38. package/dist/src/doctor-backlog.js +157 -0
  39. package/dist/src/doctor-checks.js +301 -0
  40. package/dist/src/doctor-orphans.js +217 -0
  41. package/dist/src/doctor.js +56 -0
  42. package/dist/src/errno.js +14 -0
  43. package/dist/src/error-storm.js +64 -0
  44. package/dist/src/event-format.js +261 -0
  45. package/dist/src/event-read.js +146 -0
  46. package/dist/src/event-window.js +212 -0
  47. package/dist/src/events.js +113 -0
  48. package/dist/src/exemptions.js +59 -0
  49. package/dist/src/failure-cluster.js +124 -0
  50. package/dist/src/failure-data.js +216 -0
  51. package/dist/src/failure-report.js +254 -0
  52. package/dist/src/failure-spread.js +62 -0
  53. package/dist/src/failure-state-change.js +165 -0
  54. package/dist/src/fallback-breaker.js +125 -0
  55. package/dist/src/file-queue.js +48 -0
  56. package/dist/src/files.js +168 -0
  57. package/dist/src/fix-claim.js +172 -0
  58. package/dist/src/fleet-hold.js +101 -0
  59. package/dist/src/fleet-polls.js +113 -0
  60. package/dist/src/fleet-state.js +233 -0
  61. package/dist/src/gate-polls.js +150 -0
  62. package/dist/src/gate-prompts.js +210 -0
  63. package/dist/src/git-diff.js +164 -0
  64. package/dist/src/git.js +333 -0
  65. package/dist/src/help.js +185 -0
  66. package/dist/src/history-data.js +228 -0
  67. package/dist/src/host-sleep.js +86 -0
  68. package/dist/src/inbox-attachments.js +114 -0
  69. package/dist/src/inbox-submit.js +80 -0
  70. package/dist/src/inbox.js +392 -0
  71. package/dist/src/init-templates.js +78 -0
  72. package/dist/src/init.js +275 -0
  73. package/dist/src/json-files.js +49 -0
  74. package/dist/src/json-object.js +31 -0
  75. package/dist/src/landing-batch.js +243 -0
  76. package/dist/src/landing-check-failures.js +115 -0
  77. package/dist/src/landing-core.js +234 -0
  78. package/dist/src/landing-drain.js +155 -0
  79. package/dist/src/landing-git.js +131 -0
  80. package/dist/src/landing-merge.js +285 -0
  81. package/dist/src/landing-pipeline.js +72 -0
  82. package/dist/src/landing-queue.js +109 -0
  83. package/dist/src/landing-slot.js +206 -0
  84. package/dist/src/landing-stack.js +202 -0
  85. package/dist/src/landing-vetting.js +191 -0
  86. package/dist/src/launch-services.js +99 -0
  87. package/dist/src/leftover.js +96 -0
  88. package/dist/src/lock.js +165 -0
  89. package/dist/src/loop-pi.js +218 -0
  90. package/dist/src/loop-state.js +44 -0
  91. package/dist/src/loop.js +541 -0
  92. package/dist/src/main-baseline.js +195 -0
  93. package/dist/src/main-red.js +160 -0
  94. package/dist/src/no-change.js +22 -0
  95. package/dist/src/notify.js +82 -0
  96. package/dist/src/once-round.js +95 -0
  97. package/dist/src/operator-commands.js +214 -0
  98. package/dist/src/operator-intent.js +184 -0
  99. package/dist/src/operator-requests.js +114 -0
  100. package/dist/src/orchestrator-launch.js +119 -0
  101. package/dist/src/orchestrator.js +454 -0
  102. package/dist/src/paths.js +235 -0
  103. package/dist/src/pause-gates.js +41 -0
  104. package/dist/src/pending-prompt.js +96 -0
  105. package/dist/src/phrases.js +116 -0
  106. package/dist/src/pi-args.js +39 -0
  107. package/dist/src/pi-event-line.js +101 -0
  108. package/dist/src/pi-extension/bounded-output.js +204 -0
  109. package/dist/src/pi-extension/context-budget.js +72 -0
  110. package/dist/src/pi-models.js +114 -0
  111. package/dist/src/pi-stream.js +295 -0
  112. package/dist/src/pi.js +333 -0
  113. package/dist/src/process-group.js +152 -0
  114. package/dist/src/process-table.js +157 -0
  115. package/dist/src/process.js +122 -0
  116. package/dist/src/progress-data.js +247 -0
  117. package/dist/src/project-name.js +10 -0
  118. package/dist/src/prompt-commands.js +188 -0
  119. package/dist/src/prompt-followup.js +77 -0
  120. package/dist/src/prompt.js +289 -0
  121. package/dist/src/qa-coverage.js +99 -0
  122. package/dist/src/question-commands.js +232 -0
  123. package/dist/src/quiet-hours.js +116 -0
  124. package/dist/src/rank.js +18 -0
  125. package/dist/src/readiness.js +78 -0
  126. package/dist/src/readme.js +117 -0
  127. package/dist/src/redeploy-policy.js +67 -0
  128. package/dist/src/redeploy-probes.js +72 -0
  129. package/dist/src/redeploy.js +82 -0
  130. package/dist/src/redeployer.js +544 -0
  131. package/dist/src/refusal.js +53 -0
  132. package/dist/src/reply-contract.js +210 -0
  133. package/dist/src/report-data.js +186 -0
  134. package/dist/src/retention.js +66 -0
  135. package/dist/src/review-followup.js +79 -0
  136. package/dist/src/review-precheck.js +124 -0
  137. package/dist/src/review-verdict.js +80 -0
  138. package/dist/src/review.js +325 -0
  139. package/dist/src/role-cap-gates.js +76 -0
  140. package/dist/src/role-catalog.js +339 -0
  141. package/dist/src/role-guidance.js +113 -0
  142. package/dist/src/role-view.js +71 -0
  143. package/dist/src/roles.js +108 -0
  144. package/dist/src/run-marker.js +176 -0
  145. package/dist/src/scheduling.js +168 -0
  146. package/dist/src/self-reload.js +126 -0
  147. package/dist/src/semaphore.js +81 -0
  148. package/dist/src/startup-gate.js +88 -0
  149. package/dist/src/stat-cache.js +33 -0
  150. package/dist/src/status-data.js +133 -0
  151. package/dist/src/status-polls.js +133 -0
  152. package/dist/src/streak-gate.js +88 -0
  153. package/dist/src/suite-rerun.js +177 -0
  154. package/dist/src/supervisor.js +127 -0
  155. package/dist/src/tail.js +281 -0
  156. package/dist/src/telemetry-digest.js +24 -0
  157. package/dist/src/text-width.js +77 -0
  158. package/dist/src/text.js +183 -0
  159. package/dist/src/tick-apply.js +286 -0
  160. package/dist/src/tick-detail-data.js +93 -0
  161. package/dist/src/tick-finalize.js +93 -0
  162. package/dist/src/tick-outcome.js +13 -0
  163. package/dist/src/tick-prompt.js +120 -0
  164. package/dist/src/tick-resume.js +54 -0
  165. package/dist/src/tick-stage.js +95 -0
  166. package/dist/src/tick-timing.js +191 -0
  167. package/dist/src/tick-usage.js +67 -0
  168. package/dist/src/tick-verdict.js +114 -0
  169. package/dist/src/time-spend.js +176 -0
  170. package/dist/src/ui/backlog-report.js +36 -0
  171. package/dist/src/ui/badges.js +174 -0
  172. package/dist/src/ui/change-preview.js +42 -0
  173. package/dist/src/ui/doctor-report.js +9 -0
  174. package/dist/src/ui/fleet-alerts.js +155 -0
  175. package/dist/src/ui/gui-args.js +123 -0
  176. package/dist/src/ui/gui-client-boot.js +190 -0
  177. package/dist/src/ui/gui-client-composer.js +195 -0
  178. package/dist/src/ui/gui-client-drawer.js +219 -0
  179. package/dist/src/ui/gui-client-fleet.js +308 -0
  180. package/dist/src/ui/gui-client-history.js +196 -0
  181. package/dist/src/ui/gui-client-loops.js +141 -0
  182. package/dist/src/ui/gui-client-markdown.js +159 -0
  183. package/dist/src/ui/gui-client-model.js +140 -0
  184. package/dist/src/ui/gui-client-operator.js +234 -0
  185. package/dist/src/ui/gui-client-report.js +276 -0
  186. package/dist/src/ui/gui-client-settings.js +70 -0
  187. package/dist/src/ui/gui-client-sound.js +52 -0
  188. package/dist/src/ui/gui-client.js +250 -0
  189. package/dist/src/ui/gui-endpoint-commands.js +296 -0
  190. package/dist/src/ui/gui-endpoints.js +142 -0
  191. package/dist/src/ui/gui-icons.js +49 -0
  192. package/dist/src/ui/gui-page.js +124 -0
  193. package/dist/src/ui/gui-server.js +198 -0
  194. package/dist/src/ui/gui-styles.js +518 -0
  195. package/dist/src/ui/gui.js +89 -0
  196. package/dist/src/ui/history.js +161 -0
  197. package/dist/src/ui/http-body.js +118 -0
  198. package/dist/src/ui/log-commands.js +190 -0
  199. package/dist/src/ui/report-render.js +135 -0
  200. package/dist/src/ui/report.js +55 -0
  201. package/dist/src/ui/role-report.js +65 -0
  202. package/dist/src/ui/status-model.js +362 -0
  203. package/dist/src/ui/status-payload.js +185 -0
  204. package/dist/src/ui/status-render.js +330 -0
  205. package/dist/src/ui/tick-detail.js +87 -0
  206. package/dist/src/ui/tone.js +53 -0
  207. package/dist/src/ui/transcript-tail.js +184 -0
  208. package/dist/src/ui/transcript.js +220 -0
  209. package/dist/src/ui/tui-app.js +46 -0
  210. package/dist/src/ui/tui-backlog.js +80 -0
  211. package/dist/src/ui/tui-frame.js +111 -0
  212. package/dist/src/ui/tui-input.js +310 -0
  213. package/dist/src/ui/tui-keymap.js +69 -0
  214. package/dist/src/ui/tui-keys.js +439 -0
  215. package/dist/src/ui/tui.js +187 -0
  216. package/dist/src/version.js +70 -0
  217. package/dist/src/work-landed-cache.js +51 -0
  218. package/dist/src/worktree.js +154 -0
  219. package/package.json +53 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the tumwater authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ # tumwater
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ tumwater is an opinionated autonomous development harness built on
4
+ [pi](https://github.com/badlogic/pi-mono). You write a short project brief, and a fleet of
5
+ role-driven loops (feature, bugfix, planning, tests, cleanup, docs, and more) builds the project
6
+ one small, reviewed commit at a time. It came from wanting to write only the brief and let a team
7
+ of always-on specialists do the rest: each loop owns one concern, lands one change per tick,
8
+ sleeps when it has nothing to do, and wakes when main moves. All project state lives in the
9
+ local git repo, and no remote is ever touched.
10
+
11
+ ![The tumwater web dashboard running tumwater's own fleet: a sidebar with the project, its fleet status, the Fleet, History, Usage, Failures, and Settings views, today's spend against the cap, and the pause control; an alert that the running build is behind main; the director prompt box; today's progress (loops in flight, commits landed, ticks, open backlog); and the loops grouped by what they are doing, each with its status, current work or last result, spend, and controls](docs/gui.png)
12
+
13
+ ## Status
14
+
15
+ <!-- tumwater:status:start -->
16
+ **v0.1**: working harness. All 13 roles and the director are enabled by default.
17
+
18
+ Open work: [PLANS.md](PLANS.md) (planned), [BUGS.md](BUGS.md) (open bugs),
19
+ [QUESTIONS.md](QUESTIONS.md) (open questions).
20
+ <!-- tumwater:status:end -->
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ npm install -g tumwater # or run any command with npx tumwater
26
+ # from a checkout: npm install && npm run build && npm link
27
+ cd your-project # a new or existing directory
28
+ tumwater init "Build a tiny markdown-to-html converter CLI in Python."
29
+ # add --template <id> to seed from a bundled starting point
30
+ # (blank, python-cli, node-cli, static-site); --list-templates
31
+ # prints the catalog
32
+ # add --file <path> to read the brief from a file
33
+ # add --adopt to adopt an existing repo as-is
34
+ tumwater run # start the loops (Ctrl+C to stop)
35
+ ```
36
+
37
+ Then, from another terminal:
38
+
39
+ | To | Run |
40
+ | --- | --- |
41
+ | Watch the fleet | `tumwater tui`, or `tumwater gui` for the browser dashboard at http://127.0.0.1:7180 |
42
+ | Watch per-tick history | `tumwater history [--role <id>] [-n N] [--since <duration>] [--grep <text>]`, or `tumwater history --json` for the rows as JSON; `tumwater tick <role> <n>` for one tick's full event trail (a summary header — when it ran, how long, result, usage — followed by the tick's events, oldest first; `--json` prints the payload as JSON, `null` when the log holds no such tick) |
43
+ | Check state | `tumwater status`, `tumwater logs -f`, `tumwater logs --since <duration>`, `tumwater logs --grep <text>`, `tumwater logs --json` (the event feed as NDJSON, for scripts), `tumwater logs --role <id>`, `tumwater backlog` (planned features, open bugs, open questions as Markdown), `tumwater backlog --json` (the backlog as JSON, for scripts), `tumwater role <id>` (one loop's standing prompt — find text, `instructions` override, resolved model and interval, enabled/paused state — plus its next tick's assembled prompt, which shows the oldest queued prompt without consuming it (one is dequeued per tick; `--json` for scripts)) |
44
+ | See a loop's pending change | `tumwater diff --role <id>` — that loop's branch's unlanded commits (with the patch) and its worktree's uncommitted edits (staged and unstaged); without `--role`, one line per loop holding pending work; `--json` prints the payload as data |
45
+ | Steer the project | `tumwater prompt "prefer no third-party deps"` queues a request for the director; add `--role <id>` to aim it at one loop's next tick, `tumwater prompt --list` shows the queued prompts numbered and grouped by loop, with how long each has waited (`--json` for scripts), and `tumwater prompt --cancel <n>` removes the Nth entry as `--list` shows them (add `--role <id>` when several loops share that number) |
46
+ | Answer open questions | `tumwater questions` lists QUESTIONS.md's open questions numbered (as the loops wrote them); `tumwater questions answer <n> "decision text"` moves the Nth entry to ## Answered with your dated answer (loops read the file back on their next tick); `--json` prints the list as data |
47
+ | Control the loops | `tumwater pause [--for <duration>]` / `resume [--role <id>]` (fleet or one loop; `--for 2h` auto-resumes, capped at 90d; add `--reason <text>` on a fleet pause to state why — it shows on `status`, the TUI, and the dashboard), `tumwater wake` (skip backoff), `tumwater abort --role <id>`, `tumwater stop` (drain and exit, like Ctrl+C) |
48
+ | Audit | `tumwater doctor` (pre-flight; `--json` prints the report as JSON, for scripts), `tumwater report` (usage and cost; totals include landing runs — reviewer + conflict resolution), `tumwater report --since <duration>` (totals over a trailing window, capped at 7d), `tumwater report --json` (the `--days`/`--since`/`--failures` reports as JSON, for scripts), `tumwater report --failures` (the failure digest: per-role outcomes, each role's time and spend by outcome, and the top five loss causes ranked by agent-hours, with a marker naming how many were cut) |
49
+
50
+ `tumwater help` lists every command and flag; `tumwater help <command>` shows one command's usage. `gui --all-interfaces` exposes the dashboard, and
51
+ with it the director prompt, to your whole network, so pair it with `--token <secret>`.
52
+
53
+ Settings live in `tumwater.json`: enabled roles, model, intervals, the daily spend cap
54
+ (`maxDailyCostUsd`, with optional per-role caps `maxDailyCostUsdPerRole` — a loop over its own
55
+ cap starts no new ticks until the next local day or a live edit), a nightly `quietHours` window
56
+ (e.g. `"23:00-07:00"` local time) during
57
+ which role loops start no new ticks (the director is exempt), user-defined `customLoops`, and an
58
+ optional `notify` shell command run when the fleet needs a human (a budget pause, a budget warning
59
+ at 80% of the cap while the gate is still open, an error-streak
60
+ breaker trip, a failed landing, a blocked restart — the command gets `TUMWATER_EVENT_TYPE`,
61
+ `TUMWATER_EVENT_LOOP`, and `TUMWATER_EVENT_MESSAGE` in its environment).
62
+ Edits apply live while the fleet runs.
63
+ From the terminal, `tumwater config` prints the effective config as JSON, `tumwater config get
64
+ <key>` reads one resolved value, and `tumwater config set <key> <value>` writes one top-level
65
+ key.
66
+
67
+ **Backends:** any OpenAI-compatible model pi can reach works; set `provider` and `model` in
68
+ `tumwater.json`. See [docs/backends.md](docs/backends.md) for requirements and a worked setup.
69
+
70
+ For how the loops, review gate, scheduling, and self-redeploy work, see
71
+ [docs/how-it-works.md](docs/how-it-works.md). For a measured comparison of tumwater's own
72
+ code against human-written open source, see [docs/code-metrics.md](docs/code-metrics.md).
73
+
74
+ ## Appendix: initial prompt
75
+
76
+ The brief this repository was started from, kept for history. The harness still reads it from
77
+ between the markers below on every tick.
78
+
79
+ <!-- tumwater:prompt:start -->
80
+ Idea: agentic harness
81
+
82
+ Opinionated. Built on pi. Lots of autonomous loops. You only write the markdown/initial prompt.
83
+ It builds the project with immense effort. First puts the initial prompt and project status into
84
+ README.md. Background loops are observable by gui/tui/log. GUI/TUI also gives user a main prompt.
85
+ Loop sleeps a while when the prompt results in no further changes. Starts again after a while to
86
+ see if the answer has changed due to the new state of the world. Each run attempts to find
87
+ something to do, do one thing, commit, merge to main. The find-something-to-do part is role
88
+ specific. Each loop has a role:
89
+
90
+ - Make the code more organized
91
+ - Increase unit test code coverage
92
+ - Make the code cleaner
93
+ - Make the code less repetitive
94
+ - Implement a planned feature (tracked in PLANS.md)
95
+ - Fix a bug (tracked in BUGS.md in repo)
96
+ - Plan a feature (write markdown plan, add to PLANS.md)
97
+ - Keep the README up to date
98
+ - Make an improvement to the code
99
+
100
+ Assumptions: run within a git repo dir. Each loop uses a persistent git workspace and branch.
101
+ Each loop keeps itself synced up with git main. Don't involve git remotes at all; do everything
102
+ locally and keep all project state within the git repo.
103
+ <!-- tumwater:prompt:end -->
@@ -0,0 +1,5 @@
1
+ {
2
+ "sha": "4c69994273d9f4d707b0c5a6e7d00f50a3b265e6",
3
+ "builtAt": 1791110618512,
4
+ "root": "/home/runner/work/tumwater/tumwater"
5
+ }
@@ -0,0 +1,166 @@
1
+ import path from "node:path";
2
+ import { readTextOrNull } from "./files.js";
3
+ import { headingMetadata, sectionBodyLines, fenceAwareHeadingLines } from "./backlog.js";
4
+ import { gitTry } from "./git.js";
5
+ import { collapseWhitespace } from "./text.js";
6
+ /** A heading's parenthetical dates: `(planned YYYY-MM-DD` opens a plan entry; `done
7
+ * YYYY-MM-DD` records its completion. Matched against the JOINED heading metadata
8
+ * (headingMetadata), never the bare first line — many headings wrap their dates onto a
9
+ * second line (`(planned 2026-09-02, done` / `2026-09-03)`). */
10
+ const PLANNED_DATE = /\(planned \d{4}-\d{2}-\d{2}/;
11
+ const DONE_DATE = /\bdone \d{4}-\d{2}-\d{2}/;
12
+ /** The `### ` headings of `md`'s PLANS.md that sit under the wrong section, in file order:
13
+ * (a) entries under `## Done` whose heading carries a `(planned YYYY-MM-DD…)` parenthetical
14
+ * but no `done YYYY-MM-DD` — written as plans, never stamped done, and invisible to every
15
+ * Planned reader (`plannedPlanEntries` reads only `## Planned`); and (b) entries under
16
+ * `## Planned` whose heading already carries `done YYYY-MM-DD` — finished and never moved,
17
+ * so the feature loop may implement them again. Fence-aware through backlog.ts's shared
18
+ * machinery: a `### `/`## ` line inside a fenced code block is quoted content, not structure,
19
+ * and heading dates are matched against the same joined metadata entryDates matches against.
20
+ * BUGS.md and QUESTIONS.md are deliberately out of scope — their Fixed-section headings do
21
+ * not all carry a date suffix, so the same rule would misfire there. */
22
+ export function strandedPlanEntries(md) {
23
+ const stranded = [];
24
+ // Each `## ` section is walked through backlog.ts's sectionLines — the single home of
25
+ // "where a section starts and ends", shared with entryDates' readers — so this scanner and
26
+ // the entry readers can never disagree about the boundary. Walking the section titles in
27
+ // file order (each occurrence once) keeps the output in document order, the same order the
28
+ // whole-document walk it replaced produced. Inside a section, the fence filter matches
29
+ // entryDates': a fenced `### ` line is quoted content, never an entry.
30
+ for (const section of sectionTitles(md)) {
31
+ if (section !== "Planned" && section !== "Done")
32
+ continue;
33
+ // Inside a section, the fence filter matches entryDates': a fenced `### ` line is quoted
34
+ // content, never an entry — the shared sectionBodyLines walk (backlog.ts).
35
+ const lines = sectionBodyLines(md, section);
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const line = lines[i] ?? "";
38
+ if (!line.startsWith("### "))
39
+ continue;
40
+ const { text } = headingMetadata(lines, i);
41
+ if (section === "Done" ? PLANNED_DATE.test(text) && !DONE_DATE.test(text) : DONE_DATE.test(text))
42
+ stranded.push({ title: line.slice(4).trim(), section });
43
+ }
44
+ }
45
+ return stranded;
46
+ }
47
+ /** A `### ` heading's comparison key: the heading text before its first ` (`, whitespace-
48
+ * normalized (text.ts's collapseWhitespace — the one home for this, shared with
49
+ * normalizeFixedHeading's BUGS.md counterpart below), so an entry that moved between
50
+ * sections with its dates intact keys equal on both sides of a diff. */
51
+ function planHeadingKey(title) {
52
+ const cut = title.indexOf(" (");
53
+ return collapseWhitespace(cut === -1 ? title : title.slice(0, cut));
54
+ }
55
+ /** The comparison keys of every `### ` heading in `md`, in ANY `## ` section, fence-aware
56
+ * (backlog.ts's shared tracker). The base-side set for the new-plan-under-Done rule: an entry
57
+ * whose key the base already carried is a move between sections, never a stranding. */
58
+ function planHeadingKeys(md) {
59
+ const keys = new Set();
60
+ for (const line of fenceAwareHeadingLines(md, "### ")) {
61
+ keys.add(planHeadingKey(line.slice(4).trim()));
62
+ }
63
+ return keys;
64
+ }
65
+ /** The backlog files every structural check covers — the tracked markdown loops edit and
66
+ * readers parse by `## ` section. */
67
+ const BACKLOG_FILES = ["PLANS.md", "BUGS.md", "QUESTIONS.md"];
68
+ /** The `## ` section titles of `md`, in file order, fence-aware (backlog.ts's shared tracker:
69
+ * a `## Done` quoted inside a fenced code block is body text, not structure). */
70
+ function sectionTitles(md) {
71
+ return fenceAwareHeadingLines(md, "## ").map((line) => line.slice(3).trim());
72
+ }
73
+ /** How many times each `## ` title appears in `md` — the tally `duplicateHeadings` reports
74
+ * repeats from and `backlogStructureReason` compares head against base for both backlog
75
+ * files, so the three sites count sections through one helper and cannot drift apart. */
76
+ function sectionTitleCounts(md) {
77
+ const counts = new Map();
78
+ for (const title of sectionTitles(md))
79
+ counts.set(title, (counts.get(title) ?? 0) + 1);
80
+ return counts;
81
+ }
82
+ /** The `## ` titles of `md` that appear more than once, in first-appearance order — the
83
+ * duplicate-heading signal `checkBacklogHeadings` reports for main and
84
+ * `backlogStructureReason` checks on a tree being landed. */
85
+ export function duplicateHeadings(md) {
86
+ return [...sectionTitleCounts(md)].filter(([, n]) => n > 1).map(([title]) => title);
87
+ }
88
+ /** The first structural fault a diff landing on `mainBranch` leaves in a backlog file, or
89
+ * undefined when none: for each touched backlog file it compares the `## ` heading set of the
90
+ * tree being landed (`wt`) against the diff's merge-base — the same base falseFixReason
91
+ * measures against, so a stacked batch is judged change by change — and rejects when
92
+ * (a) the head carries MORE of a title than the base carried (a duplicate `## Done` added, or
93
+ * a second one where the base had none), or (b) a title present on the base is gone from the
94
+ * head (a whole section dropped). The rule is deliberately about heading sets, not section
95
+ * names: a project whose BUGS.md adds `## Verified` (this repo's does) or a fresh repo seeded
96
+ * from src/init.ts's templates passes unchanged. A base that already carries a duplicate never
97
+ * blocks unrelated edits — rule (a) fires only when the head's count EXCEEDS the base's, so a
98
+ * change that removes a duplicate always passes. For PLANS.md there is a third rule
99
+ * (part 4/4): a head that ADDS a `### ` entry directly under `## Done` whose joined heading
100
+ * metadata carries `(planned YYYY-MM-DD` but no `done YYYY-MM-DD`, and whose key the base's
101
+ * PLANS.md never carried in ANY section, is a plan filed into the wrong section — rejected with
102
+ * a reason naming the entry and the one-line fix. Judged against the merge-base (the same base
103
+ * the heading rules use), so a Planned → Done move of an entry the base already had passes, a
104
+ * stacked batch is measured change by change, and a pre-existing stranded entry (whose key the
105
+ * base has) never blocks unrelated landings — the clean loop's repair (part 3/4) owns those.
106
+ * Detection is deterministic markdown reading —
107
+ * no pi — so both the review gate (exempt and code diffs alike) and the in-lock landing
108
+ * re-check can afford it on every landing (plans: "Backlog structure check", part 2/4). */
109
+ export async function backlogStructureReason(wt, mainBranch, files) {
110
+ const touched = files.filter((f) => BACKLOG_FILES.includes(f));
111
+ if (touched.length === 0)
112
+ return undefined;
113
+ const baseRev = (await gitTry(wt, "merge-base", "HEAD", mainBranch)) ?? mainBranch;
114
+ for (const file of touched) {
115
+ // A deleted file reads as empty: every heading the base had is gone — rule (b).
116
+ const head = readTextOrNull(path.join(wt, file)) ?? "";
117
+ const base = (await gitTry(wt, "show", `${baseRev}:${file}`)) ?? "";
118
+ const headCounts = sectionTitleCounts(head);
119
+ const baseCounts = sectionTitleCounts(base);
120
+ for (const [title, count] of headCounts)
121
+ if (count > 1 && count > (baseCounts.get(title) ?? 0))
122
+ return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
123
+ `name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
124
+ for (const title of baseCounts.keys())
125
+ if (!headCounts.has(title))
126
+ return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
127
+ `be invisible to every section reader; restore "## ${title}"`);
128
+ // Part 4/4's rule, PLANS.md only (see this function's doc comment): a NEW plan filed
129
+ // directly under ## Done with no done date. strandedPlanEntries already does the
130
+ // fence-aware, joined-metadata reading the first two conditions need; the base's key set
131
+ // supplies the third.
132
+ if (file === "PLANS.md") {
133
+ const baseKeys = planHeadingKeys(base);
134
+ const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
135
+ if (newcomer)
136
+ return (`PLANS.md files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
137
+ `written into the done section is invisible to every Planned reader; file it under ` +
138
+ `"## Planned" instead`);
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+ /** The clean loop's `<backlog-structure>` prompt block for the primary checkout at `root`
144
+ * (the same root the telemetry digest and qa coverage blocks read): a rendered block listing
145
+ * each stranded heading with its current section, or undefined when PLANS.md is missing,
146
+ * unreadable, or clean — an unreadable or clean file gives no block, so the prompt is
147
+ * unchanged in the common case. */
148
+ export function renderBacklogStructureBlock(root) {
149
+ // readTextOrNull never throws — an unreadable file arrives as null, its own contract.
150
+ const md = readTextOrNull(path.join(root, "PLANS.md"));
151
+ if (md === null)
152
+ return undefined;
153
+ const stranded = strandedPlanEntries(md);
154
+ if (stranded.length === 0)
155
+ return undefined;
156
+ const listed = stranded
157
+ .map((e) => `- (now under ## ${e.section}) ${e.title}`)
158
+ .join("\n");
159
+ // The task wording lives in the clean role's find text (src/role-catalog.ts); the block only
160
+ // carries the evidence, like the digest and coverage blocks do.
161
+ return `Plan headings filed under the wrong section of PLANS.md — invisible to the readers
162
+ that scan one section, so no loop sees them as backlog work:
163
+ <backlog-structure>
164
+ ${listed}
165
+ </backlog-structure>`;
166
+ }
@@ -0,0 +1,285 @@
1
+ import path from "node:path";
2
+ import { readTextOrNull } from "./files.js";
3
+ import { cachedByStat } from "./stat-cache.js";
4
+ /** A per-line CommonMark fenced-code state machine, shared by every line-level parser of
5
+ * backlog markdown. `inside(line)` feeds one line and returns whether it is fence syntax or
6
+ * fenced content — never markdown structure: a fence opens at a ```` ``` ````/`~~~` line (an
7
+ * info string is allowed, except that a backtick fence's info string may not contain a
8
+ * backtick — such a line is paragraph text that opens nothing), closes only at a bare fence
9
+ * line of the same character at least as long, and an unclosed fence runs to EOF. Every reader
10
+ * that classifies backlog lines as markdown structure (section boundaries, entry headings,
11
+ * bullets) must consult this, so two readers can never disagree about what is body content.
12
+ * `open()` reports whether the tracker is inside a fence where the walk stopped — true when a
13
+ * fence ran unclosed to the walk's end, so a caller that walked a bounded region can tell its
14
+ * read is fence-degraded (the region's tail quoted real structure). */
15
+ export function fenceTracker() {
16
+ // The open fence's marker (null = none): only a matching bare fence line closes it.
17
+ let fence = null;
18
+ return {
19
+ open() {
20
+ return fence !== null;
21
+ },
22
+ inside(line) {
23
+ const fenceLine = /^ {0,3}(`{3,}|~{3,})/.exec(line);
24
+ if (fenceLine) {
25
+ const marker = fenceLine[1] ?? ""; // The group always participates; "" keeps types honest.
26
+ if (fence === null) {
27
+ // CommonMark: an info string for a backtick fence cannot contain a backtick, so a
28
+ // line like ````md / ## Done / ````` quoted in prose is paragraph text, not a fence
29
+ // opener — taken as one, no later bare fence line can close it and the fence runs to
30
+ // EOF, swallowing the rest of the document as fenced content.
31
+ const info = line.replace(/^ {0,3}/, "").slice(marker.length);
32
+ if (marker.charAt(0) === "`" && info.includes("`"))
33
+ return false;
34
+ fence = { char: marker.charAt(0), length: marker.length };
35
+ }
36
+ else if (marker.charAt(0) === fence.char && marker.length >= fence.length && line.trim() === marker)
37
+ fence = null;
38
+ return true; // The fence line itself is fence syntax, never structure.
39
+ }
40
+ return fence !== null;
41
+ },
42
+ };
43
+ }
44
+ /** The body lines of the `## <sectionTitle>` section of a markdown document: everything
45
+ * between that heading line and the next `## ` line (or EOF), neither boundary included. A
46
+ * `## ` line inside a fenced code block (entries quote markdown templates and shell traces) is
47
+ * body content, never a boundary. The single home of "where a section starts and ends" — every
48
+ * reader of a `## ` section (backlog entry parsing here, the usage report's Done/Fixed date
49
+ * scan in src/report-data.ts, and backlog-structure.ts's strandedPlanEntries) walks its
50
+ * section through this, so independent readers can never disagree about the boundary. */
51
+ export function sectionLines(md, sectionTitle) {
52
+ const lines = [];
53
+ let inSection = false;
54
+ const fenced = fenceTracker();
55
+ for (const line of md.split("\n")) {
56
+ const inFence = fenced.inside(line);
57
+ if (!inFence && line.startsWith("## ")) {
58
+ inSection = line.slice(3).trim() === sectionTitle;
59
+ continue;
60
+ }
61
+ if (inSection)
62
+ lines.push(line);
63
+ }
64
+ return lines;
65
+ }
66
+ /** The body lines of one `## <sectionTitle>` section with fenced content stripped: the
67
+ * sectionLines walk, then a fresh fenceTracker's filter — the shape every reader that
68
+ * classifies a section's lines as markdown structure (entry headings, bullets, dates) walks.
69
+ * Exactly two call sites today: entryDates (below) and backlog-structure.ts's
70
+ * strandedPlanEntries. The fresh tracker is in sync with the document here: sectionLines
71
+ * only recognizes a `## ` boundary outside a fence, so the section's heading line is
72
+ * non-fenced and the fence state at the section's first line is closed — a tracker started
73
+ * there sees exactly what a document-wide tracker sees inside the section. Readers that
74
+ * must KEEP fenced lines as body content (parseEntryDetails) do not use this. */
75
+ export function sectionBodyLines(md, sectionTitle) {
76
+ const fenced = fenceTracker();
77
+ return sectionLines(md, sectionTitle).filter((line) => !fenced.inside(line));
78
+ }
79
+ /** The heading lines of `md` starting with `prefix` (a `"## "` section heading or a `"### "`
80
+ * entry heading), in file order, fence-aware (fenceTracker): a heading line quoted inside a
81
+ * fenced code block is body text, never structure. The single home of the whole-document
82
+ * prefix-heading walk — backlog-structure.ts's sectionTitles ("## ") and planHeadingKeys
83
+ * ("### ") each carried their own tracker before, so their fence handling could drift from
84
+ * sectionLines'. Callers slice and trim the prefix themselves. (sectionLines does not use
85
+ * this: its walk must track which section it is inside, not just collect headings; readers
86
+ * inside one section go through sectionLines/sectionBodyLines instead.) */
87
+ export function fenceAwareHeadingLines(md, prefix) {
88
+ const fenced = fenceTracker();
89
+ const lines = [];
90
+ for (const line of md.split("\n")) {
91
+ if (fenced.inside(line))
92
+ continue;
93
+ if (line.startsWith(prefix))
94
+ lines.push(line);
95
+ }
96
+ return lines;
97
+ }
98
+ /** The entries inside one `## <sectionTitle>` section of a markdown document, each with its
99
+ * full body: stops at the next `## ` line (so Done/Fixed entries never leak in), skips
100
+ * non-heading placeholders like `_None yet._` and any prose before the first heading, keeps
101
+ * interior blank lines within a body while trimming leading/trailing ones, and ends an open
102
+ * entry's body at EOF as well as at the next heading. */
103
+ export function parseEntryDetails(md, sectionTitle) {
104
+ const entries = [];
105
+ let title = null; // The open entry's heading (null = no entry open yet).
106
+ let bodyLines = [];
107
+ const close = () => {
108
+ if (title !== null)
109
+ entries.push({ title, body: bodyLines.join("\n").trim() });
110
+ title = null;
111
+ bodyLines = []; // Prose before the first heading never becomes a body.
112
+ };
113
+ // A `### ` line inside a fenced code block is quoted content, never an entry boundary: an
114
+ // entry quoting a markdown template keeps its whole body instead of splitting into a phantom
115
+ // entry at the quoted heading.
116
+ const fenced = fenceTracker();
117
+ for (const line of sectionLines(md, sectionTitle)) {
118
+ if (fenced.inside(line))
119
+ bodyLines.push(line);
120
+ else if (line.startsWith("### ")) {
121
+ close();
122
+ title = line.slice(4).trim();
123
+ }
124
+ else {
125
+ bodyLines.push(line);
126
+ }
127
+ }
128
+ close(); // An entry at the end of file ends with EOF, not a heading.
129
+ return entries;
130
+ }
131
+ /** The completion dates ("YYYY-MM-DD") of the entries inside one `## <sectionTitle>` section.
132
+ * An entry starts at a `### ` heading or `- ` bullet line and ends at the next such line; only
133
+ * its METADATA is matched for dates — never its body, so a body's "**Done 2026-…**" recap line
134
+ * (or a prose cross-reference like "(done 2026-…)") cannot double-count. Fenced lines are
135
+ * body content, never entry starts — an entry quoting a markdown template with a
136
+ * `### … (fixed DATE)` heading inside must not count as a completion of its own. Metadata is
137
+ * headingMetadata's join (below). Entries without a parseable date are skipped.
138
+ *
139
+ * A `- ` line needs more than a date to be an entry: the sections also hold body bullets (an
140
+ * entry's repro steps, a plan's task breakdown), and a body bullet that merely mentions a
141
+ * completion — "- same shape as the sibling bug (fixed 2026-09-24)" — is not one. The epitaph
142
+ * shape separates them: an epitaph always closes its line with a parenthetical that records
143
+ * both the completion date and the landing commit ("(planned …, done …; commit abc1234)"),
144
+ * so a bullet counts only when its trailing `(…)` group carries the date AND a `commit`
145
+ * reference; a heading entry keeps the plain metadata match (headings are the primary entry
146
+ * format and always close their metadata parenthetical by convention). */
147
+ export function entryDates(md, sectionTitle, dateRe) {
148
+ const dates = [];
149
+ const lines = sectionBodyLines(md, sectionTitle);
150
+ for (let i = 0; i < lines.length; i++) {
151
+ const line = lines[i] ?? "";
152
+ if (!line.startsWith("### ") && !line.startsWith("- "))
153
+ continue;
154
+ const meta = headingMetadata(lines, i);
155
+ let date = null;
156
+ if (line.startsWith("- ")) {
157
+ // Epitaph guard (see the doc comment): the date must live in the line's trailing
158
+ // parenthetical beside a commit reference, or the bullet is body text, not an entry.
159
+ // Within the parenthetical the completion is the LAST dated verb, matching the heading
160
+ // branch: a decomposition cross-reference ("decomposed from the sibling bug fixed
161
+ // <date>, fixed <date>") precedes the entry's own completion record.
162
+ const tail = trailingParenthetical(line);
163
+ if (/\bcommits?\b/.test(tail))
164
+ date = lastDate(tail, dateRe);
165
+ }
166
+ else {
167
+ date = lastDate(meta.text, dateRe);
168
+ }
169
+ if (date)
170
+ dates.push(date);
171
+ i = meta.next - 1; // The loop's ++ resumes at the first line not consumed as metadata.
172
+ }
173
+ return dates;
174
+ }
175
+ /** A `### `/`- ` entry start's METADATA, joined for matching: the start line plus, for `### `
176
+ * headings only, continuation lines up to and including the first line ending in `)` (capped
177
+ * at 3 lines) — wrapped headings carry their date on the second line, while `- ` epitaphs are
178
+ * single-line by construction, so a bullet's own line is its whole metadata (a following prose
179
+ * paragraph is body, never matched). Joining with a space keeps "done\n2026-…" matchable.
180
+ * Returns the joined text and the index of the first line NOT consumed as metadata, so a
181
+ * walker can resume its scan there. Extracted from entryDates (its only original caller) so
182
+ * the stranded-plan detector (src/backlog-structure.ts) matches dates against exactly the
183
+ * same joined text instead of growing a second, drifting copy of the join rule. */
184
+ export function headingMetadata(lines, start) {
185
+ const line = lines[start] ?? "";
186
+ const meta = [line];
187
+ let closed = line.endsWith(")");
188
+ let j = start + 1;
189
+ // Continuation is a heading-only concern (wrapped headings); bullets are single-line.
190
+ while (line.startsWith("### ") && meta.length < 3 && j < lines.length && !closed) {
191
+ const next = lines[j] ?? "";
192
+ if (next.startsWith("### ") || next.startsWith("- "))
193
+ break; // The entry ends at the next start.
194
+ meta.push(next);
195
+ j++;
196
+ closed = next.endsWith(")");
197
+ }
198
+ return { text: meta.join(" "), next: j };
199
+ }
200
+ /** A `- ` line's trailing parenthetical's inner text, nesting-aware: a backward scan from the
201
+ * line's closing ")" to its matching "(" returns the group's FULL inner text, so an epitaph
202
+ * that quotes a parenthetical of its own — "(planned …, done …; commit abc1234 (re-landed
203
+ * after review fix))" — still yields its date-bearing text. The previous flat `\([^()]*\)$`
204
+ * match saw only the innermost group ("" when the line ended in two closes) and silently
205
+ * dropped the epitaph's date from the day report (BUGS.md 2026-09-29). Unbalanced text (no
206
+ * matching open paren) yields "" — the guard then treats the bullet as body text, as before. */
207
+ function trailingParenthetical(line) {
208
+ if (!line.endsWith(")"))
209
+ return "";
210
+ let depth = 0;
211
+ for (let i = line.length - 1; i >= 0; i--) {
212
+ const ch = line[i];
213
+ if (ch === ")")
214
+ depth++;
215
+ else if (ch === "(" && --depth === 0)
216
+ return line.slice(i + 1, -1);
217
+ }
218
+ return "";
219
+ }
220
+ /** A text's LAST `<dateRe>` capture — the completion rule both branches of entryDates share:
221
+ * an entry's completion is its LAST dated verb, not the first, because found-by,
222
+ * decomposition, and sibling mentions ("decomposed from the X bug fixed <date>") all precede
223
+ * the completion record, so first-match let a sibling's date steal the entry's count onto the
224
+ * wrong day (BUGS.md 2026-09-29). dateRe is cloned with the g flag so matchAll sees every
225
+ * date; a text with no date yields null. */
226
+ function lastDate(text, dateRe) {
227
+ const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
228
+ const all = [...text.matchAll(global)];
229
+ return all[all.length - 1]?.[1] ?? null;
230
+ }
231
+ /** Parsed sections keyed by file + section title (a future reader of a second section from the
232
+ * same file must not collide with the first). Bounded inside cachedByStat: many short-lived
233
+ * roots in tests would otherwise accumulate. Stores the richer {title, body} shape — one parse
234
+ * per file change subsumes both the titles-only and the full-entry reads. */
235
+ const sectionCache = new Map();
236
+ /** The entries under `<root>/<fileName>`'s `## <sectionTitle>`: fresh when the file's identity
237
+ * or mtime/size changed since the last read, cached otherwise (see module docs). A missing or
238
+ * unreadable file yields [] — a render path must never throw on backlog state. */
239
+ function sectionEntries(root, fileName, sectionTitle) {
240
+ const file = path.join(root, fileName);
241
+ return (cachedByStat(sectionCache, `${file}\u0000${sectionTitle}`, file, () => {
242
+ const md = readTextOrNull(file); // Missing or unreadable — no data.
243
+ return md === null ? null : parseEntryDetails(md, sectionTitle);
244
+ }, (entries) => entries.map((e) => ({ ...e }))) ?? []);
245
+ }
246
+ /** Planned features: the `### ` headings under PLANS.md's `## Planned` section. Missing or
247
+ * unreadable → []. */
248
+ export function plannedPlans(root) {
249
+ return sectionEntries(root, "PLANS.md", "Planned").map((e) => e.title);
250
+ }
251
+ /** Open bugs: the `### ` headings under BUGS.md's `## Open` section. Missing or unreadable → []. */
252
+ export function openBugs(root) {
253
+ return sectionEntries(root, "BUGS.md", "Open").map((e) => e.title);
254
+ }
255
+ /** Open questions: the `### ` headings under QUESTIONS.md's `## Open` section — loops post
256
+ * them when a decision is genuinely the user's (see plans/questions-outbox.md). Missing or
257
+ * unreadable → []. */
258
+ export function openQuestions(root) {
259
+ return sectionEntries(root, "QUESTIONS.md", "Open").map((e) => e.title);
260
+ }
261
+ /** Planned features as full entries (title + body), in file order — the TUI's project-status
262
+ * browse and the GUI's /api/backlog endpoint read these. Missing or unreadable → []. */
263
+ export function plannedPlanEntries(root) {
264
+ return sectionEntries(root, "PLANS.md", "Planned");
265
+ }
266
+ /** Open bugs as full entries (title + body), in file order. Missing or unreadable → []. */
267
+ export function openBugEntries(root) {
268
+ return sectionEntries(root, "BUGS.md", "Open");
269
+ }
270
+ /** Open questions as full entries (title + body), in file order. Missing or unreadable → []. */
271
+ export function openQuestionEntries(root) {
272
+ return sectionEntries(root, "QUESTIONS.md", "Open");
273
+ }
274
+ /** The backlog as one machine-readable document: the three entry arrays the Markdown renderer
275
+ * and the GUI's /api/backlog endpoint serve, so `tumwater backlog --json` prints the same data
276
+ * every surface reads (status --json's "print the endpoint's payload" pattern). Each array keeps
277
+ * file order and {title, body} verbatim, and a missing or unreadable file degrades to [] like the
278
+ * individual readers — a bare directory yields the all-empty object, never an error. */
279
+ export function backlogPayload(root) {
280
+ return {
281
+ plans: plannedPlanEntries(root),
282
+ bugs: openBugEntries(root),
283
+ questions: openQuestionEntries(root),
284
+ };
285
+ }