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
@@ -8,13 +8,12 @@
8
8
  import { truncateExample } from "./failure-cluster.js";
9
9
  import { cutSplitsSurrogatePair, isNonBlankString } from "../text/text.js";
10
10
  import { finiteNumber, isJsonObject, stringList } from "../files/json-object.js";
11
- import { orchestratorStopPhrase, backendKindPhrase, budgetPhrase, teamPausePhrase, teamResumePhrase, holdPhrase, plural, rolePausePhrase, roleResumePhrase, rolesPhrase, shortSpanPhrase, rendersAsBackendFailure, PAUSE_EXPIRED_SUFFIX } from "../text/phrases.js";
11
+ import { orchestratorStopPhrase, backendKindPhrase, budgetPhrase, teamPausePhrase, teamResumePhrase, holdPhrase, plural, rolePausePhrase, roleResumePhrase, rolesPhrase, rendersAsBackendFailure, PAUSE_EXPIRED_SUFFIX } from "../text/phrases.js";
12
12
  import { shortSha } from "../text/format.js";
13
13
  import { deferredReasonText, untilClockSuffix, gigabytes, windowSuffix, windowResetPhrase, backendLabel } from "../events/event-format.js";
14
- import { claudeUtilizationPct } from "../agent/claude/claude-account.js";
15
14
  /** The transition events the digest replays: the decisions the harness made about itself (the
16
15
  * cap/team gates and the team hold, the error-streak breaker's pause, the role's own daily
17
- * cap, model fallbacks, live-config edits, self-hosted redeploys, need-based deferrals,
16
+ * cap, live-config edits, self-hosted redeploys, need-based deferrals,
18
17
  * orchestrator lifecycle, and the gate holds/lifts that stop and restart work — low disk,
19
18
  * quiet hours, the maintenance allowance and the subscription-window reserve). Everything the
20
19
  * set omits is deliberately excluded and named in STATE_CHANGE_EXCLUDED_TYPES; the two sets
@@ -36,8 +35,6 @@ export const STATE_CHANGE_TYPES = new Set([
36
35
  "role_streak_paused",
37
36
  "role_cap_paused",
38
37
  "role_cap_resumed",
39
- "model_fallback_started",
40
- "model_fallback_ended",
41
38
  "rate_limit_hold",
42
39
  "rate_limit_resumed",
43
40
  "max_concurrent_changed",
@@ -58,10 +55,8 @@ export const STATE_CHANGE_TYPES = new Set([
58
55
  "quiet_hours_ended",
59
56
  "maintenance_quota_hold",
60
57
  "maintenance_quota_resumed",
61
- "subscription_window_reserved",
62
58
  "subscription_window_limited",
63
59
  "subscription_window_reset",
64
- "provider_concurrency_changed",
65
60
  "model_changed",
66
61
  "bootstrap_complete",
67
62
  "fallback_switched",
@@ -120,13 +115,13 @@ export const STATE_CHANGE_EXCLUDED_TYPES = new Set([
120
115
  "check_wait",
121
116
  // Observations and actions.
122
117
  "disk_reclaim",
123
- "subscription_window_warning",
124
118
  "image_blind",
125
119
  // State-adjacent but not self-decisions.
126
120
  "state_migrated",
127
121
  "restart_forced",
128
122
  "backend_binary",
129
123
  "opencode_free_model_billed",
124
+ "image_reroute",
130
125
  ]);
131
126
  /** The Team state changes section's caps: newest N transitions, each line's payload capped at
132
127
  * STATE_CHANGE_MAX (a marked word-boundary cut per truncateExample), each free field within it
@@ -171,16 +166,10 @@ function field(v) {
171
166
  }
172
167
  /** The backend a fallback-ish decision line names, as "provider/model", defaulting each half
173
168
  * to pi's own default when the event omits it and slicing both through field. Shared by the
174
- * budget_fallback and model_fallback lines so the default and its slice rule stay in one place. */
169
+ * budget_fallback line so the default and its slice rule stay in one place. */
175
170
  function providerModel(ev) {
176
171
  return `${field(ev.provider ?? "pi default")}/${field(ev.model ?? "pi default")}`;
177
172
  }
178
- /** A utilization fraction as its whole-number percent, read through finiteNumber so a torn or
179
- * hand-edited subscription event renders 0% rather than NaN — the same corrupt-field rule the
180
- * feed's own subscription lines apply. */
181
- function pct(v) {
182
- return claudeUtilizationPct(finiteNumber(v, 0));
183
- }
184
173
  /** A per-family builder of one state-change line, one per event family, so the budget, pause,
185
174
  * model-wiring, provider-hold, live-config, update, wait and lifecycle wordings are each found
186
175
  * by name. Each is a total function over any HarnessEvent — its `default` returns the bare type
@@ -250,18 +239,6 @@ function pauseStateChange(ev) {
250
239
  function modelStateChange(ev) {
251
240
  let text;
252
241
  switch (ev.type) {
253
- case "model_fallback_started":
254
- // The role tripped off its primary onto its fallback after provider-class failures.
255
- text = `model fallback — ${providerModel(ev)} engaged (${field(ev.reason ?? "backend failure")})`;
256
- break;
257
- case "model_fallback_ended":
258
- // The return counterpart: names the primary the role resumed on and how long the
259
- // off-model episode ran.
260
- text = `model fallback ended — back on ${providerModel(ev)} after ${shortSpanPhrase(finiteNumber(ev.durationMs, 0))}`;
261
- break;
262
- case "provider_concurrency_changed":
263
- text = `${field(ev.provider)} concurrency ${field(ev.from)} → ${field(ev.to)}`;
264
- break;
265
242
  case "model_changed":
266
243
  // A model_cleared (or a roles.<id>.model-only) live edit arrives with `to: null`;
267
244
  // name pi's default the way the feed does so the digest never renders a bare
@@ -401,9 +378,6 @@ function waitStateChange(ev) {
401
378
  case "maintenance_quota_resumed":
402
379
  text = `maintenance allowance reopened — ${field(ev.maint)}/${field(ev.allowance)} in 24h`;
403
380
  break;
404
- case "subscription_window_reserved":
405
- text = `waiting: ${backendLabel(ev)} window ${field(ev.windowType)} at ${pct(ev.utilization)}% past its ${pct(ev.reserve)}% reserve`;
406
- break;
407
381
  case "subscription_window_limited":
408
382
  text = `waiting: ${backendLabel(ev)} window ${field(ev.windowType)} rejected${windowResetPhrase(ev)}`;
409
383
  break;
@@ -455,9 +429,6 @@ export function describeStateChange(ev) {
455
429
  case "role_cap_paused":
456
430
  case "role_cap_resumed":
457
431
  return truncateExample(pauseStateChange(ev), STATE_CHANGE_MAX);
458
- case "model_fallback_started":
459
- case "model_fallback_ended":
460
- case "provider_concurrency_changed":
461
432
  case "model_changed":
462
433
  case "fallback_switched":
463
434
  case "budget_handback":
@@ -482,7 +453,6 @@ export function describeStateChange(ev) {
482
453
  case "quiet_hours_ended":
483
454
  case "maintenance_quota_hold":
484
455
  case "maintenance_quota_resumed":
485
- case "subscription_window_reserved":
486
456
  case "subscription_window_limited":
487
457
  case "subscription_window_reset":
488
458
  return truncateExample(waitStateChange(ev), STATE_CHANGE_MAX);
@@ -87,92 +87,125 @@ function resolveQueuedRuns(runEvents, allEvents) {
87
87
  }
88
88
  /** Classify one run's loss cause into the key/kind/example triple the ranking folds, or null
89
89
  * when the run prices no loss. The branch order is the contract: refused, then cut-off, then
90
- * no_change, then clustered, then the three queued outcomes; the deadline-killed merge-block
91
- * special case still overrides `key`/`example` inside its branch. Reads the per-runner join
92
- * indexes from `indexes` so the ladder names each rule by a single lookup. */
90
+ * no_change, then clustered, then the three queued outcomes — each rule owned by its own
91
+ * named helper below, so the `??` chain reads left to right in the same order. Reads the
92
+ * per-runner join indexes from `indexes` so each helper names a rule by a single lookup. */
93
93
  function lossCauseFor(ev, cls, resolvedOutcome, indexes) {
94
- let key = null;
95
- let kind = "error-cluster";
96
- let example = "";
97
- if (ev.result === "refused") {
98
- // A refusal is a deliberate objection that blocks its entry, not quiet idleness — the
99
- // reason live in the event + lastSummary (src/verdict/refusal.ts). Rank it as its own
100
- // per-role cause so the digest surfaces the objection instead of pricing the run
101
- // exactly like a silently idle one; the branch sits BEFORE the no_change one because a
102
- // refused run is also no_change-class in the outcome table. The example rides the
103
- // refusal's summary the way a review-rejected cause rides its reason.
104
- key = `refused\u0000${eventRole(ev)}`;
105
- kind = "refused";
106
- example = typeof ev.summary === "string" ? truncateExample(ev.summary) : "";
107
- }
108
- else if (cls === "no_change" && ev.cutOff === true) {
109
- // A run truncated at its model's context ceiling ends no_change with cutOff: true
110
- // (diagnoseNoChange in src/run/run-verdict.ts), and finalizeRun rides that flag onto
111
- // the run_end. Rank it as its own per-role cause so the digest separates the churn of
112
- // a context ceiling — a cost of a long session, which the harness resumes — from
113
- // deliberate idleness; the branch sits BEFORE the no_change one because a cut-off run
114
- // is also no_change-class in the outcome table. No example, like the no_change cause.
115
- key = `cutoff\u0000${eventRole(ev)}`;
116
- kind = "cutoff";
117
- }
118
- else if (cls === "no_change") {
119
- key = `no_change\u0000${eventRole(ev)}`;
120
- kind = "no_change";
121
- }
122
- else if (CLUSTERED_RESULTS.has(String(ev.result))) {
123
- // A clustered result with no error text still owns its time: an abort's run_end never
124
- // carries one (planRunStart clears s.lastError, run-finalize logs error: undefined),
125
- // and a malformed error run can lose it too, so both take the same placeholder cause the
126
- // error-clusters section already itemizes rather than dropping the span itemized nowhere.
127
- ({ key, example } = pooledClusterCause(ev.error));
128
- }
129
- else if (ev.result === "queued" && resolvedOutcome?.result === "rejected") {
130
- key = `review-rejected\u0000${eventRole(ev)}`;
131
- kind = "review-rejected";
132
- // Match the merge ATTEMPT, not the pin's sha: every verification rebases its pin onto main
133
- // before the gate (syncPinToMain), so `review_rejected.head` is the rebased commit while
134
- // `merge_queued` keeps the authored one — exact-sha equality names no reason for a rebased
135
- // rejection (BUGS.md 2026-10-10). The rejection for this attempt is the one inside the
136
- // claimed pin and its outcome, the window no other attempt can occupy.
137
- const rej = newestAttemptEvent(indexes.rejectedByRunner, eventRole(ev), resolvedOutcome);
138
- const reasons = stringList(rej?.reasons);
139
- example = reasons[0] !== undefined ? truncateExample(reasons[0]) : "";
140
- }
141
- else if (ev.result === "queued" && resolvedOutcome?.result === "review_error") {
142
- // The review process itself failed (a dead backend, a timeout, no parseable verdict):
143
- // the pin was kept and re-queued, and the matching `review_failed` event carries the
144
- // message the review-failure section already clusters. Price the authoring span under
145
- // that cluster's key — a review timeout pools into the shared `timed out after <dur>`
146
- // cause via poolTimeoutKey — and fall back to the shared placeholder when no event is
147
- // visible, so the span is itemized rather than hidden. The match keys on the merge
148
- // ATTEMPT, not the pin's sha: every verification rebases its pin onto main before the gate
149
- // (syncPinToMain), so `review_failed.head` is the rebased commit while `merge_queued`
150
- // keeps the authored one — exact-sha equality missed every rebased verification (BUGS.md
151
- // 2026-10-10). The review failure for this attempt is the one that falls inside the
152
- // claimed pin and its outcome, the window no other attempt can occupy.
153
- const failed = newestAttemptEvent(indexes.reviewFailedByRunner, eventRole(ev), resolvedOutcome);
154
- ({ key, example } = pooledClusterCause(failed?.message));
155
- }
156
- else if (ev.result === "queued" &&
157
- (resolvedOutcome?.result === "merge_conflict" || resolvedOutcome?.result === "merge_blocked")) {
158
- // The merge failed to merge (a conflict, or a blocked fast-forward/rebase): this
159
- // authoring span burned. The `merge_failed` event carries no message — writeMergeOutcome
160
- // logs only result/commit/durationMs/usage — so the cause is named by its own result and
161
- // takes the shared no-text placeholder, the same fallback a message-less aborted run_end
162
- // uses, rather than dropping a span the error-class cell already priced (BUGS.md
163
- // 2026-10-10).
164
- key = `${resolvedOutcome.result}\u0000${eventRole(ev)}`;
165
- kind = resolvedOutcome.result === "merge_conflict" ? "merge-conflict" : "merge-blocked";
166
- example = errorTextOrPlaceholder(undefined);
167
- // A merge-scope check the harness killed at its own deadline is the team's load, not a
168
- // verdict on the change: split it into its own cause so the ranking names it.
169
- const killed = newestAttemptEvent(indexes.deadlineKilledByRunner, eventRole(ev), resolvedOutcome);
170
- if (kind === "merge-blocked" && killed) {
171
- key = `merge_blocked_deadline\u0000${eventRole(ev)}`;
172
- example = `${String(killed.scope)} build check killed at its ${nonNegativeNumber(killed.timeoutMs, 0) / 1000}s deadline (tumwater's own timeout, not a test failure)`;
173
- }
94
+ return (refusedLoss(ev) ??
95
+ cutoffLoss(ev, cls) ??
96
+ noChangeLoss(ev, cls) ??
97
+ clusteredLoss(ev) ??
98
+ reviewRejectedLoss(ev, resolvedOutcome, indexes) ??
99
+ reviewErrorLoss(ev, resolvedOutcome, indexes) ??
100
+ mergeFailureLoss(ev, resolvedOutcome, indexes));
101
+ }
102
+ /** The refused rule: a run whose verdict refused prices its own per-role cause, not no_change. */
103
+ function refusedLoss(ev) {
104
+ if (ev.result !== "refused")
105
+ return null;
106
+ // A refusal is a deliberate objection that blocks its entry, not quiet idleness — the
107
+ // reason live in the event + lastSummary (src/verdict/refusal.ts). Rank it as its own
108
+ // per-role cause so the digest surfaces the objection instead of pricing the run
109
+ // exactly like a silently idle one; the branch sits BEFORE the no_change one because a
110
+ // refused run is also no_change-class in the outcome table. The example rides the
111
+ // refusal's summary the way a review-rejected cause rides its reason.
112
+ return {
113
+ key: `refused\u0000${eventRole(ev)}`,
114
+ kind: "refused",
115
+ example: typeof ev.summary === "string" ? truncateExample(ev.summary) : "",
116
+ };
117
+ }
118
+ /** The cut-off rule: a no_change run truncated at its context ceiling prices its own cause. */
119
+ function cutoffLoss(ev, cls) {
120
+ if (cls !== "no_change" || ev.cutOff !== true)
121
+ return null;
122
+ // A run truncated at its model's context ceiling ends no_change with cutOff: true
123
+ // (diagnoseNoChange in src/run/run-verdict.ts), and finalizeRun rides that flag onto
124
+ // the run_end. Rank it as its own per-role cause so the digest separates the churn of
125
+ // a context ceiling — a cost of a long session, which the harness resumes — from
126
+ // deliberate idleness; the branch sits BEFORE the no_change one because a cut-off run
127
+ // is also no_change-class in the outcome table. No example, like the no_change cause.
128
+ return { key: `cutoff\u0000${eventRole(ev)}`, kind: "cutoff", example: "" };
129
+ }
130
+ /** The no_change rule: a silent no_change run prices its own per-role cause. */
131
+ function noChangeLoss(ev, cls) {
132
+ if (cls !== "no_change")
133
+ return null;
134
+ return { key: `no_change\u0000${eventRole(ev)}`, kind: "no_change", example: "" };
135
+ }
136
+ /** The clustered rule: a clustered result with no error text prices under the pooled cluster. */
137
+ function clusteredLoss(ev) {
138
+ if (!CLUSTERED_RESULTS.has(String(ev.result)))
139
+ return null;
140
+ // A clustered result with no error text still owns its time: an abort's run_end never
141
+ // carries one (planRunStart clears s.lastError, run-finalize logs error: undefined),
142
+ // and a malformed error run can lose it too, so both take the same placeholder cause the
143
+ // error-clusters section already itemizes rather than dropping the span itemized nowhere.
144
+ const pooled = pooledClusterCause(ev.error);
145
+ return { key: pooled.key, kind: "error-cluster", example: pooled.example };
146
+ }
147
+ /** The review-rejected rule: a queued run whose merge was rejected prices its reason. */
148
+ function reviewRejectedLoss(ev, resolvedOutcome, indexes) {
149
+ if (ev.result !== "queued" || resolvedOutcome?.result !== "rejected")
150
+ return null;
151
+ // Match the merge ATTEMPT, not the pin's sha: every verification rebases its pin onto main
152
+ // before the gate (syncPinToMain), so `review_rejected.head` is the rebased commit while
153
+ // `merge_queued` keeps the authored one — exact-sha equality names no reason for a rebased
154
+ // rejection (BUGS.md 2026-10-10). The rejection for this attempt is the one inside the
155
+ // claimed pin and its outcome, the window no other attempt can occupy.
156
+ const rej = newestAttemptEvent(indexes.rejectedByRunner, eventRole(ev), resolvedOutcome);
157
+ const reasons = stringList(rej?.reasons);
158
+ return {
159
+ key: `review-rejected\u0000${eventRole(ev)}`,
160
+ kind: "review-rejected",
161
+ example: reasons[0] !== undefined ? truncateExample(reasons[0]) : "",
162
+ };
163
+ }
164
+ /** The review_error rule: a queued run whose review process failed prices under its cluster. */
165
+ function reviewErrorLoss(ev, resolvedOutcome, indexes) {
166
+ if (ev.result !== "queued" || resolvedOutcome?.result !== "review_error")
167
+ return null;
168
+ // The review process itself failed (a dead backend, a timeout, no parseable verdict):
169
+ // the pin was kept and re-queued, and the matching `review_failed` event carries the
170
+ // message the review-failure section already clusters. Price the authoring span under
171
+ // that cluster's key — a review timeout pools into the shared `timed out after <dur>`
172
+ // cause via poolTimeoutKey — and fall back to the shared placeholder when no event is
173
+ // visible, so the span is itemized rather than hidden. The match keys on the merge
174
+ // ATTEMPT, not the pin's sha: every verification rebases its pin onto main before the gate
175
+ // (syncPinToMain), so `review_failed.head` is the rebased commit while `merge_queued`
176
+ // keeps the authored one — exact-sha equality missed every rebased verification (BUGS.md
177
+ // 2026-10-10). The review failure for this attempt is the one that falls inside the
178
+ // claimed pin and its outcome, the window no other attempt can occupy.
179
+ const failed = newestAttemptEvent(indexes.reviewFailedByRunner, eventRole(ev), resolvedOutcome);
180
+ const pooled = pooledClusterCause(failed?.message);
181
+ return { key: pooled.key, kind: "error-cluster", example: pooled.example };
182
+ }
183
+ /** The merge-failure rule: a queued run whose merge conflicted or was blocked prices its own
184
+ * cause, with the deadline-killed merge-block special case overriding key/example. */
185
+ function mergeFailureLoss(ev, resolvedOutcome, indexes) {
186
+ if (ev.result !== "queued" ||
187
+ (resolvedOutcome?.result !== "merge_conflict" && resolvedOutcome?.result !== "merge_blocked"))
188
+ return null;
189
+ // The merge failed to merge (a conflict, or a blocked fast-forward/rebase): this
190
+ // authoring span burned. The `merge_failed` event carries no message — writeMergeOutcome
191
+ // logs only result/commit/durationMs/usage — so the cause is named by its own result and
192
+ // takes the shared no-text placeholder, the same fallback a message-less aborted run_end
193
+ // uses, rather than dropping a span the error-class cell already priced (BUGS.md
194
+ // 2026-10-10).
195
+ const key = `${resolvedOutcome.result}\u0000${eventRole(ev)}`;
196
+ const kind = resolvedOutcome.result === "merge_conflict" ? "merge-conflict" : "merge-blocked";
197
+ const example = errorTextOrPlaceholder(undefined);
198
+ // A merge-scope check the harness killed at its own deadline is the team's load, not a
199
+ // verdict on the change: split it into its own cause so the ranking names it.
200
+ const killed = newestAttemptEvent(indexes.deadlineKilledByRunner, eventRole(ev), resolvedOutcome);
201
+ if (kind === "merge-blocked" && killed) {
202
+ return {
203
+ key: `merge_blocked_deadline\u0000${eventRole(ev)}`,
204
+ kind,
205
+ example: `${String(killed.scope)} build check killed at its ${nonNegativeNumber(killed.timeoutMs, 0) / 1000}s deadline (tumwater's own timeout, not a test failure)`,
206
+ };
174
207
  }
175
- return key === null ? null : { key, kind, example };
208
+ return { key, kind, example };
176
209
  }
177
210
  /** The pooled cluster key and truncated example a failure-message source's span prices under:
178
211
  * the shared placeholder when the source carries no message, the pooled render
@@ -117,6 +117,25 @@ export function readTextOrNull(file) {
117
117
  return null; // Missing or unreadable — no data.
118
118
  }
119
119
  }
120
+ /** Read a text file whole, but only when it is a regular file: a symlink (or directory,
121
+ * gitlink, FIFO) is never followed, so a hostile entry that points out of the worktree cannot
122
+ * pull an arbitrary host file's text into whatever reads it (the same lstat policy INV-G3 and
123
+ * INV-G4 pin for the merge gates). A symlink holds no text of its own and its target could
124
+ * name a sensitive host path, so it reads as absent. Null when the file is missing/unreadable,
125
+ * or not a regular file. This is the reader for the two prompt-input files whose text is
126
+ * injected into every model run — PRINCIPLES.md and a role's notes — and their operator
127
+ * readers, so the prompt and the surfaces reporting on those files cannot disagree;
128
+ * readTextOrNull stays for paths where following a symlink is acceptable or impossible. */
129
+ export function readRegularTextOrNull(file) {
130
+ try {
131
+ if (!fs.lstatSync(file).isFile())
132
+ return null; // not a regular file: never followed
133
+ return fs.readFileSync(file, "utf8");
134
+ }
135
+ catch {
136
+ return null; // Missing or unreadable — no data.
137
+ }
138
+ }
120
139
  /** Read a text file whole, or null only when it does not exist (ENOENT) — for the paths that
121
140
  * must never mistake an unreadable file for an absent one. A permission or I/O failure (EACCES,
122
141
  * EISDIR, EMFILE) is rethrown so the caller fails loudly instead of treating the file as gone:
@@ -351,6 +370,17 @@ export function removeQuiet(file) {
351
370
  // Already gone (or unremovable) — cleanup must not throw.
352
371
  }
353
372
  }
373
+ /** Delete `file` and return whether it was there to delete — the toggle-lift idiom shared by
374
+ * resumeTeam and cancelScheduledStop (the team's marker-lift functions): true when a marker
375
+ * was present and removed, false when it was already gone. Relies on removeQuiet's
376
+ * never-throws delete, so a marker vanishing between an existsSync check and the unlink reads
377
+ * as success rather than a failure to surface from a toggle. */
378
+ export function removeIfPresent(file) {
379
+ if (!fs.existsSync(file))
380
+ return false;
381
+ removeQuiet(file);
382
+ return true;
383
+ }
354
384
  /** Delete a directory tree, retrying the transient filesystem races (`ENOTEMPTY`/`EBUSY`/
355
385
  * `EPERM`) that recursive `rmSync` otherwise turns into a hard failure. Node defaults
356
386
  * `maxRetries` to 0, and on macOS an entry appearing between `rmSync`'s walk and its `rmdir`
@@ -16,8 +16,8 @@ export function isJsonObject(value) {
16
16
  * when it is an array and [] otherwise — the one home of the "an event/record field is the
17
17
  * list or it is nothing" read every formatter applies (event-format.ts's review_rejected
18
18
  * reasons, dep_install packages, config_changed keys; src/failure/failure-state-change.ts's
19
- * config_changed keys; time-spend.ts's review-rejection reasons; team-state.ts's paused-roles
20
- * list). A torn or hand-edited line
19
+ * config_changed keys; failure-time-spend.ts's review-rejection reasons; team-state.ts's
20
+ * paused-roles list). A torn or hand-edited line
21
21
  * never throws and never renders a placeholder: the field reads as absent, and the caller's
22
22
  * empty-list branch fires. Foreign entries inside an array are dropped for the same reason —
23
23
  * letting them through made `join` render "[object Object]" and handed
@@ -36,9 +36,9 @@ export function stringList(value) {
36
36
  * build_stale, slot_wait, restart, and permit_reserve renderers (aheadCommits, waitedMs,
37
37
  * abortedRuns, reserve → 0); phrases.ts's budgetPhrase (spentUsd, capUsd → 0); redeploy.ts's
38
38
  * autoRestartRecord (lastAt → null); build/build-info.ts's readBuildInfo (builtAt → 0);
39
- * failure/failure-state-change.ts's model_fallback_ended (durationMs → 0) and build_stale
40
- * renderer (aheadCommits → 0); gates/maintenance-quota.ts's foldLine and seed (ev.ts → null);
41
- * team/orchestrator-info.ts's isFiniteNumber (v → undefined); team/team-state.ts's
39
+ * failure/failure-state-change.ts's build_stale renderer (aheadCommits → 0);
40
+ * gates/maintenance-quota.ts's foldLine and seed (ev.ts → null);
41
+ * team/team-orchestrator-info.ts's isFiniteNumber (v → undefined); team/team-state.ts's
42
42
  * standingMarker, pausedRoles, and standingRolesUntil (until → undefined);
43
43
  * operator/operator-requests.ts's consumeWakeRequest (notBeforeMs → undefined);
44
44
  * git/slots-state.ts's readSlotRecord (pinnedAt, lastReleasedAt → null); git/worktree-use.ts's
@@ -57,9 +57,9 @@ export function finiteNumber(value, fallback) {
57
57
  * runner/runner-state-load.ts's healNumber (every numeric RunnerState field → 0),
58
58
  * run/run-usage.ts's counter (each persisted usage count → 0), pi/pi-stream.ts's usageNumber
59
59
  * (one wire usage number → 0), ui/progress-data.ts's message_end read (totalTokens → the
60
- * previous context, output and its peak floor → 0), time-spend.ts's runDurationMs (an event's
61
- * durationMs → null, the "no own duration" marker) and its merge_blocked_deadline example
62
- * (the deadline-killed check's timeoutMs → 0, so a torn deadline prices as 0s, never NaN);
60
+ * previous context, output and its peak floor → 0), failure-time-spend.ts's runDurationMs (an
61
+ * event's durationMs → null, the "no own duration" marker) and its merge_blocked_deadline
62
+ * example (the deadline-killed check's timeoutMs → 0, so a torn deadline prices as 0s, never NaN);
63
63
  * pi-extension/context-budget.ts's
64
64
  * contextNote (usedTokens, usedWindow → null), pi-extension/context-shake.ts's percentOf
65
65
  * (tokens, contextWindow → null), merge/merge-queue.ts's isMergeEntry (run, enqueuedAt,
@@ -1,4 +1,4 @@
1
- import { readTextOrNull, sameStatIdentity, statOrNull } from "./files.js";
1
+ import { readRegularTextOrNull, sameStatIdentity, statOrNull } from "./files.js";
2
2
  /** Safety cap so a stat-keyed cache can never grow unbounded (e.g. many short-lived roots in
3
3
  * tests). Evicting only costs one re-read per file on the next call. */
4
4
  const MAX_STAT_CACHED = 64;
@@ -32,15 +32,19 @@ export function cachedByStat(cache, key, file, load, clone) {
32
32
  return clone(value);
33
33
  }
34
34
  /** One stat-cached read of a text file, or null when it is missing or unreadable — the shared
35
- * load half over `cachedByStat` for the readers that parse a markdown/text file: readTextOrNull
36
- * reads it, and a null (no data) propagates through so a render path never throws on the file's
37
- * state. `parse` runs only on a cache miss and receives the file's text (never null); `clone`
38
- * hands each caller its own copy. The callers: backlog.ts's sectionEntries and
39
- * sectionCompletionDates, and backlog-structure.ts's strandedPlanEntriesFor and
40
- * actionableEntryRangesFor, and backlog-index.ts's renderBacklogIndexBlock. */
35
+ * load half over `cachedByStat` for the readers that parse a markdown/text file: it reads the
36
+ * file through readRegularTextOrNull, so a symlink (or directory/gitlink) is never followed —
37
+ * a hostile entry dropped at a backlog file path cannot pull an arbitrary host file's text into
38
+ * the run prompts and surfaces these readers feed (the same lstat policy INV-G3/G4 pin for the
39
+ * prompt readers and merge gates) — and a null (no data) propagates through so a render path
40
+ * never throws on the file's state. `parse` runs only on a cache miss and receives the file's
41
+ * text (never null); `clone` hands each caller its own copy. The callers: backlog.ts's
42
+ * sectionEntries and sectionCompletionDates, backlog-structure.ts's strandedPlanEntriesFor and
43
+ * actionableEntryRangesFor, backlog-index-data.ts's actionableEntryRangesFor, and
44
+ * backlog-index.ts's renderBacklogIndexBlock. */
41
45
  export function cachedTextByStat(cache, key, file, parse, clone) {
42
46
  return cachedByStat(cache, key, file, () => {
43
- const md = readTextOrNull(file); // Missing or unreadable — no data.
47
+ const md = readRegularTextOrNull(file); // Missing, unreadable, or not a regular file — no data.
44
48
  return md === null ? null : parse(md);
45
49
  }, clone);
46
50
  }