tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -9,10 +9,10 @@ import { briefFile } from "../brief/brief.js";
9
9
  import { buildDirectorPrompt, buildRunPrompt } from "../prompt/prompt.js";
10
10
  import { readPrinciples } from "../prompt/principles.js";
11
11
  import { buildCutOffNote } from "../prompt/prompt-followup.js";
12
- import { buildAssignmentNote, buildConflictDiscardNote, buildHeldEntriesNote, buildMergedPrerequisitesNote, buildRejectedReviewNote, buildWaitingPartsNote } from "../prompt/gate-notes.js";
12
+ import { buildAssignmentNote, buildConflictDiscardNote, buildHeldEntriesNote, buildMergedPrerequisitesNote, buildNoCheckNote, buildRejectedReviewNote, buildWaitingPartsNote } from "../prompt/gate-notes.js";
13
13
  import { eligibleEntries, mergedPrerequisiteRanges, waitingPartRanges } from "../backlog/backlog-eligibility.js";
14
14
  import { notesBacklogBlock } from "../prompt/notes-backlog.js";
15
- import { donePlans, openBugEntries, plannedPlanEntries } from "../backlog/backlog.js";
15
+ import { donePlans, openBugEntries, plannedPlanEntries, declarationPlanEntry } from "../backlog/backlog.js";
16
16
  import { detectBuildCheck, gateCommandOf } from "../build/build-check-detect.js";
17
17
  import { flakeCounts, KNOWN_FLAKE_WINDOW_MS } from "../review/known-flakes.js";
18
18
  import { readEventsSince } from "../events/event-window.js";
@@ -83,199 +83,238 @@ function flakyChecksNote(root) {
83
83
  `names one of these, that bug is still firing. When \`## Open\` holds no entries, a check ` +
84
84
  `excused 2 or more times is a confirmed latent bug: take it as this run's latent-bug candidate.`);
85
85
  }
86
- /** Assemble the prompt a role's next run runs on — the whole "what should this run see"
87
- * concern, split out of runner.ts so the runner owns only lifecycle and the prompt owns only
88
- * content. Returns null when the role has nothing to run this run (a director with an empty
89
- * inbox); `userPrompt` carries the dequeued user request — the director's, or a per-role one
90
- * queued by `tumwater prompt --role <id>` — back so the runner can record it as pending and
91
- * re-queue it if the run leaves it unfulfilled; `userPromptFile` is the queue file it came
92
- * from, so that bookkeeping can clean the prompt's attachments by that file's stem. */
93
- export function assembleRunPrompt({ root, config, role, state, preview = false, backend }) {
94
- // The brief's owning file (TUMWATER.md first, README.md as the compatibility path —
95
- // plans/portability.md §7a/7), named in both prompt builders' rules instead of a hardcoded
96
- // README.md. "README.md" is the fallback for a repo with no marked file yet — the team
97
- // runs blind on prompts either way, so the name in the rules should still point somewhere.
98
- const brief = briefFile(root) ?? "README.md";
99
- // The project's design principles ride along in every prompt — run and director alike — so
100
- // all roles share one standard of taste. Empty when the repo has no PRINCIPLES.md.
101
- const principles = readPrinciples(root);
102
- // The project's resolved check (plans/portability.md §6/7): the prompt names the actual
103
- // verify command instead of asserting npm. Detection is a handful of stat calls — a
104
- // per-run recompute keeps a config edit live on the next run.
105
- const check = detectBuildCheck(root, config) ?? undefined;
106
- // The bounded actionable index every run and director prompt carries, replacing the model's
107
- // per-run `grep -n '^##'` map of the backlog files (BUGS.md 2026-10-06). One render serves
108
- // both branches; the worktree starts at main at run start, so the ranges hold then.
109
- const backlogIndex = renderBacklogIndexBlock(root);
110
- // A change rejected in review is the only cross-run memory of what was built and why it
111
- // failed — every run starts a fresh session, so the full reasons (with the reviewed head and
112
- // timestamp) ride along on the next prompt until the role's next reviewed change replaces
113
- // them. buildRunPrompt renders it at the very top, before the role charter, so the author
114
- // cannot miss it (plans/reviewer-as-role.md part 4/4). A revision run carries the reasons in
115
- // buildRevisionNote instead (the rejected diff is already in the worktree), so the note is
116
- // suppressed here exactly as it was before this part.
117
- const rejectedReview = (state.lastReview?.verdict === "reject" || state.lastReview?.verdict === "reverted") && !state.revision
118
- ? buildRejectedReviewNote(state.lastReview, role)
119
- : undefined;
120
- let prompt;
86
+ /** Build the director's prompt from its deferred request: the queue peek/dequeue, the
87
+ * empty-inbox null return, the not-before marker strip, and the buildDirectorPrompt call.
88
+ * Returns null when the director has nothing to run (an empty inbox). */
89
+ function buildDirectorRunPrompt(root, config, preview, backend, brief, principles, check, backlogIndex) {
90
+ // A deferred director prompt's `tumwater:not-before` marker line is plumbing
91
+ // (src/inbox/prompt-not-before.ts),
92
+ // not content — stripped here exactly like the per-role path below, so the director's run
93
+ // prompt and the runner's re-queue both see the operator's text alone.
94
+ const entry = preview ? null : dequeuePromptEntry(root);
95
+ const dequeued = preview ? peekPrompt(root) : entry?.text ?? null;
96
+ if (!dequeued)
97
+ return null;
98
+ const userPrompt = stripNotBeforeMarker(dequeued);
99
+ const userPromptFile = entry?.file ?? null;
100
+ const prompt = buildDirectorPrompt(userPrompt, principles, check, brief, undefined, backlogIndex, backend ?? configForRole(config, DIRECTOR_ROLE).agent, config.roles[DIRECTOR_ROLE]?.instructions);
101
+ return { prompt, userPrompt, userPromptFile };
102
+ }
103
+ /** Build a non-director role's prompt from its queued request and state: the catalog/custom
104
+ * resolution and unknown-role throw, the role-prompt dequeue and marker strip, the
105
+ * observer evidence blocks, the notes and their backlog state, the plan target, and the
106
+ * flaky/no-check notes, feeding the single buildRunPrompt call. Never null — a role always
107
+ * has a prompt to run on. Each stage is read by one named helper below, so the body is the
108
+ * ordered calls and the single buildRunPrompt call. */
109
+ function buildRoleRunPrompt(root, config, role, state, preview, backend, brief, principles, check, backlogIndex, rejectedReview) {
110
+ const { base, resolved } = resolveRoleForPrompt(config, role);
111
+ const { request, promptFile } = collectRoleRequest(root, role, preview);
112
+ const digest = collectSelfcheckDigest(root, base);
113
+ const coverage = collectQaCoverageBlock(root, base);
114
+ const ciParity = collectCiParityBlock(root, config, base);
115
+ const filings = collectFeedbackFilingsBlock(root, base);
116
+ const backlogStructure = collectBacklogStructureBlock(root, base);
117
+ const { notes, notesSavedDay, notesBacklog } = readPromptNotes(root, role);
118
+ const { planTargetNote, flakyChecks, noCheck } = collectRoleDirectives(root, config, base, check);
119
+ // A run holding a claim with no queued request is assigned its one entry (the assignment
120
+ // note below). Such a run reads that entry first, so the backlog index — what an unassigned
121
+ // run chooses its task from — stays out.
122
+ const assigned = state.claim !== undefined && request === null;
121
123
  let userPrompt = null;
122
124
  let userPromptFile = null;
123
- if (role === DIRECTOR_ROLE) {
124
- // A deferred director prompt's `tumwater:not-before` marker line is plumbing
125
- // (src/inbox/prompt-not-before.ts),
126
- // not content — stripped here exactly like the per-role path below, so the director's run
127
- // prompt and the runner's re-queue both see the operator's text alone.
128
- const entry = preview ? null : dequeuePromptEntry(root);
129
- const dequeued = preview ? peekPrompt(root) : entry?.text ?? null;
130
- if (!dequeued)
131
- return null;
132
- userPrompt = stripNotBeforeMarker(dequeued);
133
- userPromptFile = entry?.file ?? null;
134
- prompt = buildDirectorPrompt(userPrompt, principles, check, brief, undefined, backlogIndex, backend ?? configForRole(config, DIRECTOR_ROLE).agent, config.roles[DIRECTOR_ROLE]?.instructions);
125
+ const prompt = buildRunPrompt({
126
+ role: resolved,
127
+ principles,
128
+ digest,
129
+ coverage,
130
+ ciParity,
131
+ filings,
132
+ backlogStructure,
133
+ backlogIndex: assigned ? undefined : backlogIndex,
134
+ notes,
135
+ notesSavedDay,
136
+ notesBacklog,
137
+ planTargetNote,
138
+ flakyChecks,
139
+ noCheck,
140
+ extraInstructions: config.roles[base]?.instructions,
141
+ check,
142
+ gateCommand: gateCommandOf(config),
143
+ briefFile: brief,
144
+ agent: backend ?? configForRole(config, role).agent,
145
+ assigned,
146
+ rejectedReview,
147
+ // A per-role prompt queued by `tumwater prompt --role <id>` rides as an explicit user
148
+ // request block; its text is also the run's userPrompt,
149
+ // so the runner's pending-prompt machinery (re-queue on unfulfilled, clear on merge)
150
+ // treats it exactly like the director's dequeued request.
151
+ userRequest: request ?? undefined,
152
+ });
153
+ if (request) {
154
+ userPrompt = request;
155
+ userPromptFile = promptFile;
156
+ }
157
+ return { prompt, userPrompt, userPromptFile };
158
+ }
159
+ /** Resolve the run's role: the base role that keys the observer evidence, and the catalog or
160
+ * user-defined charter. Throws the shared unknown-role message when neither answers. */
161
+ function resolveRoleForPrompt(config, role) {
162
+ // The role id keys its inbox and notes; every lookup ABOUT the role — catalog charter,
163
+ // instructions, the qa/selfcheck/clean blocks — resolves through the base role, so
164
+ // `feature-2` runs the feature charter and `roles.feature.instructions` while keeping its
165
+ // own queue and notes (plans/parallel-work-instances.md).
166
+ const base = baseRoleOf(role);
167
+ // Catalog first, then user-defined roles (plans/user-defined-loops.md): a custom's task
168
+ // is its entire find-something-to-do text and the title identifies it in the prompt.
169
+ const custom = config.customRoles.find((c) => c.name === role);
170
+ const resolved = roleById(role) ?? (custom ? customRole(custom.name, custom.task) : undefined);
171
+ if (!resolved) {
172
+ // The shared unknownRoleMessage (parseRoleFlag, the operator commands) names the valid
173
+ // ids; this defensive path — a runner asked to run a role no catalog entry or
174
+ // customRoles task can answer — says the same, so if it ever fires it reads as the
175
+ // harness bug it is instead of a bare dead end.
176
+ const validIds = [...allRoleIds(), ...config.customRoles.map((c) => c.name)];
177
+ throw new Error(unknownRoleMessage(role, validIds));
135
178
  }
136
- else {
137
- // The role id keys its inbox and notes; every lookup ABOUT the role — catalog charter,
138
- // instructions, the qa/selfcheck/clean blocks — resolves through the base role, so
139
- // `feature-2` runs the feature charter and `roles.feature.instructions` while keeping its
140
- // own queue and notes (plans/parallel-work-instances.md).
141
- const base = baseRoleOf(role);
142
- // Catalog first, then user-defined roles (plans/user-defined-loops.md): a custom's task
143
- // is its entire find-something-to-do text and the title identifies it in the prompt.
144
- const custom = config.customRoles.find((c) => c.name === role);
145
- const resolved = roleById(role) ?? (custom ? customRole(custom.name, custom.task) : undefined);
146
- if (!resolved) {
147
- // The shared unknownRoleMessage (parseRoleFlag, the operator commands) names the valid
148
- // ids; this defensive path — a runner asked to run a role no catalog entry or
149
- // customRoles task can answer — says the same, so if it ever fires it reads as the
150
- // harness bug it is instead of a bare dead end.
151
- const validIds = [...allRoleIds(), ...config.customRoles.map((c) => c.name)];
152
- throw new Error(unknownRoleMessage(role, validIds));
179
+ return { base, resolved };
180
+ }
181
+ /** Dequeue (or, in preview, peek) the role's queued request and strip its not-before marker,
182
+ * returning the delivered text and the queue file it came from. */
183
+ function collectRoleRequest(root, role, preview) {
184
+ // A queued per-role prompt is dequeued here, before the prompt is built, so its text rides
185
+ // in the run's prompt; PendingPrompt records it as pending and re-queues it on every
186
+ // unfulfilled outcome — including a merge-side `main_red` pin, which returns before any run.
187
+ // A deferred prompt's `tumwater:not-before` marker line is plumbing
188
+ // (src/inbox/prompt-not-before.ts), not
189
+ // content: it is stripped at delivery, so the role sees the operator's text alone and the
190
+ // runner's re-queue writes clean text.
191
+ const entry = preview ? null : dequeueRolePromptEntry(root, role);
192
+ const dequeued = preview ? peekRolePrompt(root, role) : entry?.text ?? null;
193
+ const request = dequeued === null ? null : stripNotBeforeMarker(dequeued);
194
+ return { request, promptFile: entry?.file ?? null };
195
+ }
196
+ /** The selfcheck and feedback observers' evidence is the harness's own event log, one level
197
+ * outside this worktree, so its evidence module renders it (selfcheckDigest) and the run
198
+ * injects it. */
199
+ function collectSelfcheckDigest(root, base) {
200
+ return base === "selfcheck" || base === "feedback" ? selfcheckDigest(root) : undefined;
201
+ }
202
+ /** The `qa` observer's flow-coverage ledger: rerendered from disk each run, degrading to no
203
+ * block on a missing or unreadable ledger (plans/observer-roles.md 2/2). */
204
+ function collectQaCoverageBlock(root, base) {
205
+ // The `qa` observer's flow rotation needs a memory of what it last exercised; every run
206
+ // is a fresh session, and a passing cheap check leaves nothing in the repo. The ledger is
207
+ // runtime state, and a missing or unreadable one degrades to no block.
208
+ let coverage;
209
+ if (base === "qa") {
210
+ try {
211
+ coverage = renderCoverageBlock(readQaCoverage(root));
153
212
  }
154
- // A queued per-role prompt is dequeued here, before the prompt is built, so its text rides
155
- // in the run's prompt; PendingPrompt records it as pending and re-queues it on every
156
- // unfulfilled outcome — including a red-main gate block, which returns before any run.
157
- // A deferred prompt's `tumwater:not-before` marker line is plumbing
158
- // (src/inbox/prompt-not-before.ts), not
159
- // content: it is stripped at delivery, so the role sees the operator's text alone and the
160
- // runner's re-queue writes clean text.
161
- const entry = preview ? null : dequeueRolePromptEntry(root, role);
162
- const dequeued = preview ? peekRolePrompt(root, role) : entry?.text ?? null;
163
- const request = dequeued === null ? null : stripNotBeforeMarker(dequeued);
164
- // The selfcheck and feedback observers' evidence is the harness's own event log, one level
165
- // outside this worktree, so its evidence module renders it (selfcheckDigest) and the run
166
- // injects it.
167
- const digest = base === "selfcheck" || base === "feedback" ? selfcheckDigest(root) : undefined;
168
- // The `qa` observer's flow rotation needs a memory of what it last exercised; every run
169
- // is a fresh session, and a passing cheap check leaves nothing in the repo. The ledger is
170
- // runtime state, and a missing or unreadable one degrades to no block
171
- // (plans/observer-roles.md 2/2).
172
- let coverage;
173
- if (base === "qa") {
174
- try {
175
- coverage = renderCoverageBlock(readQaCoverage(root));
176
- }
177
- catch {
178
- coverage = undefined;
179
- }
213
+ catch {
214
+ coverage = undefined;
180
215
  }
181
- // The `ci` observer's parity block: what the last run covered (tip and lock hash) so a
182
- // fresh session can decide nothing-to-do without re-running the whole suite (part 3/3).
183
- // Runtime state, and a missing/unreadable record degrades to no block, exactly like qa's.
184
- let ciParity;
185
- if (base === "ci") {
186
- try {
187
- ciParity = parityBlockFor(root, config);
188
- }
189
- catch {
190
- ciParity = undefined;
191
- }
216
+ }
217
+ return coverage;
218
+ }
219
+ /** The `ci` observer's parity block: what the last run covered (tip and lock hash) so a
220
+ * fresh session can decide nothing-to-do without re-running the whole suite (part 3/3).
221
+ * Runtime state, and a missing/unreadable record degrades to no block, exactly like qa's. */
222
+ function collectCiParityBlock(root, config, base) {
223
+ let ciParity;
224
+ if (base === "ci") {
225
+ try {
226
+ ciParity = parityBlockFor(root, config);
192
227
  }
193
- // The `feedback` observer's per-install filing ledger: what this install already filed as
194
- // GitHub issues on znation/tumwater, so the fresh session's charter dedupes against a
195
- // "previously filed" block instead of the network alone. Runtime state, and a missing or
196
- // unreadable ledger degrades to no block (plans/feedback-role.md 2/2).
197
- // The render returns undefined for an empty ledger, so the prompt is unchanged in the common
198
- // first-run case.
199
- let filings;
200
- if (base === "feedback") {
201
- try {
202
- filings = renderFilingsBlock(readFeedbackFilings(root));
203
- }
204
- catch {
205
- filings = undefined;
206
- }
228
+ catch {
229
+ ciParity = undefined;
207
230
  }
208
- // The clean role's deterministic backlog repair: stranded plan headings in the primary
209
- // checkout's PLANS.md (plans, part 3/4), rendered like the digest and coverage blocks.
210
- // An unreadable or clean file gives no block, so the prompt is unchanged in the common case.
211
- const backlogStructure = base === "clean" ? renderBacklogStructureBlock(root) : undefined;
212
- // The role's notes (PLANS.md "Role notebook"): its own earlier runs' note. A missing,
213
- // empty, or unreadable file yields undefined, so the block is omitted; the write
214
- // instruction in buildRunPrompt is present either way. The director branch above never
215
- // reads this path, so it carries no notes.
216
- const notes = readRoleNote(root, role);
217
- // The note file's mtime is when the notes were last saved (writes replace the file), so the
218
- // run can weigh how stale they are.
219
- const savedStat = notes === undefined ? null : statOrNull(roleNotesPath(root, role));
220
- const notesSavedDay = savedStat ? dayKey(savedStat.mtimeMs) : undefined;
221
- // The live state of each entry and series the notes name, so a stale note cannot keep a hold
222
- // the backlog has lifted (notes-backlog.ts). The three readers are already stat-cached. A
223
- // note naming nothing live yields undefined and no block — the common case, since most notes
224
- // are about the code, not the backlog.
225
- const notesBacklog = notes === undefined || notes.trim() === ""
226
- ? undefined
227
- : notesBacklogBlock(notes, {
228
- planned: plannedPlanEntries(root),
229
- openBugs: openBugEntries(root),
230
- donePlanTitles: donePlans(root),
231
- });
232
- // The plan charter's stop threshold is derived from config (plans/parallel-work-
233
- // instances.md "…keeping the plan role ahead", part 5c/7). The catalog text stays a
234
- // config-free description of the rule; this note supplies the concrete eligible-plan count
235
- // so the plan role stops only once every configured feature instance has a plan waiting.
236
- const planTargetNote = base === "plan"
237
- ? `This run's stop target: when PLANS.md \`## Planned\` already holds ` +
238
- `${planBacklogTarget(config)} or more eligible plans, end with ${NOTHING_TO_DO}. ` +
239
- `It holds ${eligibleEntries(root, "feature").length} now (tumwater's count of entries ` +
240
- `feature could take, the same count that decides whether a plan run is deferred).`
241
- : undefined;
242
- const flakyChecks = base === "bugfix" ? flakyChecksNote(root) : undefined;
243
- // A run holding a claim with no queued request is assigned its one entry (the assignment
244
- // note below). Such a run reads that entry first, so the backlog index — what an unassigned
245
- // run chooses its task from — stays out.
246
- const assigned = state.claim !== undefined && request === null;
247
- prompt = buildRunPrompt({
248
- role: resolved,
249
- principles,
250
- digest,
251
- coverage,
252
- ciParity,
253
- filings,
254
- backlogStructure,
255
- backlogIndex: assigned ? undefined : backlogIndex,
256
- notes,
257
- notesSavedDay,
258
- notesBacklog,
259
- planTargetNote,
260
- flakyChecks,
261
- extraInstructions: config.roles[base]?.instructions,
262
- check,
263
- gateCommand: gateCommandOf(config),
264
- briefFile: brief,
265
- agent: backend ?? configForRole(config, role).agent,
266
- assigned,
267
- rejectedReview,
268
- // A per-role prompt queued by `tumwater prompt --role <id>` rides as an explicit user
269
- // request block; its text is also the run's userPrompt,
270
- // so the runner's pending-prompt machinery (re-queue on unfulfilled, clear on merge)
271
- // treats it exactly like the director's dequeued request.
272
- userRequest: request ?? undefined,
273
- });
274
- if (request) {
275
- userPrompt = request;
276
- userPromptFile = entry?.file ?? null;
231
+ }
232
+ return ciParity;
233
+ }
234
+ /** The `feedback` observer's per-install filing ledger: what this install already filed as
235
+ * GitHub issues on znation/tumwater, so the fresh session's charter dedupes against a
236
+ * "previously filed" block instead of the network alone. Runtime state, and a missing or
237
+ * unreadable ledger degrades to no block (plans/feedback-role.md 2/2). */
238
+ function collectFeedbackFilingsBlock(root, base) {
239
+ // The render returns undefined for an empty ledger, so the prompt is unchanged in the common
240
+ // first-run case.
241
+ let filings;
242
+ if (base === "feedback") {
243
+ try {
244
+ filings = renderFilingsBlock(readFeedbackFilings(root));
245
+ }
246
+ catch {
247
+ filings = undefined;
277
248
  }
278
249
  }
250
+ return filings;
251
+ }
252
+ /** The clean role's deterministic backlog repair: stranded plan headings in the primary
253
+ * checkout's PLANS.md (plans, part 3/4), rendered like the digest and coverage blocks. */
254
+ function collectBacklogStructureBlock(root, base) {
255
+ // An unreadable or clean file gives no block, so the prompt is unchanged in the common case.
256
+ return base === "clean" ? renderBacklogStructureBlock(root) : undefined;
257
+ }
258
+ /** Read the role's own notes plus their saved day and live backlog state (notes-backlog.ts),
259
+ * returning all three in one place. */
260
+ function readPromptNotes(root, role) {
261
+ // The role's notes (PLANS.md "Role notebook"): its own earlier runs' note. A missing,
262
+ // empty, or unreadable file yields undefined, so the block is omitted; the write
263
+ // instruction in buildRunPrompt is present either way. The director branch above never
264
+ // reads this path, so it carries no notes.
265
+ const notes = readRoleNote(root, role);
266
+ // The note file's mtime is when the notes were last saved (writes replace the file), so the
267
+ // run can weigh how stale they are.
268
+ const savedStat = notes === undefined ? null : statOrNull(roleNotesPath(root, role));
269
+ const notesSavedDay = savedStat ? dayKey(savedStat.mtimeMs) : undefined;
270
+ // The live state of each entry and series the notes name, so a stale note cannot keep a hold
271
+ // the backlog has lifted (notes-backlog.ts). The three readers are already stat-cached. A
272
+ // note naming nothing live yields undefined and no block — the common case, since most notes
273
+ // are about the code, not the backlog.
274
+ const notesBacklog = notes === undefined || notes.trim() === ""
275
+ ? undefined
276
+ : notesBacklogBlock(notes, {
277
+ planned: plannedPlanEntries(root),
278
+ openBugs: openBugEntries(root),
279
+ donePlanTitles: donePlans(root),
280
+ });
281
+ return { notes, notesSavedDay, notesBacklog };
282
+ }
283
+ /** The directive notes a run may carry: the plan charter's stop target, the bugfix flake
284
+ * tally, and the plan/feature no-check note — each exactly as it stood inline. */
285
+ function collectRoleDirectives(root, config, base, check) {
286
+ // The plan charter's stop threshold is derived from config (plans/parallel-work-
287
+ // instances.md "…keeping the plan role ahead", part 5c/7). The catalog text stays a
288
+ // config-free description of the rule; this note supplies the concrete eligible-plan count
289
+ // so the plan role stops only once every configured feature instance has a plan waiting.
290
+ const planTargetNote = base === "plan"
291
+ ? `This run's stop target: when PLANS.md \`## Planned\` already holds ` +
292
+ `${planBacklogTarget(config)} or more eligible plans, end with ${NOTHING_TO_DO}. ` +
293
+ `It holds ${eligibleEntries(root, "feature").length} now (tumwater's count of entries ` +
294
+ `feature could take, the same count that decides whether a plan run is deferred).`
295
+ : undefined;
296
+ const flakyChecks = base === "bugfix" ? flakyChecksNote(root) : undefined;
297
+ // The no-check note: when the project declares no deterministic check and the team is
298
+ // closing the gap itself, the plan note tells the plan role to write the declaration entry
299
+ // (no marker entry waits yet) and the feature note tells the feature role to implement the
300
+ // marker entry that does wait. Decided by CHECK_DECLARATION_MARKER (declarationPlanEntry)
301
+ // rather than prose, so a feature run never invents a declaration the plan role never wrote
302
+ // and a plan run cannot stack a second declaration on one already waiting. Any other role,
303
+ // or a declared check, gets neither note — byte-unchanged prompts.
304
+ const declaration = (base === "plan" || base === "feature") && check === undefined ? declarationPlanEntry(root) : undefined;
305
+ // Whole declaration drives the decision: `declarationPlanEntry` returns null (not
306
+ // undefined) when no marker entry waits, so a truthy test selects the feature note and a
307
+ // falsy one the plan note.
308
+ const noCheck = check === undefined && (base === "plan" || base === "feature")
309
+ ? (declaration
310
+ ? (base === "feature" ? buildNoCheckNote(base) : undefined)
311
+ : (base === "plan" ? buildNoCheckNote(base) : undefined))
312
+ : undefined;
313
+ return { planTargetNote, flakyChecks, noCheck };
314
+ }
315
+ /** Append the shared claim/held/discard/cut-off tail — identical for a director and a role —
316
+ * in its current order, and return the finished prompt. */
317
+ function appendRunEpilogue(root, role, state, prompt, userPrompt, rejectedReview) {
279
318
  // A work instance holding a claim is told which entry is its one task (plans/parallel-work-
280
319
  // instances.md "Claims", part 4/7) — the harness picked it, so the charter's choosing step
281
320
  // is replaced. A run that dequeued a user request runs that request instead and keeps the
@@ -314,5 +353,50 @@ export function assembleRunPrompt({ root, config, role, state, preview = false,
314
353
  if ((state.cutOffStreak ?? 0) > 0) {
315
354
  prompt = appendPromptNote(prompt, buildCutOffNote(state.cutOffStreak ?? 0));
316
355
  }
317
- return { prompt, userPrompt, userPromptFile };
356
+ return prompt;
357
+ }
358
+ /** Assemble the prompt a role's next run operates on — the whole "what should this run see"
359
+ * concern, split out of runner.ts so the runner owns only lifecycle and the prompt owns only
360
+ * content. Returns null when the role has nothing to run this run (a director with an empty
361
+ * inbox); `userPrompt` carries the dequeued user request — the director's, or a per-role one
362
+ * queued by `tumwater prompt --role <id>` — back so the runner can record it as pending and
363
+ * re-queue it if the run leaves it unfulfilled; `userPromptFile` is the queue file it came
364
+ * from, so that bookkeeping can clean the prompt's attachments by that file's stem. */
365
+ export function assembleRunPrompt({ root, config, role, state, preview = false, backend }) {
366
+ // The brief's owning file (TUMWATER.md first, README.md as the compatibility path —
367
+ // plans/portability.md §7a/7), named in both prompt builders' rules instead of a hardcoded
368
+ // README.md. "README.md" is the fallback for a repo with no marked file yet — the team
369
+ // runs blind on prompts either way, so the name in the rules should still point somewhere.
370
+ const brief = briefFile(root) ?? "README.md";
371
+ // The project's design principles ride along in every prompt — run and director alike — so
372
+ // all roles share one standard of taste. Empty when the repo has no PRINCIPLES.md.
373
+ const principles = readPrinciples(root);
374
+ // The project's resolved check (plans/portability.md §6/7): the prompt names the actual
375
+ // verify command instead of asserting npm. Detection is a handful of stat calls — a
376
+ // per-run recompute keeps a config edit live on the next run.
377
+ const check = detectBuildCheck(root, config) ?? undefined;
378
+ // The bounded actionable index every run and director prompt carries, replacing the model's
379
+ // per-run `grep -n '^##'` map of the backlog files (BUGS.md 2026-10-06). One render serves
380
+ // both branches; the worktree starts at main at run start, so the ranges hold then.
381
+ const backlogIndex = renderBacklogIndexBlock(root);
382
+ // A change rejected in review is the only cross-run memory of what was built and why it
383
+ // failed — every run starts a fresh session, so the full reasons (with the reviewed head and
384
+ // timestamp) ride along on the next prompt until the role's next reviewed change replaces
385
+ // them. buildRunPrompt renders it at the very top, before the role charter, so the author
386
+ // cannot miss it (plans/reviewer-as-role.md part 4/4). A revision run carries the reasons in
387
+ // buildRevisionNote instead (the rejected diff is already in the worktree), so the note is
388
+ // suppressed here exactly as it was before this part.
389
+ const rejectedReview = (state.lastReview?.verdict === "reject" || state.lastReview?.verdict === "reverted") && !state.revision
390
+ ? buildRejectedReviewNote(state.lastReview, role)
391
+ : undefined;
392
+ const built = role === DIRECTOR_ROLE
393
+ ? buildDirectorRunPrompt(root, config, preview, backend, brief, principles, check, backlogIndex)
394
+ : buildRoleRunPrompt(root, config, role, state, preview, backend, brief, principles, check, backlogIndex, rejectedReview);
395
+ if (built === null)
396
+ return null;
397
+ return {
398
+ prompt: appendRunEpilogue(root, role, state, built.prompt, built.userPrompt, rejectedReview),
399
+ userPrompt: built.userPrompt,
400
+ userPromptFile: built.userPromptFile,
401
+ };
318
402
  }
@@ -29,12 +29,11 @@ export function planWithSalvagedWork(role, plan, restored, note) {
29
29
  restored,
30
30
  };
31
31
  }
32
- /** Decide how a run starts, consuming the resume flags as a side effect (the flag is
33
- * consumed here so a resume that fails falls back to a normal fresh run; another shutdown
34
- * mid-resume sets it again) and reclaiming the interrupted run's re-queued prompt. Returns
35
- * null — meaning the run is skipped — when the role has nothing to run (an empty director
36
- * inbox); a role runner's assembly never returns null. */
37
- export function planRunStart(opts) {
32
+ /** Resolve how a run starts, consuming the resume flags as a side effect (a flag is consumed
33
+ * here so a resume that fails falls back to a normal fresh run; another shutdown mid-resume
34
+ * sets it again) and reclaiming the interrupted run's re-queued prompt. Owns every read and
35
+ * write of the resume flags, so a new cause or handoff rule is added here. */
36
+ function resolveRunStartDecision(opts) {
38
37
  const s = opts.state;
39
38
  // The last merge's failure, read before the clear below: a merge that failed
40
39
  // non-terminally kept its pin (the merger's own vocabulary — review_error, merge_conflict,
@@ -88,12 +87,7 @@ export function planRunStart(opts) {
88
87
  // survive, but the next run is a FRESH session — the fallback seam may be a different backend,
89
88
  // and claude keeps no flags across `--resume` (plans/agent-backends.md §7, part 7a/7).
90
89
  const budgetCapped = pendingResumeCause === "budget-capped";
91
- // An image-blind run with an image-capable peer tier ends `aborted` with this cause
92
- // (plans/image-input.md part 4/5): the next run runs a FRESH session on the TARGET seam
93
- // over the kept worktree — never `--continue` — because the old session carries pi's
94
- // "image omitted" note and the target may be a different backend.
95
- const imageReroute = pendingResumeCause === "image-reroute";
96
- const handoff = (latest !== null && !resumable) || budgetCapped || imageReroute;
90
+ const handoff = (latest !== null && !resumable) || budgetCapped;
97
91
  s.resumePending = false;
98
92
  s.resumeCause = undefined;
99
93
  s.resumeMissing = undefined;
@@ -105,7 +99,7 @@ export function planRunStart(opts) {
105
99
  const resumeBlocked = s.phase === "review";
106
100
  // A budget-capped handback never resumes the session whose seam the cap moved off of, even
107
101
  // when its transcript is still resumable: it hands off instead.
108
- const resuming = resumableSession && !resumeBlocked && !budgetCapped && !imageReroute;
102
+ const resuming = resumableSession && !resumeBlocked && !budgetCapped;
109
103
  const handingOff = handoff && !resumeBlocked;
110
104
  // Reclaim the prompt the interrupted run re-queued for its resume (src/inbox/pending-prompt.ts):
111
105
  // the resumed session still owns that request in its context, so this run's bookkeeping
@@ -122,39 +116,46 @@ export function planRunStart(opts) {
122
116
  const resumeCause = handingOff
123
117
  ? budgetCapped
124
118
  ? "budget-capped"
125
- : imageReroute
126
- ? "image-reroute"
127
- : "handoff"
119
+ : "handoff"
128
120
  : pendingResumeCause ?? ((s.cutOffStreak ?? 0) > 0 ? "cut-off" : "restart");
121
+ return { priorMergeFailure, resuming, handoff: handingOff, resumeCause };
122
+ }
123
+ /** Build the run's prompt from the resolved decision: the resume bridge on a resume, the
124
+ * handoff bridge on a handoff, the assembled prompt otherwise. Returns null only when a
125
+ * handoff/fresh assemble yields nothing. The raw user prompt is read by the caller only when a
126
+ * prompt exists. */
127
+ function buildRunStartPrompt(opts, d) {
128
+ const agent = opts.agent ?? "pi";
129
129
  let prompt;
130
- if (resuming) {
131
- prompt = buildResumePrompt(opts.role, resumeCause, agent);
130
+ if (d.resuming) {
131
+ prompt = buildResumePrompt(opts.role, d.resumeCause, agent);
132
132
  }
133
133
  else {
134
134
  const assembled = opts.runPrompt(agent);
135
- // The image-reroute handoff note names the model the blind run was on and the image it
136
- // read; those facts ride the one-shot marker, which is consumed here. A handoff's fresh
137
- // session also needs the to-be-run model named for its own side of the note, but the
138
- // bridge already carries the facts that matter.
139
- const rerouteFacts = imageReroute && s.imageReroute
140
- ? { from: s.imageReroute.from ?? "the prior model", path: s.imageReroute.path ?? "an image" }
141
- : undefined;
142
- if (imageReroute)
143
- s.imageReroute = undefined;
144
135
  prompt =
145
136
  assembled === null
146
137
  ? null
147
- : handingOff
148
- ? buildHandoffPrompt(opts.role, assembled, resumeCause, rerouteFacts)
138
+ : d.handoff
139
+ ? buildHandoffPrompt(opts.role, assembled, d.resumeCause)
149
140
  : assembled;
150
141
  }
142
+ return prompt;
143
+ }
144
+ /** Decide how a run starts, consuming the resume flags as a side effect (a flag is consumed
145
+ * here so a resume that fails falls back to a normal fresh run; another shutdown mid-resume
146
+ * sets it again) and reclaiming the interrupted run's re-queued prompt. Returns null — meaning
147
+ * the run is skipped — when the role has nothing to run (an empty director inbox); a role
148
+ * runner's assembly never returns null. */
149
+ export function planRunStart(opts) {
150
+ const d = resolveRunStartDecision(opts);
151
+ const prompt = buildRunStartPrompt(opts, d);
151
152
  if (prompt === null) {
152
153
  if (!opts.recoveryOnly)
153
154
  return null;
154
- return { priorMergeFailure, resuming: false, handoff: false, resumeCause, prompt: null, userPrompt: null };
155
+ return { priorMergeFailure: d.priorMergeFailure, resuming: false, handoff: false, resumeCause: d.resumeCause, prompt: null, userPrompt: null };
155
156
  }
156
157
  // The raw user prompt a director run is executing (null for role runners), so an
157
158
  // unfulfilled outcome below can re-queue it. Captured before the field is cleared.
158
159
  const userPrompt = opts.pending.get();
159
- return { priorMergeFailure, resuming, handoff: handingOff, resumeCause, prompt, userPrompt };
160
+ return { priorMergeFailure: d.priorMergeFailure, resuming: d.resuming, handoff: d.handoff, resumeCause: d.resumeCause, prompt, userPrompt };
160
161
  }