elanous 0.2.31 → 0.2.32

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 (282) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1834-50625904996a8fa8.js → 1834-98b64172bc1efe77.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/69-0a2c8209c6d4a158.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8097-9530c03bcc550f02.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/app/approvals/page-504204b58c82c253.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3535a4a69f1fafaa.js → page-a9717a67c2eecbaf.js} +1 -1
  8. package/apps/pwa/out/_next/static/chunks/app/board/page-5c9ec30aee62dfbf.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/botlab/page-584191533721fa16.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/bots/page-73c4b4bb3ac7ab5f.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/ceo/{page-5a503541353e60a8.js → page-c0f1ca371f710b06.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/chat/{page-6025101384e361a5.js → page-46430d45a9e04eac.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/consult/page-3b2ebf1dc64ff0be.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/control/{page-a645f00a06483e9a.js → page-a27b1e5a0d1b308f.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/decisions/page-25654b82094f7d22.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-e9bab7413b028b28.js → page-8ea49a4c6dd2c6e7.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/editor/page-ce5113aefea0a8d1.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/app/exec/{page-e547e6ce55f1fb33.js → page-0e5a41fac1c75396.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/field/{page-d27e1b9850fd54bf.js → page-31aaf1ed37c6df9a.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/inside/page-3dcc1fa411e1f4e0.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/intake/{page-0fec29c7ba01894b.js → page-c69247e7134d329d.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/{layout-339a07410cd6f36b.js → layout-66dc5cd3a0999abe.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/live/page-26ff7ff651a7217d.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/live-run/page-617b3976981efa96.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/loops/{page-2fd93ed8c9f16580.js → page-d256f5d5d9552823.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/market/{page-9b564dbff0fefc35.js → page-ce8f1907b8a6101c.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-33e182a316263116.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-814ba51385225776.js → page-32866485913d6a04.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-397d479626e5a459.js → not-found-7607cca9b84950c6.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-af1bba4e3251b728.js → page-eeab86176b03dc3b.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-3ad3be5e7d3fdce9.js → page-5b93dbae3432b996.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-77a6cb90a9cf2b10.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-4da93ad35d4a8d28.js → page-f47e768b5b242b5a.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/page-eb41aa3367fc2505.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/page-4d13ab0b8a267e3f.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/page-84dad32ff0fc0831.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-3a658d5fa6d16a25.js → page-95306f892aa2d0ee.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-0157de6e85f8bb6b.js → page-940fb6c217f6b470.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-0a07363b130182a1.js → page-8c66a3b24a745630.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-841d73d4ac44ed49.js → page-7b675dfd35c36fc9.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-6f43a3d1146e6377.js → page-8bd0d3cf7665bcfc.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/setup/{page-521ee23ac8c0abfa.js → page-c219f2ed171131be.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/share/{page-a9992ee285efaa83.js → page-40f8fa21bdae955b.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-3d48e85fa89ec04f.js → page-468f8313e4f1fbd9.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/page-7bbb8fa353a246c4.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/term/page-04d00ae9b0890349.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/today/{page-233c71cd5f68f0d7.js → page-4214b855d9233ea8.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/trace/{page-70151d78de2f375e.js → page-936752c7aa58762e.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/vault/{page-dcac33a3e2cb72c0.js → page-ec052ad359df1797.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-8305570551091338.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-0448145ccd5c45c1.js → page-fad650e4793adb65.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/page-d90e64743826e1d8.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-a16bcfd49af86a32.js +1 -0
  54. package/apps/pwa/out/_next/static/css/b5d88eba78a3d7bf.css +1 -0
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +14 -14
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +14 -14
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +14 -14
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +14 -14
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +14 -14
  65. package/apps/pwa/out/ceo/index.html +1 -1
  66. package/apps/pwa/out/ceo/index.txt +14 -14
  67. package/apps/pwa/out/chat/index.html +1 -1
  68. package/apps/pwa/out/chat/index.txt +14 -14
  69. package/apps/pwa/out/consult/index.html +1 -1
  70. package/apps/pwa/out/consult/index.txt +14 -14
  71. package/apps/pwa/out/control/index.html +1 -1
  72. package/apps/pwa/out/control/index.txt +14 -14
  73. package/apps/pwa/out/decisions/index.html +1 -1
  74. package/apps/pwa/out/decisions/index.txt +14 -14
  75. package/apps/pwa/out/design-check/index.html +1 -1
  76. package/apps/pwa/out/design-check/index.txt +14 -14
  77. package/apps/pwa/out/editor/index.html +1 -1
  78. package/apps/pwa/out/editor/index.txt +14 -14
  79. package/apps/pwa/out/exec/index.html +1 -1
  80. package/apps/pwa/out/exec/index.txt +14 -14
  81. package/apps/pwa/out/field/index.html +1 -1
  82. package/apps/pwa/out/field/index.txt +14 -14
  83. package/apps/pwa/out/healthz +1 -1
  84. package/apps/pwa/out/index.html +1 -1
  85. package/apps/pwa/out/index.txt +14 -14
  86. package/apps/pwa/out/inside/index.html +1 -1
  87. package/apps/pwa/out/inside/index.txt +14 -14
  88. package/apps/pwa/out/intake/index.html +1 -1
  89. package/apps/pwa/out/intake/index.txt +14 -14
  90. package/apps/pwa/out/live/index.html +1 -1
  91. package/apps/pwa/out/live/index.txt +17 -14
  92. package/apps/pwa/out/live-run/index.html +1 -1
  93. package/apps/pwa/out/live-run/index.txt +14 -14
  94. package/apps/pwa/out/loops/index.html +1 -1
  95. package/apps/pwa/out/loops/index.txt +14 -14
  96. package/apps/pwa/out/market/index.html +1 -1
  97. package/apps/pwa/out/market/index.txt +14 -14
  98. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  99. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  100. package/apps/pwa/out/morning/index.html +1 -1
  101. package/apps/pwa/out/morning/index.txt +14 -14
  102. package/apps/pwa/out/observatory/index.html +1 -1
  103. package/apps/pwa/out/observatory/index.txt +14 -14
  104. package/apps/pwa/out/ops/checklist/index.html +1 -1
  105. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  106. package/apps/pwa/out/ops/release/index.html +1 -1
  107. package/apps/pwa/out/ops/release/index.txt +14 -14
  108. package/apps/pwa/out/ops/seats/index.html +1 -1
  109. package/apps/pwa/out/ops/seats/index.txt +14 -14
  110. package/apps/pwa/out/outputs/index.html +1 -1
  111. package/apps/pwa/out/outputs/index.txt +14 -14
  112. package/apps/pwa/out/reflection/index.html +1 -1
  113. package/apps/pwa/out/reflection/index.txt +15 -15
  114. package/apps/pwa/out/scheduler/index.html +1 -1
  115. package/apps/pwa/out/scheduler/index.txt +14 -14
  116. package/apps/pwa/out/sessions/index.html +1 -1
  117. package/apps/pwa/out/sessions/index.txt +14 -14
  118. package/apps/pwa/out/settings/devices/index.html +1 -1
  119. package/apps/pwa/out/settings/devices/index.txt +14 -14
  120. package/apps/pwa/out/settings/index.html +1 -1
  121. package/apps/pwa/out/settings/index.txt +14 -14
  122. package/apps/pwa/out/setup/done/index.html +1 -1
  123. package/apps/pwa/out/setup/done/index.txt +14 -14
  124. package/apps/pwa/out/setup/index.html +1 -1
  125. package/apps/pwa/out/setup/index.txt +14 -14
  126. package/apps/pwa/out/share/index.html +1 -1
  127. package/apps/pwa/out/share/index.txt +14 -14
  128. package/apps/pwa/out/showroom/index.html +1 -1
  129. package/apps/pwa/out/showroom/index.txt +14 -14
  130. package/apps/pwa/out/tasks/index.html +1 -1
  131. package/apps/pwa/out/tasks/index.txt +14 -14
  132. package/apps/pwa/out/term/index.html +1 -1
  133. package/apps/pwa/out/term/index.txt +14 -14
  134. package/apps/pwa/out/today/index.html +1 -1
  135. package/apps/pwa/out/today/index.txt +14 -14
  136. package/apps/pwa/out/trace/index.html +1 -1
  137. package/apps/pwa/out/trace/index.txt +14 -14
  138. package/apps/pwa/out/vault/index.html +1 -1
  139. package/apps/pwa/out/vault/index.txt +14 -14
  140. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  141. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  142. package/apps/pwa/out/workflows/index.html +1 -1
  143. package/apps/pwa/out/workflows/index.txt +14 -14
  144. package/apps/pwa/out/workspace/index.html +1 -1
  145. package/apps/pwa/out/workspace/index.txt +14 -14
  146. package/apps/pwa/out/worktrees/index.html +1 -1
  147. package/apps/pwa/out/worktrees/index.txt +14 -14
  148. package/graphs/implement-loop.yaml +5 -0
  149. package/package.json +1 -1
  150. package/scripts/ci-typecheck-changed.ts +26 -1
  151. package/scripts/measure-si-task-cpu.ts +143 -0
  152. package/scripts/ratchets/test-interference-patterns.allowlist.txt +0 -2
  153. package/scripts/release-loop/prepare-node.ts +102 -4
  154. package/src/acp/core-turn-bridge.ts +16 -4
  155. package/src/acp/elanous-extensions.ts +3 -0
  156. package/src/acp/server.ts +3 -0
  157. package/src/acp/tool-trace.ts +186 -11
  158. package/src/agent/self-ambient-cache.ts +156 -0
  159. package/src/agent/self-ambient.ts +8 -1
  160. package/src/agent/task-notification.ts +5 -0
  161. package/src/agent-substrate/pr-reviewer.ts +3 -1
  162. package/src/ask-user-question/tool.ts +22 -0
  163. package/src/autopilot/build/llm-conflict-merge.ts +56 -10
  164. package/src/autopilot/pr-manager.ts +23 -5
  165. package/src/capture/frame-state-detect.ts +39 -0
  166. package/src/capture/tui-self-report.ts +134 -6
  167. package/src/chat/index.ts +61 -15
  168. package/src/chat/pickers/modal-runtime.ts +6 -0
  169. package/src/chat/pickers/modals.ts +8 -2
  170. package/src/cli/ask-bare.ts +1 -0
  171. package/src/cli/import-cli.ts +12 -0
  172. package/src/cli/log-event-names.ts +10 -0
  173. package/src/cli/msg-cli.ts +1 -0
  174. package/src/cli/pod-cli.ts +14 -3
  175. package/src/cli/pr-cli.ts +67 -32
  176. package/src/cli/pr-land-base-read.ts +34 -0
  177. package/src/cli/pr-land-heavy-remote.ts +81 -25
  178. package/src/cli/pr-land-overlap-evidence.ts +46 -2
  179. package/src/cli/pr-land-pod-shadow-job.ts +26 -3
  180. package/src/cli/pr-land-refusal.ts +20 -5
  181. package/src/cli/pty-takeover-cli.ts +88 -14
  182. package/src/cli/sim-cli.ts +18 -0
  183. package/src/context-bus/ops-events.ts +6 -2
  184. package/src/core-turn/run-core-turn.ts +1 -0
  185. package/src/core-turn/run-goal-loop.ts +21 -0
  186. package/src/core-turn/types.ts +3 -0
  187. package/src/dashboard/acp-active-model.ts +42 -1
  188. package/src/dashboard/background-jobs-status.ts +66 -0
  189. package/src/dashboard/index.ts +117 -44
  190. package/src/dashboard/input/agent-wake.ts +242 -0
  191. package/src/dashboard/input/chat-main-entry-runtime.ts +5 -0
  192. package/src/dashboard/input/external-input-host.ts +23 -9
  193. package/src/dashboard/input/mouse-wiring.ts +108 -11
  194. package/src/dashboard/log-click-runtime.ts +118 -2
  195. package/src/dashboard/turn-preamble.ts +116 -40
  196. package/src/dashboard/turn-session-layers.ts +4 -2
  197. package/src/dashboard/turn-start-segments.ts +222 -0
  198. package/src/display/prompt-row-owner.ts +146 -0
  199. package/src/display/resize-listener.ts +33 -2
  200. package/src/fold-stack.ts +4 -2
  201. package/src/git-fs/index.ts +263 -36
  202. package/src/harness/gate-reserve.ts +87 -0
  203. package/src/harness/harness-incidents.ts +13 -0
  204. package/src/harness/harness-queue.ts +15 -0
  205. package/src/index.ts +8 -6
  206. package/src/input/external-input-queue.ts +34 -9
  207. package/src/llm/route-decision.ts +36 -0
  208. package/src/llm/tool-loop-steered-rejection.ts +8 -0
  209. package/src/llm-request-shape.ts +209 -0
  210. package/src/llm.ts +166 -267
  211. package/src/loops/orchestrator/tick.ts +24 -2
  212. package/src/maturity/feature-maturity.ts +1 -1
  213. package/src/mss/logging/render-categories.ts +5 -0
  214. package/src/ops/overview.ts +59 -2
  215. package/src/pr-tool/census.ts +19 -4
  216. package/src/pr-tool/inflight-registry.ts +143 -0
  217. package/src/pty-shell/pty-manifest.ts +12 -3
  218. package/src/pty-shell/tui-run-state.ts +34 -3
  219. package/src/seat-loop/traffic-signal.ts +2 -1
  220. package/src/self-dev/ask-launch-flow.ts +13 -0
  221. package/src/self-dev/draft-residue.ts +243 -24
  222. package/src/self-dev/shard-plan.ts +11 -3
  223. package/src/self-dev/shard-run.ts +103 -10
  224. package/src/self-implement/elanous-tui-spawn.ts +14 -0
  225. package/src/self-implement/follow-up-placement.ts +79 -0
  226. package/src/self-implement/gate-artifacts.ts +15 -4
  227. package/src/self-implement/gate-baseline.ts +96 -5
  228. package/src/self-implement/gate-cli.ts +7 -2
  229. package/src/self-implement/gate-remote.ts +26 -1
  230. package/src/self-implement/host-regate.ts +36 -4
  231. package/src/self-implement/orchestrator.ts +101 -11
  232. package/src/self-implement/run-ledger.ts +16 -14
  233. package/src/session-runtime/index.ts +22 -267
  234. package/src/shell-runner/background-surface.ts +15 -5
  235. package/src/skills/tools/agent.ts +39 -6
  236. package/src/skills/tools/list-dir.ts +11 -0
  237. package/src/skills/tools/schedule-wakeup.ts +408 -0
  238. package/src/skills/tools/shell.ts +245 -3
  239. package/src/status/bar.ts +26 -0
  240. package/src/steward/digest-delivery.ts +118 -0
  241. package/src/steward/hold-hygiene.ts +120 -0
  242. package/src/steward/triage.ts +10 -0
  243. package/src/surface/shell-runner-boot.ts +5 -2
  244. package/src/task-agent/task-hand.ts +24 -0
  245. package/src/task-orchestrator/surfaces/pod-command-job.ts +65 -11
  246. package/src/task-orchestrator/surfaces/pod-lease.ts +98 -0
  247. package/src/task-orchestrator/surfaces/pod-ledger-events.ts +1 -1
  248. package/src/task-orchestrator/surfaces/pod-pool.ts +3 -1
  249. package/src/task-orchestrator/surfaces/pod-reuse-cleanup.ts +10 -6
  250. package/src/task-orchestrator/surfaces/pod-warm-worker-pod.ts +525 -0
  251. package/src/task-orchestrator/surfaces/pod-warm-worker-runner.ts +90 -0
  252. package/src/task-orchestrator/surfaces/pod-warm-worker-runtime.ts +156 -2
  253. package/src/task-orchestrator/surfaces/self-implement-pod.ts +7 -5
  254. package/src/tui/chat/execution-badge.ts +5 -1
  255. package/src/tui/input-lag-probe.ts +230 -0
  256. package/src/tui.ts +4 -0
  257. package/src/ux-sim/scenario.ts +255 -0
  258. package/src/version/packed-revision.json +1 -1
  259. package/apps/pwa/out/_next/static/chunks/8097-92f87bd108322f66.js +0 -1
  260. package/apps/pwa/out/_next/static/chunks/app/approvals/page-bf3634a117322df7.js +0 -1
  261. package/apps/pwa/out/_next/static/chunks/app/board/page-b9f51447f757fbf2.js +0 -1
  262. package/apps/pwa/out/_next/static/chunks/app/botlab/page-2ebb8fd59b9461f9.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/bots/page-a2fc4e49a4fa4ba8.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/consult/page-f4b630cc8b5273cd.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/decisions/page-61ac22cbab1ee4ec.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/editor/page-c992eafe028b642e.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/inside/page-805ac2e137b7a121.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/live/page-349f2752d8cd37ad.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/live-run/page-d5c6dcf31c25cecc.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-62f4475d3c38fb9b.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-f219ccdbc0bf27df.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/outputs/page-456666766e5ce6fc.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/page-7404e377e8adb8c5.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/reflection/page-14456240ed2a36ad.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d2ced71132fac9b2.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/term/page-f08358bdf251adb9.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-149dc1caba411bb9.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/workspace/page-9cb0c444903da65d.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-17e504c396e0e389.js +0 -1
  280. package/apps/pwa/out/_next/static/css/3f5fc6f96e2cf7b2.css +0 -1
  281. /package/apps/pwa/out/_next/static/{LSXfY0u9QtfDbejY0Ukf_ → IVKr3AlRUVLKsw2fh7eXF}/_buildManifest.js +0 -0
  282. /package/apps/pwa/out/_next/static/{LSXfY0u9QtfDbejY0Ukf_ → IVKr3AlRUVLKsw2fh7eXF}/_ssgManifest.js +0 -0
@@ -9,7 +9,7 @@ import { appendRunLedgerEntry, listRunLedgers, loadRunLedger, runLedgerDir, type
9
9
  import { queryRunningRuns } from '../self-implement/running-runs.js';
10
10
  import { clipRegateDetail, regateFailureFacts, type HostRegateFailure } from '../self-implement/regate-detail.js';
11
11
  import { DRAFT_SWEEP_CLOSE_CAP } from './draft-sweep.js';
12
- import { PROTECTED_LABELS, sameVerifiedGoal, type DraftTriagePr } from './draft-triage-rules.js';
12
+ import { PROTECTED_LABELS, type DraftTriagePr } from './draft-triage-rules.js';
13
13
 
14
14
  export type PodTerminalClass = 'landed' | 'merge-ready-not-merged' | 'failed-with-pr' | 'failed-no-pr' | 'failed-pr-unobserved' | 'timed-out-harvestable';
15
15
  export interface PrTerminalRecord {
@@ -43,7 +43,26 @@ export interface PodTerminalInput {
43
43
  at?: string;
44
44
  }
45
45
 
46
- const cardOf = (text?: string): string | null => /^칸:\s*(\S.*?)\s*$/m.exec(text ?? '')?.[1]?.trim() || null;
46
+ function cardOf(text?: string): string | null {
47
+ const lines = (text ?? '').split(/\r?\n/).filter((line) => line.startsWith('칸:'));
48
+ if (lines.length !== 1) return null;
49
+ const slot = lines[0]!.slice('칸:'.length).trim();
50
+ // A blank marker cannot borrow an ID from the following line. Duplicate markers are
51
+ // ambiguous even when they happen to name the same card.
52
+ return slot && !/\s/.test(slot) ? slot : null;
53
+ }
54
+ const validCardSlot = (text: string | null): text is string => !!text && /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/.test(text);
55
+ const goalLineage = (pr: ResiduePr): string | undefined =>
56
+ /(?:^|[-/])goalid-([a-f0-9]+)(?=-|\/|$)/i.exec(pr.branch)?.[1]?.toLowerCase()
57
+ ?? /^골(?: ID)?:\s*(\S.*?)\s*$/m.exec(pr.body ?? '')?.[1];
58
+ const hasCodeDiff = (paths: readonly string[]): boolean => paths.some((path) =>
59
+ !path.startsWith('docs/') && (
60
+ /\.(?:[cm]?[jt]sx?|py|sh|bash|zsh|rs|go|c|cc|cpp|h|hpp|java|kt|swift|rb|php|lua|sql)$/.test(path)
61
+ || /(?:^|\/)(?:Dockerfile|Containerfile|Makefile)(?:\.[^/]*)?$/.test(path)
62
+ || (path.startsWith('graphs/') && /\.ya?ml$/.test(path))));
63
+ function sameCardSlot(previous: ResiduePr, current: ResiduePr, slot: string): boolean {
64
+ return cardOf(previous.body) === slot && cardOf(current.body) === slot;
65
+ }
47
66
  const harvestable = PR_LABELS.find((label) => label.name === 'elanous:harvestable')!.name;
48
67
  const superseded = PR_LABELS.find((label) => label.name === 'elanous:superseded')!.name;
49
68
 
@@ -83,6 +102,8 @@ export function classifyPodTerminal(input: PodTerminalInput): PrTerminalRecord {
83
102
  export interface ResiduePr extends DraftTriagePr {
84
103
  createdAt: string;
85
104
  labels: readonly string[];
105
+ headSha?: string;
106
+ isDraft?: boolean;
86
107
  body?: string;
87
108
  comments?: readonly string[];
88
109
  /** GitHub PR state at lookup time (review r8) — absent only from injected inventories. */
@@ -92,8 +113,12 @@ export interface DraftResidueAdapters {
92
113
  loadLedger(runId: string, ledgerDir: string): readonly RunLedgerEntry[] | null;
93
114
  appendLedger(entry: RunLedgerEntry, ledgerDir: string): void;
94
115
  getPr(number: number): Promise<ResiduePr | undefined> | ResiduePr | undefined;
116
+ /** Return all issue comments only after pagination completes; undefined means completeness is unknown. */
117
+ getAllComments?(repository: string, number: number): Promise<readonly string[] | undefined> | readonly string[] | undefined;
95
118
  /** Undefined means the open-draft inventory could not be observed («못 셌음») — never a disguised zero count. */
96
119
  listOpenDrafts(repository: string): Promise<readonly ResiduePr[] | undefined> | readonly ResiduePr[] | undefined;
120
+ /** Complete changed-path inventory for the newly opened PR; undefined is unmeasured. */
121
+ listPrChangedPaths?(repository: string, number: number): Promise<readonly string[] | undefined> | readonly string[] | undefined;
97
122
  addLabel(repository: string, number: number, label: string): Promise<void> | void;
98
123
  setLabels(repository: string, number: number, change: { add: string; remove: readonly string[] }): Promise<void> | void;
99
124
  comment(repository: string, number: number, body: string): Promise<void> | void;
@@ -125,9 +150,9 @@ function gh(args: string[]): string {
125
150
  if (result.status !== 0) throw new Error((result.stderr || result.error?.message || `gh exited ${result.status}`).trim().slice(0, 200));
126
151
  return result.stdout.trim();
127
152
  }
128
- function ghPr(row: { number: number; title: string; headRefName: string; createdAt: string; body: string; labels: Array<{ name: string }>; comments?: Array<{ body: string }>; state?: string }): ResiduePr {
153
+ function ghPr(row: { number: number; title: string; headRefName: string; headRefOid?: string; isDraft?: boolean; createdAt: string; body: string; labels: Array<{ name: string }>; comments?: Array<{ body: string }>; state?: string }): ResiduePr {
129
154
  const state = row.state === 'OPEN' || row.state === 'CLOSED' || row.state === 'MERGED' ? row.state : undefined;
130
- return { number: row.number, title: row.title, branch: row.headRefName, createdAt: row.createdAt, body: row.body,
155
+ return { number: row.number, title: row.title, branch: row.headRefName, headSha: row.headRefOid, isDraft: row.isDraft, createdAt: row.createdAt, body: row.body,
131
156
  labels: row.labels.map((label) => label.name), comments: row.comments?.map((comment) => comment.body), ...(state ? { state } : {}) };
132
157
  }
133
158
  /** Where the default `getRunStatus` reads run facts. Production reads the federated host ledgers and the running-runs
@@ -174,11 +199,22 @@ export function residueRunStatus(draft: Pick<ResiduePr, 'number'>, repository: s
174
199
  }
175
200
 
176
201
  export function defaultAdapters(sources: ResidueRunStatusSources = defaultRunStatusSources): DraftResidueAdapters {
177
- const fields = 'number,title,headRefName,createdAt,body,labels,comments,state';
202
+ const fields = 'number,title,headRefName,headRefOid,isDraft,createdAt,body,labels,comments,state';
178
203
  return {
179
204
  loadLedger: loadRunLedger,
180
205
  appendLedger: appendRunLedgerEntry,
181
206
  getPr: (number) => ghPr(JSON.parse(gh(['pr', 'view', String(number), '-R', hostRepository() ?? '', '--json', fields]))),
207
+ getAllComments: (repository, number) => {
208
+ try {
209
+ const pages: unknown = JSON.parse(gh(['api', `repos/${repository}/issues/${number}/comments?per_page=100`, '--paginate', '--slurp']));
210
+ if (!Array.isArray(pages) || !pages.every((page) => Array.isArray(page)
211
+ && page.every((row: unknown) => !!row && typeof row === 'object' && typeof (row as { body?: unknown }).body === 'string'))) return undefined;
212
+ return (pages as Array<Array<{ body: string }>>).flat().map((row) => row.body);
213
+ } catch (error) {
214
+ debug.log('draft.residue', 'comments-unobserved', { repository, number, reason: error instanceof Error ? error.message : String(error) });
215
+ return undefined;
216
+ }
217
+ },
182
218
  listOpenDrafts: (repository) => {
183
219
  const rows: ResiduePr[] = [];
184
220
  try {
@@ -198,8 +234,19 @@ export function defaultAdapters(sources: ResidueRunStatusSources = defaultRunSta
198
234
  return undefined;
199
235
  }
200
236
  },
237
+ listPrChangedPaths: (repository, number) => {
238
+ try {
239
+ const pages: unknown = JSON.parse(gh(['api', `repos/${repository}/pulls/${number}/files?per_page=100`, '--paginate', '--slurp']));
240
+ if (!Array.isArray(pages) || !pages.every((page) => Array.isArray(page)
241
+ && page.every((row: unknown) => !!row && typeof row === 'object' && typeof (row as { filename?: unknown }).filename === 'string'))) return undefined;
242
+ return (pages as Array<Array<{ filename: string }>>).flat().map((row) => row.filename);
243
+ } catch { return undefined; }
244
+ },
201
245
  addLabel: (repository, number, label) => { gh(['pr', 'edit', String(number), '-R', repository, '--add-label', label]); },
202
- setLabels: (repository, number, change) => { gh(['pr', 'edit', String(number), '-R', repository, '--add-label', change.add, ...change.remove.flatMap((label) => ['--remove-label', label])]); },
246
+ setLabels: (repository, number, change) => {
247
+ const flags = [...(change.add ? ['--add-label', change.add] : []), ...change.remove.flatMap((label) => ['--remove-label', label])];
248
+ if (flags.length) gh(['pr', 'edit', String(number), '-R', repository, ...flags]);
249
+ },
203
250
  comment: (repository, number, body) => { gh(['pr', 'comment', String(number), '-R', repository, '--body', body]); },
204
251
  closePr: (repository, number) => { gh(['pr', 'close', String(number), '-R', repository]); },
205
252
  listLiveBranches: (repository) => {
@@ -334,7 +381,7 @@ export async function replayPodTerminals(options: DraftResidueReplayOptions = {}
334
381
  const outcome = await applyPodTerminal(record, { ledgerDir, ledgerRunId, adapters: replayAdapters, closeBudget });
335
382
  if (outcome.status === 'recorded' || outcome.reason === 'already-recorded') recovered++;
336
383
  else if (outcome.status === 'skipped' && outcome.reason === 'repository-unobserved') unresolved++;
337
- else if (outcome.status === 'skipped' && !['previous-run-unverified', 'previous-run-live', 'close-cap', 'host-repository-unobserved'].includes(outcome.reason ?? '')) recovered++;
384
+ else if (outcome.status === 'skipped' && !['previous-run-unverified', 'previous-run-live', 'close-cap', 'host-repository-unobserved', 'restore-unverified'].includes(outcome.reason ?? '')) recovered++;
338
385
  else pending++;
339
386
  }
340
387
  }
@@ -348,6 +395,7 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
348
395
  const skipped = (reason: string): DraftResidueResult => { log('skipped', { reason }); return { status: 'skipped', reason }; };
349
396
  const budget: DraftCloseBudget = ctx.closeBudget ?? { remaining: DRAFT_SWEEP_CLOSE_CAP };
350
397
  let step = 'ledger';
398
+ let pendingSupersession: { repository: string; number: number; prNumber: number; commentMayExist: boolean } | null = null;
351
399
  try {
352
400
  const ledger = adapters.loadLedger(ctx.ledgerRunId, ctx.ledgerDir);
353
401
  if (ledger === undefined) throw new Error('Terminal ledger unavailable');
@@ -422,6 +470,28 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
422
470
  step = 'ledger';
423
471
  acknowledge('owner-comment', pr.number);
424
472
  }
473
+ // PR1a records a recovery pointer but never closes this failed draft. PR1b owns the close decision.
474
+ if (record.terminalClass === 'failed-with-pr' && !completed('restore-comment', pr.number)) {
475
+ step = 'get-pr';
476
+ let latest: ResiduePr | undefined;
477
+ try { latest = await adapters.getPr(pr.number); }
478
+ catch { return skipped('restore-unverified'); }
479
+ if (!latest || latest.state !== 'OPEN' || latest.isDraft !== true || !record.headSha
480
+ || latest.headSha?.toLowerCase() !== record.headSha.toLowerCase()
481
+ || !latest.branch?.trim() || latest.branch !== pr.branch || (record.branch && latest.branch !== record.branch)
482
+ || !Array.isArray(latest.labels) || latest.labels.some((label) => PROTECTED_LABELS.has(label))) {
483
+ return skipped('restore-unverified');
484
+ }
485
+ step = 'list-comments';
486
+ let comments: readonly string[] | undefined;
487
+ try { comments = await adapters.getAllComments?.(record.repository, pr.number); }
488
+ catch { return skipped('restore-unverified'); }
489
+ if (!Array.isArray(comments) || comments.some((comment) => typeof comment !== 'string'
490
+ || /landing-verified\s*:|land-enqueue\s*:/i.test(comment))) return skipped('restore-unverified');
491
+ const restore = `Draft residue: failed-run recovery · run=${record.childRunId} · head=${record.headSha} · branch=${latest.branch} preserved. Restore from this branch.`;
492
+ if (!comments.includes(restore)) { step = 'comment'; await adapters.comment(record.repository, pr.number, restore); }
493
+ step = 'ledger'; acknowledge('restore-comment', pr.number);
494
+ }
425
495
  } else skipped('landed');
426
496
  step = 'list-open-drafts';
427
497
  const drafts = await adapters.listOpenDrafts(record.repository);
@@ -430,12 +500,33 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
430
500
  throw new Error('Open draft lookup unavailable or incomplete');
431
501
  }
432
502
  const newerAt = Date.parse(pr.createdAt);
433
- const candidates = drafts.filter((draft: ResiduePr) => draft.number !== pr.number && draft.branch.startsWith('self-impl/')
503
+ const slot = cardOf(pr.body);
504
+ const taSlot = validCardSlot(slot) ? slot : undefined;
505
+ const earlier: ResiduePr[] = drafts.filter((draft: ResiduePr) => draft.number !== pr.number && draft.branch.startsWith('self-impl/')
434
506
  && Date.parse(draft.createdAt) < newerAt && !draft.labels.some((label: string) => PROTECTED_LABELS.has(label))
435
- && !completed('superseded-close', draft.number)
436
- && (!draft.labels.includes(superseded) || completed('supersede-intent', draft.number))
437
- // Only a verified goal identifier may close a PR — a shared «칸:» value or seat is not the same goal.
438
- && sameVerifiedGoal(draft, pr));
507
+ && !completed('superseded-close', draft.number));
508
+ const sameSlot = taSlot ? earlier.filter((draft) => /^[a-f0-9]{40}$/i.test(draft.headSha ?? '') && sameCardSlot(draft, pr, taSlot)
509
+ && !draft.labels.some((label) => label === 'elanous:running' || label.includes('harvest-claimed'))) : [];
510
+ let codeDiff = false;
511
+ if (taSlot && sameSlot.length) {
512
+ // A changed PR head can turn a measured code PR into a goal-only PR while files are being fetched.
513
+ step = 'get-pr';
514
+ const beforeFiles = await adapters.getPr(pr.number);
515
+ if (!beforeFiles || beforeFiles.number !== pr.number || beforeFiles.state !== pr.state
516
+ || !pr.headSha || beforeFiles.headSha !== pr.headSha || beforeFiles.branch !== pr.branch
517
+ || !sameCardSlot(beforeFiles, pr, taSlot)) return skipped('new-pr-changed-before-files');
518
+ step = 'list-pr-changed-paths';
519
+ const paths = await adapters.listPrChangedPaths?.(record.repository, pr.number);
520
+ if (!paths || !Array.isArray(paths) || paths.some((path) => typeof path !== 'string')) return skipped('new-pr-code-diff-unobserved');
521
+ step = 'get-pr';
522
+ const afterFiles = await adapters.getPr(pr.number);
523
+ if (!afterFiles || afterFiles.number !== pr.number || afterFiles.state !== pr.state
524
+ || afterFiles.headSha !== pr.headSha || afterFiles.branch !== pr.branch
525
+ || !sameCardSlot(afterFiles, pr, taSlot)) return skipped('new-pr-changed-after-files');
526
+ codeDiff = hasCodeDiff(paths);
527
+ if (!codeDiff) log('skipped', { reason: 'new-pr-goal-only', prNumber: pr.number });
528
+ }
529
+ const candidates = codeDiff ? sameSlot : [];
439
530
  let liveBranches: ReadonlySet<string> | undefined;
440
531
  if (candidates.length) {
441
532
  step = 'list-live-branches';
@@ -446,6 +537,16 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
446
537
  let capped = false;
447
538
  for (const draft of candidates) {
448
539
  if (budget.remaining <= 0) { capped = true; break; }
540
+ if (taSlot) {
541
+ step = 'get-pr';
542
+ const latest = await adapters.getPr(draft.number);
543
+ if (!latest || latest.number !== draft.number || latest.state !== 'OPEN' || latest.isDraft !== true
544
+ || !draft.headSha
545
+ || latest.branch !== draft.branch || latest.headSha !== draft.headSha
546
+ || !Array.isArray(latest.labels) || latest.labels.some((label) => PROTECTED_LABELS.has(label)
547
+ || label === 'elanous:running' || label.includes('harvest-claimed'))
548
+ || !sameCardSlot(latest, pr, taSlot)) { deferred = true; skipped('previous-pr-changed'); continue; }
549
+ }
449
550
  step = 'get-run-status';
450
551
  const status = await adapters.getRunStatus(draft, record.repository);
451
552
  if (liveBranches!.has(draft.branch) || !['completed', 'failed', 'cancelled', 'abandoned', 'self-implement.result final'].includes(status ?? '')) {
@@ -453,38 +554,145 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
453
554
  skipped(`previous-run-${status === 'running' || status === 'probable-running' || liveBranches!.has(draft.branch) ? 'live' : 'unobserved'}`);
454
555
  continue;
455
556
  }
557
+ if (taSlot) {
558
+ step = 'list-live-branches';
559
+ const liveNow = await adapters.listLiveBranches(record.repository);
560
+ step = 'get-run-status';
561
+ const statusNow = await adapters.getRunStatus(draft, record.repository);
562
+ if (!liveNow || liveNow.has(draft.branch)
563
+ || !['completed', 'failed', 'cancelled', 'abandoned', 'self-implement.result final'].includes(statusNow ?? '')) {
564
+ deferred = true; skipped('previous-run-changed-before-label'); continue;
565
+ }
566
+ }
567
+ // Revalidate the effective diff and both PRs before applying any public supersession marker.
568
+ if (taSlot) {
569
+ const newBeforeClose = await adapters.getPr(pr.number);
570
+ if (!newBeforeClose || newBeforeClose.number !== pr.number || newBeforeClose.state !== pr.state
571
+ || newBeforeClose.headSha !== pr.headSha || newBeforeClose.branch !== pr.branch
572
+ || !sameCardSlot(newBeforeClose, pr, taSlot)) { deferred = true; skipped('new-pr-changed-before-close'); continue; }
573
+ // The base can move without changing this PR's head. Re-read the effective diff before closing the old draft.
574
+ step = 'list-pr-changed-paths';
575
+ const currentPaths = await adapters.listPrChangedPaths?.(record.repository, pr.number);
576
+ if (!currentPaths || !Array.isArray(currentPaths) || currentPaths.some((path) => typeof path !== 'string')
577
+ || !hasCodeDiff(currentPaths)) { deferred = true; skipped('new-pr-code-diff-changed-before-close'); continue; }
578
+ step = 'get-pr';
579
+ const afterCloseDiff = await adapters.getPr(pr.number);
580
+ if (!afterCloseDiff || afterCloseDiff.number !== pr.number || afterCloseDiff.state !== pr.state
581
+ || afterCloseDiff.headSha !== pr.headSha || afterCloseDiff.branch !== pr.branch
582
+ || !sameCardSlot(afterCloseDiff, pr, taSlot)) { deferred = true; skipped('new-pr-changed-after-close-diff'); continue; }
583
+ const beforeClose = await adapters.getPr(draft.number);
584
+ if (!beforeClose || beforeClose.number !== draft.number || beforeClose.state !== 'OPEN' || beforeClose.isDraft !== true
585
+ || !draft.headSha
586
+ || beforeClose.branch !== draft.branch || beforeClose.headSha !== draft.headSha
587
+ || !Array.isArray(beforeClose.labels) || beforeClose.labels.some((label) => PROTECTED_LABELS.has(label)
588
+ || label === 'elanous:running' || label.includes('harvest-claimed'))
589
+ || !sameCardSlot(beforeClose, pr, taSlot)) { deferred = true; skipped('previous-pr-changed-before-close'); continue; }
590
+ step = 'list-live-branches';
591
+ const liveBeforeClose = await adapters.listLiveBranches(record.repository);
592
+ step = 'get-run-status';
593
+ const statusBeforeClose = await adapters.getRunStatus(draft, record.repository);
594
+ if (!liveBeforeClose || liveBeforeClose.has(draft.branch)
595
+ || !['completed', 'failed', 'cancelled', 'abandoned', 'self-implement.result final'].includes(statusBeforeClose ?? '')) {
596
+ deferred = true; skipped('previous-run-changed-before-close'); continue;
597
+ }
598
+ }
456
599
  if (!completed('supersede-intent', draft.number)) {
457
600
  step = 'ledger';
458
601
  acknowledge('supersede-intent', draft.number);
459
602
  }
460
- if (!completed('superseded-label', draft.number)) {
461
- if (!draft.labels.includes(superseded)) {
462
- step = 'set-labels';
463
- await adapters.setLabels(record.repository, draft.number, { add: superseded,
464
- remove: PR_LABELS.filter((label) => label.axis === 'state' && draft.labels.includes(label.name) && label.name !== superseded).map((label) => label.name) });
465
- }
466
- step = 'ledger';
467
- acknowledge('superseded-label', draft.number);
603
+ // The inventory was read before the liveness and diff checks. Labels can change while
604
+ // those checks run, so ownership and the state to restore come from a fresh PR read.
605
+ step = 'get-pr';
606
+ const beforeLabel = await adapters.getPr(draft.number);
607
+ if (!beforeLabel || beforeLabel.number !== draft.number || beforeLabel.state !== 'OPEN'
608
+ || beforeLabel.isDraft !== true || beforeLabel.branch !== draft.branch
609
+ || beforeLabel.headSha !== draft.headSha || !Array.isArray(beforeLabel.labels)
610
+ || beforeLabel.labels.some((label) => PROTECTED_LABELS.has(label)
611
+ || label === 'elanous:running' || label.includes('harvest-claimed'))
612
+ || (taSlot && !sameCardSlot(beforeLabel, pr, taSlot))) {
613
+ deferred = true; skipped('previous-pr-changed-before-label-write'); continue;
468
614
  }
469
- const body = `Draft residue: superseded-by #${pr.number} (newer run ${record.childRunId} of the same goal). Branch preserved.`;
615
+ // There is no conditional GitHub add-label operation. A label written before close
616
+ // cannot be safely removed on a race: another run may have added that same label
617
+ // between our read and write. Apply the public state marker only after close succeeds.
618
+ pendingSupersession = { repository: record.repository, number: draft.number, prNumber: pr.number,
619
+ commentMayExist: false };
620
+ const body = taSlot
621
+ ? `Draft residue: superseded-by #${pr.number} (same card ${taSlot}; old goalid=${goalLineage(draft) ?? 'unobserved'}; new goalid=${goalLineage(pr) ?? 'unobserved'}; newer run ${record.childRunId}). Branch preserved.`
622
+ : `Draft residue: superseded-by #${pr.number} (newer run ${record.childRunId} of the same goal). Branch preserved.`;
623
+ pendingSupersession.commentMayExist = completed('supersede-comment', draft.number);
470
624
  if (!completed('supersede-comment', draft.number)) {
471
- // REST draft listings do not contain comments; fetch them on a retry after the label was applied.
625
+ // REST draft listings do not contain comments; fetch them before deciding whether to comment.
472
626
  let comments = draft.comments;
473
- if (comments === undefined && completed('superseded-label', draft.number)) {
627
+ if (comments === undefined) {
474
628
  step = 'get-pr';
475
629
  const prior = await adapters.getPr(draft.number);
476
630
  if (!prior || prior.number !== draft.number) throw new Error('Previous PR lookup unavailable');
477
631
  comments = prior.comments;
478
632
  }
479
- if (!comments?.includes(body)) { step = 'comment'; await adapters.comment(record.repository, draft.number, body); }
633
+ if (!comments?.includes(body)) {
634
+ pendingSupersession.commentMayExist = true;
635
+ step = 'comment'; await adapters.comment(record.repository, draft.number, body);
636
+ }
480
637
  step = 'ledger';
481
638
  acknowledge('supersede-comment', draft.number);
482
639
  }
640
+ if (taSlot) {
641
+ // A claimant or new commit can arrive while GitHub accepts the label/comment writes.
642
+ step = 'list-pr-changed-paths';
643
+ const pathsAfterEffects = await adapters.listPrChangedPaths?.(record.repository, pr.number);
644
+ // The diff lookup can race a new head; bind its result to the head read after it.
645
+ step = 'get-pr';
646
+ const newAfterEffects = await adapters.getPr(pr.number);
647
+ step = 'list-live-branches';
648
+ const liveAfterEffects = await adapters.listLiveBranches(record.repository);
649
+ step = 'get-run-status';
650
+ const statusAfterEffects = await adapters.getRunStatus(draft, record.repository);
651
+ step = 'get-pr';
652
+ const oldAfterEffects = await adapters.getPr(draft.number);
653
+ if (!newAfterEffects || newAfterEffects.number !== pr.number || newAfterEffects.state !== pr.state
654
+ || newAfterEffects.headSha !== pr.headSha || newAfterEffects.branch !== pr.branch
655
+ || !sameCardSlot(newAfterEffects, pr, taSlot)
656
+ || !pathsAfterEffects || !Array.isArray(pathsAfterEffects)
657
+ || pathsAfterEffects.some((path) => typeof path !== 'string') || !hasCodeDiff(pathsAfterEffects)
658
+ || !oldAfterEffects || oldAfterEffects.number !== draft.number || oldAfterEffects.state !== 'OPEN'
659
+ || oldAfterEffects.isDraft !== true || oldAfterEffects.headSha !== draft.headSha
660
+ || oldAfterEffects.branch !== draft.branch || !Array.isArray(oldAfterEffects.labels)
661
+ || oldAfterEffects.labels.some((label) => PROTECTED_LABELS.has(label)
662
+ || label === 'elanous:running' || label.includes('harvest-claimed'))
663
+ || !sameCardSlot(oldAfterEffects, pr, taSlot)
664
+ || !liveAfterEffects || liveAfterEffects.has(draft.branch)
665
+ || !['completed', 'failed', 'cancelled', 'abandoned', 'self-implement.result final'].includes(statusAfterEffects ?? '')) {
666
+ const correction = `Draft residue: supersession by #${pr.number} deferred after PR or run changed; branch preserved. Prior superseded-by comment is no longer a close decision.`;
667
+ if (!oldAfterEffects?.comments?.includes(correction)) {
668
+ step = 'comment';
669
+ await adapters.comment(record.repository, draft.number, correction);
670
+ }
671
+ pendingSupersession = null;
672
+ deferred = true; skipped('supersession-changed-after-effects'); continue;
673
+ }
674
+ }
483
675
  step = 'close-pr';
484
676
  await adapters.closePr(record.repository, draft.number);
677
+ pendingSupersession = null;
485
678
  budget.remaining--;
486
679
  step = 'ledger';
487
680
  acknowledge('superseded-close', draft.number);
681
+ // The close is irreversible. Label failure is diagnostic, never a reason to retry
682
+ // supersession or to roll back a label whose writer cannot be identified.
683
+ if (!beforeLabel.labels.includes(superseded)) {
684
+ try {
685
+ step = 'set-labels';
686
+ // A state label read before close may have been written by another run
687
+ // before this call. Its writer is unknown, so only add our marker.
688
+ await adapters.setLabels(record.repository, draft.number, { add: superseded, remove: [] });
689
+ step = 'ledger';
690
+ if (!completed('superseded-label', draft.number)) acknowledge('superseded-label', draft.number);
691
+ } catch (labelError) {
692
+ log('superseded-label-unobserved', { previousPrNumber: draft.number,
693
+ reason: labelError instanceof Error ? labelError.message : String(labelError) });
694
+ }
695
+ }
488
696
  log('superseded-previous', { previousPrNumber: draft.number });
489
697
  }
490
698
  if (deferred) return skipped('previous-run-unverified');
@@ -494,6 +702,17 @@ export async function applyPodTerminal(record: PrTerminalRecord, ctx: DraftResid
494
702
  return { status: 'recorded' };
495
703
  } catch (error) {
496
704
  const reason = error instanceof Error ? error.message : String(error);
705
+ if (pendingSupersession) {
706
+ const pending = pendingSupersession;
707
+ try {
708
+ if (pending.commentMayExist) await adapters.comment(pending.repository, pending.number,
709
+ `Draft residue: supersession by #${pending.prNumber} deferred after an interrupted check; branch preserved. Prior superseded-by comment is no longer a close decision.`);
710
+ log('supersession-compensated', { previousPrNumber: pending.number, failedStep: step });
711
+ } catch (compensationError) {
712
+ log('supersession-compensation-failed', { previousPrNumber: pending.number,
713
+ reason: compensationError instanceof Error ? compensationError.message : String(compensationError) });
714
+ }
715
+ }
497
716
  log('failed', { step, reason });
498
717
  return { status: 'failed', step, reason };
499
718
  }
@@ -7,6 +7,11 @@ export interface GateTestShard {
7
7
  plannedSeconds: number;
8
8
  }
9
9
 
10
+ /** A requested shard count cannot hold the measured files; more shards may make the same plan valid. */
11
+ export class GateShardCapacityError extends Error {
12
+ constructor(message: string) { super(message); this.name = 'Error'; }
13
+ }
14
+
10
15
  /** Keep real child/PTY tests out of ordinary self-gate shards, within the caller's shard budget. */
11
16
  export function planSelfGateSpawnSeparatedShards(
12
17
  files: readonly string[],
@@ -19,8 +24,11 @@ export function planSelfGateSpawnSeparatedShards(
19
24
  const spawn = unique.filter(isGateSpawnLaneFile);
20
25
  const regular = unique.filter((file) => !isGateSpawnLaneFile(file));
21
26
  if (!spawn.length || !regular.length) return planGateTestShards(unique, rssMb, seconds, budgetGiB, shardCount);
22
- if (shardCount < 2) throw new Error('self gate spawn lane needs at least two shards when regular and spawn tests coexist');
23
- const spawnCount = Math.min(3, spawn.length, shardCount - 1);
27
+ if (shardCount < 2) throw new GateShardCapacityError('self gate spawn lane needs at least two shards when regular and spawn tests coexist');
28
+ // Three spawn bundles are the normal lane width, not a memory ceiling. A 5 GiB spawn file cannot share an 8 GiB
29
+ // bundle with another such file; retain lane isolation while allowing the measured plan to grow past three.
30
+ const neededSpawn = planGateTestShards(spawn, rssMb, seconds, budgetGiB).length;
31
+ const spawnCount = Math.min(spawn.length, shardCount - 1, Math.max(3, neededSpawn));
24
32
  const planned = [
25
33
  ...planGateTestShards(regular, rssMb, seconds, budgetGiB, shardCount - spawnCount),
26
34
  ...planGateTestShards(spawn, rssMb, seconds, budgetGiB, spawnCount),
@@ -65,7 +73,7 @@ export function planGateTestShards(
65
73
  if (!shard) {
66
74
  const totalRssMb = sorted.reduce((sum, candidate) => sum + rssMb.get(candidate)!, 0);
67
75
  const largestRssMb = rssMb.get(sorted[0]!)!;
68
- throw new Error(`test files exceed ${shardCount} shard memory budgets (files=${unique.length} · totalRssMb=${Math.round(totalRssMb)} · largestRssMb=${Math.round(largestRssMb)} · budgetMb=${capacity} · neededShards≥${Math.ceil(totalRssMb / capacity)})`);
76
+ throw new GateShardCapacityError(`test files exceed ${shardCount} shard memory budgets (files=${unique.length} · totalRssMb=${Math.round(totalRssMb)} · largestRssMb=${Math.round(largestRssMb)} · budgetMb=${capacity} · neededShards≥${Math.ceil(totalRssMb / capacity)})`);
69
77
  }
70
78
  shard.files.push(file);
71
79
  shard.plannedRssMb += rss;
@@ -1,12 +1,12 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statfsSync, statSync, writeFileSync } from 'node:fs';
2
+ import { availableParallelism, freemem, tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { debug, redactSecretText } from '../debug/log.js';
5
5
  import { prepareDeterministicChildEnvironment } from '../../scripts/lib/deterministic-env.js';
6
6
  import { withGateTestEnv } from '../../scripts/lib/gate-test-env.js';
7
7
  import { withBaselineWorktree } from '../self-implement/gate-baseline.js';
8
8
  import { aggregateGateTestShards, type GateShardAttempt, type GateShardAggregate } from './shard-aggregate.js';
9
- import { planFixedGateTestShards, planSelfGateSpawnSeparatedShards, type GateTestShard } from './shard-plan.js';
9
+ import { GateShardCapacityError, planFixedGateTestShards, planSelfGateSpawnSeparatedShards, type GateTestShard } from './shard-plan.js';
10
10
  import { isGateSpawnLaneFile } from './spawn-lane-files.js';
11
11
 
12
12
  export interface ShardRunResult {
@@ -15,11 +15,16 @@ export interface ShardRunResult {
15
15
  attempts: GateShardAttempt[];
16
16
  reason?: string;
17
17
  measurementFailures?: number;
18
- /** Present only for opt-in `--fixed-shard-distribution` (no RSS pre-measurement). Absent = the measured planner. */
19
- distribution?: 'fixed';
18
+ /** The measured RSS plan needed more bundles than the requested count. */
19
+ expandedFrom?: number;
20
+ /** A fixed plan selected explicitly, or after the measured plan exceeds host expansion capacity. */
21
+ distribution?: 'fixed' | 'fixed-cap-fallback';
22
+ fallbackReason?: string;
20
23
  /** OP FU (MK #8178 shard-9) — raw evidence of every failed shard process (exit≠0 · signal · no JUnit). Observation only:
21
24
  * the aggregate never reads it. Absent = no shard process reported failure output. */
22
25
  failureEvidence?: ShardFailureEvidence[];
26
+ /** Latest failed attempt's reason for each incomplete shard; copied into the gate artifact summary. */
27
+ unmeasuredByShard?: Record<string, string>;
23
28
  }
24
29
 
25
30
  /** What a failed shard process leaves behind so «test failure vs runner error» can be told apart after the fact. */
@@ -42,6 +47,38 @@ export interface ShardFailureEvidence extends ShardFailureOutput {
42
47
  export interface ShardRunMode {
43
48
  /** OP FU — sorted round-robin into `count` shards, zero pre-measurement runs. Default off: the measured planner. */
44
49
  fixedDistribution?: boolean;
50
+ /** Measured on the executing host; override only for deterministic planning tests. */
51
+ hostResources?: { cores: number; freeMemoryGiB: number; freeTmpGiB: number };
52
+ }
53
+
54
+ function hostResources(): ShardRunMode['hostResources'] | undefined {
55
+ try {
56
+ const tmp = statfsSync(tmpdir());
57
+ // Linux page cache is reclaimable. `freemem()` alone saw only ~10 GiB on a WSL host
58
+ // with 24 GiB MemAvailable and would incorrectly cap a two-shard plan at one.
59
+ let freeMemoryGiB = freemem() / (1024 ** 3);
60
+ if (process.platform === 'linux') {
61
+ const availableKiB = /^MemAvailable:\s+(\d+)\s+kB/m.exec(readFileSync('/proc/meminfo', 'utf8'))?.[1];
62
+ if (availableKiB) freeMemoryGiB = Number(availableKiB) / (1024 ** 2);
63
+ }
64
+ return {
65
+ cores: availableParallelism(),
66
+ freeMemoryGiB,
67
+ freeTmpGiB: Number(tmp.bavail) * Number(tmp.bsize) / (1024 ** 3),
68
+ };
69
+ } catch { return undefined; }
70
+ }
71
+
72
+ /** One expansion slot needs one CPU, one shard memory budget, and 1 GiB of free tmp space.
73
+ * Explicitly requested shards remain the caller's choice; this only limits automatic growth. */
74
+ export function maxAutomaticShardCount(requested: number, budgetGiB: number, resources: ShardRunMode['hostResources']): number {
75
+ if (!resources || !Number.isFinite(budgetGiB) || budgetGiB <= 0) return requested;
76
+ const limit = Math.min(
77
+ Math.floor(resources.cores),
78
+ Math.floor(resources.freeMemoryGiB / budgetGiB),
79
+ Math.floor(resources.freeTmpGiB),
80
+ );
81
+ return Math.max(requested, Number.isFinite(limit) ? limit : requested);
45
82
  }
46
83
 
47
84
  export type ShardProcess = (cwd: string, files: readonly string[]) => {
@@ -155,13 +192,45 @@ export function runShardedGateTests(
155
192
  }
156
193
  }
157
194
  const measurementFailures = failedMeasurements.length || undefined;
158
- let shards: GateTestShard[];
195
+ const fixedAtHostCap = (maxCount: number, reason: string): ShardRunResult => {
196
+ try {
197
+ const fixed = planFixedGateTestShards(files, maxCount);
198
+ debug.log('self-dev.shard', 'host-cap-fixed-fallback', { requested: count, cap: maxCount,
199
+ files: new Set(files).size, reason, measurementFailures: measurementFailures ?? 0 });
200
+ return { ...runPlannedShards(cwd, fixed, baseRef, run, baselineWorktree), distribution: 'fixed-cap-fallback',
201
+ fallbackReason: reason, ...(measurementFailures ? { measurementFailures } : {}) };
202
+ } catch (error) {
203
+ return { aggregate: { status: 'unmeasured', retryShardIds: [] }, shards: [], attempts: [],
204
+ reason: `host-cap fixed fallback failed: ${String(error)}; measured plan: ${reason}`,
205
+ ...(measurementFailures ? { measurementFailures } : {}) };
206
+ }
207
+ };
208
+ let shards: GateTestShard[] | undefined;
209
+ let plannedCount = count;
159
210
  try {
160
- shards = planSelfGateSpawnSeparatedShards(files, rss, seconds, budgetGiB, count);
211
+ const fileCount = new Set(files).size;
212
+ const totalRss = [...rss.values()].reduce((sum, value) => sum + value, 0);
213
+ const lowerBound = Number.isFinite(budgetGiB) && budgetGiB > 0 ? Math.ceil(totalRss / (budgetGiB * 1024)) : count;
214
+ plannedCount = Math.max(count, Math.min(fileCount, lowerBound));
215
+ // The requested count may exceed the number of files. Only expansion is bounded by file count.
216
+ const maxCount = Math.max(count, Math.min(fileCount, maxAutomaticShardCount(count, budgetGiB, mode.hostResources ?? hostResources())));
217
+ if (plannedCount > maxCount) return fixedAtHostCap(maxCount, `host shard expansion cap ${maxCount} < needed ${plannedCount} (requested ${count}, measured RSS sum ${Math.round(totalRss)} MiB, budget ${budgetGiB} GiB)`);
218
+ for (; plannedCount <= maxCount; plannedCount++) {
219
+ try { shards = planSelfGateSpawnSeparatedShards(files, rss, seconds, budgetGiB, plannedCount); break; }
220
+ catch (error) {
221
+ if (!(error instanceof GateShardCapacityError)) throw error;
222
+ if (plannedCount === maxCount) return fixedAtHostCap(maxCount, `host shard expansion cap ${maxCount} < needed ≥${maxCount + 1} (requested ${count}, ${error.message})`);
223
+ }
224
+ }
161
225
  } catch (error) {
162
226
  return { aggregate: { status: 'unmeasured', retryShardIds: [] }, shards: [], attempts: [], reason: String(error), ...(measurementFailures ? { measurementFailures } : {}) };
163
227
  }
164
- return { ...runPlannedShards(cwd, shards, baseRef, run, baselineWorktree), ...(measurementFailures ? { measurementFailures } : {}) };
228
+ if (!shards) return { aggregate: { status: 'unmeasured', retryShardIds: [] }, shards: [], attempts: [], reason: 'shard planning produced no result' };
229
+ if (plannedCount > count) debug.log('self-dev.shard', 'plan-expanded', {
230
+ requested: count, planned: shards.length, files: new Set(files).size, budgetGiB,
231
+ measurementFailures: measurementFailures ?? 0,
232
+ });
233
+ return { ...runPlannedShards(cwd, shards, baseRef, run, baselineWorktree), ...(plannedCount > count ? { expandedFrom: count } : {}), ...(measurementFailures ? { measurementFailures } : {}) };
165
234
  }
166
235
 
167
236
  /** Shared by the measured and the fixed planner: current + baseline per shard, one retry round, one aggregate. */
@@ -214,8 +283,32 @@ function runPlannedShards(
214
283
  return aggregateGateTestShards(shards, attempts);
215
284
  });
216
285
  const evidence = failureEvidence.length ? { failureEvidence } : {};
217
- if ('retryShardIds' in baseline) return { aggregate: baseline, shards, attempts, ...evidence };
218
- return { aggregate: { status: 'unmeasured', retryShardIds: shards.map((shard) => shard.id) }, shards, attempts, reason: baseline.log, ...evidence };
286
+ if ('retryShardIds' in baseline) return {
287
+ aggregate: baseline, shards, attempts, ...evidence,
288
+ ...(baseline.retryShardIds.length ? { unmeasuredByShard: incompleteShardReasons(baseline.retryShardIds, attempts) } : {}),
289
+ };
290
+ const retryShardIds = shards.map((shard) => shard.id);
291
+ return {
292
+ aggregate: { status: 'unmeasured', retryShardIds }, shards, attempts, reason: baseline.log, ...evidence,
293
+ unmeasuredByShard: Object.fromEntries(retryShardIds.map((id) => [id, `baseline worktree: ${redactSecretText(baseline.log).replace(/[\r\n\t\x00-\x1f\x7f]+/g, ' ').slice(0, 500)}`])),
294
+ };
295
+ }
296
+
297
+ function incompleteShardReasons(shardIds: readonly string[], attempts: readonly GateShardAttempt[]): Record<string, string> {
298
+ return Object.fromEntries(shardIds.map((id) => {
299
+ const latest = attempts.filter((attempt) => attempt.shardId === id).at(-1);
300
+ if (!latest) return [id, 'no shard attempt recorded'];
301
+ const side = (name: string, rc: number | null, signal: string | null | undefined, junit: string | undefined, diagnostic: string | undefined): string => {
302
+ const safeDiagnostic = diagnostic ? redactSecretText(diagnostic).replace(/[\r\n\t\x00-\x1f\x7f]+/g, ' ').slice(0, 500) : undefined;
303
+ return `${name}: exit ${rc ?? 'unavailable'}, signal ${signal ?? 'none'}, JUnit ${junit === undefined ? 'missing' : 'present'}`
304
+ + (safeDiagnostic ? `; ${safeDiagnostic}` : '');
305
+ };
306
+ const statuses = [
307
+ side('current', latest.currentExitCode, latest.currentSignal, latest.currentJUnit, latest.currentDiagnostic),
308
+ ...(latest.baselineFiles?.length === 0 ? [] : [side('baseline', latest.baselineExitCode, latest.baselineSignal, latest.baselineJUnit, latest.baselineDiagnostic)]),
309
+ ];
310
+ return [id, `${statuses.join(' · ')} · aggregate rejected (incomplete, malformed JUnit, or exit/JUnit mismatch)`];
311
+ }));
219
312
  }
220
313
 
221
314
  export function selfGateShardTimeoutMs(files: readonly string[]): number {
@@ -13,6 +13,10 @@ import { selfDevRunsDir } from '../self-dev/run-store.js';
13
13
  import { syncTestConfig } from '../cli/config-test-sync.js';
14
14
  import { debug } from '../debug/log.js';
15
15
 
16
+ /** = `PREAMBLE_SYNC_BLOCK_ENV`(dashboard/turn-preamble.ts). 그 모듈을 끌어오지 않으려 이름만 둔다 —
17
+ * 같은 값인지는 elanous-tui-spawn-identity.test.ts 가 잡는다. */
18
+ export const PREAMBLE_SYNC_BLOCK_ENV = 'ELANOUS_TEST_PREAMBLE_SYNC_BLOCK_MS';
19
+
16
20
  export interface ElanousTuiSpawnOptions {
17
21
  readonly repoRoot: string;
18
22
  readonly cwd: string;
@@ -138,6 +142,13 @@ export function elanousTuiSpawnOptions(opts: ElanousTuiSpawnOptions): StartOpts
138
142
  }
139
143
  const { runId } = resolveRunIdentity({ explicit: opts.runId, inherited: opts.space.runId });
140
144
  const debugLevel = process.env.ELANOUS_DEBUG_LEVEL;
145
+ // ⭐ PTY 는 기동 때 잡은 로그인 셸 env 위에 «여기 적은 키만» 얹는다(registry `buildPtyEnv`) —
146
+ // 부모에 건 시험 seam env 는 적어 주지 않으면 자식에 안 닿는다(run-fbe85b7c · 자식 env 실측).
147
+ // 격리 자식에만 넘긴다. 자식도 운영 우주면 무시한다(`preambleSyncBlockMs`).
148
+ const preambleSyncBlock = isolated ? process.env[PREAMBLE_SYNC_BLOCK_ENV]?.trim() : undefined;
149
+ if (preambleSyncBlock) {
150
+ debug.log('self-implement.elanous-tui-spawn', 'test-seam-env', { name: PREAMBLE_SYNC_BLOCK_ENV, value: preambleSyncBlock });
151
+ }
141
152
  const controller = opts.controller ?? deriveChildController(process.env, process.pid);
142
153
  const channels = 'pty,inbox';
143
154
  debug.log('self-implement.elanous-tui-spawn', 'controller', { controller, channels });
@@ -149,6 +160,8 @@ export function elanousTuiSpawnOptions(opts: ElanousTuiSpawnOptions): StartOpts
149
160
  ],
150
161
  accessMode: 'auto',
151
162
  transitionPolicy: 'open',
163
+ // 안 넘길 때는 로그인 셸 캡처본에 그 변수가 있어도 지운다 — 비격리 자식에 seam 이 새지 않게.
164
+ ...(preambleSyncBlock ? {} : { unsetEnv: [PREAMBLE_SYNC_BLOCK_ENV] }),
152
165
  // ⭐ 대표 2026-08-02 — 자식 화면 기본 **160×40**. 가상 PTY라 사람 터미널과 무관하고,
153
166
  // 좁으면 렌더 블록이 화면을 넘어 **관측이 잘린다**(그 실측이 `--cols/--rows` 를 열게 했다).
154
167
  cols: opts.cols ?? 160,
@@ -164,6 +177,7 @@ export function elanousTuiSpawnOptions(opts: ElanousTuiSpawnOptions): StartOpts
164
177
  ELANOUS_CONTROL_CHANNELS: channels,
165
178
  ...observeOnlyFlagEnv(opts.observeOnly),
166
179
  ...(debugLevel === undefined ? {} : { ELANOUS_DEBUG_LEVEL: debugLevel }),
180
+ ...(preambleSyncBlock ? { [PREAMBLE_SYNC_BLOCK_ENV]: preambleSyncBlock } : {}),
167
181
  ...childRunContextEnv('self-build'),
168
182
  ...childNestEnv(),
169
183
  ...harnessSpaceEnv(opts.space.kind, opts.space.id, runId),