elanous 0.2.17 → 0.2.19

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 (491) 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/J5H7BvqgSI8DUjtRRcA_S/_buildManifest.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/1105-c473ed46272fba79.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/1157-cba0e8553d35a980.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/{726.83532d69452ee24b.js → 1470.98a6ab61ccb4131c.js} +1 -1
  7. package/apps/pwa/out/_next/static/chunks/171-23a877ed11720de9.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/216-5acd01cbde4acda7.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/2330-c2f07246c8788af1.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/2336-e830c777eb6bce04.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/{9247-269e4464cc4b491c.js → 2375-e061bb24cd2b5cde.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/2415.ed6428ff334c7ce2.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/{211-9c9090c59e2c5907.js → 2977-e38442e86289f1d3.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/{f6b7c9ba.64a895b8b2fedbdc.js → 2ea96958.e8c7e112de50b067.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/3094.67339318c7f72a3a.js +2 -0
  16. package/apps/pwa/out/_next/static/chunks/338-ceb956612dcfaa86.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/3395-835e4ceaa6335ba4.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/3403.4bc97b4066945a94.js +5 -0
  19. package/apps/pwa/out/_next/static/chunks/3441-be82e3595aef3a20.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/3689-89905039bf3e7257.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/371-4ad6af55dad02ced.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/3822-6f268bc26c6a84ac.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/4114-3e63117452378807.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/{1268.0840ff91e083fcbd.js → 4323.c5dfb6f503304775.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/4457-d7df81c90f9d0268.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/4551-77dadae76f6cbace.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/{6110.1297d4093fdfbb0d.js → 4847.56f5bc454fbcc827.js} +2 -2
  28. package/apps/pwa/out/_next/static/chunks/4978.6fd93b974441e87f.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/5012-f746f6549b869f91.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/5609-012bb3557211f07e.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/5791-bb82743fa6299562.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/{7327-2d69fb321ba73389.js → 6062-73e68343a9814f6a.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/62-5bfed1e23790b745.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/6320-9bc8561fde416e07.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/649-ca96d1c5d09e02b8.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/6725-68ab7cc0b1faf00d.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/6726-85a7db59b288c53f.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/6735-33267c4630d0d963.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/6998-a654fe5c58271600.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/7633-141682015873beb6.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/{935.4ddaa99fc1d1126c.js → 7647.2fcd30d894d5ee30.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/7673-2eda1507d38dadb8.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/7990-e12efba616f46e03.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/{f6231284-45726550aba08fca.js → 8046cadf-211ca3c0e00f2b72.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/8067-143791231c99bd02.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/8142-744998d65d7bb17e.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/8430-a10cfba8ef282d4e.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/8870-9614a213b990b10b.js +25 -0
  49. package/apps/pwa/out/_next/static/chunks/920-f7dceb78f1c585ef.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/9454-12c2f30dea62291c.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/976-4a9b4ebdca7c739a.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/9989-15cdf568bafa9fc8.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/ae787e4c-e5b1ef00b2dbceca.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-859cea1570b6227a.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/approvals/page-deef5ee191830f14.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-b193da485f9a14b2.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/board/page-b6e1a79df0e214f4.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/botlab/page-e84df4d907f703c7.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/bots/page-a5fb9a5db89ae4ea.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/ceo/page-1499f481d7fc52d6.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/chat/page-c7cf753aeb648790.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/consult/page-4118469e514e10fd.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/control/page-e968f7630654332a.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/decisions/page-505915052f947022.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/design-check/page-c2f962fb4a3b06c6.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/editor/page-7b5ed0bd8a589ec4.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/exec/page-cf8193250dd42dd4.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/field/page-cf3f5dfa35dea8e9.js +1 -0
  69. package/apps/pwa/out/_next/static/chunks/app/healthz/route-859cea1570b6227a.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/inside/page-5b29d9b06ceadade.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/intake/page-a15e7fcc283e9ca7.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/layout-d2915e7b70ff3d0e.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/live/page-b70cd005d9169e25.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/loops/page-2595f0eba29f57e1.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/market/page-c776322454156e36.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-77bbd17f4435aa10.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/morning/page-049689e611ecc936.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/not-found-85954a4fb09c44eb.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/observatory/page-e773e552e1068ee6.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-5633c4bfa7684b4a.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-d829b92bd01b4994.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-061c317fc771becf.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/outputs/page-3a45ece8865ed4b6.js +1 -0
  84. package/apps/pwa/out/_next/static/chunks/app/page-0be990618c0d56bd.js +1 -0
  85. package/apps/pwa/out/_next/static/chunks/app/reflection/page-bfea4865dc244b73.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-0d6ba8e337741484.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/app/sessions/page-76a88b55d816482e.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-bba1542df59f61ce.js +1 -0
  89. package/apps/pwa/out/_next/static/chunks/app/settings/page-4185c95689bb372e.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-b699e1a8d5d34b8b.js +1 -0
  91. package/apps/pwa/out/_next/static/chunks/app/setup/layout-859cea1570b6227a.js +1 -0
  92. package/apps/pwa/out/_next/static/chunks/app/setup/page-c225e338c31bf274.js +1 -0
  93. package/apps/pwa/out/_next/static/chunks/app/share/page-449353f70507ca1f.js +1 -0
  94. package/apps/pwa/out/_next/static/chunks/app/showroom/page-90b889714102476d.js +1 -0
  95. package/apps/pwa/out/_next/static/chunks/app/tasks/page-f1ee7fbeb7f96ddc.js +1 -0
  96. package/apps/pwa/out/_next/static/chunks/app/term/page-d59a95e1434dfaa0.js +1 -0
  97. package/apps/pwa/out/_next/static/chunks/app/today/page-71491a23414d1a47.js +1 -0
  98. package/apps/pwa/out/_next/static/chunks/app/trace/page-de7112bdb65fa4fe.js +1 -0
  99. package/apps/pwa/out/_next/static/chunks/app/vault/page-feb4d0d88079660d.js +1 -0
  100. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-f17d887b40dd6b1d.js +1 -0
  101. package/apps/pwa/out/_next/static/chunks/app/workflows/page-8da77be2192bcd5d.js +1 -0
  102. package/apps/pwa/out/_next/static/chunks/app/workspace/page-72c3b9a5e9582b29.js +1 -0
  103. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-c3afe4c8d0c48c39.js +1 -0
  104. package/apps/pwa/out/_next/static/chunks/framework-ea806bb57f2b8923.js +1 -0
  105. package/apps/pwa/out/_next/static/chunks/main-8d2302399c2c8f87.js +1 -0
  106. package/apps/pwa/out/_next/static/chunks/main-app-b9308f2d8a931531.js +1 -0
  107. package/apps/pwa/out/_next/static/chunks/pages/_app-b10928c60f0b8488.js +1 -0
  108. package/apps/pwa/out/_next/static/chunks/pages/_error-689039227d443fab.js +1 -0
  109. package/apps/pwa/out/_next/static/chunks/webpack-6f35f9d892fa630b.js +1 -0
  110. package/apps/pwa/out/_next/static/css/{e99e3cff94ced78a.css → 599369d853c61df7.css} +1 -1
  111. package/apps/pwa/out/_next/static/css/85f53c6f35d11382.css +1 -0
  112. package/apps/pwa/out/_next/static/css/d3427996da11e480.css +1 -0
  113. package/apps/pwa/out/approvals/index.html +1 -1
  114. package/apps/pwa/out/approvals/index.txt +23 -23
  115. package/apps/pwa/out/autopilot/index.html +1 -1
  116. package/apps/pwa/out/autopilot/index.txt +23 -23
  117. package/apps/pwa/out/board/index.html +1 -1
  118. package/apps/pwa/out/board/index.txt +23 -23
  119. package/apps/pwa/out/botlab/index.html +1 -1
  120. package/apps/pwa/out/botlab/index.txt +22 -22
  121. package/apps/pwa/out/bots/index.html +1 -1
  122. package/apps/pwa/out/bots/index.txt +22 -22
  123. package/apps/pwa/out/ceo/index.html +1 -1
  124. package/apps/pwa/out/ceo/index.txt +23 -23
  125. package/apps/pwa/out/chat/index.html +1 -1
  126. package/apps/pwa/out/chat/index.txt +22 -22
  127. package/apps/pwa/out/consult/index.html +1 -1
  128. package/apps/pwa/out/consult/index.txt +23 -23
  129. package/apps/pwa/out/control/index.html +1 -1
  130. package/apps/pwa/out/control/index.txt +23 -23
  131. package/apps/pwa/out/decisions/index.html +1 -0
  132. package/apps/pwa/out/decisions/index.txt +30 -0
  133. package/apps/pwa/out/design-check/index.html +1 -1
  134. package/apps/pwa/out/design-check/index.txt +23 -23
  135. package/apps/pwa/out/editor/index.html +1 -1
  136. package/apps/pwa/out/editor/index.txt +25 -24
  137. package/apps/pwa/out/exec/index.html +1 -1
  138. package/apps/pwa/out/exec/index.txt +23 -23
  139. package/apps/pwa/out/field/index.html +1 -1
  140. package/apps/pwa/out/field/index.txt +23 -23
  141. package/apps/pwa/out/healthz +1 -1
  142. package/apps/pwa/out/index.html +1 -1
  143. package/apps/pwa/out/index.txt +22 -22
  144. package/apps/pwa/out/inside/index.html +1 -1
  145. package/apps/pwa/out/inside/index.txt +24 -23
  146. package/apps/pwa/out/intake/index.html +1 -1
  147. package/apps/pwa/out/intake/index.txt +23 -23
  148. package/apps/pwa/out/live/index.html +1 -1
  149. package/apps/pwa/out/live/index.txt +23 -23
  150. package/apps/pwa/out/loops/index.html +1 -1
  151. package/apps/pwa/out/loops/index.txt +28 -27
  152. package/apps/pwa/out/market/index.html +1 -1
  153. package/apps/pwa/out/market/index.txt +22 -22
  154. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  155. package/apps/pwa/out/missions/placeholder/index.txt +22 -22
  156. package/apps/pwa/out/morning/index.html +1 -1
  157. package/apps/pwa/out/morning/index.txt +23 -23
  158. package/apps/pwa/out/observatory/index.html +1 -1
  159. package/apps/pwa/out/observatory/index.txt +23 -23
  160. package/apps/pwa/out/ops/checklist/index.html +1 -1
  161. package/apps/pwa/out/ops/checklist/index.txt +22 -22
  162. package/apps/pwa/out/ops/release/index.html +1 -1
  163. package/apps/pwa/out/ops/release/index.txt +22 -22
  164. package/apps/pwa/out/ops/seats/index.html +1 -1
  165. package/apps/pwa/out/ops/seats/index.txt +22 -22
  166. package/apps/pwa/out/outputs/index.html +1 -1
  167. package/apps/pwa/out/outputs/index.txt +22 -22
  168. package/apps/pwa/out/reflection/index.html +1 -1
  169. package/apps/pwa/out/reflection/index.txt +23 -23
  170. package/apps/pwa/out/scheduler/index.html +1 -1
  171. package/apps/pwa/out/scheduler/index.txt +23 -23
  172. package/apps/pwa/out/sessions/index.html +1 -1
  173. package/apps/pwa/out/sessions/index.txt +22 -22
  174. package/apps/pwa/out/settings/devices/index.html +1 -1
  175. package/apps/pwa/out/settings/devices/index.txt +23 -23
  176. package/apps/pwa/out/settings/index.html +1 -1
  177. package/apps/pwa/out/settings/index.txt +23 -23
  178. package/apps/pwa/out/setup/done/index.html +1 -1
  179. package/apps/pwa/out/setup/done/index.txt +23 -23
  180. package/apps/pwa/out/setup/index.html +1 -1
  181. package/apps/pwa/out/setup/index.txt +23 -23
  182. package/apps/pwa/out/share/index.html +1 -1
  183. package/apps/pwa/out/share/index.txt +23 -23
  184. package/apps/pwa/out/showroom/index.html +1 -1
  185. package/apps/pwa/out/showroom/index.txt +22 -22
  186. package/apps/pwa/out/tasks/index.html +1 -1
  187. package/apps/pwa/out/tasks/index.txt +23 -23
  188. package/apps/pwa/out/term/index.html +1 -1
  189. package/apps/pwa/out/term/index.txt +23 -23
  190. package/apps/pwa/out/today/index.html +1 -1
  191. package/apps/pwa/out/today/index.txt +23 -23
  192. package/apps/pwa/out/trace/index.html +1 -1
  193. package/apps/pwa/out/trace/index.txt +23 -23
  194. package/apps/pwa/out/vault/index.html +1 -1
  195. package/apps/pwa/out/vault/index.txt +23 -23
  196. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  197. package/apps/pwa/out/workflows/chat-ui/index.txt +23 -23
  198. package/apps/pwa/out/workflows/index.html +1 -1
  199. package/apps/pwa/out/workflows/index.txt +23 -23
  200. package/apps/pwa/out/workspace/index.html +1 -1
  201. package/apps/pwa/out/workspace/index.txt +23 -23
  202. package/apps/pwa/out/worktrees/index.html +1 -1
  203. package/apps/pwa/out/worktrees/index.txt +23 -23
  204. package/graphs/ops/guardian.yaml +26 -0
  205. package/graphs/ops/recipes.yaml +3 -0
  206. package/package.json +2 -1
  207. package/samples/workflows/templates/approval-gated-deploy.yaml +30 -0
  208. package/samples/workflows/templates/chat-driven-research.yaml +35 -0
  209. package/samples/workflows/templates/discord-listen-and-respond.yaml +29 -0
  210. package/samples/workflows/templates/http-poll-then-alert.yaml +30 -0
  211. package/samples/workflows/templates/manual-quickstart.yaml +21 -0
  212. package/samples/workflows/templates/scheduled-daily-digest.yaml +30 -0
  213. package/samples/workflows/templates/webhook-deploy-hook.yaml +27 -0
  214. package/scripts/ci-public-export-import-gate.ts +7 -2
  215. package/scripts/ci-typecheck-changed.ts +21 -6
  216. package/scripts/coord-channel-watch.sh +2 -2
  217. package/scripts/discord-seat-probe.ts +6 -3
  218. package/scripts/docs/generate-cli-anatomy.ts +162 -0
  219. package/scripts/goals/authoring-metrics.ts +73 -0
  220. package/scripts/landing-heal/must-fix-match.ts +7 -7
  221. package/scripts/landing-heal/run-mission.ts +3 -2
  222. package/scripts/lib/pch-regress-map.ts +23 -0
  223. package/scripts/morning-report.ts +18 -4
  224. package/scripts/pch-regress.ts +88 -0
  225. package/scripts/public-export-test-run.ts +37 -2
  226. package/scripts/release-loop/checklist-gate-node.ts +13 -11
  227. package/scripts/release-loop/cut-branch.ts +2 -1
  228. package/scripts/release-loop/cutoff.ts +4 -2
  229. package/scripts/release-loop/docs-land-node.ts +8 -1
  230. package/scripts/release-loop/docs-node.ts +1 -1
  231. package/scripts/release-loop/gate-diff.ts +18 -0
  232. package/scripts/release-loop/gate-node.ts +436 -59
  233. package/scripts/release-loop/gate-partial.ts +106 -0
  234. package/scripts/release-loop/gate-timing.ts +120 -0
  235. package/scripts/release-loop/node-verdict.ts +2 -1
  236. package/scripts/release-loop/npm-publish-node.ts +11 -7
  237. package/scripts/release-loop/ops-upgrade-node.ts +8 -1
  238. package/scripts/release-loop/preflight.ts +246 -0
  239. package/scripts/release-loop/publish-node.ts +8 -1
  240. package/scripts/release-loop/release-readiness.ts +11 -6
  241. package/scripts/release-loop/release-version.ts +48 -0
  242. package/scripts/release-loop/resume-release.ts +174 -0
  243. package/scripts/release-loop/shard-plan.ts +65 -0
  244. package/scripts/release-loop/unattended-release.ts +45 -7
  245. package/scripts/release-loop/version-node.ts +145 -40
  246. package/scripts/rhythm/daily.ts +25 -4
  247. package/scripts/skills/public-check.ts +62 -12
  248. package/scripts/test-deterministic.ts +51 -33
  249. package/scripts/test-diet/effectiveness.ts +62 -1
  250. package/scripts/test-diet/lib.ts +1 -1
  251. package/scripts/test-diet/node.ts +8 -1
  252. package/scripts/video/scene-table.ts +159 -0
  253. package/src/acp/core-turn-bridge.ts +39 -2
  254. package/src/agent-mission/review-loop.ts +4 -3
  255. package/src/agent-substrate/pr-comment-meta.ts +20 -0
  256. package/src/autopilot/build/llm-conflict-merge.ts +28 -8
  257. package/src/away/presence.ts +40 -0
  258. package/src/away/release-watch.ts +255 -0
  259. package/src/briefing/brief-items.ts +86 -17
  260. package/src/chat/index.ts +2 -1
  261. package/src/chat/short-question.ts +3 -40
  262. package/src/cli/autopilot-cli.ts +30 -11
  263. package/src/cli/away-cli.ts +68 -0
  264. package/src/cli/brief-cli.ts +36 -2
  265. package/src/cli/freeze-cli.ts +3 -2
  266. package/src/cli/guardian-cli.ts +49 -0
  267. package/src/cli/intake-cli.ts +85 -7
  268. package/src/cli/log-event-names.ts +1 -0
  269. package/src/cli/notify-cli.ts +75 -0
  270. package/src/cli/pod-cli.ts +5 -1
  271. package/src/cli/provider-cli.ts +12 -2
  272. package/src/cli/pty-drive-cli.ts +14 -3
  273. package/src/cli/release-cli.ts +128 -19
  274. package/src/cli/setup-cli.ts +31 -1
  275. package/src/cli/tasks-cli.ts +403 -4
  276. package/src/context-bus/condense.ts +19 -3
  277. package/src/context-bus/context-day.ts +77 -24
  278. package/src/context-bus/context-first.ts +33 -0
  279. package/src/context-bus/context-now-public.ts +1 -1
  280. package/src/context-bus/context-now-surfaces.ts +16 -6
  281. package/src/context-bus/context-now.ts +97 -3
  282. package/src/core-turn/short-question.ts +100 -0
  283. package/src/dashboard/dashboard-status-lines.ts +24 -4
  284. package/src/dashboard/index.ts +50 -5
  285. package/src/dashboard/input/slash-executor.ts +6 -2
  286. package/src/dashboard/slash-runtime/dashboard-handlers.ts +63 -36
  287. package/src/dashboard/slash-runtime/decide-slash.ts +60 -0
  288. package/src/dashboard/slash-runtime/help-from-registry.ts +21 -8
  289. package/src/dashboard/slash-runtime/help-groups.ts +44 -0
  290. package/src/dashboard/slash-runtime/model-choices.ts +125 -0
  291. package/src/discord-self-message.ts +20 -2
  292. package/src/discord-slash-wire.ts +1 -0
  293. package/src/domains/guardian-tick.ts +138 -0
  294. package/src/domains/outbound-alert.ts +244 -40
  295. package/src/domains/outbound-log-probe.ts +16 -1
  296. package/src/domains/release-tool.ts +2 -2
  297. package/src/domains/schedule-registry.ts +23 -0
  298. package/src/domains/telegram-kind-route.ts +39 -0
  299. package/src/domains/watch-default-scheduler.ts +95 -0
  300. package/src/esc-abort-gate.ts +44 -0
  301. package/src/execution-loop/gates.ts +11 -2
  302. package/src/flow-loop/split.ts +8 -4
  303. package/src/git-fs/worktree.ts +23 -3
  304. package/src/graph-runner/runner.ts +25 -5
  305. package/src/harness/harness-cli-command.ts +12 -2
  306. package/src/harness/harness-queue.ts +388 -33
  307. package/src/harness/harness-salvage-cli.ts +78 -0
  308. package/src/harness/harness-worktree-add.ts +68 -0
  309. package/src/harness/helper-scan.ts +10 -3
  310. package/src/harness/ledger-write-guard.ts +6 -16
  311. package/src/hq/hq.ts +6 -1
  312. package/src/hq/seats.ts +369 -0
  313. package/src/index.ts +185 -18
  314. package/src/instance/test-write-guard-spy-first.fixture.ts +13 -0
  315. package/src/instance/test-write-guard.ts +200 -0
  316. package/src/intake-plane/adopt-gate.ts +49 -0
  317. package/src/intake-plane/check.ts +3 -0
  318. package/src/intake-plane/collect-github.ts +57 -25
  319. package/src/intake-plane/digest.ts +39 -15
  320. package/src/intake-plane/discord-seat-work.ts +8 -2
  321. package/src/intake-plane/harness-target.ts +4 -3
  322. package/src/intake-plane/intake-sources.ts +1 -0
  323. package/src/intake-plane/items.ts +1 -0
  324. package/src/intake-plane/requirement-funnel.ts +316 -0
  325. package/src/knowledge/know-find.ts +10 -1
  326. package/src/loops/budget.ts +36 -0
  327. package/src/loops/orchestrator/degradation.ts +57 -0
  328. package/src/loops/orchestrator/finish-rate.ts +106 -4
  329. package/src/loops/orchestrator/grid-place.ts +11 -0
  330. package/src/loops/orchestrator/tick.ts +450 -54
  331. package/src/maturity/feature-maturity.ts +8 -5
  332. package/src/maturity/tui-slash-maturity.ts +1 -0
  333. package/src/mss/logging/log-store.ts +3 -0
  334. package/src/nexus/api/decisions-route.ts +11 -1
  335. package/src/nexus/api/http-server.ts +16 -5
  336. package/src/nexus/api/loop-edges.ts +352 -23
  337. package/src/nexus/api/ops-api.ts +96 -28
  338. package/src/nexus/api/ops-seats-sources.ts +2 -2
  339. package/src/nexus/api/workflow-templates.ts +3 -0
  340. package/src/nexus/index.ts +7 -0
  341. package/src/oauth/codex.ts +38 -0
  342. package/src/onboarding/first-chat-gate.ts +29 -0
  343. package/src/release-loop/checklist.ts +106 -6
  344. package/src/release-loop/feature-store.ts +64 -16
  345. package/src/release-loop/landing-freeze.ts +6 -4
  346. package/src/release-loop/light-rc.ts +173 -0
  347. package/src/release-loop/placement.ts +64 -20
  348. package/src/release-loop/rubric.ts +136 -0
  349. package/src/seat-dispatch/seat-ask.ts +79 -16
  350. package/src/seat-loop/seat-loop.ts +12 -6
  351. package/src/self-dev/ask-launch-flow.ts +24 -3
  352. package/src/self-dev/ask-path-candidates.ts +68 -0
  353. package/src/self-dev/dev-cli.ts +36 -0
  354. package/src/self-dev/dev-pipeline.ts +4 -0
  355. package/src/self-dev/draft-triage-rules.ts +1 -1
  356. package/src/self-dev/orchestrate-cli.ts +22 -0
  357. package/src/self-dev/orchestrate.ts +1 -0
  358. package/src/self-dev/pr-preview.ts +179 -0
  359. package/src/self-dev/round-pr-supersede.ts +244 -0
  360. package/src/self-dev/run-supervisor.ts +14 -0
  361. package/src/self-dev/shard-run.ts +23 -7
  362. package/src/self-implement/follow-up-goals.ts +2 -2
  363. package/src/self-implement/gate-cli.ts +29 -8
  364. package/src/self-implement/gate-remote.ts +427 -0
  365. package/src/self-implement/goal-author-clarification.ts +88 -13
  366. package/src/self-implement/goal-author.ts +8 -3
  367. package/src/self-implement/goal-clarification-escalation.ts +2 -0
  368. package/src/self-implement/l8-merge-queue.ts +29 -5
  369. package/src/self-implement/orchestrator.ts +61 -13
  370. package/src/self-implement/prior-draft-findings.ts +4 -3
  371. package/src/self-implement/salvage-branches.ts +254 -0
  372. package/src/skills/skill-feedback.ts +76 -0
  373. package/src/task-agent/actions.ts +101 -7
  374. package/src/task-agent/board.ts +132 -0
  375. package/src/task-agent/card-evidence.ts +203 -0
  376. package/src/task-agent/completion-evidence.ts +126 -0
  377. package/src/task-agent/judge.ts +49 -5
  378. package/src/task-agent/live-moves.ts +176 -0
  379. package/src/task-agent/mission.ts +449 -0
  380. package/src/task-agent/shadow.ts +258 -0
  381. package/src/task-agent/task-hand.ts +337 -0
  382. package/src/task-orchestrator/surfaces/pod-command-job.ts +64 -4
  383. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +10 -0
  384. package/src/task-orchestrator/surfaces/pod-lease.ts +142 -13
  385. package/src/task-orchestrator/surfaces/pod-pool.ts +13 -5
  386. package/src/task-orchestrator/surfaces/self-implement-pod.ts +88 -20
  387. package/src/telegram-away-command.ts +38 -0
  388. package/src/telegram-commands.ts +11 -0
  389. package/src/telegram-loops-command.ts +1 -1
  390. package/src/telegram-report-log-probe.ts +21 -0
  391. package/src/telegram-report.ts +27 -3
  392. package/src/telegram.ts +5 -20
  393. package/src/tui-client/dashboard-session.ts +1 -0
  394. package/src/user-config.ts +196 -4
  395. package/src/version/packed-revision.json +1 -1
  396. package/apps/pwa/out/_next/static/chunks/1307-aac7332576c4d6fc.js +0 -1
  397. package/apps/pwa/out/_next/static/chunks/1826-5d279da64bbbc2de.js +0 -1
  398. package/apps/pwa/out/_next/static/chunks/1844-9ec4b1d12f6e80eb.js +0 -1
  399. package/apps/pwa/out/_next/static/chunks/1866-e7a6999f25548b30.js +0 -1
  400. package/apps/pwa/out/_next/static/chunks/1948.15f356b15a625597.js +0 -5
  401. package/apps/pwa/out/_next/static/chunks/2196-4700653d12a44b40.js +0 -1
  402. package/apps/pwa/out/_next/static/chunks/224-798346143007ff65.js +0 -1
  403. package/apps/pwa/out/_next/static/chunks/2699-63b73fa4d3bcf6ff.js +0 -1
  404. package/apps/pwa/out/_next/static/chunks/2957-96ffab3d521fb08f.js +0 -1
  405. package/apps/pwa/out/_next/static/chunks/311.e685738f92e6e2ec.js +0 -1
  406. package/apps/pwa/out/_next/static/chunks/3194-807b212fb8eae065.js +0 -1
  407. package/apps/pwa/out/_next/static/chunks/3324-d1d2979821dfa845.js +0 -1
  408. package/apps/pwa/out/_next/static/chunks/3558-bbb5ab553a0471d3.js +0 -1
  409. package/apps/pwa/out/_next/static/chunks/3916-3d02dd1fc257dd3c.js +0 -1
  410. package/apps/pwa/out/_next/static/chunks/4144-a0041c8dc3be57ac.js +0 -1
  411. package/apps/pwa/out/_next/static/chunks/4554-3565eea1f1495ba7.js +0 -1
  412. package/apps/pwa/out/_next/static/chunks/4614-21403aac730d4586.js +0 -1
  413. package/apps/pwa/out/_next/static/chunks/4795-f372490b178583fc.js +0 -1
  414. package/apps/pwa/out/_next/static/chunks/5135-44008fe8b1f2aa41.js +0 -1
  415. package/apps/pwa/out/_next/static/chunks/5233-bf28bd6bd7a32099.js +0 -1
  416. package/apps/pwa/out/_next/static/chunks/5360-be1f1d8f9fcc9755.js +0 -1
  417. package/apps/pwa/out/_next/static/chunks/5977-c952712c87a1f757.js +0 -1
  418. package/apps/pwa/out/_next/static/chunks/5994-ae92c0de046ab9ba.js +0 -1
  419. package/apps/pwa/out/_next/static/chunks/6158.9c0449a9a8d0064b.js +0 -2
  420. package/apps/pwa/out/_next/static/chunks/6877-fe94e76a5841ee9b.js +0 -1
  421. package/apps/pwa/out/_next/static/chunks/7290-f09c928a06c52e2d.js +0 -1
  422. package/apps/pwa/out/_next/static/chunks/7395-164e238f471b5d7b.js +0 -1
  423. package/apps/pwa/out/_next/static/chunks/7787-02a1c0b04e25e811.js +0 -1
  424. package/apps/pwa/out/_next/static/chunks/8060-cc008cc6fedfa818.js +0 -1
  425. package/apps/pwa/out/_next/static/chunks/8085-d065d0393c1760f5.js +0 -1
  426. package/apps/pwa/out/_next/static/chunks/8401-4ce1c42c8d89903f.js +0 -1
  427. package/apps/pwa/out/_next/static/chunks/8682-7061dd4fa7bd62ed.js +0 -1
  428. package/apps/pwa/out/_next/static/chunks/8868-10a3a4440fb0ccd8.js +0 -25
  429. package/apps/pwa/out/_next/static/chunks/8949-ecb4128a0858b911.js +0 -1
  430. package/apps/pwa/out/_next/static/chunks/899-f41be85e8a680651.js +0 -1
  431. package/apps/pwa/out/_next/static/chunks/9070.067c106528ec1ae6.js +0 -1
  432. package/apps/pwa/out/_next/static/chunks/9103-60fbef33e66f0a46.js +0 -1
  433. package/apps/pwa/out/_next/static/chunks/a9f2b54d-7865aba0f4d2ee49.js +0 -1
  434. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-c1a96669158471ec.js +0 -1
  435. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3143b07a817d1da9.js +0 -1
  436. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-17ba086ef82d339b.js +0 -1
  437. package/apps/pwa/out/_next/static/chunks/app/board/page-7eb661ff23af7da2.js +0 -1
  438. package/apps/pwa/out/_next/static/chunks/app/botlab/page-6b8444914da74ca6.js +0 -1
  439. package/apps/pwa/out/_next/static/chunks/app/bots/page-6e5c3a867557298b.js +0 -1
  440. package/apps/pwa/out/_next/static/chunks/app/ceo/page-7110f732f45bf944.js +0 -1
  441. package/apps/pwa/out/_next/static/chunks/app/chat/page-9a41d65355fc25b9.js +0 -1
  442. package/apps/pwa/out/_next/static/chunks/app/consult/page-5e77cd1802567c89.js +0 -1
  443. package/apps/pwa/out/_next/static/chunks/app/control/page-41afec0b255ed2bb.js +0 -1
  444. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4187666132fc98c4.js +0 -1
  445. package/apps/pwa/out/_next/static/chunks/app/editor/page-09328d499162bae4.js +0 -1
  446. package/apps/pwa/out/_next/static/chunks/app/exec/page-29ff3a8f84f03b6a.js +0 -1
  447. package/apps/pwa/out/_next/static/chunks/app/field/page-80ec7401bc83f511.js +0 -1
  448. package/apps/pwa/out/_next/static/chunks/app/healthz/route-c1a96669158471ec.js +0 -1
  449. package/apps/pwa/out/_next/static/chunks/app/inside/page-ab93b240f687d69a.js +0 -1
  450. package/apps/pwa/out/_next/static/chunks/app/intake/page-fc7d86467291ae38.js +0 -1
  451. package/apps/pwa/out/_next/static/chunks/app/layout-de43a186e5ba5683.js +0 -1
  452. package/apps/pwa/out/_next/static/chunks/app/live/page-8b08cafad27759c9.js +0 -1
  453. package/apps/pwa/out/_next/static/chunks/app/loops/page-0e2d6985fb42cd2f.js +0 -1
  454. package/apps/pwa/out/_next/static/chunks/app/market/page-13e4108999f58d13.js +0 -1
  455. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-e8296ee284aa1293.js +0 -1
  456. package/apps/pwa/out/_next/static/chunks/app/morning/page-043a9111e74f6b24.js +0 -1
  457. package/apps/pwa/out/_next/static/chunks/app/not-found-23a4601ceb7f452e.js +0 -1
  458. package/apps/pwa/out/_next/static/chunks/app/observatory/page-1ed1324b1dc36754.js +0 -1
  459. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-10d958605a77b3bd.js +0 -1
  460. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-166745e51ac0c7b0.js +0 -1
  461. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-a04260b8ccc968d4.js +0 -1
  462. package/apps/pwa/out/_next/static/chunks/app/outputs/page-7a1d85e121065cc8.js +0 -1
  463. package/apps/pwa/out/_next/static/chunks/app/page-1f50e3d4e13435f4.js +0 -1
  464. package/apps/pwa/out/_next/static/chunks/app/reflection/page-12916918988ac97e.js +0 -1
  465. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-d8a3347a9d0c3f4a.js +0 -1
  466. package/apps/pwa/out/_next/static/chunks/app/sessions/page-a9117d2cd3093d36.js +0 -1
  467. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-5a64cb5208c6a8fd.js +0 -1
  468. package/apps/pwa/out/_next/static/chunks/app/settings/page-1407ec83ea64c1f9.js +0 -1
  469. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-1d824510df025d18.js +0 -1
  470. package/apps/pwa/out/_next/static/chunks/app/setup/layout-c1a96669158471ec.js +0 -1
  471. package/apps/pwa/out/_next/static/chunks/app/setup/page-6e878ebbe1737d00.js +0 -1
  472. package/apps/pwa/out/_next/static/chunks/app/share/page-7f49fe645d5132f7.js +0 -1
  473. package/apps/pwa/out/_next/static/chunks/app/showroom/page-81a4e7f8dbfe74cb.js +0 -1
  474. package/apps/pwa/out/_next/static/chunks/app/tasks/page-27d71f131acd9320.js +0 -1
  475. package/apps/pwa/out/_next/static/chunks/app/term/page-3ab59d19913b200e.js +0 -1
  476. package/apps/pwa/out/_next/static/chunks/app/today/page-bf85b2f5bf077925.js +0 -1
  477. package/apps/pwa/out/_next/static/chunks/app/trace/page-424659bacfb8cdd7.js +0 -1
  478. package/apps/pwa/out/_next/static/chunks/app/vault/page-ab32a8f522a60371.js +0 -1
  479. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-fc2df261f8a2c7df.js +0 -1
  480. package/apps/pwa/out/_next/static/chunks/app/workflows/page-09461366af82ef13.js +0 -1
  481. package/apps/pwa/out/_next/static/chunks/app/workspace/page-5d4fef9ccd7d406b.js +0 -1
  482. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ea90a8ac73ce2749.js +0 -1
  483. package/apps/pwa/out/_next/static/chunks/framework-5c169757551134ed.js +0 -1
  484. package/apps/pwa/out/_next/static/chunks/main-59fe95fed40c7a01.js +0 -1
  485. package/apps/pwa/out/_next/static/chunks/main-app-4ca74dbbee7057c6.js +0 -1
  486. package/apps/pwa/out/_next/static/chunks/pages/_app-13a0a886df4d97a1.js +0 -1
  487. package/apps/pwa/out/_next/static/chunks/pages/_error-6af8591cd3a3f038.js +0 -1
  488. package/apps/pwa/out/_next/static/chunks/webpack-539cde99554be20f.js +0 -1
  489. package/apps/pwa/out/_next/static/css/001d5fbaee388a25.css +0 -1
  490. package/apps/pwa/out/_next/static/yRA5ur6DcPl0YHSRheH2O/_buildManifest.js +0 -1
  491. /package/apps/pwa/out/_next/static/{yRA5ur6DcPl0YHSRheH2O → J5H7BvqgSI8DUjtRRcA_S}/_ssgManifest.js +0 -0
@@ -0,0 +1,138 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { debug } from '../debug/log.js';
4
+ import { calendarFields, resolveTimeZone } from '../time/format.js';
5
+ import { cronMatches } from './cron-match.js';
6
+ import { listScheduleRuns } from './schedule-runs.js';
7
+ import { listSchedules, openSchedulesDb, type ScheduleRow } from './schedule-registry.js';
8
+ import type { SpawnOutcome } from './schedule-runner.js';
9
+
10
+ export interface GuardianPlan {
11
+ jobs: readonly (Pick<ScheduleRow, 'id' | 'cron' | 'enabled' | 'source' | 'run_via'> & Partial<Pick<ScheduleRow, 'command'>>)[];
12
+ timeZone?: string;
13
+ }
14
+
15
+ export interface GuardianDeps {
16
+ config?: { guardian?: { mode?: 'shadow' | 'live' } };
17
+ /** Inject a cron-run-compatible executor in tests; shadow never calls it. */
18
+ execute?: (command: string) => Promise<SpawnOutcome>;
19
+ }
20
+
21
+ function shellQuote(value: string): string {
22
+ return `'${value.replaceAll("'", "'\\''")}'`;
23
+ }
24
+
25
+ function matchedJobs(plan: GuardianPlan): GuardianPlan['jobs'] {
26
+ return plan.jobs.filter(job => job.source === 'crontab' && job.run_via === 'crontab' && !!job.enabled && !!job.cron);
27
+ }
28
+
29
+ /** The wrapper prefix a live run may reuse as-is: `<bun> <…/>cron-run.ts --schedule-id <id> …` as the command's own
30
+ * first program (cron-run honours --schedule-id only as argv[0]). Fail-closed: any other mention of cron-run.ts —
31
+ * e.g. inside a `sh -c` body, behind `cd … &&`, or with shell control operators — is ambiguous and refused, so a run
32
+ * can never be attributed to the wrong (or no) schedule id. Unwrapped commands are wrapped with this job's id. */
33
+ function liveCommand(jobId: string, raw: string): string {
34
+ if (!/cron-run\.ts/.test(raw)) {
35
+ return `${shellQuote(process.execPath)} ${shellQuote(fileURLToPath(new URL('../../scripts/cron-run.ts', import.meta.url)))} --schedule-id ${shellQuote(jobId)} --shell /bin/sh -c ${shellQuote(raw)}`;
36
+ }
37
+ // First program must be bun itself (bare `bun`, a path ending in /bun, or this process's executable).
38
+ const prefix = /^\s*(?:'(?:[^']*\/)?bun'|(?:[^\s'"]*\/)?bun)\s+(?:'[^']*\/)?(?:[^\s'"]*\/)?cron-run\.ts'?\s+--schedule-id\s+(?:'([^']*)'|([^\s'"]+))(?:\s|$)/.exec(raw);
39
+ const declaredId = prefix ? (prefix[1] ?? prefix[2]) : undefined;
40
+ const controls = /[;&|`\n]|\$\(/.test(raw);
41
+ if (declaredId !== jobId || controls || (raw.match(/cron-run\.ts/g) ?? []).length !== 1) {
42
+ throw new Error(`guardian live job ${jobId}: cron-run --schedule-id is ${declaredId ?? 'missing'}${controls ? ' (shell control operators)' : ''} — refusing misattributed run`);
43
+ }
44
+ return raw;
45
+ }
46
+
47
+ /** One five-minute guardian observation. No schedule state or crontab is mutated in shadow mode. */
48
+ export async function guardianTick(now: Date, plan: GuardianPlan, deps: GuardianDeps = {}): Promise<{ mode: 'shadow' | 'live'; wouldRun: string[]; executed: number }> {
49
+ if (!Number.isFinite(now.getTime())) throw new RangeError('invalid tick time');
50
+ const mode = deps.config?.guardian?.mode === 'live' ? 'live' : 'shadow';
51
+ const timeZone = plan.timeZone ?? resolveTimeZone().timeZone;
52
+ const wouldRun: string[] = [];
53
+ let executed = 0;
54
+ for (const job of matchedJobs(plan)) {
55
+ if (!cronMatches(job.cron!, now, { timeZone })) continue;
56
+ wouldRun.push(job.id);
57
+ debug.log('guardian.tick', 'would-run', { job: job.id, cron: job.cron });
58
+ if (mode === 'live') {
59
+ if (!job.command) throw new Error(`guardian live job has no command: ${job.id}`);
60
+ const command = liveCommand(job.id, job.command);
61
+ const execute = deps.execute ?? (await import('./schedule-runner.js')).defaultSpawnJob;
62
+ const outcome = await execute(command);
63
+ executed++;
64
+ if (outcome.code !== 0) throw new Error(`guardian job ${job.id} exited ${outcome.code}`);
65
+ }
66
+ }
67
+ return { mode, wouldRun, executed };
68
+ }
69
+
70
+ export interface GuardianDayDeps {
71
+ db: Database;
72
+ plan: GuardianPlan;
73
+ /** Optional measured alert counts, keyed by stable schedule id. Absent means unmeasured, not zero. */
74
+ alertsActual?: (jobId: string, day: string) => number | null;
75
+ }
76
+
77
+ export interface GuardianComparison {
78
+ day: string;
79
+ timeZone: string;
80
+ jobs: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
81
+ diff: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
82
+ same: boolean;
83
+ /** Jobs whose measured alert count differs from would. Empty when alerts are unmeasured. */
84
+ alertDiff: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
85
+ }
86
+
87
+ /** Count the 5-minute shadow ticks for a calendar day in the scheduler's time zone — using the CURRENT plan
88
+ * (today's cron/enabled), so a past day is judged against the present schedule, not the one in force that day —
89
+ * then compare actual fire history by stable id and instant (never by job name). */
90
+ export function guardianShadowDay(day: string, deps?: GuardianDayDeps): GuardianComparison {
91
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(`${day}T00:00:00Z`))
92
+ || new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day) {
93
+ throw new RangeError('day must be a valid YYYY-MM-DD');
94
+ }
95
+ if (!deps) {
96
+ const db = openSchedulesDb();
97
+ try { return guardianShadowDay(day, { db, plan: { jobs: listSchedules(db) } }); }
98
+ finally { db.close(); }
99
+ }
100
+ const timeZone = deps.plan.timeZone ?? resolveTimeZone().timeZone;
101
+ const jobs = matchedJobs(deps.plan);
102
+ const counts = new Map(jobs.map(job => [job.id, 0]));
103
+ const date = Date.parse(`${day}T00:00:00Z`);
104
+ // Bracket every possible IANA civil-day offset, including daylight-saving transitions.
105
+ for (let ms = date - 14 * 3600_000; ms < date + 38 * 3600_000; ms += 5 * 60_000) {
106
+ const at = new Date(ms);
107
+ const fields = calendarFields(at, { timeZone });
108
+ if (`${fields.year}-${String(fields.month).padStart(2, '0')}-${String(fields.day).padStart(2, '0')}` !== day) continue;
109
+ for (const job of jobs) {
110
+ if (cronMatches(job.cron!, at, { timeZone })) counts.set(job.id, counts.get(job.id)! + 1);
111
+ }
112
+ }
113
+ const rows = jobs.map(job => {
114
+ // schedule_runs retains only the latest 500 fires per id: older days can be incomplete.
115
+ const runs = listScheduleRuns(deps.db, job.id, { limit: 500 });
116
+ const actual = runs.filter(run => {
117
+ const fields = calendarFields(new Date(run.fired_at), { timeZone });
118
+ return `${fields.year}-${String(fields.month).padStart(2, '0')}-${String(fields.day).padStart(2, '0')}` === day;
119
+ }).length;
120
+ if (runs.length === 500 && runs.length > 0) {
121
+ const oldest = calendarFields(new Date(runs[runs.length - 1]!.fired_at), { timeZone });
122
+ const oldestDay = `${oldest.year}-${String(oldest.month).padStart(2, '0')}-${String(oldest.day).padStart(2, '0')}`;
123
+ if (oldestDay >= day) throw new Error(`guardian comparison incomplete: schedule_runs history truncated for ${job.id} on ${day}`);
124
+ }
125
+ const alertsActual = deps.alertsActual?.(job.id, day) ?? null;
126
+ if (alertsActual !== null && (!Number.isSafeInteger(alertsActual) || alertsActual < 0)) {
127
+ throw new RangeError('alertsActual must be a non-negative integer or null');
128
+ }
129
+ return { job: job.id, would: counts.get(job.id)!, actual, alertsActual };
130
+ });
131
+ // `same` judges scheduled vs recorded fires only. Alert counts are a separate axis: unmeasured (null) by default,
132
+ // and a measured mismatch is reported in alertDiff without changing the fire verdict.
133
+ const diff = rows.filter(row => row.would !== row.actual);
134
+ const same = diff.length === 0;
135
+ const alertDiff = rows.filter(row => row.alertsActual !== null && row.would !== row.alertsActual);
136
+ debug.log('guardian.compare', 'day', { same, diff, alertDiff });
137
+ return { day, timeZone, jobs: rows, diff, same, alertDiff };
138
+ }
@@ -5,8 +5,8 @@
5
5
  // send.py 와 동일 동작을 TS 로 포팅(완전 elanous 소유). sync(curl) 계약.
6
6
 
7
7
  import * as childProcess from 'node:child_process';
8
- import { existsSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'node:fs';
9
- import { dirname, join, relative } from 'node:path';
8
+ import { existsSync, readFileSync, appendFileSync, mkdirSync, unlinkSync, readdirSync, renameSync, openSync, closeSync, statSync, writeFileSync } from 'node:fs';
9
+ import { basename, dirname, join, relative } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { getElanousConfigDir } from '../elanous-config-dir.js';
12
12
  import { conatusEnv } from './conatus-env.js';
@@ -15,7 +15,7 @@ import { openSurfaceEventsDb, recordEvent } from './surface-events.js';
15
15
  import { latestUserIntentTs } from '../user-intent/index.js';
16
16
  import { getUserConfig, type UserConfig } from '../user-config.js';
17
17
  import { resolveChannelBotToken } from '../channel-bot-token.js';
18
- import { isOperationalKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './telegram-kind-route.js';
18
+ import { explainReportRoute, isOperationalKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './telegram-kind-route.js';
19
19
  import { debug } from '../debug/log.js';
20
20
  import { registerLogStoreSink, setLogInstanceName } from '../mss/logging/log-store.js';
21
21
  import type { LogSink } from '../mss/logging/sink.js';
@@ -24,7 +24,8 @@ import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js'
24
24
  // ★ origin 되돌림(대표 2026-07-12) — 미션 알림을 발신 채널(메인 Q&A 봇)로 되돌린다. type-only
25
25
  // import 라 런타임 순환 없음(발송 로직은 이 파일에 self-contained). origin 없으면 report 폴백.
26
26
  import type { MissionOrigin } from '../autopilot/mission-origin.js';
27
- import { conatusPath } from './conatus-data-dir.js';
27
+ import { conatusDataDir, conatusPath } from './conatus-data-dir.js';
28
+ import { assertNoRealSendFromTest, assertNotTestWritingOps, isTestDouble } from '../instance/test-write-guard.js';
28
29
 
29
30
  /** Explicit `ELANOUS_NEXUS_URL` wins. Otherwise ask the daemon endpoint resolver.
30
31
  * A missing daemon is not a guessed port — the caller falls through to direct send. */
@@ -37,6 +38,8 @@ function nexusUrl(): string | null {
37
38
  // /v1/outbound 로 POST — getElanousConfigDir() 치환은 prod 동치(~/.elanous) + --config-dir 정합.
38
39
  const ACP_TOKEN_PATH = join(getElanousConfigDir(), 'acp-token');
39
40
  const DEFERRED_PATH = conatusPath('outbound_deferred.jsonl');
41
+ // The real transport, captured before any test can spy on it — a pass-through spy is not a fake.
42
+ const REAL_EXEC_FILE_SYNC = childProcess.execFileSync;
40
43
 
41
44
  // CLI/cron do not inherit the daemon's StoreSink. Register once before the first
42
45
  // send, but never attach a second sink to a daemon which already owns one.
@@ -138,11 +141,12 @@ export function userRecentlyActive(now: Date = new Date()): boolean {
138
141
 
139
142
  /** 야간 보류 적재 (jsonl append — 크론 동시 실행에 안전). origin 있으면 함께 적재 —
140
143
  * 아침 flush 가 그 origin(발신 채널)으로 되돌려 발송(없으면 report 묶음). */
141
- function deferOutbound(text: string, kind: string, origin?: MissionOrigin | null, path = DEFERRED_PATH): boolean {
144
+ export function deferOutbound(text: string, kind: string, origin?: MissionOrigin | null, path = DEFERRED_PATH): boolean {
145
+ assertNotTestWritingOps(path, 'append the deferred-alert queue');
142
146
  try {
143
147
  mkdirSync(dirname(path), { recursive: true });
144
148
  const rec = { ts: new Date().toISOString(), kind, text, ...(origin ? { origin } : {}) };
145
- appendFileSync(path, JSON.stringify(rec) + '\n');
149
+ withQueueLock(path, () => appendFileSync(path, JSON.stringify(rec) + '\n'));
146
150
  console.error(`[outbound] 야간 무음(00:00~06:30 KST) — 보류 적재 (${kind})`);
147
151
  return true;
148
152
  } catch (e) {
@@ -171,9 +175,12 @@ function resolveBotToken(botId?: string): string | null {
171
175
  function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: string) => void): boolean {
172
176
  if (origin.channel === 'discord') {
173
177
  if (!origin.channelId) return false;
178
+ let token: string | undefined;
179
+ try { token = resolveChannelBotToken('discord', getUserConfig())?.token; } catch { return false; }
180
+ if (!token) return false;
181
+ // Outside the swallowing try below: a refused test send must surface, not read as «delivery failed».
182
+ assertTestSendFaked('send a real Discord message');
174
183
  try {
175
- const token = resolveChannelBotToken('discord', getUserConfig())?.token;
176
- if (!token) return false;
177
184
  onBot?.('discord:configured');
178
185
  const channelId = origin.discordThreadId || origin.channelId;
179
186
  const out = spillLongContent(text).text;
@@ -184,6 +191,7 @@ function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: stri
184
191
  `https://discord.com/api/v10/channels/${channelId}/messages`,
185
192
  JSON.stringify({ content: out.slice(start, end) }),
186
193
  [`Authorization: Bot ${token}`, 'Content-Type: application/json'],
194
+ 'send a real Discord message',
187
195
  );
188
196
  if (typeof response?.id !== 'string' || !response.id) return false;
189
197
  start = end;
@@ -195,35 +203,200 @@ function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: stri
195
203
  const token = resolveBotToken(origin.botId);
196
204
  if (!token) return false;
197
205
  onBot?.(botLabel(token));
206
+ assertTestSendFaked('send a real Telegram message to the origin');
198
207
  try { return sendTelegramRaw(token, origin.chatId, text, origin.threadId); } catch { return false; }
199
208
  }
200
209
 
201
- /** 보류분 일괄 발송 — 묶음 1건(아침 폭주 방지). 발송 건수 반환.
202
- * 무음 창 밖 첫 sendOutbound 가 자동 호출 + 06:31 플러시 크론이 보장. */
203
- export function flushDeferred(path = DEFERRED_PATH): number {
210
+ /** 보류 큐 재시도 상한 env — config `outbound.deferredMaxAttempts` 가 없을 때만 읽는다. */
211
+ export const DEFERRED_MAX_ATTEMPTS_ENV = 'ELANOUS_OUTBOUND_DEFERRED_MAX_ATTEMPTS';
212
+
213
+ /** 보류 항목이 격리되기 전까지 허용하는 실패 flush 수 — config > env > 5. */
214
+ export function deferredMaxAttempts(config?: Pick<UserConfig, 'outbound'>): number {
215
+ try {
216
+ const v = (config ?? getUserConfig()).outbound?.deferredMaxAttempts;
217
+ if (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) return v;
218
+ } catch { /* unreadable config: env, then default */ }
219
+ const raw = Number(process.env[DEFERRED_MAX_ATTEMPTS_ENV]?.trim() || NaN);
220
+ if (Number.isSafeInteger(raw) && raw > 0) return raw;
221
+ return 5;
222
+ }
223
+
224
+ /** 격리 파일 — 보류 큐 옆 `<name>.failed.jsonl`. */
225
+ export function deferredQuarantinePath(path: string): string {
226
+ return path.endsWith('.jsonl') ? `${path.slice(0, -'.jsonl'.length)}.failed.jsonl` : `${path}.failed`;
227
+ }
228
+
229
+ function pidAlive(pid: number): boolean {
230
+ try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
231
+ }
232
+
233
+ /** 큐 적재·claim 이 서로를 가로지르지 않게 하는 짧은 배타 잠금(`<queue>.lock`, O_EXCL).
234
+ * 적재는 open→write→close 를 잠금 안에서 하므로 claim 의 rename 이 «열린 채 쓰기 전»인 적재를 가져가지 않는다.
235
+ * 잠금을 못 얻으면(2초 · 30초 넘은 잔여 잠금은 걷는다) 그대로 진행한다 — 알림을 잃는 것보다 낫다. */
236
+ function withQueueLock<T>(path: string, fn: () => T): T {
237
+ const lock = `${path}.lock`;
238
+ const deadline = Date.now() + 2_000;
239
+ let fd: number | null = null;
240
+ while (fd === null && Date.now() <= deadline) {
241
+ try { fd = openSync(lock, 'wx'); break; } catch (e) {
242
+ const code = (e as NodeJS.ErrnoException).code;
243
+ if (code === 'ENOENT') { try { mkdirSync(dirname(path), { recursive: true }); } catch { break; } continue; }
244
+ if (code !== 'EEXIST') break;
245
+ try { if (Date.now() - statSync(lock).mtimeMs > 30_000) { unlinkSync(lock); continue; } } catch { continue; }
246
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
247
+ }
248
+ }
249
+ if (fd === null) { try { debug.log('outbound.send', 'deferred-lock-unavailable', { lock: basename(lock) }); } catch { /* */ } }
250
+ try { return fn(); } finally {
251
+ if (fd !== null) { try { closeSync(fd); } catch { /* */ } try { unlinkSync(lock); } catch { /* */ } }
252
+ }
253
+ }
254
+
255
+ /** 이보다 오래된 claim 은 소유 pid 가 살아 있어도 회수한다(pid 재사용 대비 · 정상 flush 는 이보다 훨씬 짧다). */
256
+ const STALE_CLAIM_MS = 15 * 60_000;
257
+
258
+ let claimSeq = 0;
259
+ /** 큐를 «가져간다» — 원자적 rename 으로 이 flush 만의 파일로 옮긴다. flush 도중 적재되는 항목은 새 큐 파일에
260
+ * 쌓이므로 잃지 않는다. 죽은 flusher(또는 이 프로세스의 앞 호출)가 남긴 claim 도 같이 회수한다. */
261
+ function claimDeferred(path: string): string[] {
262
+ const claims: string[] = [];
263
+ const next = (): string => `${path}.flushing-${process.pid}-${Date.now()}-${claimSeq++}`;
264
+ const dir = dirname(path);
265
+ const prefix = `${basename(path)}.flushing-`;
266
+ try {
267
+ for (const name of readdirSync(dir)) {
268
+ if (!name.startsWith(prefix)) continue;
269
+ const m = /^(\d+)-(\d+)-/.exec(name.slice(prefix.length));
270
+ const pid = Number(m?.[1]);
271
+ const at = Number(m?.[2]);
272
+ if (!Number.isSafeInteger(pid)) continue;
273
+ // flushDeferred 는 동기라 같은 pid 의 claim 은 «지난 호출의 잔여»다. 남의 것은 죽었거나 오래됐을 때만.
274
+ const stale = Number.isFinite(at) && Date.now() - at > STALE_CLAIM_MS;
275
+ if (pid !== process.pid && !stale && pidAlive(pid)) continue;
276
+ const own = next();
277
+ try { renameSync(join(dir, name), own); claims.push(own); } catch { /* another flusher took it */ }
278
+ }
279
+ } catch { /* no directory yet */ }
280
+ withQueueLock(path, () => {
281
+ if (!existsSync(path)) return;
282
+ const own = next();
283
+ try { renameSync(path, own); claims.push(own); } catch { /* another flusher took it */ }
284
+ });
285
+ return claims;
286
+ }
287
+
288
+ /** flushDeferred 주입점 — 시험은 실제 발송 대신 sendBatch 를 준다. */
289
+ export type FlushDeferredDeps = {
290
+ sendBatch?: (text: string, kind: string, origin?: MissionOrigin) => boolean;
291
+ maxAttempts?: number;
292
+ };
293
+
294
+ /** 보류분 일괄 발송 — 묶음 단위. 배달 건수 반환.
295
+ * 무음 창 밖 첫 sendOutbound 가 자동 호출 + 06:31 플러시 크론이 보장.
296
+ * ⭐ 실패한 묶음의 항목만 큐에 남긴다(성공 묶음은 다시 보내지 않는다 · 10-07 야간 11회 재발송 사고).
297
+ * 실패가 `maxAttempts` 번 쌓인 항목은 `<name>.failed.jsonl` 로 격리한다. */
298
+ export function flushDeferred(path = DEFERRED_PATH, deps: FlushDeferredDeps = {}): number {
299
+ assertNotTestWritingOps(path, 'flush the deferred-alert queue');
204
300
  ensureOutboundLogSink();
205
- const observeFlush = (count: number, lagMin: number, kinds: string[]): void => {
301
+ const observeFlush = (
302
+ count: number, lagMin: number, kinds: string[],
303
+ outcome: { sent: number; kept: number; quarantined: number },
304
+ ): void => {
206
305
  const lagWarnMin = flushLagWarnMin();
207
306
  const over = count > 0 && lagMin > lagWarnMin;
208
307
  logSend(
209
308
  'flush',
210
309
  'deferred-batch',
211
- { count, lagMin, kinds: kinds.map(safeObservationLabel), path, lagWarnMin },
310
+ { count, lagMin, kinds: kinds.map(safeObservationLabel), path, lagWarnMin, ...outcome },
212
311
  over ? { level: 'warn' } : undefined,
213
312
  );
214
313
  };
215
- if (!existsSync(path)) {
216
- observeFlush(0, 0, []);
314
+ const none = { sent: 0, kept: 0, quarantined: 0 };
315
+ const claims = claimDeferred(path);
316
+ if (claims.length === 0) {
317
+ observeFlush(0, 0, [], none);
217
318
  return 0;
218
319
  }
219
- type Item = { ts: string; kind: string; text: string; origin?: MissionOrigin };
220
- let items: Item[] = [];
221
- try {
222
- items = readFileSync(path, 'utf-8').split('\n').filter(Boolean).map(l => JSON.parse(l));
223
- } catch { /* 손상 라인 무시 */ }
320
+ type Item = { ts: string; kind: string; text: string; origin?: MissionOrigin; attempts?: number };
321
+ const items: Item[] = [];
322
+ const corrupt: string[] = [];
323
+ const readClaims: string[] = [];
324
+ for (const claim of claims) {
325
+ let body = '';
326
+ try { body = readFileSync(claim, 'utf-8'); } catch (e) {
327
+ // 못 읽은 claim 은 지우지 않는다 — 다음 flush 가 다시 회수한다.
328
+ try { debug.log('outbound.send', 'deferred-claim-unreadable', { error: e instanceof Error ? e.message : String(e) }, { level: 'warn' }); } catch { /* */ }
329
+ continue;
330
+ }
331
+ readClaims.push(claim);
332
+ for (const line of body.split('\n')) {
333
+ if (!line.trim()) continue;
334
+ try {
335
+ const rec = JSON.parse(line) as Item;
336
+ if (rec && typeof rec === 'object' && typeof rec.text === 'string') items.push(rec);
337
+ else corrupt.push(line);
338
+ } catch { corrupt.push(line); }
339
+ }
340
+ }
341
+ const qPath = deferredQuarantinePath(path);
342
+ const settleFailed = (stage: string, e: unknown): void => {
343
+ try { debug.log('outbound.send', 'deferred-settle-failed', { stage, error: e instanceof Error ? e.message : String(e) }, { level: 'warn' }); } catch { /* */ }
344
+ };
345
+ /** 읽은 claim 을 정산한다. 격리 기록이 실패하면 그 줄은 큐로 돌린다(다음에 다시 격리).
346
+ * 큐 기록이 실패하면 claim 하나를 «남길 줄만»으로 바꿔 두어 다음 flush 가 배달분을 다시 보내지 않게 한다. */
347
+ /** 정산된 claim 을 걷는다. 삭제가 실패하면 비워 둔다 — 남은 claim 이 다음 flush 에 배달분을 다시 내지 않게. */
348
+ const retireClaim = (claim: string): void => {
349
+ try { unlinkSync(claim); return; } catch (e) { settleFailed('claim-unlink', e); }
350
+ try { writeFileSync(claim, ''); } catch (e) { settleFailed('claim-truncate', e); }
351
+ };
352
+ type Quarantine = { line: string; kind?: string; attempts?: number };
353
+ /** 반환 = 실제 정산 결과(관측은 이것으로 센다). */
354
+ const settle = (keep: string[], quarantine: Quarantine[]): { kept: number; quarantined: number } => {
355
+ let back = keep;
356
+ let quarantined = 0;
357
+ if (quarantine.length) {
358
+ try {
359
+ appendFileSync(qPath, quarantine.map(q => q.line).join('\n') + '\n');
360
+ quarantined = quarantine.length;
361
+ for (const q of quarantine) {
362
+ if (q.kind === undefined) continue;
363
+ try { debug.log('outbound.send', 'deferred-quarantined', { kind: safeObservationLabel(q.kind), attempts: q.attempts }); } catch { /* */ }
364
+ }
365
+ } catch (e) { settleFailed('quarantine', e); back = [...keep, ...quarantine.map(q => q.line)]; }
366
+ }
367
+ const result = { kept: back.length, quarantined };
368
+ if (readClaims.length === 0) return result;
369
+ let wrote = back.length === 0;
370
+ if (!wrote) {
371
+ try { withQueueLock(path, () => appendFileSync(path, back.join('\n') + '\n')); wrote = true; } catch (e) { settleFailed('queue', e); }
372
+ }
373
+ if (!wrote) {
374
+ const first = readClaims[0]!;
375
+ const tmp = join(dirname(first), `.${basename(first)}.tmp`);
376
+ try {
377
+ writeFileSync(tmp, back.join('\n') + '\n');
378
+ renameSync(tmp, first);
379
+ for (const claim of readClaims.slice(1)) retireClaim(claim);
380
+ } catch (e) { settleFailed('claim-rewrite', e); }
381
+ return result;
382
+ }
383
+ for (const claim of readClaims) retireClaim(claim);
384
+ return result;
385
+ };
386
+ const maxAttempts = deps.maxAttempts ?? deferredMaxAttempts();
387
+ const priorAttempts = (it: Item): number =>
388
+ typeof it.attempts === 'number' && Number.isSafeInteger(it.attempts) && it.attempts > 0 ? it.attempts : 0;
389
+ const quarantine: Quarantine[] = corrupt.map(line => ({ line }));
390
+ // 이미 상한에 닿은 줄(앞 flush 의 격리 기록이 실패해 큐로 돌아온 것)은 보내지 않고 다시 격리만 시도한다.
391
+ for (let i = items.length - 1; i >= 0; i--) {
392
+ const it = items[i]!;
393
+ if (priorAttempts(it) < maxAttempts) continue;
394
+ quarantine.push({ line: JSON.stringify(it), kind: it.kind, attempts: priorAttempts(it) });
395
+ items.splice(i, 1);
396
+ }
224
397
  if (items.length === 0) {
225
- try { unlinkSync(path); } catch { /* */ }
226
- observeFlush(0, 0, []);
398
+ const settled = settle([], quarantine);
399
+ observeFlush(0, 0, [], { ...none, ...settled });
227
400
  return 0;
228
401
  }
229
402
  const kst = (iso: string) => {
@@ -236,8 +409,7 @@ export function flushDeferred(path = DEFERRED_PATH): number {
236
409
  `🌙 야간 보류 알림 ${list.length}건 (00:00~06:30 KST 무음 · ⏳ 최대 ${lagMin}분 지연 — 일괄 전달)`,
237
410
  ...list.map(i => `\n── [${kst(i.ts)} · ${i.kind}] ──\n${i.text}`),
238
411
  ].join('\n');
239
- observeFlush(items.length, lagMin, [...new Set(items.map(i => i.kind))]);
240
- const sendBatch = (text: string, kind: string, origin?: MissionOrigin): boolean => {
412
+ const sendBatch = deps.sendBatch ?? ((text: string, kind: string, origin?: MissionOrigin): boolean => {
241
413
  let bot = 'unknown';
242
414
  const path = origin
243
415
  ? (deliverToOrigin(origin, text, label => { bot = label; }) ? 'origin' : false)
@@ -250,15 +422,17 @@ export function flushDeferred(path = DEFERRED_PATH): number {
250
422
  });
251
423
  } catch { /* observation must not change delivery */ }
252
424
  return path !== false;
425
+ });
426
+ const failed: Item[] = [];
427
+ const send = (batch: Item[], kind: string, origin?: MissionOrigin): void => {
428
+ if (!sendBatch(fmt(batch), kind, origin)) failed.push(...batch);
253
429
  };
254
- let delivered = 0;
255
430
  // origin 없는 매매 알림만 report 묶음. 그 외는 kind 별로 운영 봇 경계를 유지한다.
256
431
  const noOrigin = items.filter(i => !i.origin);
257
432
  const tradingBatch = noOrigin.filter(i => !isOperationalKind(i.kind));
258
- if (tradingBatch.length && sendBatch(fmt(tradingBatch), 'report')) delivered += tradingBatch.length;
433
+ if (tradingBatch.length) send(tradingBatch, 'report');
259
434
  for (const kind of new Set(noOrigin.filter(i => isOperationalKind(i.kind)).map(i => i.kind))) {
260
- const batch = noOrigin.filter(i => i.kind === kind);
261
- if (sendBatch(fmt(batch), kind)) delivered += batch.length;
435
+ send(noOrigin.filter(i => i.kind === kind), kind);
262
436
  }
263
437
  // origin 있는 것 = 발신 채널·스레드별로 묶어 서로 다른 수신자에게 섞이지 않게 발송.
264
438
  const groups = new Map<string, { origin: MissionOrigin; list: Item[] }>();
@@ -272,15 +446,34 @@ export function flushDeferred(path = DEFERRED_PATH): number {
272
446
  }
273
447
  for (const g of groups.values()) {
274
448
  const kinds = new Set(g.list.map(i => i.kind));
275
- if (sendBatch(fmt(g.list), kinds.size === 1 ? g.list[0]!.kind : 'mixed', g.origin)) delivered += g.list.length;
449
+ send(g.list, kinds.size === 1 ? g.list[0]!.kind : 'mixed', g.origin);
276
450
  }
277
- // 전량 배달 성공 시에만 파일 제거(부분 실패는 다음 flush 재시도 — 성공분 중복은 드문 엣지 수용).
278
- if (delivered >= items.length) { try { unlinkSync(path); } catch { /* */ } }
451
+ // 실패 묶음의 항목만 되돌린다(시도 수 +1). 상한에 닿은 항목은 격리 — 영원히 재시도하지 않는다.
452
+ const keep: string[] = [];
453
+ for (const it of failed) {
454
+ const attempts = priorAttempts(it) + 1;
455
+ const line = JSON.stringify({ ...it, attempts });
456
+ if (attempts >= maxAttempts) quarantine.push({ line, kind: it.kind, attempts });
457
+ else keep.push(line);
458
+ }
459
+ const settled = settle(keep, quarantine);
460
+ const delivered = items.length - failed.length;
461
+ observeFlush(items.length, lagMin, [...new Set(items.map(i => i.kind))], { sent: delivered, ...settled });
279
462
  return delivered;
280
463
  }
281
464
 
282
- /** 동기 curl(POST). body 는 stdin. 파싱 실패/에러 → null. */
283
- function curlPost(url: string, body: string, headers: string[]): any {
465
+ /** TEST-PROD-LEAK — a test process may not reach a real bot or the ops daemon. `external` = a third-party bot API
466
+ * (refused unless curl is a test double replacing the real one); the local daemon is refused only when ops-routed. */
467
+ function assertTestSendFaked(what: string, external = true): void {
468
+ let roots: string[] = [];
469
+ try { roots = [getElanousConfigDir(), dirname(ACP_TOKEN_PATH), effectiveInstanceRoot(), conatusDataDir(), dirname(DEFERRED_PATH)]; }
470
+ catch { roots = [dirname(ACP_TOKEN_PATH), dirname(DEFERRED_PATH)]; }
471
+ assertNoRealSendFromTest(what, { transportFaked: isTestDouble(childProcess.execFileSync, REAL_EXEC_FILE_SYNC), routingRoots: roots, external });
472
+ }
473
+
474
+ /** 동기 curl(POST). body 는 stdin. 파싱 실패/에러 → null. `what` names the send for the test guard (never the URL — it carries the token). */
475
+ function curlPost(url: string, body: string, headers: string[], what = 'send a real outbound message', external = true): any {
476
+ assertTestSendFaked(what, external);
284
477
  const args = ['-s', '-m', '25', '-X', 'POST'];
285
478
  for (const h of headers) args.push('-H', h);
286
479
  args.push('--data', '@-', url);
@@ -387,6 +580,7 @@ export function setInProcessOutbound(send: InProcessOutbound | null): void { inP
387
580
  export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => void, source?: string): 'daemon' | 'direct' | false {
388
581
  // 0) inside the daemon — route in-process, asynchronously; the caller's synchronous answer is «accepted».
389
582
  if (inProcessOutbound && process.env.SEND_VIA_ELANOUS !== '0') {
583
+ assertTestSendFaked('route through the in-process daemon sender', false);
390
584
  onBot?.('daemon:configured');
391
585
  const send = inProcessOutbound;
392
586
  const fallback = (): void => {
@@ -419,7 +613,9 @@ export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => v
419
613
  ...(token ? [`Authorization: Bearer ${token}`] : [])];
420
614
  const nexus = nexusUrl();
421
615
  const j = nexus
422
- ? curlPost(`${nexus}/v1/outbound`, JSON.stringify({ text, markdown: false, kind }), headers)
616
+ // An explicit ELANOUS_NEXUS_URL can name any daemon, the ops one included: a test treats it like a real bot API.
617
+ ? curlPost(`${nexus}/v1/outbound`, JSON.stringify({ text, markdown: false, kind }), headers, 'post to the daemon /v1/outbound',
618
+ Boolean(process.env.ELANOUS_NEXUS_URL?.trim()))
423
619
  : null;
424
620
  const classification = classifyDaemonResponse(j);
425
621
  if (classification === 'ok') { onBot?.('daemon:configured'); return 'daemon'; }
@@ -429,27 +625,33 @@ export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => v
429
625
  logDaemonPath(classification, kind, extra);
430
626
  }
431
627
  // 2) fallback: 텔레그램 sendMessage 직접(3900자 분할) — 데몬 미경유라 클라가 원장 기록.
432
- if (sendTelegramDirect(text, kind, {}, onBot)) return 'direct';
433
- reportUndeliverable(kind, daemonPath);
628
+ // The same config judges the route and explains a failure (review r1).
629
+ let cfg: UserConfig | undefined;
630
+ try { cfg = getUserConfig(); } catch { /* sendTelegramDirect fails closed without config */ }
631
+ if (sendTelegramDirect(text, kind, cfg ? { config: cfg } : {}, onBot)) return 'direct';
632
+ reportUndeliverable(kind, daemonPath, cfg);
434
633
  return false;
435
634
  }
436
635
 
437
636
  /** OB8b — both paths failed: say so loudly, with the universe this process resolved, instead of a silent false.
438
637
  * The usual cause (10-01 · MK): an ad-hoc `bun -e` or a script from a source tree resolves a cwd-derived test
439
638
  * universe, so it finds neither the production daemon nor the production bot token. */
440
- function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-found' | 'disabled'): void {
639
+ function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-found' | 'disabled', cfg?: UserConfig): void {
441
640
  let root = '?';
442
641
  let universe: 'prod' | 'test' | '?' = '?';
443
642
  try {
444
643
  root = effectiveInstanceRoot();
445
644
  universe = root === prodInstanceRoot() ? 'prod' : 'test';
446
645
  } catch { /* the report still goes out */ }
646
+ // Which routing rule closed the direct path — «토큰 없음 또는 전송 실패» hid a missing role for four days (BRIEF-DELIVERY-1007).
647
+ let route: ReturnType<typeof explainReportRoute> | null = null;
648
+ try { if (cfg) route = explainReportRoute(cfg, kind); } catch { /* the report still goes out */ }
447
649
  ensureOutboundLogSink();
448
- try { debug.log('outbound.send', 'undeliverable', { kind: safeObservationLabel(kind), daemonPath, universe, root }); } catch { /* fail-soft */ }
650
+ try { debug.log('outbound.send', 'undeliverable', { kind: safeObservationLabel(kind), daemonPath, universe, root, route: route?.reason ?? 'unknown' }); } catch { /* fail-soft */ }
449
651
  const hint = universe === 'prod'
450
652
  ? '운영 데몬이 떠 있는지 확인: elanous nexus show'
451
653
  : '운영으로 보내려면 설치본 elanous 로 실행하거나 ELANOUS_STATE_DIR=~/.elanous 와 --config-dir ~/.elanous 를 준다';
452
- try { console.error(`[outbound] ⛔ 못 보냄(${kind}) — 데몬 ${daemonPath} · 직접 발송도 실패(토큰 없음 또는 전송 실패) · 이 프로세스의 우주 ${universe} (${root}) · ${hint}`); } catch { /* fail-soft */ }
654
+ try { console.error(`[outbound] ⛔ 못 보냄(${kind}) — 데몬 ${daemonPath} · 직접 발송도 실패(${route && route.reason !== 'routed' ? `${route.reason}: ${route.hint}` : '토큰 없음 또는 전송 실패'}) · 이 프로세스의 우주 ${universe} (${root}) · ${hint}`); } catch { /* fail-soft */ }
453
655
  }
454
656
 
455
657
  /** 텔레그램 raw 발송(토큰·chatId 명시) — spill + 3900자 분할(줄 경계). thread 지원. */
@@ -468,7 +670,7 @@ function sendTelegramRaw(token: string, chatId: string | number, text: string, t
468
670
  for (const ch of chunks) {
469
671
  const params: Record<string, string> = { chat_id: String(chatId), text: ch, disable_web_page_preview: 'true' };
470
672
  if (threadId !== undefined) params.message_thread_id = String(threadId);
471
- if (!curlPost(url, new URLSearchParams(params).toString(), ['Content-Type: application/x-www-form-urlencoded'])) ok = false;
673
+ if (!curlPost(url, new URLSearchParams(params).toString(), ['Content-Type: application/x-www-form-urlencoded'], 'send a real Telegram message')) ok = false;
472
674
  }
473
675
  return ok;
474
676
  }
@@ -480,6 +682,8 @@ export function sendTelegramDirect(
480
682
  onBot?: (bot: string) => void,
481
683
  ): boolean {
482
684
  const sendRaw = deps.sendRaw ?? sendTelegramRaw;
685
+ // An injected sender still may not run in a test that resolved the ops universe.
686
+ if (deps.sendRaw) assertTestSendFaked('send a Telegram message from the ops universe', false);
483
687
  let cfg: UserConfig | undefined;
484
688
  try { cfg = deps.config ?? getUserConfig(); } catch { /* unavailable config: operational delivery still fails closed */ }
485
689
  if (cfg) {
@@ -1,8 +1,23 @@
1
- import { sendOutbound, setInProcessOutbound } from './outbound-alert.js';
1
+ import { inQuietHours, kstMinutes, sendOutbound, setInProcessOutbound } from './outbound-alert.js';
2
2
  import { setUserConfigOverlay } from '../user-config.js';
3
3
 
4
4
  // Focused subprocess fixture: isolated state/config roots and a fake curl on PATH.
5
5
  setUserConfigOverlay(cfg => ({ ...cfg, telegram: { ...cfg.telegram, botToken: '123456:private-secret', homeChannel: 98765 } }));
6
6
  process.env.SEND_VIA_ELANOUS = '0';
7
+ // Quiet hours (00:00~06:30 KST) defer instead of sending, so a gate run at night saw no `sent` row.
8
+ // Shift this fixture's clock back to 23:59 KST of the previous day; the shift stays inside this process.
9
+ const quietShiftMs = inQuietHours() ? (kstMinutes() + 1) * 60_000 : 0;
10
+ if (quietShiftMs > 0) {
11
+ const RealDate = Date;
12
+ const shifted = () => RealDate.now() - quietShiftMs;
13
+ class ShiftedDate extends RealDate {
14
+ constructor(...args: unknown[]) {
15
+ if (args.length === 0) super(shifted());
16
+ else super(...(args as [string | number | Date]));
17
+ }
18
+ static override now(): number { return shifted(); }
19
+ }
20
+ globalThis.Date = ShiftedDate as DateConstructor;
21
+ }
7
22
  setInProcessOutbound(null);
8
23
  if (!sendOutbound('SECRET-BODY-DO-NOT-LOG', 'ops-alert', { channel: 'cli', surface: process.env.OUTBOUND_PROBE_SOURCE ?? 'outbound-log-probe' })) process.exitCode = 1;
@@ -2,7 +2,7 @@ import type { LLMToolSpec } from '../llm.js';
2
2
  import type { ToolRuntimeContext } from '../tool-runtime/types.js';
3
3
  import { debug } from '../debug/log.js';
4
4
  import { requestConfirmation, type ConfirmOpts, type ConfirmResult } from '../hitl/confirm.js';
5
- import { devVersion, listChecklist, summarizeChecklist, type Checklist } from '../release-loop/checklist.js';
5
+ import { devVersion, checklistDevVersion, listChecklist, summarizeChecklist, type Checklist } from '../release-loop/checklist.js';
6
6
  import { add, move, validateVersion } from '../release-loop/feature-store.js';
7
7
  import { formatKst, getSchedule, setSchedule, type ReleaseSchedule } from '../release-loop/release-schedule.js';
8
8
  import { latestGraphRun, type GraphRunState } from '../graph-runner/runner.js';
@@ -64,7 +64,7 @@ function dDay(days: number): string {
64
64
  export function dispatchReleaseStatus(args: Record<string, unknown>, deps: ReleaseToolDeps = {}): { text: string; structured: Record<string, unknown> } {
65
65
  const requested = args.version;
66
66
  if (requested !== undefined && (typeof requested !== 'string' || !requested.trim())) throw new Error('판 이름이 필요합니다');
67
- const version = requested ? null : (deps.devVersion?.() ?? devVersion());
67
+ const version = requested ? null : checklistDevVersion(deps.devVersion?.() ?? devVersion());
68
68
  const versions = requested ? [requested as string] : [version!, nextVersion(version!)];
69
69
  const now = deps.now ?? new Date();
70
70
  const run = (deps.latestRun ?? latestGraphRun)('release-loop');