elanous 0.2.18 → 0.2.20

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 (350) 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/{1307-30dff22c9e1f3073.js → 1307-77fad80cf54477c6.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/1612-f60c0c93ec773f81.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/2116-6429c188eb846ab8.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/224-e7be8f762fe74986.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/2489-ff55a35b202c0f94.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/2549-89c518a4c8ed4d00.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/3857-995ea9f5ef33d71e.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/4144-fad095b48a1862ee.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/5755-ea7ed88eb956f47b.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/5977-94ed6f78e9814b2b.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/7748-6e51bfe927019f5c.js +25 -0
  14. package/apps/pwa/out/_next/static/chunks/8401-35e94e9091f5eb8d.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/9247-c1753a059e7bac08.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/a9f2b54d-33c0689b70ed8cf3.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/approvals/{page-d92f7ee3994e84c7.js → page-c855c24de7db74fe.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-4733c967b56a4277.js → page-85b316bb4235980b.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/board/{page-d22fd849cc22f104.js → page-9b2b26a33cb2cd42.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-bb742f995c6769e7.js → page-c9955fde67b1bcb5.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/bots/{page-02b073a2ac793b83.js → page-7a852956c74015a3.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/ceo/page-c5a5c621be0e04ae.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/chat/{page-8a9b3594bc23289b.js → page-4ba4abe096b6c2a8.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/consult/page-575d4a8b4f7becb2.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/control/{page-308111abadf35670.js → page-f5d56ff5f45ad164.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/decisions/page-2260e9eef0357421.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-5d97b2901e0d1ea6.js → page-7c058069c5e7fa63.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/editor/page-dc5da0b8a2edb8a4.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/exec/{page-8c3d8e63ae2e1c98.js → page-466bf0ee16ad1432.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/field/{page-9b72a07d424701c6.js → page-a23bc71713bcc08f.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/inside/page-ed4a6f799f005d09.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/intake/{page-a572f58cd3ca5864.js → page-9cd565005b93aca6.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/layout-55f0f40c6db95f15.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/live/{page-be5ea965a07fbc41.js → page-474cb5888ea0e8f3.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/loops/page-35bfec2e631d3eae.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/market/{page-fa244bd146a00ae8.js → page-9ca21f9ad3208a61.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-44d56d6391965a6a.js → page-f9ccb1b1adbea14d.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/morning/{page-83a700d4c754d802.js → page-ec102c9b8b691e37.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/{not-found-7b3170f374f77b9d.js → not-found-a9c2cfa8dda922a5.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/observatory/page-6da35144878feb6d.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-f6055495e9ed7ea8.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-25204694fa9984a5.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-a60e035f151d84d3.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/outputs/page-189c587f36874538.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/page-c56634bde705a65d.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1467047104141297.js → page-db03ae3589a81c9e.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-4a6463b97f0ad94f.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-102310930791197a.js → page-75bffecc9aa1004e.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-a8d2fe0c669e0de4.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/settings/{page-6ed0b73bae8351d8.js → page-31d5b875760bb62d.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-c26fa10aac6c21c4.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/setup/{page-43336055451f6e37.js → page-68793c6e10331d66.js} +1 -1
  53. package/apps/pwa/out/_next/static/chunks/app/share/{page-3cc47dca0922ab9a.js → page-46d5201d2207a511.js} +1 -1
  54. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-a77ee355a2304ff0.js → page-d81c07a7b4f618ae.js} +1 -1
  55. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-b17cd46cf0e9b8cf.js → page-be1c4e8697f22360.js} +1 -1
  56. package/apps/pwa/out/_next/static/chunks/app/term/page-cf0a4b4abead71ea.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/today/page-a4717e2c4bf7a28f.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/trace/{page-7bf54c9bb9274af8.js → page-0fd895fab9b62c1b.js} +1 -1
  59. package/apps/pwa/out/_next/static/chunks/app/vault/page-8b6c1454a8e8f306.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-c6e4364d9de8f717.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/workflows/page-0acc8e198f7b0dfb.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-134da468538baf48.js → page-34882c84f84021c5.js} +1 -1
  63. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-0506a1e0e4bdb2f5.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/{webpack-c2782b330447ffb6.js → webpack-c260571513bf43c2.js} +1 -1
  65. package/apps/pwa/out/_next/static/css/{e99e3cff94ced78a.css → 599369d853c61df7.css} +1 -1
  66. package/apps/pwa/out/_next/static/css/c23d959d3ee15e57.css +1 -0
  67. package/apps/pwa/out/_next/static/css/d3427996da11e480.css +1 -0
  68. package/apps/pwa/out/approvals/index.html +1 -1
  69. package/apps/pwa/out/approvals/index.txt +14 -14
  70. package/apps/pwa/out/autopilot/index.html +1 -1
  71. package/apps/pwa/out/autopilot/index.txt +14 -14
  72. package/apps/pwa/out/board/index.html +1 -1
  73. package/apps/pwa/out/board/index.txt +14 -14
  74. package/apps/pwa/out/botlab/index.html +1 -1
  75. package/apps/pwa/out/botlab/index.txt +14 -14
  76. package/apps/pwa/out/bots/index.html +1 -1
  77. package/apps/pwa/out/bots/index.txt +14 -14
  78. package/apps/pwa/out/ceo/index.html +1 -1
  79. package/apps/pwa/out/ceo/index.txt +14 -14
  80. package/apps/pwa/out/chat/index.html +1 -1
  81. package/apps/pwa/out/chat/index.txt +14 -14
  82. package/apps/pwa/out/consult/index.html +1 -1
  83. package/apps/pwa/out/consult/index.txt +14 -14
  84. package/apps/pwa/out/control/index.html +1 -1
  85. package/apps/pwa/out/control/index.txt +14 -14
  86. package/apps/pwa/out/decisions/index.html +1 -1
  87. package/apps/pwa/out/decisions/index.txt +14 -14
  88. package/apps/pwa/out/design-check/index.html +1 -1
  89. package/apps/pwa/out/design-check/index.txt +14 -14
  90. package/apps/pwa/out/editor/index.html +1 -1
  91. package/apps/pwa/out/editor/index.txt +16 -15
  92. package/apps/pwa/out/exec/index.html +1 -1
  93. package/apps/pwa/out/exec/index.txt +14 -14
  94. package/apps/pwa/out/field/index.html +1 -1
  95. package/apps/pwa/out/field/index.txt +14 -14
  96. package/apps/pwa/out/healthz +1 -1
  97. package/apps/pwa/out/index.html +1 -1
  98. package/apps/pwa/out/index.txt +14 -14
  99. package/apps/pwa/out/inside/index.html +1 -1
  100. package/apps/pwa/out/inside/index.txt +16 -15
  101. package/apps/pwa/out/intake/index.html +1 -1
  102. package/apps/pwa/out/intake/index.txt +14 -14
  103. package/apps/pwa/out/live/index.html +1 -1
  104. package/apps/pwa/out/live/index.txt +14 -14
  105. package/apps/pwa/out/loops/index.html +1 -1
  106. package/apps/pwa/out/loops/index.txt +24 -23
  107. package/apps/pwa/out/market/index.html +1 -1
  108. package/apps/pwa/out/market/index.txt +14 -14
  109. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  110. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  111. package/apps/pwa/out/morning/index.html +1 -1
  112. package/apps/pwa/out/morning/index.txt +14 -14
  113. package/apps/pwa/out/observatory/index.html +1 -1
  114. package/apps/pwa/out/observatory/index.txt +14 -14
  115. package/apps/pwa/out/ops/checklist/index.html +1 -1
  116. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  117. package/apps/pwa/out/ops/release/index.html +1 -1
  118. package/apps/pwa/out/ops/release/index.txt +14 -14
  119. package/apps/pwa/out/ops/seats/index.html +1 -1
  120. package/apps/pwa/out/ops/seats/index.txt +14 -14
  121. package/apps/pwa/out/outputs/index.html +1 -1
  122. package/apps/pwa/out/outputs/index.txt +14 -14
  123. package/apps/pwa/out/reflection/index.html +1 -1
  124. package/apps/pwa/out/reflection/index.txt +15 -15
  125. package/apps/pwa/out/scheduler/index.html +1 -1
  126. package/apps/pwa/out/scheduler/index.txt +14 -14
  127. package/apps/pwa/out/sessions/index.html +1 -1
  128. package/apps/pwa/out/sessions/index.txt +14 -14
  129. package/apps/pwa/out/settings/devices/index.html +1 -1
  130. package/apps/pwa/out/settings/devices/index.txt +14 -14
  131. package/apps/pwa/out/settings/index.html +1 -1
  132. package/apps/pwa/out/settings/index.txt +14 -14
  133. package/apps/pwa/out/setup/done/index.html +1 -1
  134. package/apps/pwa/out/setup/done/index.txt +14 -14
  135. package/apps/pwa/out/setup/index.html +1 -1
  136. package/apps/pwa/out/setup/index.txt +14 -14
  137. package/apps/pwa/out/share/index.html +1 -1
  138. package/apps/pwa/out/share/index.txt +14 -14
  139. package/apps/pwa/out/showroom/index.html +1 -1
  140. package/apps/pwa/out/showroom/index.txt +14 -14
  141. package/apps/pwa/out/tasks/index.html +1 -1
  142. package/apps/pwa/out/tasks/index.txt +14 -14
  143. package/apps/pwa/out/term/index.html +1 -1
  144. package/apps/pwa/out/term/index.txt +14 -14
  145. package/apps/pwa/out/today/index.html +1 -1
  146. package/apps/pwa/out/today/index.txt +14 -14
  147. package/apps/pwa/out/trace/index.html +1 -1
  148. package/apps/pwa/out/trace/index.txt +14 -14
  149. package/apps/pwa/out/vault/index.html +1 -1
  150. package/apps/pwa/out/vault/index.txt +14 -14
  151. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  152. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  153. package/apps/pwa/out/workflows/index.html +1 -1
  154. package/apps/pwa/out/workflows/index.txt +14 -14
  155. package/apps/pwa/out/workspace/index.html +1 -1
  156. package/apps/pwa/out/workspace/index.txt +14 -14
  157. package/apps/pwa/out/worktrees/index.html +1 -1
  158. package/apps/pwa/out/worktrees/index.txt +14 -14
  159. package/graphs/demo/editor-recipes.yaml +22 -0
  160. package/graphs/ops/guardian.yaml +26 -0
  161. package/graphs/ops/recipes.yaml +3 -0
  162. package/graphs/retro/daily.yaml +25 -0
  163. package/graphs/retro/recipes.yaml +24 -0
  164. package/package.json +1 -1
  165. package/scripts/ask-marker-check.ts +3 -1
  166. package/scripts/coord-channel-watch.sh +2 -2
  167. package/scripts/discord-seat-probe.ts +3 -1
  168. package/scripts/land-global-checks.ts +89 -0
  169. package/scripts/lib/pch-regress-map.ts +24 -0
  170. package/scripts/morning-report.ts +18 -4
  171. package/scripts/pch-regress.ts +88 -0
  172. package/scripts/release-loop/gate-diff.ts +18 -0
  173. package/scripts/release-loop/gate-node.ts +628 -65
  174. package/scripts/release-loop/gate-partial.ts +106 -0
  175. package/scripts/release-loop/gate-timing.ts +229 -0
  176. package/scripts/release-loop/preflight.ts +6 -4
  177. package/scripts/release-loop/publish-window.ts +141 -0
  178. package/scripts/release-loop/release-docs.ts +1 -4
  179. package/scripts/release-loop/release-universe.ts +38 -0
  180. package/scripts/release-loop/resume-release.ts +67 -5
  181. package/scripts/release-loop/shard-plan.ts +65 -0
  182. package/scripts/release-loop/unattended-release.ts +12 -2
  183. package/scripts/release-story/draft.ts +5 -1
  184. package/scripts/retro/shadow-compare.ts +71 -0
  185. package/scripts/rhythm/daily.ts +6 -1
  186. package/scripts/video/scene-table.ts +159 -0
  187. package/src/acp/core-turn-bridge.ts +39 -2
  188. package/src/agent-mission/driver.ts +34 -6
  189. package/src/autopilot/build/llm-conflict-merge.ts +132 -7
  190. package/src/autopilot/build/merge-intent.ts +135 -7
  191. package/src/away/presence.ts +40 -0
  192. package/src/away/release-watch.ts +272 -0
  193. package/src/chat/index.ts +2 -1
  194. package/src/chat/short-question.ts +3 -40
  195. package/src/cli/away-cli.ts +68 -0
  196. package/src/cli/config-drift.ts +513 -0
  197. package/src/cli/guardian-cli.ts +49 -0
  198. package/src/cli/intake-cli.ts +13 -7
  199. package/src/cli/log-event-names.ts +5 -0
  200. package/src/cli/pod-cli.ts +5 -1
  201. package/src/cli/pr-cli.ts +20 -0
  202. package/src/cli/pty-drive-cli.ts +14 -3
  203. package/src/cli/release-cli.ts +52 -12
  204. package/src/cli/retro-cli.ts +78 -0
  205. package/src/cli/setup-cli.ts +31 -1
  206. package/src/cli/tasks-cli.ts +193 -14
  207. package/src/context-bus/context-now-public.ts +1 -1
  208. package/src/context-bus/context-now-surfaces.ts +16 -6
  209. package/src/context-bus/context-now.ts +97 -3
  210. package/src/core-turn/short-question.ts +100 -0
  211. package/src/dashboard/dashboard-status-lines.ts +24 -4
  212. package/src/dashboard/index.ts +50 -5
  213. package/src/dashboard/input/slash-executor.ts +6 -2
  214. package/src/dashboard/slash-runtime/dashboard-handlers.ts +68 -40
  215. package/src/dashboard/slash-runtime/decide-slash.ts +60 -0
  216. package/src/dashboard/slash-runtime/help-from-registry.ts +62 -23
  217. package/src/dashboard/slash-runtime/help-groups.ts +44 -0
  218. package/src/dashboard/slash-runtime/model-choices.ts +125 -0
  219. package/src/domains/guardian-tick.ts +138 -0
  220. package/src/domains/outbound-alert.ts +44 -13
  221. package/src/domains/outbound-log-probe.ts +6 -1
  222. package/src/domains/schedule-registry.ts +23 -0
  223. package/src/domains/telegram-kind-route.ts +39 -0
  224. package/src/domains/watch-default-scheduler.ts +95 -0
  225. package/src/esc-abort-gate.ts +44 -0
  226. package/src/graph-runner/graph-run-api.ts +205 -0
  227. package/src/graph-runner/runner.ts +36 -6
  228. package/src/harness/harness-cli-command.ts +420 -51
  229. package/src/harness/harness-queue.ts +315 -25
  230. package/src/harness/harness-salvage-cli.ts +140 -0
  231. package/src/harness/harness-worktree-add.ts +68 -0
  232. package/src/harness/ledger-write-guard.ts +6 -16
  233. package/src/hq/fence-outcomes.ts +63 -0
  234. package/src/hq/fence-wrapper.ts +33 -0
  235. package/src/hq/hq.ts +17 -2
  236. package/src/index.ts +118 -6
  237. package/src/instance/test-write-guard-spy-first.fixture.ts +13 -0
  238. package/src/instance/test-write-guard.ts +200 -0
  239. package/src/intake-plane/digest.ts +39 -15
  240. package/src/intake-plane/harness-target.ts +4 -3
  241. package/src/intake-plane/intake-sources.ts +1 -0
  242. package/src/intake-plane/items.ts +1 -0
  243. package/src/loops/orchestrator/degradation.ts +57 -0
  244. package/src/loops/orchestrator/finish-rate.ts +33 -3
  245. package/src/loops/orchestrator/tick.ts +166 -45
  246. package/src/maturity/feature-maturity.ts +6 -4
  247. package/src/maturity/tui-slash-maturity.ts +1 -0
  248. package/src/mss/logging/log-store.ts +3 -0
  249. package/src/nexus/api/graph-kinds.ts +10 -4
  250. package/src/nexus/api/graphs-api.ts +178 -6
  251. package/src/nexus/api/http-server.ts +54 -8
  252. package/src/nexus/api/loop-edges.ts +292 -17
  253. package/src/nexus/api/ops-api.ts +112 -29
  254. package/src/nexus/api/ops-board-route.ts +48 -0
  255. package/src/nexus/index.ts +7 -0
  256. package/src/onboarding/entry-hints.ts +5 -0
  257. package/src/onboarding.ts +24 -6
  258. package/src/release-loop/checklist.ts +2 -0
  259. package/src/release-loop/feature-store.ts +68 -18
  260. package/src/release-loop/gate-shards.ts +158 -0
  261. package/src/release-loop/light-rc.ts +173 -0
  262. package/src/release-loop/placement.ts +51 -17
  263. package/src/release-loop/release-schedule.ts +6 -2
  264. package/src/release-loop/rubric.ts +57 -1
  265. package/src/retro/daily.ts +372 -0
  266. package/src/seat-loop/op-decide.ts +173 -0
  267. package/src/seat-loop/seat-loop.ts +60 -3
  268. package/src/seat-loop/seat-report.ts +58 -3
  269. package/src/self-dev/ask-launch-flow.ts +5 -4
  270. package/src/self-dev/ask-launch-io.ts +49 -1
  271. package/src/self-dev/ask-path-candidates.ts +40 -2
  272. package/src/self-dev/dev-cli.ts +36 -0
  273. package/src/self-dev/draft-sweep.ts +371 -16
  274. package/src/self-dev/draft-triage-rules.ts +19 -4
  275. package/src/self-dev/orchestrate.ts +1 -0
  276. package/src/self-dev/pr-preview.ts +247 -0
  277. package/src/self-dev/shard-run.ts +23 -7
  278. package/src/self-implement/blocked-draft-policy.ts +101 -0
  279. package/src/self-implement/execution-clarification.ts +11 -0
  280. package/src/self-implement/gate-cli.ts +29 -8
  281. package/src/self-implement/gate-env-retry.ts +83 -0
  282. package/src/self-implement/goal-author-clarification.ts +88 -13
  283. package/src/self-implement/goal-clarification-escalation.ts +2 -0
  284. package/src/self-implement/orchestrator.ts +220 -6
  285. package/src/self-implement/run-stop.ts +123 -0
  286. package/src/self-implement/run-ttl.ts +258 -0
  287. package/src/self-implement/salvage-branches.ts +254 -0
  288. package/src/self-implement/salvage-retention.ts +123 -0
  289. package/src/self-implement/seams.ts +4 -0
  290. package/src/self-implement/self-implement-cli.ts +2 -0
  291. package/src/self-implement/sibling-resync.ts +223 -0
  292. package/src/self-implement/stop-autoheal.ts +204 -0
  293. package/src/task-agent/actions.ts +63 -2
  294. package/src/task-agent/board.ts +132 -0
  295. package/src/task-agent/card-evidence.ts +292 -0
  296. package/src/task-agent/completion-evidence.ts +126 -0
  297. package/src/task-agent/judge.ts +25 -0
  298. package/src/task-agent/live-moves.ts +276 -0
  299. package/src/task-agent/mission.ts +33 -7
  300. package/src/task-agent/shadow.ts +192 -15
  301. package/src/task-agent/task-hand.ts +124 -17
  302. package/src/task-orchestrator/surfaces/pod-command-job.ts +82 -8
  303. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +4 -0
  304. package/src/task-orchestrator/surfaces/pod-install-slots.ts +89 -0
  305. package/src/task-orchestrator/surfaces/pod-lease.ts +233 -21
  306. package/src/task-orchestrator/surfaces/pod-pool.ts +155 -34
  307. package/src/task-orchestrator/surfaces/self-implement-pod.ts +21 -5
  308. package/src/telegram-away-command.ts +38 -0
  309. package/src/telegram-commands.ts +52 -0
  310. package/src/telegram-report-log-probe.ts +21 -0
  311. package/src/telegram-report.ts +27 -3
  312. package/src/testing/real-cli-spawn.ts +106 -0
  313. package/src/tui-client/dashboard-session.ts +1 -0
  314. package/src/user-config.ts +190 -5
  315. package/src/version/packed-revision.json +1 -1
  316. package/apps/pwa/out/_next/static/chunks/224-798346143007ff65.js +0 -1
  317. package/apps/pwa/out/_next/static/chunks/3857-4f936cdb30322aa6.js +0 -1
  318. package/apps/pwa/out/_next/static/chunks/4144-a0041c8dc3be57ac.js +0 -1
  319. package/apps/pwa/out/_next/static/chunks/4554-5761612531d0146b.js +0 -1
  320. package/apps/pwa/out/_next/static/chunks/5977-a11f37254196b659.js +0 -1
  321. package/apps/pwa/out/_next/static/chunks/5994-ae92c0de046ab9ba.js +0 -1
  322. package/apps/pwa/out/_next/static/chunks/8401-8615a660c84cdb03.js +0 -1
  323. package/apps/pwa/out/_next/static/chunks/8868-10a3a4440fb0ccd8.js +0 -25
  324. package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +0 -1
  325. package/apps/pwa/out/_next/static/chunks/a9f2b54d-7865aba0f4d2ee49.js +0 -1
  326. package/apps/pwa/out/_next/static/chunks/app/ceo/page-60c3da708f9a0ccc.js +0 -1
  327. package/apps/pwa/out/_next/static/chunks/app/consult/page-a0eb02638b1e6f31.js +0 -1
  328. package/apps/pwa/out/_next/static/chunks/app/decisions/page-6eaf7bac37830c21.js +0 -1
  329. package/apps/pwa/out/_next/static/chunks/app/editor/page-c39554c321576982.js +0 -1
  330. package/apps/pwa/out/_next/static/chunks/app/inside/page-bd5d586429167fea.js +0 -1
  331. package/apps/pwa/out/_next/static/chunks/app/layout-59b7834d497c2255.js +0 -1
  332. package/apps/pwa/out/_next/static/chunks/app/loops/page-c130b63862437814.js +0 -1
  333. package/apps/pwa/out/_next/static/chunks/app/observatory/page-5f23d0c66a3b4793.js +0 -1
  334. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-a3c58f5c72a5f3f9.js +0 -1
  335. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-a2c4625596f8d69c.js +0 -1
  336. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-0e9099531d239f11.js +0 -1
  337. package/apps/pwa/out/_next/static/chunks/app/outputs/page-e8e1ecaea7cd71d2.js +0 -1
  338. package/apps/pwa/out/_next/static/chunks/app/page-a1b7ffed710a93e0.js +0 -1
  339. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-2da6f9b8a38c6978.js +0 -1
  340. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-f331e38fefc00393.js +0 -1
  341. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-44d5f767491692bf.js +0 -1
  342. package/apps/pwa/out/_next/static/chunks/app/term/page-61553851aa606146.js +0 -1
  343. package/apps/pwa/out/_next/static/chunks/app/today/page-a1f585e154e61c59.js +0 -1
  344. package/apps/pwa/out/_next/static/chunks/app/vault/page-5150ef17b5383a57.js +0 -1
  345. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-b9d8ce70792c1672.js +0 -1
  346. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a5a3de231f2cc7d5.js +0 -1
  347. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-31ac4afa2dcd915c.js +0 -1
  348. package/apps/pwa/out/_next/static/css/7fa358fec7da7c47.css +0 -1
  349. /package/apps/pwa/out/_next/static/{P6YTVgzuZz5oTZhY4Din_ → Bam1YziK2iH9bN4K0utGk}/_buildManifest.js +0 -0
  350. /package/apps/pwa/out/_next/static/{P6YTVgzuZz5oTZhY4Din_ → Bam1YziK2iH9bN4K0utGk}/_ssgManifest.js +0 -0
@@ -9,6 +9,8 @@
9
9
  * - @ssh호스트 원격 클러스터면 그 기계 — 이미지 판 대조·반입(`docker load` ⊕ `k3d image import`)에 쓴다
10
10
  * - 상한 이 노드에 동시에 둘 Job 수(기본 2)
11
11
  * Job 마다 «빈 자리 비율»이 가장 큰 노드를 고른다(동률은 앞 순위 · 측정 실패 노드는 측정된 노드가 다 찼을 때만). 다 차면 자리가 날 때까지 기다린다.
12
+ * POOL-LITE(10-07): 골 종류를 주면 lite·문서·측정 골은 상한이 가장 작은 노드(node-c), 구현 골은 가장 큰 노드(node-b)를 먼저 쓰고,
13
+ * 그 노드가 차면 빈 자리 비율로 넘친다. 상한 0 노드는 «꺼짐» — 해석에서 빠지고 절대 고르지 않는다.
12
14
  * 노드 선택의 진행 중 수는 로컬 프로세스 기준이다. 발사 허가는 별도의 lease 측정으로
13
15
  * 다른 호스트의 Running/Pending 및 메모리 예약까지 고려한다.
14
16
  * 풀을 안 주면 종전처럼 «현재 컨텍스트» 하나다(동작 불변).
@@ -21,7 +23,7 @@ import { debug } from '../../debug/log.js';
21
23
  import { PodLeaseAdmission, type PodLeasePredecessor, type PodLeaseRelease } from '../../pod-lease/admission.js';
22
24
  import { predecessorState, type PredecessorState } from '../../pod-lease/dependency-state.js';
23
25
  import { HostPoolLease, leaseHasPendingPod } from '../../pod-lease/host-lease.js';
24
- import { measurePoolLease, recommendConcurrency, type PodLeaseMember, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
26
+ import { harnessCpuSlotsBesideGate, measurePoolLease, recommendConcurrency, type PodLeaseMember, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
25
27
 
26
28
  export interface PodPoolMember {
27
29
  readonly context: string;
@@ -111,7 +113,9 @@ export function parsePodPool(spec: string): PodPoolMember[] {
111
113
  const m = MEMBER.exec(part);
112
114
  if (!m) throw new Error(`--pod-pool: 못 읽는 노드 「${part}」 — 형식 컨텍스트[@ssh호스트][:상한][#k3d-레지스트리:포트]`);
113
115
  const capacity = m[3] === undefined ? 2 : Number(m[3]);
114
- if (!Number.isInteger(capacity) || capacity < 1) throw new Error(`--pod-pool: 상한은 1 이상 — 「${part}」`);
116
+ // POOL-LITE (10-07): `:0` means «this member is off» — a `node-c:0` entry once threw here and broke every launch.
117
+ // It is dropped below (never chosen); only a pool with no live member left is an error.
118
+ if (!Number.isInteger(capacity) || capacity < 0) throw new Error(`--pod-pool: 상한은 1 이상 — 「${part}」`);
115
119
  if (m[5] && Number(m[5]) > 65535) throw new Error(`--pod-pool: 레지스트리 포트는 65535 이하 — 「${part}」`);
116
120
  const context = m[1]!;
117
121
  const k3dCluster = context.startsWith('k3d-') ? context.slice(4) : 'elanous-pool';
@@ -119,7 +123,44 @@ export function parsePodPool(spec: string): PodPoolMember[] {
119
123
  });
120
124
  if (members.length === 0) throw new Error('--pod-pool: 노드가 없다');
121
125
  if (new Set(members.map((m) => m.context)).size !== members.length) throw new Error('--pod-pool: 컨텍스트가 겹친다');
122
- return members;
126
+ const live = members.filter((m) => m.capacity > 0);
127
+ if (live.length === 0) throw new Error(`--pod-pool: 상한은 1 이상 — 상한 0 이 아닌 노드가 없다 「${spec}」`);
128
+ if (live.length !== members.length) {
129
+ debug.log('pod.pool', 'member-disabled', { members: members.filter((m) => m.capacity <= 0).map((m) => m.context), reason: 'capacity-0' });
130
+ }
131
+ return live;
132
+ }
133
+
134
+ /**
135
+ * POOL-LITE (10-07) — what kind of Job is being placed. `lite` = lite memory tier or a docs/measurement goal;
136
+ * `standard` = an implementation goal. Absent kind keeps the plain free-share placement unchanged.
137
+ */
138
+ export type PodPlacementKind = 'lite' | 'standard';
139
+
140
+ export interface PodPlacement {
141
+ readonly member: string;
142
+ readonly kind: PodPlacementKind | null;
143
+ /** `preferred` = landed on the kind's preferred member · `overflow` = preferred member(s) full/skipped · `share` = no preference applies. */
144
+ readonly outcome: 'preferred' | 'overflow' | 'share';
145
+ readonly preferred: readonly string[];
146
+ /** One line for launch output and the run ledger. */
147
+ readonly reason: string;
148
+ }
149
+
150
+ /**
151
+ * Preferred members for a placement kind: lite → the smallest-capacity member(s) (node-c), standard → the largest (node-b).
152
+ * Capacity is the operator's own statement of member size (`pool-node-b@node-b:40,pool-node-c@node-c:6`), so no new spec syntax.
153
+ * When every member has the same capacity there is no small/big member and no preference (empty list).
154
+ */
155
+ export function preferredMembers(members: readonly PodPoolMember[], kind: PodPlacementKind | null | undefined): string[] {
156
+ const live = members.filter((m) => m.capacity > 0);
157
+ if (!kind || live.length < 2) return [];
158
+ const caps = live.map((m) => m.capacity);
159
+ const min = Math.min(...caps);
160
+ const max = Math.max(...caps);
161
+ if (min === max) return [];
162
+ const target = kind === 'lite' ? min : max;
163
+ return live.filter((m) => m.capacity === target).map((m) => m.context);
123
164
  }
124
165
 
125
166
  /** 스펙 해석 순서: 명시 인자 → `ELANOUS_POD_POOL` → 없음(null = 현재 컨텍스트 하나). */
@@ -178,12 +219,21 @@ export function podPoolHostLease(members: readonly PodPoolMember[]): HostPoolLea
178
219
  export class PodPoolScheduler {
179
220
  private readonly inflight = new Map<string, number>();
180
221
  private readonly admission: PodLeaseAdmission;
222
+ /**
223
+ * GATE-ADMIT-EXEMPT — the gate's own shard Jobs (GATE-MEM-ADMIT) queue here: memory admission without the
224
+ * GATE-RESERVE-AUTO CPU bound, which exists to protect the gate from harness Pods, not from itself.
225
+ */
226
+ private readonly gateAdmission: PodLeaseAdmission;
181
227
  private readonly outstanding = new Set<PodLeaseRelease>();
182
228
  private lastFree = 0;
183
229
  private budget = 0;
230
+ /** GATE-RESERVE-AUTO — local permits whose Pod was observed (Running/terminal): already inside the CPU reading. */
231
+ private seenActive = 0;
184
232
  private initialized = false;
185
233
  private readonly measure: () => Promise<PoolLeaseRecommendation>;
186
234
  private lastRaw: PoolLeaseRecommendation | null = null;
235
+ /** GATE-ADMIT-EXEMPT — the same reading before the gate CPU cap (memory/limitedBy unchanged); gate callers read it. */
236
+ private lastUncapped: PoolLeaseRecommendation | null = null;
187
237
  private readonly hostLease: HostPoolLease;
188
238
  private readonly pollMs: number;
189
239
  private readonly occupancy: () => Record<string, MemberOccupancy> | Promise<Record<string, MemberOccupancy>>;
@@ -192,18 +242,30 @@ export class PodPoolScheduler {
192
242
  this.pollMs = options.pollMs ?? 15_000;
193
243
  // One lease directory per pool (contexts, not caps) — every CLI process launching into the same clusters shares it.
194
244
  this.hostLease = options.hostLease ?? podPoolHostLease(members);
195
- const measureStatus = options.status ?? (() => {
196
- const measure = measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) });
197
- return recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 });
198
- });
199
- this.measure = async () => measureStatus();
245
+ // GATE-RESERVE-AUTO: the gate reservation is returned beside the recommendation, never folded into it — the
246
+ // delta budget below must not see a gate start/end as a free-slot change (that made the result order-dependent).
247
+ const measureStatus: () => Promise<{ status: PoolLeaseRecommendation; gateSlots: number | null | undefined }> = options.status
248
+ ? async () => ({ status: await options.status!(), gateSlots: undefined })
249
+ : async () => {
250
+ // POD-ADMIT-BY-USAGE: admission reads observed Pod usage (`kubectl top`); tryAcquire's occupancy read does not.
251
+ const measure = measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}), measureUsage: true });
252
+ return { status: recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 }), gateSlots: harnessCpuSlotsBesideGate(measure) };
253
+ };
254
+ /** Cluster-level additional slots for the cross-process host lease: the measured free slots, capped by the gate's CPU. */
255
+ const capped = ({ status, gateSlots }: { status: PoolLeaseRecommendation; gateSlots: number | null | undefined }): PoolLeaseRecommendation =>
256
+ gateSlots === undefined || status.recommended === null ? status
257
+ : gateSlots === null ? { ...status, recommended: null, reason: '측정 불가: gate CPU' }
258
+ : { ...status, recommended: Math.min(status.recommended, gateSlots) };
259
+ this.measure = async () => { const reading = await measureStatus(); this.lastUncapped = reading.status; return capped(reading); };
200
260
  // An injected status (tests, callers with their own measurement) without memberMemory never shells out to kubectl here.
201
261
  this.memberMemory = options.memberMemory ?? (options.status ? (() => []) : () => measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }).members
202
262
  .map((m: PodLeaseMember) => ({ context: m.context, allocatableMemoryBytes: m.allocatableMemoryBytes })));
203
263
  this.occupancy = options.occupancy ?? (() => measureMemberOccupancy(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }));
204
- this.admission = new PodLeaseAdmission({ status: async () => {
205
- const measured = await measureStatus();
206
- this.lastRaw = measured;
264
+ const decide = async (gate: boolean): Promise<PoolLeaseRecommendation> => {
265
+ const reading = await measureStatus();
266
+ const measured = reading.status;
267
+ this.lastRaw = capped(reading);
268
+ this.lastUncapped = measured;
207
269
  const pendingJobs = measured.pendingJobs ?? [];
208
270
  const ownStart = this.hostLease.selfStart();
209
271
  const transferId = process.env.ELANOUS_POD_RESERVED_LEASE;
@@ -219,16 +281,40 @@ export class PodPoolScheduler {
219
281
  this.budget = Math.max(0, this.budget + decision.recommended - (this.initialized ? this.lastFree : 0));
220
282
  this.initialized = true;
221
283
  this.lastFree = decision.recommended;
222
- return { ...decision, recommended: this.admission.snapshot().active + this.budget };
223
- }, ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged: options.dependencyMerged ?? ((after, signal) => predecessorState(after, {}, signal)) });
284
+ // Each queue's limit is its own permits plus the shared remaining budget (every permit already took one from it).
285
+ const active = (gate ? this.gateAdmission : this.admission).snapshot().active;
286
+ const total = active + this.budget;
287
+ if (reading.gateSlots === undefined) return { ...decision, recommended: total };
288
+ if (gate) {
289
+ debug.log('pod.pool', 'gate-reserve', { gateSlots: reading.gateSlots, active, budget: this.budget, recommended: total, exempt: 'gate-caller' });
290
+ return { ...decision, recommended: total };
291
+ }
292
+ // GATE-RESERVE-AUTO: while a gate shard Job is active, harness admission is also bounded by the CPU left after
293
+ // the gate. The bound is absolute (this reading ⊕ current local permits). Pods already in the reading — observed
294
+ // ones (`seenActive`) and ones measured Pending (own leases with a pending Pod) — are not subtracted again;
295
+ // the remaining local permits and other processes' leases without a measured Pod (`others`) are.
296
+ const ownMeasuredPending = this.hostLease.live().filter((r) => r.pid === process.pid && r.startedAt === ownStart && leaseHasPendingPod(r, pendingJobs)).length;
297
+ const unseen = Math.max(0, active - this.seenActive - ownMeasuredPending);
298
+ const cpuFree = reading.gateSlots === null ? 0 : Math.max(0, reading.gateSlots - others + (transferIsOther ? 1 : 0) - unseen);
299
+ const recommended = Math.min(total, active + cpuFree);
300
+ if (recommended < total) debug.log('pod.pool', 'gate-reserve', { gateSlots: reading.gateSlots, active, unseen, ownMeasuredPending, others, budget: this.budget, recommended });
301
+ return reading.gateSlots === null
302
+ ? { ...decision, recommended, reason: '측정 불가: gate CPU' }
303
+ : { ...decision, recommended };
304
+ };
305
+ const dependencyMerged = options.dependencyMerged ?? ((after: PodLeasePredecessor, signal?: AbortSignal) => predecessorState(after, {}, signal));
306
+ this.admission = new PodLeaseAdmission({ status: () => decide(false), ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged });
307
+ this.gateAdmission = new PodLeaseAdmission({ status: () => decide(true), ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged });
224
308
  }
225
309
  /** FIFO gate within this process, then a host lease so independent CLI processes cannot over-admit the same pool. */
226
- async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease & { applied: (job: string, context: string, namespace: string) => void; observed: () => void }> {
227
- const local = await this.acquireLocalAdmission(signal, after);
310
+ async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor, opts: { gate?: boolean } = {}): Promise<PodLeaseRelease & { applied: (job: string, context: string, namespace: string) => void; observed: () => void }> {
311
+ const gate = opts.gate === true;
312
+ const local = await this.acquireLocalAdmission(signal, after, gate);
228
313
  try {
229
314
  for (;;) {
230
315
  if (signal?.aborted) throw new Error('pod lease admission aborted');
231
- const free = this.lastRaw?.recommended ?? null;
316
+ // GATE-ADMIT-EXEMPT: a gate caller is not bounded by its own gate's CPU reservation (memory still bounds it).
317
+ const free = (gate ? this.lastUncapped : this.lastRaw)?.recommended ?? null;
232
318
  // Same check as the local gate, but atomic across processes: other authoring and pre-Running leases hold slots.
233
319
  const reservedId = process.env.ELANOUS_POD_RESERVED_LEASE;
234
320
  const transferred = reservedId ? this.hostLease.claim(reservedId) : null;
@@ -241,9 +327,16 @@ export class PodPoolScheduler {
241
327
  if (lease) {
242
328
  let hostReleased = false;
243
329
  const releaseHost = () => { if (!hostReleased) { hostReleased = true; lease(); } };
244
- return Object.assign(() => { releaseHost(); local(); }, {
330
+ // GATE-RESERVE-AUTO: an observed Pod (Running or terminal) is in the CPU reading; until then it is subtracted.
331
+ let seen = false, done = false;
332
+ return Object.assign(() => {
333
+ if (done) return;
334
+ done = true;
335
+ if (seen) this.seenActive--;
336
+ releaseHost(); local();
337
+ }, {
245
338
  applied: (job: string, context: string, namespace: string) => lease.applied(job, context, namespace),
246
- observed: releaseHost,
339
+ observed: () => { if (!seen && !done) { seen = true; this.seenActive++; } releaseHost(); },
247
340
  });
248
341
  }
249
342
  await new Promise<void>((resolve, reject) => {
@@ -251,15 +344,15 @@ export class PodPoolScheduler {
251
344
  const onAbort = () => { clearTimeout(timer); reject(new Error('pod lease admission aborted')); };
252
345
  signal?.addEventListener('abort', onAbort, { once: true });
253
346
  });
254
- try { this.lastRaw = await this.measure(); } catch { this.lastRaw = null; }
347
+ try { this.lastRaw = await this.measure(); } catch { this.lastRaw = null; this.lastUncapped = null; }
255
348
  }
256
349
  } catch (error) {
257
350
  local();
258
351
  throw error;
259
352
  }
260
353
  }
261
- private acquireLocalAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease> {
262
- return this.admission.acquire(signal, after).then((release) => {
354
+ private acquireLocalAdmission(signal?: AbortSignal, after?: PodLeasePredecessor, gate = false): Promise<PodLeaseRelease> {
355
+ return (gate ? this.gateAdmission : this.admission).acquire(signal, after).then((release) => {
263
356
  this.budget = Math.max(0, this.budget - 1);
264
357
  this.outstanding.add(release);
265
358
  return () => {
@@ -289,32 +382,47 @@ export class PodPoolScheduler {
289
382
  if (!r) return null;
290
383
  return `recommended=${r.recommended ?? 'unknown'} limitedBy=${r.limitedBy ?? '-'} memorySlots=${r.memorySlots ?? '?'}${r.reason ? ` reason=${r.reason}` : ''}`;
291
384
  }
292
- admissionSnapshot(): ReturnType<PodLeaseAdmission['snapshot']> {
293
- return this.admission.snapshot();
385
+ /** The harness queue by default; `{ gate: true }` reads the gate-caller queue (GATE-ADMIT-EXEMPT). */
386
+ admissionSnapshot(opts: { gate?: boolean } = {}): ReturnType<PodLeaseAdmission['snapshot']> {
387
+ return (opts.gate ? this.gateAdmission : this.admission).snapshot();
294
388
  }
295
389
  /**
296
390
  * Pick the member with the largest free share: (capacity − measured cluster occupancy − this process's inflight) / capacity.
297
391
  * POOL-SPREAD (10-06): comparing absolute free slots always picked the big member (node-b 25 vs node-c 4), so the small
298
392
  * member idled until the big one was full. Comparing the free share fills members in proportion to their capacity.
299
393
  * A member whose occupancy could not be read falls back to inflight-only (never treated as zero occupied).
394
+ * `selfCapped` (POOLGLOBAL-REG 10-07): a caller with its own concurrency cap (the release gate) counts only its own Jobs
395
+ * — free = capacity − this scheduler's inflight, summed over members as the pool total — and reads no cluster occupancy.
396
+ * #24253 made every acquire subtract all Running+Pending harness goal Pods, so with ~24 goal Pods on the gate's 25-slot
397
+ * member the 0.2.15 gate launched its shards one at a time.
300
398
  * Ties keep the configured member order. Returns null when every readable member is full.
301
399
  */
302
- async tryAcquire(skip?: ReadonlySet<string>, onEmpty?: (reason: string) => void): Promise<PodPoolMember | null> {
400
+ async tryAcquire(skip?: ReadonlySet<string>, onEmpty?: (reason: string) => void, mode: { selfCapped?: boolean; kind?: PodPlacementKind | null; onPlacement?: (placement: PodPlacement) => void } = {}): Promise<PodPoolMember | null> {
401
+ const selfCapped = mode.selfCapped === true;
402
+ const kind = mode.kind ?? null;
403
+ // POOL-LITE: lite goals try the small member first, implementation goals the big one; full → overflow by free share.
404
+ const preferred = new Set(preferredMembers(this.members, kind));
303
405
  let reading: Record<string, MemberOccupancy>;
304
- try { reading = await this.occupancy(); }
406
+ // A self-capped caller's own Jobs are its inflight; other workloads' Pods are not its slots (no cluster read).
407
+ if (selfCapped) reading = Object.fromEntries(this.members.map((m) => [m.context, { occupied: 0 }]));
408
+ else try { reading = await this.occupancy(); }
305
409
  catch (error) {
306
410
  const reason = error instanceof Error ? error.message : String(error);
307
411
  reading = Object.fromEntries(this.members.map((m) => [m.context, { occupied: null, reason }]));
308
412
  }
309
413
  const free: Record<string, number> = {};
310
414
  const measured: Record<string, boolean> = {};
311
- let best: { member: PodPoolMember; slots: number; measured: boolean } | null = null;
415
+ let best: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean } | null = null;
312
416
  // A measured member always beats an unmeasured one (its share would read 100% and draw every Job to a node
313
417
  // that did not answer); unmeasured members compete only when no measured member has a free slot.
314
- const better = (cand: { member: PodPoolMember; slots: number; measured: boolean }) => !best
418
+ // Among equally measured members a preferred one (POOL-LITE) beats the rest; then the larger free share wins.
419
+ const better = (cand: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean }) => !best
315
420
  || (cand.measured !== best.measured ? cand.measured
316
- : cand.slots / cand.member.capacity > best.slots / best.member.capacity);
421
+ : cand.preferred !== best.preferred ? cand.preferred
422
+ : cand.slots / cand.member.capacity > best.slots / best.member.capacity);
317
423
  for (const m of this.members) {
424
+ // A capacity-0 member is off — never chosen (and no division by zero in the share).
425
+ if (m.capacity <= 0) continue;
318
426
  // LAUNCH-STALL: a member this launch cannot fit is skipped for this call only (other launches may fit it).
319
427
  if (skip?.has(m.context)) continue;
320
428
  const inflight = this.inflight.get(m.context) ?? 0;
@@ -325,17 +433,30 @@ export class PodPoolScheduler {
325
433
  const slots = m.capacity - occupied - inflight;
326
434
  free[m.context] = slots;
327
435
  if (slots <= 0) continue;
328
- const cand = { member: m, slots, measured: ok };
436
+ const cand = { member: m, slots, measured: ok, preferred: preferred.has(m.context) };
329
437
  if (better(cand)) best = cand;
330
438
  }
331
439
  if (!best) {
332
- onEmpty?.(`no free slot · free=${JSON.stringify(free)} · measured=${JSON.stringify(measured)}${skip?.size ? ` · skipped=${[...skip].join(',')}` : ''}`);
440
+ onEmpty?.(`no free slot · free=${JSON.stringify(free)} · measured=${JSON.stringify(measured)}${selfCapped ? ' · selfCapped' : ''}${skip?.size ? ` · skipped=${[...skip].join(',')}` : ''}${kind ? ` · kind=${kind}` : ''}`);
333
441
  return null;
334
442
  }
335
- this.inflight.set(best.member.context, (this.inflight.get(best.member.context) ?? 0) + 1);
336
- const share = Object.fromEntries(this.members.map((m) => [m.context, Math.round(Math.max(0, free[m.context] ?? 0) / m.capacity * 100) / 100]));
337
- debug.log('pod.pool', 'member-selected', { member: best.member.context, free, measured, share });
338
- return best.member;
443
+ const chosen: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean } = best;
444
+ this.inflight.set(chosen.member.context, (this.inflight.get(chosen.member.context) ?? 0) + 1);
445
+ const share = Object.fromEntries(this.members.map((m) => [m.context, m.capacity > 0 ? Math.round(Math.max(0, free[m.context] ?? 0) / m.capacity * 100) / 100 : 0]));
446
+ const outcome: PodPlacement['outcome'] = preferred.size === 0 ? 'share' : chosen.preferred ? 'preferred' : 'overflow';
447
+ const pref = [...preferred];
448
+ const why = (c: string) => skip?.has(c) ? 'skipped' : measured[c] === false ? 'unmeasured' : 'full';
449
+ const reason = outcome === 'share'
450
+ ? `${chosen.member.context} · free share ${share[chosen.member.context]}${kind ? ` · ${kind} 골 · 선호 멤버 없음(${this.members.filter((m) => m.capacity > 0).length < 2 ? '단일 멤버' : '상한이 같다'})` : ''}`
451
+ : outcome === 'preferred'
452
+ ? `${chosen.member.context} · ${kind} 골 선호 멤버 · free share ${share[chosen.member.context]}`
453
+ : `${chosen.member.context} · ${kind} 골 넘침 — 선호 ${pref.map((c) => `${c}(${why(c)})`).join(',')} · free share ${share[chosen.member.context]}`;
454
+ const placement: PodPlacement = { member: chosen.member.context, kind, outcome, preferred: pref, reason };
455
+ debug.log('pod.pool', 'member-selected', { member: chosen.member.context, free, measured, share, ...(selfCapped ? { selfCapped: true } : {}), ...(kind ? { kind, outcome, preferred: pref, reason } : {}) });
456
+ // The callback only observes the placement — a throwing observer must not leak the slot it was told about.
457
+ try { mode.onPlacement?.(placement); }
458
+ catch (error) { debug.log('pod.pool', 'placement-observer-failed', { member: chosen.member.context, reason: error instanceof Error ? error.message : String(error) }, { level: 'warn' }); }
459
+ return chosen.member;
339
460
  }
340
461
  release(member: PodPoolMember): void {
341
462
  this.inflight.set(member.context, Math.max(0, (this.inflight.get(member.context) ?? 0) - 1));
@@ -48,9 +48,9 @@ export const POD_JOB_DEADLINE_SECONDS = 10_800;
48
48
  // POD-DIET (10-06 03:47~05:18 · node-b goal Pods · 89 one-minute samples · 42 Pods): per-Pod observed max median 4.6Gi ·
49
49
  // p75 6.2Gi · p90 11.7Gi — 23/42 went above the old 4Gi request, so the scheduler over-packed. 6Gi ≈ p75; limits stay.
50
50
  // Node allocatable ≈ 343Gi ≫ 25 × 6Gi. One-minute samples can miss the true peak (lower bound).
51
- export const POD_CHILD_REQUESTS = { cpu: '1', memory: '6Gi' } as const;
52
- import type { PodPoolMember, PodPoolScheduler } from './pod-pool.js';
53
- import { measurePoolLease, recommendConcurrency, POD_HOST_LEASE_ANNOTATION, LEASE_KUBECTL_MAX_BUFFER, memoryQuantityBytes } from './pod-lease.js';
51
+ import type { PodPlacement, PodPlacementKind, PodPoolMember, PodPoolScheduler } from './pod-pool.js';
52
+ export { POD_CHILD_REQUESTS } from './pod-lease.js';
53
+ import { measurePoolLease, recommendConcurrency, POD_CHILD_REQUESTS, POD_HOST_LEASE_ANNOTATION, LEASE_KUBECTL_MAX_BUFFER, memoryQuantityBytes } from './pod-lease.js';
54
54
  import { ACTUAL_SUBSTRATE_ENV, RUN_CONTRACT_ENV, carryRunContract, completionFloorFor } from '../../self-implement/graph-run-contract.js';
55
55
  import { spawnSync } from 'node:child_process';
56
56
  import { createHash } from 'node:crypto';
@@ -654,6 +654,16 @@ export function parsePodMemoryTier(v: string | undefined): PodMemoryTier | null
654
654
  return t && (POD_MEMORY_TIERS as readonly string[]).includes(t) ? (t as PodMemoryTier) : null;
655
655
  }
656
656
 
657
+ /**
658
+ * POOL-LITE (10-07): which pool member a Job prefers. Lite tier, or a docs/measurement goal not raised to `high`,
659
+ * goes small-first (node-c); everything else is an implementation Job and goes big-first (node-b).
660
+ */
661
+ export function podPlacementKind(tier: PodMemoryTier, declared: DeclaredGoalType | null, requestKind: string | null): PodPlacementKind {
662
+ if (tier === 'lite') return 'lite';
663
+ if (tier !== 'high' && (declared === 'document' || declared === 'research' || requestKind === 'docs')) return 'lite';
664
+ return 'standard';
665
+ }
666
+
657
667
  function parsePodGoalType(value: string | undefined): DeclaredGoalType | null {
658
668
  return value === 'implement' || value === 'research' || value === 'document' || value === 'operate' ? value : null;
659
669
  }
@@ -991,6 +1001,8 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
991
1001
  // pool-slot reason = what THIS launch saw on its last empty tryAcquire; admission reason = the pool-wide lease measurement (one pool, one reading).
992
1002
  const stageReason: { 'pool-slot'?: string | null } = {};
993
1003
  const unfitContexts = new Set<string>();
1004
+ // POOL-LITE (10-07): the last placement of this launch — printed at launch and journaled with the Job.
1005
+ let lastPlacement = null as PodPlacement | null;
994
1006
  const watchStall = (stage: 'admission' | 'pool-slot'): (() => void) => {
995
1007
  const started = Date.now();
996
1008
  const timer = setTimeout(() => {
@@ -1026,9 +1038,12 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
1026
1038
  for (;;) {
1027
1039
  if (input.signal?.aborted) return null;
1028
1040
  // The reason comes back inside this call, so a concurrent launch cannot overwrite it.
1029
- const acquired = await options.pool.tryAcquire(unfitContexts, (reason) => { stageReason['pool-slot'] = reason; });
1041
+ const kind = podPlacementKind(oomRetried ? retryTier : memoryTier, selectedGoalType, requestKind);
1042
+ const acquired = await options.pool.tryAcquire(unfitContexts, (reason) => { stageReason['pool-slot'] = reason; },
1043
+ { kind, onPlacement: (placement) => { lastPlacement = placement; } });
1030
1044
  if (acquired) {
1031
- debug.log('self-implement.pod', 'pool-slot', { spaceId: input.spaceId, context: acquired.context, inflight: options.pool.snapshot() });
1045
+ debug.log('self-implement.pod', 'pool-slot', { spaceId: input.spaceId, context: acquired.context, inflight: options.pool.snapshot(), ...(lastPlacement ? { placement: lastPlacement } : {}) });
1046
+ if (lastPlacement) console.warn(`[pod] 배치: ${lastPlacement.reason}`);
1032
1047
  return acquired;
1033
1048
  }
1034
1049
  await sleep(options.pollMs ?? 15_000);
@@ -1451,6 +1466,7 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
1451
1466
  }
1452
1467
  try {
1453
1468
  appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-child-run', data: { childRunId: liveChildRunId, job: name, attempt: oomRetried ? 2 : 1, ...(goalFile ? { goalFile } : {}) } }, ledgerDir);
1469
+ if (lastPlacement) appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-placement', data: { job: name, childRunId: liveChildRunId, attempt: oomRetried ? 2 : 1, member: lastPlacement.member, kind: lastPlacement.kind, outcome: lastPlacement.outcome, preferred: lastPlacement.preferred, reason: lastPlacement.reason } }, ledgerDir);
1454
1470
  appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-memory-selected', data: { job: name, childRunId: liveChildRunId, attempt: oomRetried ? 2 : 1, limit: memoryLimit, ...(appliedMemoryRequest ? { request: appliedMemoryRequest } : {}), tier: oomRetried ? retryTier : memoryTier, source: oomRetried ? 'oom-retry' : memorySource, reason: oomRetried ? 'OOMKilled' : memoryReason, declaredGoalType: selectedGoalType } }, ledgerDir);
1455
1471
  if (oomRetried && retryFromChildRunId) appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-oom-retry', data: { attempt: 2, from: retryFromMemoryLimit ?? selectedMemoryLimit, to: memoryLimit, tier: retryTier, reason: 'OOMKilled', job: name, childRunId: liveChildRunId, fromChildRunId: retryFromChildRunId } }, ledgerDir);
1456
1472
  } catch (error) {
@@ -0,0 +1,38 @@
1
+ import { readPresence, writePresence, type Presence } from './away/presence.js';
2
+ import { releaseNowText, releaseWatchTick } from './away/release-watch.js';
3
+ import { readReleaseRuns, type ReleaseRunView } from './nexus/api/ops-api.js';
4
+
5
+ /** AWAY-MODE-1 ③ — 텔레그램 /release: PWA /ops/release 와 같은 원장을 읽어 지금 런·노드를 한 통으로. */
6
+ export function telegramReleaseStatus(args: string[], runs: () => ReleaseRunView[] | 'unavailable' = () => readReleaseRuns(null, undefined, { facts: true }), now: () => number = Date.now): string {
7
+ if (args.length) return '사용법: /release';
8
+ return releaseNowText(runs(), now());
9
+ }
10
+
11
+ export interface AwaySlashDeps {
12
+ read?: () => Presence;
13
+ write?: (away: boolean, by: string) => Presence;
14
+ tick?: () => unknown;
15
+ release?: () => string;
16
+ }
17
+
18
+ /** AWAY-MODE-1 ① — 텔레그램 /away on|off|status. 답은 이 대화로 돌아오므로 따로 발송하지 않는다. */
19
+ export function telegramAwaySlash(args: string[], deps: AwaySlashDeps = {}): string {
20
+ const read = deps.read ?? (() => readPresence());
21
+ const write = deps.write ?? ((away: boolean, by: string) => writePresence(away, by));
22
+ const tick = deps.tick ?? (() => releaseWatchTick());
23
+ const release = deps.release ?? (() => telegramReleaseStatus([]));
24
+ const sub = (args[0] ?? 'status').toLowerCase();
25
+ if (args.length > 1 || !['on', 'off', 'status'].includes(sub)) return '사용법: /away on | off | status';
26
+ if (sub === 'on') {
27
+ write(true, 'telegram');
28
+ tick();
29
+ return `🚶 외출 모드 켬 — 발행 노드 전이·막힘·60분 생존 줄을 여기로 보낸다(끄기: /away off)\n${release()}`;
30
+ }
31
+ if (sub === 'off') {
32
+ write(false, 'telegram');
33
+ tick();
34
+ return '🏠 외출 모드 끔 — 발행 알림은 평소대로';
35
+ }
36
+ const presence = read();
37
+ return `${presence.away ? '🚶 외출 중' : '🏠 자리에 있음'}${presence.since ? ` · ${presence.since}` : ''}\n${release()}`;
38
+ }
@@ -50,10 +50,12 @@ import { createAdPipelineDeps, runAdPipeline } from './ad-pipeline/run.js';
50
50
  import { projectCommand } from './telegram-project-command.js';
51
51
  import { telegramNowSlash } from './context-bus/context-now-surfaces.js';
52
52
  import { telegramLoopsStatus } from './telegram-loops-command.js';
53
+ import { telegramAwaySlash, telegramReleaseStatus } from './telegram-away-command.js';
53
54
  import { createWishCard } from './intake-plane/wish-card.js';
54
55
  import { telegramDecisionOwner } from './decisions/telegram-decision-cards.js';
55
56
  import { CardStore } from './task-cards/card-store.js';
56
57
  import type { ContextNowDeps } from './context-bus/context-now.js';
58
+ import type { RunningRunsResult } from './self-implement/running-runs.js';
57
59
 
58
60
  /** How many prior user/assistant turns to pass into `executeSkill` as
59
61
  * the `## Recent conversation` block. Mirrors the dashboard default
@@ -304,6 +306,40 @@ function dropChatSessionReply(ctx: TgIncoming): string {
304
306
  : `_Session \`${sess.id.slice(0, 8)}\` was already gone._`;
305
307
  }
306
308
 
309
+ /** `/runs` — the Telegram twin of TUI `/harness runs`: the same `renderRunningRuns` text over the same
310
+ * production-universe query. The query is injectable so a test can prove the reply equals the renderer
311
+ * output for one data set. Long replies are chunked by the bot's sender, so no run line is dropped here. */
312
+ export async function telegramRunsSlash(args: readonly string[], query?: () => RunningRunsResult,
313
+ runCli: (argv: readonly string[]) => Promise<{ code: number | null; stdout: string }> = runSelfCli): Promise<string> {
314
+ if (args.length > 0) return 'Usage: /runs';
315
+ const { queryRunningRuns, renderRunningRuns } = await import('./self-implement/running-runs.js');
316
+ if (query) return renderRunningRuns(query());
317
+ // The ledger scan is synchronous and took 30–58 s under load (TC 10-07 17:37) — run inside the daemon it would freeze
318
+ // every bot and HTTP reply for that long. Ask the CLI twin (`self running-runs`, same renderer) in a child process,
319
+ // pinned to this daemon's universe; fall back in-process only when the child cannot start.
320
+ const { effectiveInstanceRoot } = await import('./instance/resolve.js');
321
+ try {
322
+ const { code, stdout } = await runCli(['--config-dir', effectiveInstanceRoot(), 'self', 'running-runs']);
323
+ if (code === 0 && stdout.trim()) return stdout.trimEnd();
324
+ } catch { /* fall through */ }
325
+ return renderRunningRuns(queryRunningRuns({ includeTest: false, caller: 'telegram' }));
326
+ }
327
+
328
+ /** Run this same elanous entry as a child (`bun <entry> …`) with a hard time limit. */
329
+ async function runSelfCli(argv: readonly string[]): Promise<{ code: number | null; stdout: string }> {
330
+ const { spawn } = await import('node:child_process');
331
+ const entry = process.argv[1];
332
+ if (!entry) throw new Error('no elanous entry');
333
+ return new Promise((resolve, reject) => {
334
+ const child = spawn(process.execPath, [entry, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] });
335
+ let stdout = '';
336
+ const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('timeout')); }, 120_000);
337
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; });
338
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
339
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout }); });
340
+ });
341
+ }
342
+
307
343
  /** Build the default command set for a Telegram bot wired via
308
344
  * botFromConfig. Returns the array rather than mutating globals so
309
345
  * tests can construct a variant set with stubbed handlers. */
@@ -311,6 +347,7 @@ export function defaultTelegramCommands(
311
347
  nowDeps?: ContextNowDeps,
312
348
  coreCommands: readonly SlashCommand[] = SLASH_COMMANDS,
313
349
  maturity: { tuiSlash: Readonly<Record<string, Maturity>>; telegramCommand: Readonly<Record<string, Maturity>>; discordCommand: Readonly<Record<string, Maturity>> } = FEATURE_MATURITY,
350
+ runningRunsQuery?: () => RunningRunsResult,
314
351
  ): TgSlashCommand[] {
315
352
  const handled: TgSlashCommand[] = [
316
353
  {
@@ -334,6 +371,21 @@ export function defaultTelegramCommands(
334
371
  description: '루프·크론 현황 — 늦음·실패를 먼저 보여줍니다',
335
372
  handler: (args) => telegramLoopsStatus(args),
336
373
  },
374
+ {
375
+ name: 'runs',
376
+ description: 'Running harness runs — same output as TUI /harness runs',
377
+ handler: async (args) => telegramRunsSlash(args, runningRunsQuery),
378
+ },
379
+ {
380
+ name: 'release',
381
+ description: '발행 현황 — 지금 런·노드·막힘을 한 통으로',
382
+ handler: async (args) => telegramReleaseStatus(args),
383
+ },
384
+ {
385
+ name: 'away',
386
+ description: '외출 모드 — /away on|off|status (켜면 발행 전이를 여기로)',
387
+ handler: async (args) => telegramAwaySlash(args),
388
+ },
337
389
  {
338
390
  name: 'status',
339
391
  description: 'Show bot identity, current session, provider + model',
@@ -0,0 +1,21 @@
1
+ import { sendTelegramReport } from './telegram-report.js';
2
+ import { getUserConfig, setUserConfigOverlay } from './user-config.js';
3
+ import { registerStandaloneLogSink } from './domains/standalone-log-sink.js';
4
+
5
+ // Focused subprocess fixture (BRIEF-DELIVERY-1007): the production shape — a channels table whose
6
+ // trading channel lacks the `report` role while the legacy reportChannel points at that bot.
7
+ setUserConfigOverlay(cfg => ({
8
+ ...cfg,
9
+ telegram: {
10
+ ...cfg.telegram,
11
+ channels: [
12
+ { name: 'main', botToken: '111111:private-secret', chatId: 98765, interactive: true, roles: ['qa', 'default', 'system'] },
13
+ { name: 'conatus', botToken: '222222:private-secret', chatId: 98766, interactive: false, roles: ['investment', 'finance'] },
14
+ ],
15
+ reportChannel: { botToken: '222222:private-secret', chatId: 98766 },
16
+ },
17
+ }));
18
+ // The entry point attaches logs.db, exactly as cron morning-report does.
19
+ if (process.env.NODE_ENV !== 'test') await registerStandaloneLogSink('telegram-report-probe');
20
+ console.log(`probe-start ${new Date().toISOString()}`);
21
+ if (await sendTelegramReport(getUserConfig(), 'SECRET-BODY-DO-NOT-LOG', { kind: 'report' })) process.exitCode = 1;
@@ -11,11 +11,25 @@
11
11
  // the report channel while interactive Q&A stays on the main bot.
12
12
 
13
13
  import { TelegramBot } from './telegram.js';
14
- import { isTradingKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './domains/telegram-kind-route.js';
14
+ import { explainReportRoute, isTradingKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './domains/telegram-kind-route.js';
15
+ export { explainReportRoute } from './domains/telegram-kind-route.js';
15
16
  export { DEFAULT_KIND_ROLES, roleForKind } from './domains/telegram-kind-route.js';
16
17
  import { resolveChannelBotToken } from './channel-bot-token.js';
17
18
  import type { UserConfig } from './user-config.js';
18
19
  import { findSessionByTelegramChat, createSession, appendMessage } from './session/index.js';
20
+ import { debug } from './debug/log.js';
21
+ // Observations go through debug.log only: a standalone caller (cron morning-report) registers its own
22
+ // logs.db sink at its entry point (registerStandaloneLogSink) — registering here would duplicate every
23
+ // row inside the daemon, which already has one (review r4).
24
+ function logReport(event: 'sent' | 'unconfirmed' | 'unrouted', data: Record<string, unknown>): void {
25
+ try { debug.log('telegram.report', event, data); } catch { /* fail-soft */ }
26
+ }
27
+
28
+ /** Public numeric bot id only — never the token's secret suffix. */
29
+ function botId(token: string): string {
30
+ const id = token.split(':', 1)[0] ?? '';
31
+ return /^\d+$/.test(id) ? id : 'configured';
32
+ }
19
33
 
20
34
  /** Cross-surface memory — mirror an outbound report/alert into the
21
35
  * RECEIVING channel's session transcript so a follow-up question in that
@@ -95,14 +109,24 @@ export async function sendTelegramReport(
95
109
  opts: SendReportOpts = {},
96
110
  ): Promise<boolean> {
97
111
  const target = resolveReportTarget(cfg, opts.kind);
98
- if (!target || !text) return false;
112
+ if (!text) return false;
113
+ if (!target) {
114
+ const why = explainReportRoute(cfg, opts.kind);
115
+ logReport('unrouted', { kind: opts.kind ?? null, role: why.role, reason: why.reason });
116
+ return false;
117
+ }
99
118
  const bot = new TelegramBot({
100
119
  token: target.botToken,
101
120
  allowedUsers: [],
102
121
  onMessage: async () => undefined,
103
122
  ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
104
123
  });
105
- await bot.sendMessage(target.chatId, text, { markdown: opts.markdown ?? true });
124
+ const sent = await bot.sendMessage(target.chatId, text, { markdown: opts.markdown ?? true });
125
+ // Delivered only with Telegram's message id back — an empty answer is unconfirmed and returns false (review r4).
126
+ const confirmed = Number.isSafeInteger(sent?.messageId) && (sent?.messageId ?? 0) > 0;
127
+ logReport(confirmed ? 'sent' : 'unconfirmed',
128
+ { kind: opts.kind ?? null, role: explainReportRoute(cfg, opts.kind).role, bot: botId(target.botToken), chars: text.length });
129
+ if (!confirmed) return false;
106
130
  // Mirror into the report channel's session so a follow-up question in
107
131
  // that chat can recall this alert (Fix — was surface_events-only).
108
132
  mirrorOutboundToSession(target, text);