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
@@ -34,6 +34,8 @@ import {
34
34
  import type { ShellHandle, ShellResult as RunnerResult } from '../../shell-runner/types.js';
35
35
  import { debug } from '../../debug/log.js';
36
36
  import { getSessionCwd } from '../../session/working-dir.js';
37
+ import { getUserConfig } from '../../user-config.js';
38
+ import { SCHEDULE_WAKEUP_TOOL_NAME } from './schedule-wakeup.js';
37
39
  import {
38
40
  globalTaskNotificationQueue,
39
41
  type TaskNotificationQueue,
@@ -288,6 +290,231 @@ export function rejectedLedgerCliResult(
288
290
  };
289
291
  }
290
292
 
293
+ // ── long sleep inside a turn (TUI-RUNSHELL-LONG-SLEEP · #38) ──────────────
294
+ // A seat agent told "check again at 22:45" held its turn inside RunShell with
295
+ // a long sleep, so the TUI stayed busy for 8+ minutes and the human could not
296
+ // type. The command still runs unchanged (blocking it would break legitimate
297
+ // waits); the result gains one warning line and `shell.run long-sleep` is
298
+ // logged BEFORE the wait starts, so the hold is visible while it happens.
299
+ // Static argv/script inspection only — no shell is spawned to analyse.
300
+
301
+ export const DEFAULT_LONG_SLEEP_WARN_SECONDS = 60;
302
+
303
+ export type LongSleepPattern = 'sleep' | 'shell-sleep' | 'poll-loop' | 'python-sleep' | 'computed-sleep';
304
+
305
+ export interface LongSleepFinding {
306
+ pattern: LongSleepPattern;
307
+ /** Estimated total wait in seconds; null when unbounded or not literal. */
308
+ seconds: number | null;
309
+ /** Per-iteration sleep of a poll loop (poll-loop only; null when not literal). */
310
+ perIterationSeconds?: number | null;
311
+ limitSeconds: number;
312
+ }
313
+
314
+ /** `tools.runShell.longSleepWarnSeconds` from a raw config root (positive number) or the default. */
315
+ export function longSleepWarnSecondsFrom(raw: Record<string, unknown> | undefined): number {
316
+ const tools = raw?.tools as { runShell?: { longSleepWarnSeconds?: unknown } } | undefined;
317
+ const v = tools?.runShell?.longSleepWarnSeconds;
318
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : DEFAULT_LONG_SLEEP_WARN_SECONDS;
319
+ }
320
+
321
+ function longSleepWarnSeconds(): number {
322
+ try {
323
+ return longSleepWarnSecondsFrom(getUserConfig().raw);
324
+ } catch { /* config unreadable → default */ }
325
+ return DEFAULT_LONG_SLEEP_WARN_SECONDS;
326
+ }
327
+
328
+ const SLEEP_UNIT_SECONDS: Readonly<Record<string, number>> = { '': 1, s: 1, m: 60, h: 3600, d: 86400 };
329
+
330
+ /** coreutils `sleep` operand → seconds (`480`, `5m`, `1.5h`, `infinity`), null when not literal. */
331
+ function parseSleepOperand(token: string): number | null {
332
+ if (/^inf(?:inity)?$/i.test(token)) return Infinity;
333
+ const m = /^(\d+(?:\.\d+)?|\.\d+)([smhd]?)$/.exec(token);
334
+ return m ? Number(m[1]) * SLEEP_UNIT_SECONDS[m[2]!]! : null;
335
+ }
336
+
337
+ function binName(arg: string | undefined): string {
338
+ return (arg ?? '').split(/[/\\]/).pop() ?? '';
339
+ }
340
+
341
+ /** Strip argv-preserving wrappers (env/timeout/nice/nohup/command/exec/time).
342
+ * `timeout D …` caps the wait, so its duration is returned as `capSeconds`. */
343
+ function unwrapArgv(command: readonly string[]): { argv: readonly string[]; capSeconds: number } {
344
+ let argv = command;
345
+ let capSeconds = Infinity;
346
+ for (let guard = 0; guard < 8 && argv.length > 0; guard += 1) {
347
+ const bin = binName(argv[0]);
348
+ let i = 1;
349
+ if (bin === 'env') {
350
+ while (i < argv.length && (argv[i]!.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]!))) {
351
+ if (['-u', '--unset', '-C', '--chdir', '-S', '--split-string'].includes(argv[i]!)) i += 1;
352
+ i += 1;
353
+ }
354
+ } else if (bin === 'timeout' || bin === 'gtimeout') {
355
+ while (i < argv.length && argv[i]!.startsWith('-')) {
356
+ if (['-s', '--signal', '-k', '--kill-after'].includes(argv[i]!)) i += 1;
357
+ i += 1;
358
+ }
359
+ const cap = parseSleepOperand(argv[i] ?? '');
360
+ if (cap !== null) capSeconds = Math.min(capSeconds, cap);
361
+ i += 1;
362
+ } else if (bin === 'nice') {
363
+ if (argv[1] === '-n') i = 3;
364
+ else if ((argv[1] ?? '').startsWith('-')) i = 2;
365
+ } else if (!['nohup', 'command', 'exec', 'time'].includes(bin)) {
366
+ break;
367
+ }
368
+ argv = argv.slice(i);
369
+ }
370
+ return { argv, capSeconds };
371
+ }
372
+
373
+ interface ScriptSleep { index: number; seconds: number | null }
374
+ interface ScriptScan { sleeps: ScriptSleep[]; loopBodyStart: number; loopIterations: number | null }
375
+
376
+ function scanShellScript(script: string): ScriptScan {
377
+ const sleeps: ScriptSleep[] = [];
378
+ // Command position only — `echo sleep 480` is an argument, not a wait.
379
+ const sleepRe = /(?:^|[;&|(`{\n]|\b(?:do|then|else)\b)[ \t]*sleep[ \t]+([^;&|)\n`}]+)/g;
380
+ for (let m = sleepRe.exec(script); m; m = sleepRe.exec(script)) {
381
+ const operands = m[1]!.trim().split(/\s+/).filter((t) => !t.startsWith('-'));
382
+ let total = 0;
383
+ for (const op of operands) {
384
+ const s = parseSleepOperand(op.replace(/^['"]|['"]$/g, ''));
385
+ if (s === null) { total = NaN; break; }
386
+ total += s;
387
+ }
388
+ sleeps.push({ index: m.index + m[0].indexOf('sleep'), seconds: operands.length > 0 && !Number.isNaN(total) ? total : null });
389
+ }
390
+ const loopRe = /(?:^|[;&|\n({]|\b(?:then|do|else)\b)\s*(while|until|for)\b([\s\S]*?)(?:;|\n)\s*do\b/;
391
+ const loop = loopRe.exec(script);
392
+ if (!loop) return { sleeps, loopBodyStart: -1, loopIterations: null };
393
+ let loopIterations: number | null = null;
394
+ if (loop[1] === 'for') {
395
+ const header = loop[2]!;
396
+ const brace = /\{(\d+)\.\.(\d+)\}/.exec(header);
397
+ const seq = /\$\(\s*seq\s+(\d+)(?:\s+(\d+))?\s*\)/.exec(header);
398
+ const words = /\bin\s+([^`$(){}]*)$/.exec(header.trim());
399
+ if (brace) loopIterations = Math.abs(Number(brace[2]) - Number(brace[1])) + 1;
400
+ else if (seq) loopIterations = seq[2] === undefined ? Number(seq[1]) : Math.max(0, Number(seq[2]) - Number(seq[1]) + 1);
401
+ else if (words) loopIterations = words[1]!.trim().split(/\s+/).filter(Boolean).length;
402
+ }
403
+ return { sleeps, loopBodyStart: loop.index + loop[0].length, loopIterations };
404
+ }
405
+
406
+ function scanPythonCode(code: string): ScriptScan {
407
+ const sleeps: ScriptSleep[] = [];
408
+ const sleepRe = /\b(?:time\.)?sleep\s*\(\s*([^)]*)\)/g;
409
+ for (let m = sleepRe.exec(code); m; m = sleepRe.exec(code)) {
410
+ const arg = m[1]!.trim();
411
+ sleeps.push({ index: m.index, seconds: /^(\d+(?:\.\d+)?|\.\d+)$/.test(arg) ? Number(arg) : null });
412
+ }
413
+ const loop = /(?:^|[;\n])\s*(while|for)\b([^:\n]*):/.exec(code);
414
+ if (!loop) return { sleeps, loopBodyStart: -1, loopIterations: null };
415
+ let loopIterations: number | null = null;
416
+ if (loop[1] === 'for') {
417
+ const range = /\brange\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)/.exec(loop[2]!);
418
+ if (range) loopIterations = range[2] === undefined ? Number(range[1]) : Math.max(0, Number(range[2]) - Number(range[1]));
419
+ }
420
+ return { sleeps, loopBodyStart: loop.index + loop[0].length, loopIterations };
421
+ }
422
+
423
+ function evaluateScan(
424
+ scan: ScriptScan, sleepPattern: LongSleepPattern, capSeconds: number, limitSeconds: number,
425
+ ): LongSleepFinding | null {
426
+ if (scan.sleeps.length === 0) return null;
427
+ const inLoop = scan.loopBodyStart >= 0 ? scan.sleeps.filter((s) => s.index >= scan.loopBodyStart) : [];
428
+ const outside = scan.sleeps.filter((s) => !inLoop.includes(s));
429
+ let total = outside.reduce((acc, s) => acc + (s.seconds ?? 0), 0);
430
+ if (inLoop.length > 0) {
431
+ const perIteration = inLoop.some((s) => s.seconds === null)
432
+ ? null : inLoop.reduce((acc, s) => acc + s.seconds!, 0);
433
+ const loopTotal = scan.loopIterations === null || perIteration === null
434
+ ? Infinity : perIteration * scan.loopIterations;
435
+ total = Math.min(total + loopTotal, capSeconds);
436
+ if (total > limitSeconds) {
437
+ return { pattern: 'poll-loop', seconds: Number.isFinite(total) ? total : null, perIterationSeconds: perIteration, limitSeconds };
438
+ }
439
+ return null;
440
+ }
441
+ total = Math.min(total, capSeconds);
442
+ if (total > limitSeconds) return { pattern: sleepPattern, seconds: Number.isFinite(total) ? total : null, limitSeconds };
443
+ if (outside.some((s) => s.seconds === null) && capSeconds > limitSeconds) {
444
+ return { pattern: 'computed-sleep', seconds: null, limitSeconds };
445
+ }
446
+ return null;
447
+ }
448
+
449
+ /** Long in-turn wait in a RunShell argv, or null. Exported for tests. */
450
+ export function detectLongSleep(
451
+ command: readonly string[], limitSeconds: number = DEFAULT_LONG_SLEEP_WARN_SECONDS,
452
+ ): LongSleepFinding | null {
453
+ const { argv, capSeconds } = unwrapArgv(command);
454
+ const bin = binName(argv[0]);
455
+ if (bin === 'sleep') {
456
+ const operands = argv.slice(1).filter((t) => !t.startsWith('-'));
457
+ let total = 0;
458
+ for (const op of operands) {
459
+ const s = parseSleepOperand(op);
460
+ if (s === null) return null; // sleep itself rejects it — no wait happens
461
+ total += s;
462
+ }
463
+ total = Math.min(total, capSeconds);
464
+ return total > limitSeconds
465
+ ? { pattern: 'sleep', seconds: Number.isFinite(total) ? total : null, limitSeconds }
466
+ : null;
467
+ }
468
+ const scriptFlag = argv.findIndex((a, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(a));
469
+ const script = scriptFlag > 0 ? argv[scriptFlag + 1] : undefined;
470
+ if (script === undefined) return null;
471
+ if (/^(?:ba|z|da|k)?sh$/.test(bin)) {
472
+ return evaluateScan(scanShellScript(script), 'shell-sleep', capSeconds, limitSeconds);
473
+ }
474
+ if (/^python(?:\d+(?:\.\d+)?)?$/.test(bin) && argv[scriptFlag] === '-c') {
475
+ return evaluateScan(scanPythonCode(script), 'python-sleep', capSeconds, limitSeconds);
476
+ }
477
+ return null;
478
+ }
479
+
480
+ /**
481
+ * Which resume path the warning names (TUI-RUNSHELL-LONG-SLEEP follow-up):
482
+ * `schedule-wakeup` when the turn was offered the ScheduleWakeup host tool
483
+ * (seat TUI · dashboard host tool), `generic` elsewhere (harness children,
484
+ * MCP, CLI agents) where that tool does not exist.
485
+ */
486
+ export type LongSleepResumeHint = 'schedule-wakeup' | 'generic';
487
+
488
+ /** `ScheduleWakeup` in this turn's tool list → name it; otherwise the generic resume advice. */
489
+ export function longSleepResumeHint(availableToolNames: readonly string[] | undefined): LongSleepResumeHint {
490
+ return availableToolNames?.includes(SCHEDULE_WAKEUP_TOOL_NAME) ? 'schedule-wakeup' : 'generic';
491
+ }
492
+
493
+ /** The one warning line prefixed to a RunShell result. Exported for tests. */
494
+ export function formatLongSleepWarning(
495
+ finding: LongSleepFinding, bgAvailable: boolean, resumeHint: LongSleepResumeHint = 'generic',
496
+ ): string {
497
+ const wait = finding.pattern === 'poll-loop'
498
+ ? `a poll loop sleeping ${finding.perIterationSeconds ?? '?'}s per iteration (total ${finding.seconds === null ? 'unbounded' : `~${finding.seconds}s`})`
499
+ : finding.pattern === 'computed-sleep'
500
+ ? 'a sleep of computed length'
501
+ : `a sleep of ${finding.seconds === null ? 'unbounded length' : `~${finding.seconds}s`}`;
502
+ const resume = resumeHint === 'schedule-wakeup'
503
+ ? `end the turn and call ${SCHEDULE_WAKEUP_TOOL_NAME} (delaySeconds ≥60, at "HH:MM", or repeatMinutes) so a wakeup turn resumes the work`
504
+ : 'end the turn and let the next turn or a mail wake resume the work';
505
+ return `⚠ RunShell long-sleep: this command waited inside the turn with ${wait} (limit ${finding.limitSeconds}s) — `
506
+ + `the turn stays busy and the human cannot type meanwhile. Do not hold the turn to wait: ${resume}`
507
+ + (bgAvailable ? ", or start long-running work with mode:'bg' (its completion arrives as a notification)." : '.');
508
+ }
509
+
510
+ function withLongSleepWarning(
511
+ result: RunShellDispatchResult, finding: LongSleepFinding | null, bgAvailable: boolean,
512
+ resumeHint: LongSleepResumeHint,
513
+ ): RunShellDispatchResult {
514
+ if (!finding) return result;
515
+ return { ...result, output: `${formatLongSleepWarning(finding, bgAvailable, resumeHint)}\n${result.output}` };
516
+ }
517
+
291
518
  function logShellDispatch(event: string, data: Record<string, unknown>): void {
292
519
  debug.log('shell.dispatch', event, data);
293
520
  }
@@ -379,6 +606,21 @@ export async function dispatchRunShell(
379
606
  // Explicit inline/bg uses the file-backed runner when its deps are installed;
380
607
  // omitted mode and uninitialized hosts keep the legacy argv path.
381
608
  const deps = mode ? getShellRunnerDeps() : null;
609
+ // A bg handle returns immediately, so only blocking paths can hold the turn.
610
+ // Note `mode:'bg'` without runner deps falls through to the blocking legacy path.
611
+ const longSleep = mode === 'bg' && deps ? null : detectLongSleep(command, longSleepWarnSeconds());
612
+ const resumeHint = longSleepResumeHint(opts.availableToolNames);
613
+ if (longSleep) {
614
+ debug.log('shell.run', 'long-sleep', {
615
+ seconds: longSleep.seconds,
616
+ pattern: longSleep.pattern,
617
+ ...(longSleep.perIterationSeconds !== undefined ? { perIterationSeconds: longSleep.perIterationSeconds } : {}),
618
+ limitSeconds: longSleep.limitSeconds,
619
+ path: mode && deps ? 'runner' : 'legacy',
620
+ resumeHint,
621
+ });
622
+ }
623
+ const bgAvailable = getShellRunnerDeps() !== null;
382
624
  if (mode && deps) {
383
625
  const result = await dispatchViaRunner({
384
626
  command, cwd, timeoutMs, mode, description,
@@ -386,7 +628,7 @@ export async function dispatchRunShell(
386
628
  deps,
387
629
  });
388
630
  logShellDispatch(`runner.${result.outcome}`, { cwd: effectiveCwd, cwdSource });
389
- return result;
631
+ return withLongSleepWarning(result, longSleep, bgAvailable, resumeHint);
390
632
  }
391
633
 
392
634
  const result = await runShell({
@@ -401,7 +643,7 @@ export async function dispatchRunShell(
401
643
 
402
644
  logShellDispatch(`legacy.${result.outcome}`, { cwd: effectiveCwd, cwdSource });
403
645
  const summary = formatSummary(result);
404
- return {
646
+ return withLongSleepWarning({
405
647
  output: summary,
406
648
  exitCode: result.exitCode,
407
649
  outcome: result.outcome,
@@ -412,7 +654,7 @@ export async function dispatchRunShell(
412
654
  approvalKey: result.approvalKey,
413
655
  sandboxed: result.sandboxed,
414
656
  sandboxTool: result.sandboxTool,
415
- };
657
+ }, longSleep, bgAvailable, resumeHint);
416
658
  }
417
659
 
418
660
  /** Route a request through the file-backed shell-runner and project its
package/src/status/bar.ts CHANGED
@@ -659,6 +659,32 @@ export function shellRollupSegment(
659
659
  return text(`🐚 ${parts.join(' ')}`, tone, opts);
660
660
  }
661
661
 
662
+ /** TUI-BG-JOB-VISIBILITY (#71) — 턴이 끝난 뒤에도 남는 백그라운드 일을 상태줄에서 보이게 한다.
663
+ *
664
+ * 🚨 10-16 07:3x OPT 가 «남은 리뷰 4건 실행 중 · 다음 점검 = …» 로 턴을 «✔ 완료» 로 끝내자
665
+ * 바깥에선 «12분 유휴» 로 보였다.
666
+ *
667
+ * ⧗ 백그라운드 2 — 등록된(ShellRegistry) 백그라운드 일 2개가 돈다
668
+ * (빈 문자열) — 0 · 끝나면 사라진다
669
+ *
670
+ * `background` = 살아 있는 bg 모드 핸들 ⊕ backgrounded(인라인 전경 실행은 안 셈 — 그건 🐚 몫).
671
+ * 명령 안 `&`·`nohup` 으로 떼어 낸 프로세스는 등록되지 않아 여기 안 잡힌다.
672
+ * 깨우기(끝나면 턴을 여는 것)는 이 세그먼트 몫이 아니다 — TUI-AGENT-IDLE-WAKE 칸.
673
+ * `runningAgentsSegment` 와 같은 꼴 · 같은 색 계열(peach). */
674
+ export interface BackgroundJobsSummary {
675
+ background: number;
676
+ }
677
+
678
+ export function backgroundJobsSegment(
679
+ summary: BackgroundJobsSummary | null | undefined,
680
+ opts: StatusBarRenderOptions = {},
681
+ ): string {
682
+ if (!summary) return '';
683
+ const live = Number.isFinite(summary.background) ? Math.max(0, Math.floor(summary.background)) : 0;
684
+ if (live <= 0) return '';
685
+ return text(`⧗ 백그라운드 ${live}`, 'peach', opts);
686
+ }
687
+
662
688
  export type DockStripDensity = 'full' | 'compact' | 'count-only';
663
689
 
664
690
  export interface DockStripSegmentOptions extends StatusBarRenderOptions {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 스튜어드 발송 모듈 — 흐름 판정 변화 브리핑(OVW-BRIEF · RFC §A4·§A7).
3
+ *
4
+ * §A4 — 브리핑은 판정이 «마지막으로 브리핑한 값»과 달라질 때만 한 줄.
5
+ * §A7 — 경계값 플래핑 방지: «2연속» 같은 판정으로 관측될 때만 발송.
6
+ * 발송 킨드 = `ops-report`(운영 봇) — 스튜어드 digest 와 같은 통로, 트레이딩 봇으로는 내려가지 않는다.
7
+ */
8
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { debug } from '../debug/log.js';
11
+ import { collectOverview, parseSince } from '../ops/overview-collect.js';
12
+ import { VERDICT_LABEL, VERDICTS, computeVerdict, type OverviewSnapshot, type Verdict, type VerdictResult } from '../ops/overview.js';
13
+
14
+ /** 브리핑 상태 파일 — 인스턴스 루트 기준 상대 경로. */
15
+ export const VERDICT_BRIEF_STATE = 'steward/verdict-brief.json';
16
+
17
+ export interface VerdictBriefState {
18
+ /** 마지막 관측 판정 — streak 를 센다. */
19
+ lastObserved: Verdict | null;
20
+ /** 마지막으로 «브리핑을 보낸» 판정 — 이 값과 같으면 다시 보내지 않는다(§A4). */
21
+ lastBriefed: Verdict | null;
22
+ /** lastObserved 가 «연속»으로 관측된 횟수 — 2 이상이어야 발송한다(§A7). */
23
+ streak: number;
24
+ }
25
+
26
+ export function emptyVerdictBriefState(): VerdictBriefState {
27
+ return { lastObserved: null, lastBriefed: null, streak: 0 };
28
+ }
29
+
30
+ /** 판정 한 번 관측 — 같은 판정이면 streak 를 쌓고, 다른 판정이면 1로 되돌린다. */
31
+ export function observeVerdict(state: VerdictBriefState, observed: Verdict): VerdictBriefState {
32
+ if (state.lastObserved === observed) return { ...state, streak: state.streak + 1 };
33
+ return { lastBriefed: state.lastBriefed, lastObserved: observed, streak: 1 };
34
+ }
35
+
36
+ /** 발송 조건 — §A7(2연속 관측) ⊕ §A4(마지막 브리핑과 다른 판정). */
37
+ export function briefDue(state: VerdictBriefState): boolean {
38
+ return state.streak >= 2 && state.lastObserved !== state.lastBriefed;
39
+ }
40
+
41
+ /** 브리핑 한 줄 — 새 판정 ⊕ 이전 판정(있으면) ⊕ 첫 사유 ⊕ 첫 다음 행동. */
42
+ export function formatVerdictBrief(current: VerdictResult, previous: Verdict | null): string {
43
+ const one = (text: string | undefined) => (text ?? '').replace(/\s+/g, ' ').trim();
44
+ const reason = one(current.reasons[0]);
45
+ const next = one(current.nextActions[0]);
46
+ const from = previous ? ` (이전 ${VERDICT_LABEL[previous]})` : '';
47
+ return `스튜어드 흐름 · 판정 변화 ${VERDICT_LABEL[current.verdict]}${from}${reason ? ` · ${reason}` : ''}${next ? ` · 다음: ${next}` : ''}`;
48
+ }
49
+
50
+ export interface VerdictBriefDeps {
51
+ /** 판정 계산용 스냅샷 — 기본: `elanous ops overview` CLI 와 같은 창(최근 1h)으로 수집. */
52
+ collect?: () => Promise<OverviewSnapshot>;
53
+ /** 브리핑 발송 — 기본: 킨드 `ops-report`(운영 봇)으로 직접 발송. */
54
+ send?: (text: string) => Promise<void>;
55
+ /** 상태 파일 경로 — 기본: <root>/steward/verdict-brief.json. */
56
+ statePath?: string;
57
+ }
58
+
59
+ export function verdictBriefStatePath(root: string): string {
60
+ return join(root, VERDICT_BRIEF_STATE);
61
+ }
62
+
63
+ function isVerdict(value: unknown): value is Verdict {
64
+ return typeof value === 'string' && (VERDICTS as readonly string[]).includes(value);
65
+ }
66
+
67
+ function readBriefState(path: string): VerdictBriefState {
68
+ try {
69
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<VerdictBriefState>;
70
+ if (isVerdict(raw.lastObserved) && (raw.lastBriefed == null || isVerdict(raw.lastBriefed)) && typeof raw.streak === 'number') {
71
+ return { lastObserved: raw.lastObserved, lastBriefed: raw.lastBriefed ?? null, streak: raw.streak };
72
+ }
73
+ } catch {
74
+ /* 상태가 없거나 깨졌으면 새로 시작한다 — 다음 관측이 streak 를 다시 쌓는다. */
75
+ }
76
+ return emptyVerdictBriefState();
77
+ }
78
+
79
+ function writeBriefState(path: string, state: VerdictBriefState): void {
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
82
+ }
83
+
84
+ /** 스튜어드 운영 발송 — 킨드 `ops-report`(운영 봇). digest 와 같은 통로, 다른 줄. */
85
+ export async function sendStewardReport(text: string): Promise<void> {
86
+ const { deliver } = await import('../domains/outbound-alert.js');
87
+ if (!deliver(text, 'ops-report')) throw new Error('steward report delivery failed');
88
+ }
89
+
90
+ /**
91
+ * 흐름 판정을 관측해, «2연속 같은 판정»이 «마지막 브리핑과 다를 때»만 한 줄을 보낸다(§A4·§A7).
92
+ * 관측(streak)은 항상 상태로 남고, 마지막 브리핑 값은 발송 성공 뒤에만 진행한다 —
93
+ * 실패한 브리핑은 다음 틱의 같은 판정 관측이 재시도한다.
94
+ * @returns 보낸 브리핑 원문 — 이번 관측에서 보내지 않았으면 null.
95
+ */
96
+ export async function briefVerdictChange(root: string, deps: VerdictBriefDeps = {}): Promise<string | null> {
97
+ const collect = deps.collect ?? defaultCollect;
98
+ const snapshot = await collect();
99
+ const result = computeVerdict(snapshot);
100
+ const statePath = deps.statePath ?? verdictBriefStatePath(root);
101
+ const state = readBriefState(statePath);
102
+ const observed = observeVerdict(state, result.verdict);
103
+ if (!briefDue(observed)) {
104
+ writeBriefState(statePath, observed);
105
+ debug.log('steward.verdict-brief', 'held', { verdict: result.verdict, streak: observed.streak });
106
+ return null;
107
+ }
108
+ const text = formatVerdictBrief(result, state.lastBriefed);
109
+ await (deps.send ?? sendStewardReport)(text);
110
+ writeBriefState(statePath, { ...observed, lastBriefed: result.verdict });
111
+ debug.log('steward.verdict-brief', 'sent', { verdict: result.verdict, previous: state.lastBriefed });
112
+ return text;
113
+ }
114
+
115
+ async function defaultCollect(): Promise<OverviewSnapshot> {
116
+ const { ms, label } = parseSince(undefined);
117
+ return collectOverview({ sinceMs: ms, sinceLabel: label });
118
+ }
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { openMsgStore } from '../msg/msg-store.js';
4
+ import { opSeatStatePath, readTrafficSignal } from '../seat-loop/traffic-signal.js';
5
+
6
+ const SEATS = ['OP', 'MK', 'TC', 'UX'] as const;
7
+ type Seat = typeof SEATS[number];
8
+ export type HygieneCell = { id: string; owner?: string | null; status: string };
9
+ export type HygieneNotice = { to: Seat; key: string; body: string };
10
+ export type HygieneDeps = {
11
+ root: string;
12
+ now: Date;
13
+ cells: readonly HygieneCell[];
14
+ trafficPath?: string;
15
+ metaPath?: string;
16
+ dispatcherDir?: string;
17
+ };
18
+
19
+ function readOptional(path: string): string | null {
20
+ try { return readFileSync(path, 'utf8'); }
21
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
22
+ }
23
+
24
+ /** A following budget line closes the preceding cycle; a trailing cycle may still launch. */
25
+ export function starvingCycles(log: string): { reasons: Record<string, number> } | null {
26
+ const cycles: Array<{ budget: number; launches: number; noCapacity: number; reasons: Record<string, number> }> = [];
27
+ for (const line of log.split(/\r?\n/)) {
28
+ const budget = /역압 예산\s*(\d+)/.exec(line);
29
+ if (budget) cycles.push({ budget: Number(budget[1]), launches: 0, noCapacity: 0, reasons: {} });
30
+ else if (cycles.length) {
31
+ const current = cycles[cycles.length - 1]!;
32
+ if (/발사\s+\S/.test(line)) current.launches++;
33
+ if (line.includes('여유 없음')) current.noCapacity++;
34
+ const skip = /건너뜀\s*(.*)/.exec(line);
35
+ if (skip) {
36
+ const reason = skip[1]?.trim() || '사유 미기록';
37
+ current.reasons[reason] = (current.reasons[reason] ?? 0) + 1;
38
+ }
39
+ }
40
+ }
41
+ const last = cycles.slice(0, -1).slice(-3);
42
+ if (last.length !== 3 || last.some(cycle => cycle.budget <= 0 || cycle.launches !== 0 || cycle.noCapacity !== 0)) return null;
43
+ const reasons: Record<string, number> = {};
44
+ for (const cycle of last) for (const [reason, count] of Object.entries(cycle.reasons)) reasons[reason] = (reasons[reason] ?? 0) + count;
45
+ return { reasons };
46
+ }
47
+
48
+ type HoldMeta = { id?: unknown; op?: unknown; reason?: unknown; until?: unknown };
49
+ function holdMetadata(path: string): Map<string, HoldMeta> {
50
+ const latest = new Map<string, HoldMeta>();
51
+ for (const line of (readOptional(path) ?? '').split(/\r?\n/)) {
52
+ if (!line.trim()) continue;
53
+ try {
54
+ const row = JSON.parse(line) as HoldMeta;
55
+ if (typeof row.id === 'string') latest.set(row.id, row);
56
+ } catch { /* An invalid row cannot supply a reason for any hold. */ }
57
+ }
58
+ return latest;
59
+ }
60
+
61
+ function untilTime(value: unknown): number | null {
62
+ if (typeof value !== 'string' || !value.trim()) return null;
63
+ const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value);
64
+ const time = compact ? Date.parse(`${compact[1]}-${compact[2]}-${compact[3]}T${compact[4]}:${compact[5]}:00+09:00`) : Date.parse(value);
65
+ return Number.isFinite(time) ? time : null;
66
+ }
67
+
68
+ export function inspectHoldHygiene(deps: HygieneDeps): HygieneNotice[] {
69
+ const notices: HygieneNotice[] = [];
70
+ const dir = deps.dispatcherDir ?? opSeatStatePath('feeder');
71
+ for (const seat of SEATS) {
72
+ const log = readOptional(join(dir, `dispatcher-${seat}.out`));
73
+ const finding = log === null ? null : starvingCycles(log);
74
+ if (!finding) continue;
75
+ const detail = Object.entries(finding.reasons).map(([reason, count]) => `${reason} ×${count}`).join(' · ') || '건너뜀 사유 미기록';
76
+ const body = `${seat} 디스패처 굶주림: 최근 3주기 예산>0 · 발사 0 · 여유 없음 0. 건너뜀: ${detail}. 후보 칸·잠금·경로를 확인해 주세요.`;
77
+ for (const to of new Set<Seat>(['OP', seat])) notices.push({ to, key: `starve:${seat}:${to}`, body });
78
+ }
79
+ const signal = readTrafficSignal(deps.trafficPath);
80
+ if (!signal || 'unreadable' in signal) return notices;
81
+ const held = new Set(signal.hold ?? []);
82
+ const metadata = holdMetadata(deps.metaPath ?? opSeatStatePath(join('tools', 'hold-meta.jsonl')));
83
+ const seen = new Set<string>();
84
+ for (const cell of deps.cells) {
85
+ if (!held.has(cell.id) || cell.status !== 'yellow' || seen.has(cell.id)) continue;
86
+ seen.add(cell.id);
87
+ const owner = cell.owner;
88
+ if (!SEATS.some(seat => seat === owner)) continue;
89
+ const meta = metadata.get(cell.id);
90
+ const reason = meta?.op === 'add' && typeof meta.reason === 'string' ? meta.reason.trim() : '';
91
+ const expiry = untilTime(meta?.until);
92
+ const expired = typeof meta?.until === 'string' && meta.until.trim() !== '' && (expiry === null || expiry <= deps.now.getTime());
93
+ if (reason && !expired) continue;
94
+ notices.push({ to: owner as Seat, key: `hold:${cell.id}:${owner}`,
95
+ body: `노랑 칸 ${cell.id} traffic hold: ${!reason ? '사유 없음' : `사유 ${reason}`} ${expired ? `· until ${String(meta?.until)} 지남` : ''}. 풀기/유지(사유·until)를 판단해 OP에 알려 주세요. 자동 해제하지 않습니다.` });
96
+ }
97
+ return notices;
98
+ }
99
+
100
+ /** One durable msg per recipient/key per 30 minutes, even when two tick processes race. */
101
+ export function runHoldHygiene(deps: HygieneDeps): HygieneNotice[] {
102
+ const notices = inspectHoldHygiene(deps);
103
+ if (!notices.length) return [];
104
+ const store = openMsgStore(join(deps.root, 'msg', 'messages.db'));
105
+ const sent: HygieneNotice[] = [];
106
+ try {
107
+ store.db.exec(`CREATE TABLE IF NOT EXISTS hold_hygiene_notices (key TEXT PRIMARY KEY, sent_at INTEGER NOT NULL)`);
108
+ for (const notice of notices) {
109
+ store.db.transaction(() => {
110
+ const prior = store.db.query('SELECT sent_at FROM hold_hygiene_notices WHERE key = ?').get(notice.key) as { sent_at: number } | null;
111
+ if (prior && deps.now.getTime() - prior.sent_at < 30 * 60_000) return;
112
+ store.append({ from: 'ORCHESTRATOR', to: notice.to, body: notice.body, kind: 'hold-hygiene' });
113
+ store.db.query('INSERT INTO hold_hygiene_notices(key, sent_at) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET sent_at = excluded.sent_at')
114
+ .run(notice.key, deps.now.getTime());
115
+ sent.push(notice);
116
+ }).immediate();
117
+ }
118
+ } finally { store.close(); }
119
+ return sent;
120
+ }
@@ -24,6 +24,7 @@ import { CardStore } from '../task-cards/card-store.js';
24
24
  import { draftWithinBudget, triageWithinBudget } from './triage-budget.js';
25
25
  import { askSteward, classifyIssue, installedCapabilities, planIssues, ruleJudgment, unsafeReason, type ClassifiedIssue, type StewardAsk } from './triage-plan.js';
26
26
  import { failureAlertText, markStageStart, markStageEnd, readFailureStreak, shouldAlert, writeFailureStreak, type FailureStreak, type StewardStage } from './failure-streak.js';
27
+ import { briefVerdictChange } from './digest-delivery.js';
27
28
 
28
29
  export type Rung = 0 | 1 | 2 | 3 | 4 | 5 | 'hitl';
29
30
  /** `other` = the judge chose human review for a reason outside the four named ones (kept as HITL, never downgraded). */
@@ -56,6 +57,8 @@ export interface StewardDeps {
56
57
  judge?: (issue: TriageIssue, issues: TriageIssue[], tracks?: Record<string, string>) => Promise<unknown>;
57
58
  decide?: typeof emitDecision;
58
59
  sendDigest?: (text: string) => Promise<void>;
60
+ /** 흐름 판정 변화 브리핑 한 줄 발송(OVW-BRIEF) — 생략하면 킨드 `ops-report`(운영 봇)이 기본. */
61
+ sendBrief?: (text: string) => Promise<void>;
59
62
  now?: () => Date;
60
63
  root?: string;
61
64
  cardStore?: StewardCardDeps['store'];
@@ -602,6 +605,13 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
602
605
  writeFileSync(path, JSON.stringify(state));
603
606
  digested = true;
604
607
  }
608
+ try {
609
+ await briefVerdictChange(root, { send: deps.sendBrief });
610
+ } catch (error) {
611
+ const message = `steward report: verdict brief failed: ${redactSecretText(error instanceof Error ? error.message : String(error))}`;
612
+ debug.log('steward.verdict-brief', 'failed', { error: message });
613
+ (deps.warn ?? console.warn)(message);
614
+ }
605
615
  const launched = readLaunchDecision(root);
606
616
  if (launched) logTickDecision(root, launched.verdict, launched.reason, launched.goalId);
607
617
  else if (outcomes.length || digested) logTickDecision(root, 'reported', outcomes.length ? `${outcomes.length} outcome(s) reported` : 'daily digest sent');
@@ -7,7 +7,7 @@ interface BackgroundSurfaceLike<Handle> {
7
7
 
8
8
  export interface DashboardShellRunnerBootDeps<Handle> {
9
9
  createBackgroundSurface: (deps: {
10
- onUpdate: (rollup: { running: number; backgrounded: number }) => void;
10
+ onUpdate: (rollup: { running: number; backgrounded: number; background: number }) => void;
11
11
  }) => BackgroundSurfaceLike<Handle>;
12
12
  initShellRegistry: (deps: {
13
13
  onRegister: (handle: Handle) => void;
@@ -19,7 +19,7 @@ export interface DashboardShellRunnerBootDeps<Handle> {
19
19
  registry: ShellRegistryLike<Handle>;
20
20
  fileEngine?: unknown;
21
21
  }): void;
22
- setLatestShellRollup: (rollup: { running: number; backgrounded: number }) => void;
22
+ setLatestShellRollup: (rollup: { running: number; backgrounded: number; background: number }) => void;
23
23
  }
24
24
 
25
25
  export function bootDashboardShellRunner<Handle>(
@@ -30,6 +30,9 @@ export function bootDashboardShellRunner<Handle>(
30
30
  deps.setLatestShellRollup({
31
31
  running: rollup.running,
32
32
  backgrounded: rollup.backgrounded,
33
+ // TUI-BG-JOB-VISIBILITY — turn-outliving jobs (bg mode ⊕ backgrounded).
34
+ // Required: guessing it from `backgrounded` would under-count running bg-mode jobs.
35
+ background: rollup.background,
33
36
  });
34
37
  },
35
38
  });
@@ -28,6 +28,9 @@ import { applyRunIdentityTag, applyRunIdentityTags, deriveBirthIdentity, parseRu
28
28
  import { parseWorkOrder, renderWorkOrderGoal, WORK_ORDER_FILE_PATTERN, type WorkOrder } from './work-order.js';
29
29
  import { inspectPrelaunchAlreadyDone, type AlreadyDoneDecision, type AlreadyDoneCandidate,
30
30
  type PrelaunchGitRunnerForTest } from './prelaunch-already-done.js';
31
+ import { draftReleaseComment } from '../self-dev/draft-triage-rules.js';
32
+ import { parseAskTargetPathHints } from '../self-dev/launch-preflight-text.js';
33
+ import { INFLIGHT_REGISTRY_FILE, registerInflight } from '../pr-tool/inflight-registry.js';
31
34
 
32
35
  export interface ResolveHandTextDeps {
33
36
  cwd: string;
@@ -338,6 +341,16 @@ export function boundPrTerminal(card: Pick<TaskCard, 'pr' | 'prTerminal'>): Task
338
341
  return marker && typeof card.pr === 'object' && card.pr.number === marker.pr && card.pr.url === marker.url ? marker : undefined;
339
342
  }
340
343
 
344
+ /**
345
+ * DRAFT-CLAIM-AUTO-2 — 포기 경로(failed · launch-failed)로 끝난 카드가 PR 을 남겼으면 그 PR 에 달 «🔧 처리 끝» 문면.
346
+ * 이 조각은 순수 함수만 — 실제 PR 코멘트(gh) · draft-sweep 배선은 다음 조각. launched · handed · PR 없음은 null.
347
+ */
348
+ export function abandonedDraftReleaseNote(card: Pick<TaskCard, 'id' | 'status' | 'pr'>, at: Date): { pr: number; body: string } | null {
349
+ if (card.status !== 'failed' && card.status !== 'launch-failed') return null;
350
+ const pr = cardPrNumber(card);
351
+ return pr === undefined ? null : { pr, body: draftReleaseComment(at, `${card.id} 포기 경로 — 자동`) };
352
+ }
353
+
341
354
  /** A uniquely launched code-PR card may own a draft through its PR or its opening run. */
342
355
  export function draftTaskOwner(cards: readonly TaskCard[], draft: { number: number; runId?: string | null }): TaskCard | undefined {
343
356
  const owners = cards.filter((card) => card.status === 'launched' && (card.completion ?? 'code-pr') === 'code-pr'
@@ -796,6 +809,17 @@ export async function handTask(opts: HandTaskOptions): Promise<HandTaskResult> {
796
809
  ],
797
810
  };
798
811
  writeCard(path, launched);
812
+ // INFLIGHT-REGISTRY — 출발 때 맥락을 남긴다(머지 때 같은 파일 줄의 의도를 스마트 머지가 읽는다 · 착지하면 지운다).
813
+ try {
814
+ const [head = '', ...rest] = text.split(/\r?\n/);
815
+ registerInflight({
816
+ runId: boundRunId ?? runId, cell: card.checklistId ?? card.id, cardId: card.id,
817
+ intent: (/^\s*(?:대상\s*경로|target\s*paths?)\s*[::]/i.test(head) ? rest.join(' ') : text) || text,
818
+ files: parseAskTargetPathHints(text),
819
+ }, { path: join(dirname(path), INFLIGHT_REGISTRY_FILE), ...(opts.now ? { now: opts.now } : {}) });
820
+ } catch (error) {
821
+ try { debug.log('pr-tool.inflight', 'register-failed', { taskId: id, error: String((error as Error)?.message ?? error).slice(0, 200) }, { level: 'warn' }); } catch { /* fail-soft */ }
822
+ }
799
823
  try { debug.log('task-agent', 'launched', { taskId: id, seat: card.seat ?? null, runId: boundRunId ?? null }); } catch { /* fail-soft */ }
800
824
  if (boundRunId) {
801
825
  try { debug.log('task-agent', 'card-run-bound', { card: id, runId: boundRunId, launchId }); } catch { /* fail-soft */ }