elanous 0.2.14 → 0.2.15

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 (293) 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/LZcstaO9_NXypPFmujolv/_buildManifest.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/{1307-6e7e0377c1db0bdc.js → 1307-33ffcc49ab45123b.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/5977-c952712c87a1f757.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/app/approvals/{page-74383f890f075511.js → page-0e2c57fe6add4b2a.js} +1 -1
  8. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-7b2b4f378354869a.js → page-e05a77df7e005b04.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/app/board/page-f544338193a9a762.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/botlab/page-5491c115b1f9a923.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/bots/{page-1512d91c7491ed62.js → page-b8c49f6b4fa2df91.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/ceo/page-96398e364fbd4548.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/chat/{page-a627db94ec6904a0.js → page-976813c91d7394ea.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/consult/{page-fb260b9283dbf646.js → page-28b9448091776c06.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/control/page-1d4901eb7c2d8822.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/design-check/page-72d3e7ff35a8c954.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/editor/{page-fed69f9759d248d0.js → page-60c7a8e1a4fe62ce.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/exec/{page-15374909d8f1aebd.js → page-2a52b316cb143d9c.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/field/{page-012a742f81218b01.js → page-c3ce483c8d5db370.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/inside/page-13c7b53e0c2519a9.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/intake/{page-99effd9d893a43dc.js → page-96f074f6538e8a2c.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/{layout-938de57ae0315e46.js → layout-e97be5334d83a9f5.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/live/page-65e484302921a932.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/loops/page-34d08facaf08f556.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/market/{page-629e1ede482ec6eb.js → page-955ec2da3be4c560.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-9f88fbc15d9058d1.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/morning/{page-27e99b4e54a849d4.js → page-c1343f9499d86f3f.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/not-found-8739a2e0ae793130.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/observatory/page-c9fc5706b8abaa11.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-32cde713be47cddb.js → page-d23b12bb92242558.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-8d47f45a0dc044ac.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-c7fea475488201a8.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-04250f0e2c0d03df.js → page-e2ee2896811e98d0.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/page-83b19dae8619de05.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-e64caff084f7ce21.js → page-5efbf81124c474ae.js} +1 -1
  36. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-db07eaffba0df7e3.js → page-85f1a1fc0a82738c.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-8ac8d71c2adf9c8a.js → page-da6c08aae0f92768.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-06ee266953d9c879.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/settings/{page-86f91034bd5cabe5.js → page-153c2b3bcc89e8de.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-94dc383d3d54db1a.js → page-87a11e6a05f15199.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/{page-b3759cc14da80908.js → page-7d4182964407c09c.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/share/{page-ce4d8a1215ce53a3.js → page-0d6d23b40263a7ea.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/showroom/page-7e8c39241f8eb6d0.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/tasks/page-008c1a96618a4887.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/term/{page-274703bc41b71333.js → page-f5597e894c02cd33.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/today/page-0337fdf7fb5e37b6.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/trace/{page-35bdeae2e2c17147.js → page-73735b64bfe1ae67.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/vault/{page-3b7e821dc492bdfd.js → page-8fcec4c9ea2e7f70.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-f7e631e9b4daf66e.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/page-4881b8f9f63694b4.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-2f810b18d89ecba6.js → page-9817c7f9007466cc.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-134c1bc762aee62c.js → page-97f9f43172a3a90e.js} +1 -1
  53. package/apps/pwa/out/_next/static/css/1f7c958c41ffa3b9.css +1 -0
  54. package/apps/pwa/out/approvals/index.html +1 -1
  55. package/apps/pwa/out/approvals/index.txt +14 -14
  56. package/apps/pwa/out/autopilot/index.html +1 -1
  57. package/apps/pwa/out/autopilot/index.txt +14 -14
  58. package/apps/pwa/out/board/index.html +1 -1
  59. package/apps/pwa/out/board/index.txt +14 -14
  60. package/apps/pwa/out/botlab/index.html +1 -1
  61. package/apps/pwa/out/botlab/index.txt +14 -14
  62. package/apps/pwa/out/bots/index.html +1 -1
  63. package/apps/pwa/out/bots/index.txt +14 -14
  64. package/apps/pwa/out/ceo/index.html +1 -0
  65. package/apps/pwa/out/ceo/index.txt +33 -0
  66. package/apps/pwa/out/chat/index.html +1 -1
  67. package/apps/pwa/out/chat/index.txt +14 -14
  68. package/apps/pwa/out/consult/index.html +1 -1
  69. package/apps/pwa/out/consult/index.txt +14 -14
  70. package/apps/pwa/out/control/index.html +1 -1
  71. package/apps/pwa/out/control/index.txt +14 -14
  72. package/apps/pwa/out/design-check/index.html +1 -1
  73. package/apps/pwa/out/design-check/index.txt +14 -14
  74. package/apps/pwa/out/editor/index.html +1 -1
  75. package/apps/pwa/out/editor/index.txt +14 -14
  76. package/apps/pwa/out/exec/index.html +1 -1
  77. package/apps/pwa/out/exec/index.txt +14 -14
  78. package/apps/pwa/out/field/index.html +1 -1
  79. package/apps/pwa/out/field/index.txt +14 -14
  80. package/apps/pwa/out/healthz +1 -1
  81. package/apps/pwa/out/index.html +1 -1
  82. package/apps/pwa/out/index.txt +14 -14
  83. package/apps/pwa/out/inside/index.html +1 -1
  84. package/apps/pwa/out/inside/index.txt +14 -14
  85. package/apps/pwa/out/intake/index.html +1 -1
  86. package/apps/pwa/out/intake/index.txt +14 -14
  87. package/apps/pwa/out/live/index.html +1 -1
  88. package/apps/pwa/out/live/index.txt +14 -14
  89. package/apps/pwa/out/loops/index.html +1 -1
  90. package/apps/pwa/out/loops/index.txt +14 -14
  91. package/apps/pwa/out/market/index.html +1 -1
  92. package/apps/pwa/out/market/index.txt +14 -14
  93. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  94. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  95. package/apps/pwa/out/morning/index.html +1 -1
  96. package/apps/pwa/out/morning/index.txt +14 -14
  97. package/apps/pwa/out/observatory/index.html +1 -1
  98. package/apps/pwa/out/observatory/index.txt +14 -14
  99. package/apps/pwa/out/ops/checklist/index.html +1 -1
  100. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  101. package/apps/pwa/out/ops/release/index.html +1 -1
  102. package/apps/pwa/out/ops/release/index.txt +14 -14
  103. package/apps/pwa/out/ops/seats/index.html +1 -1
  104. package/apps/pwa/out/ops/seats/index.txt +14 -14
  105. package/apps/pwa/out/outputs/index.html +1 -1
  106. package/apps/pwa/out/outputs/index.txt +14 -14
  107. package/apps/pwa/out/reflection/index.html +1 -1
  108. package/apps/pwa/out/reflection/index.txt +15 -15
  109. package/apps/pwa/out/scheduler/index.html +1 -1
  110. package/apps/pwa/out/scheduler/index.txt +14 -14
  111. package/apps/pwa/out/sessions/index.html +1 -1
  112. package/apps/pwa/out/sessions/index.txt +14 -14
  113. package/apps/pwa/out/settings/devices/index.html +1 -1
  114. package/apps/pwa/out/settings/devices/index.txt +14 -14
  115. package/apps/pwa/out/settings/index.html +1 -1
  116. package/apps/pwa/out/settings/index.txt +14 -14
  117. package/apps/pwa/out/setup/done/index.html +1 -1
  118. package/apps/pwa/out/setup/done/index.txt +14 -14
  119. package/apps/pwa/out/setup/index.html +1 -1
  120. package/apps/pwa/out/setup/index.txt +14 -14
  121. package/apps/pwa/out/share/index.html +1 -1
  122. package/apps/pwa/out/share/index.txt +14 -14
  123. package/apps/pwa/out/showroom/index.html +1 -1
  124. package/apps/pwa/out/showroom/index.txt +14 -14
  125. package/apps/pwa/out/tasks/index.html +1 -1
  126. package/apps/pwa/out/tasks/index.txt +14 -14
  127. package/apps/pwa/out/term/index.html +1 -1
  128. package/apps/pwa/out/term/index.txt +14 -14
  129. package/apps/pwa/out/today/index.html +1 -1
  130. package/apps/pwa/out/today/index.txt +14 -14
  131. package/apps/pwa/out/trace/index.html +1 -1
  132. package/apps/pwa/out/trace/index.txt +14 -14
  133. package/apps/pwa/out/vault/index.html +1 -1
  134. package/apps/pwa/out/vault/index.txt +14 -14
  135. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  136. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  137. package/apps/pwa/out/workflows/index.html +1 -1
  138. package/apps/pwa/out/workflows/index.txt +14 -14
  139. package/apps/pwa/out/workspace/index.html +1 -1
  140. package/apps/pwa/out/workspace/index.txt +14 -14
  141. package/apps/pwa/out/worktrees/index.html +1 -1
  142. package/apps/pwa/out/worktrees/index.txt +14 -14
  143. package/graphs/heal/heal-loop.yaml +1 -1
  144. package/graphs/implement-loop.yaml +1 -1
  145. package/graphs/release/recipes.yaml +3 -0
  146. package/graphs/release/release-loop.yaml +4 -2
  147. package/package.json +1 -1
  148. package/scripts/brand/check.ts +7 -5
  149. package/scripts/ci-typecheck-changed.ts +50 -18
  150. package/scripts/docs/generate-graph-anatomy.ts +315 -0
  151. package/scripts/expose-rubric-suggest.ts +18 -12
  152. package/scripts/lecture-note/node.ts +31 -11
  153. package/scripts/lessons/handbook-page.ts +30 -3
  154. package/scripts/marketing/generate-ax-brief.ts +140 -0
  155. package/scripts/marketing/generate-feature-map.ts +118 -0
  156. package/scripts/release-loop/checklist-gate-node.ts +53 -14
  157. package/scripts/release-loop/gate-node.ts +80 -14
  158. package/scripts/release-loop/vault-note-node.ts +240 -8
  159. package/scripts/release-story/draft.ts +57 -9
  160. package/scripts/rhythm/daily.ts +263 -76
  161. package/scripts/rhythm/weekly.ts +30 -9
  162. package/scripts/smart-merge/resync-shadow.ts +147 -0
  163. package/src/agent-env/profile.ts +161 -0
  164. package/src/agent-substrate/pr-reviewer.ts +2 -0
  165. package/src/ask-user-question/pending-questions.ts +5 -0
  166. package/src/auth/github-app-token.ts +112 -13
  167. package/src/autopilot/build/llm-conflict-merge.ts +32 -6
  168. package/src/briefing/brief-items.ts +121 -0
  169. package/src/claims/claims-render.ts +14 -5
  170. package/src/cli/agent-env-cli.ts +21 -0
  171. package/src/cli/decisions-cli.ts +9 -0
  172. package/src/cli/doctor-cli.ts +23 -1
  173. package/src/cli/doctor-node-modules.ts +147 -0
  174. package/src/cli/intake-cli.ts +46 -17
  175. package/src/cli/pod-cli.ts +2 -0
  176. package/src/cli/release-cli.ts +61 -27
  177. package/src/cli/seat-requests-cli.ts +52 -0
  178. package/src/context-bus/context-now-public.ts +16 -6
  179. package/src/context-bus/context-now.ts +3 -1
  180. package/src/context-bus/coord-events.ts +6 -4
  181. package/src/decisions/decision-cards.ts +33 -7
  182. package/src/decisions/proact-meter.ts +286 -0
  183. package/src/decisions/proact1-lite.ts +86 -0
  184. package/src/decisions/web-push-decision-cards.ts +1 -1
  185. package/src/domains/alert-audit.ts +271 -0
  186. package/src/domains/core-tools.ts +4 -3
  187. package/src/domains/ops-facts-tool.ts +31 -1
  188. package/src/harness/harness-cli-command.ts +111 -15
  189. package/src/harness/harness-incidents.ts +2 -0
  190. package/src/harness/harness-pod-dispatch.ts +100 -11
  191. package/src/harness/harness-queue.ts +380 -50
  192. package/src/harness/helper-repair.ts +87 -0
  193. package/src/harness/helper-scan.ts +16 -4
  194. package/src/harness/launch-stamp.ts +236 -0
  195. package/src/harness/ledger-write-guard-child.fixture.ts +12 -0
  196. package/src/harness/ledger-write-guard.ts +35 -0
  197. package/src/harness/pod-memory-policy.ts +15 -0
  198. package/src/harness/repository-name.ts +55 -0
  199. package/src/hq/fence-audit.ts +90 -0
  200. package/src/hq/hq.ts +62 -4
  201. package/src/hq/lease.ts +4 -4
  202. package/src/hq/move-plan.ts +126 -0
  203. package/src/hq/promote.ts +31 -0
  204. package/src/index.ts +77 -7
  205. package/src/intake-plane/check.ts +63 -0
  206. package/src/intake-plane/digest.ts +37 -15
  207. package/src/intake-plane/front-classifier.ts +6 -0
  208. package/src/intake-plane/items.ts +3 -2
  209. package/src/intake-plane/lens.ts +177 -0
  210. package/src/intake-plane/route.ts +145 -1
  211. package/src/launch-head/seat-goal-queue-lock.fixture.ts +15 -0
  212. package/src/launch-head/seat-goal-queue.ts +198 -0
  213. package/src/loops/budget.ts +36 -5
  214. package/src/loops/checker.ts +2 -2
  215. package/src/loops/orchestrator/author-depth.ts +65 -9
  216. package/src/loops/orchestrator/author-input-check.ts +8 -3
  217. package/src/loops/orchestrator/author-ledger.ts +3 -1
  218. package/src/loops/orchestrator/seat-share.ts +56 -0
  219. package/src/loops/orchestrator/tick.ts +11 -4
  220. package/src/loops/orchestrator/traffic.ts +117 -11
  221. package/src/loops/registry.ts +142 -61
  222. package/src/maturity/feature-maturity.ts +2 -1
  223. package/src/maturity/tui-slash-availability.ts +31 -0
  224. package/src/mss/logging/log-store.ts +35 -4
  225. package/src/nexus/api/http-server.ts +28 -14
  226. package/src/nexus/api/loop-edges.ts +173 -0
  227. package/src/nexus/api/schedules-read.ts +13 -0
  228. package/src/nexus/api/seat-requests.ts +79 -56
  229. package/src/prompt-enhance/enhance.ts +6 -1
  230. package/src/release-loop/auto-start.ts +6 -3
  231. package/src/release-loop/checklist.ts +13 -4
  232. package/src/release-loop/feature-store.ts +58 -31
  233. package/src/release-loop/merged-pr-checklist.ts +125 -0
  234. package/src/release-loop/placement.ts +86 -26
  235. package/src/seat-dispatch/seat-request-eligibility.ts +19 -0
  236. package/src/seat-dispatch/seat-request-ledger.ts +133 -0
  237. package/src/seat-loop/seat-loop.ts +126 -10
  238. package/src/self-dev/dev-pipeline.ts +8 -4
  239. package/src/self-dev/draft-sweep.ts +90 -10
  240. package/src/self-dev/draft-triage-rules.ts +25 -8
  241. package/src/self-dev/orchestrate.ts +15 -1
  242. package/src/self-dev/review-provider-fallback.ts +6 -1
  243. package/src/self-dev/run-supervisor.ts +49 -2
  244. package/src/self-implement/frozen-merges.ts +3 -2
  245. package/src/self-implement/goal-author.ts +43 -5
  246. package/src/self-implement/goal-clarification-escalation.ts +5 -0
  247. package/src/self-implement/heal-intake.ts +120 -2
  248. package/src/self-implement/host-regate.ts +9 -2
  249. package/src/self-implement/l8-merge-queue.ts +132 -0
  250. package/src/self-implement/orchestrator.ts +187 -40
  251. package/src/self-implement/pipeline-shape.ts +1 -1
  252. package/src/self-implement/revert-guard.ts +42 -7
  253. package/src/self-implement/run-ledger.ts +2 -0
  254. package/src/self-implement/run-status-mapping.ts +2 -0
  255. package/src/self-implement/seams.ts +1 -1
  256. package/src/self-implement/self-implement-cli.ts +1 -0
  257. package/src/self-implement/speed-claims.ts +338 -0
  258. package/src/steward/triage.ts +74 -3
  259. package/src/steward/working-backwards.ts +4 -1
  260. package/src/task-orchestrator/surfaces/pod-artifact-return.ts +63 -48
  261. package/src/task-orchestrator/surfaces/pod-command-job.ts +84 -9
  262. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +48 -0
  263. package/src/task-orchestrator/surfaces/pod-pool.ts +60 -8
  264. package/src/task-orchestrator/surfaces/self-implement-pod.ts +147 -36
  265. package/src/task-orchestrator/surfaces/self-implement.ts +5 -1
  266. package/src/telegram.ts +25 -4
  267. package/src/user-config.ts +29 -5
  268. package/src/version/packed-revision.json +1 -1
  269. package/src/workflow-runtime/nodes/bash.ts +9 -1
  270. package/apps/pwa/out/_next/static/chunks/4562-071d8bac44a01069.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/5977-2df489622a52a33a.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/board/page-c496bb16da85385f.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/botlab/page-1abfe3aec3be6bdc.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/control/page-e6b84ab07d3567d5.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/design-check/page-61ec1d2e92bdd50a.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/inside/page-fd7e200d4d83fbd7.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/live/page-d47266766766d227.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/loops/page-160089123a40dd91.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-2ce5471926761fdd.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/not-found-e995d30f6800b6bf.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/observatory/page-39f89c53038a51d4.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-e7e10cd3386222e5.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-5a753bb4ec0cc511.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/page-341480ed8e3c1ea8.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-e1e47d8d940aba3d.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/showroom/page-58043db78ba519ae.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/tasks/page-357750dfb91bf960.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/today/page-194dce4a83143fce.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-16bef3da05a900c3.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/workflows/page-f8da5dc6f3752276.js +0 -1
  291. package/apps/pwa/out/_next/static/css/2e33713c444d5b39.css +0 -1
  292. package/apps/pwa/out/_next/static/x6mjI6vqOkVVu0jFuDkz1/_buildManifest.js +0 -1
  293. /package/apps/pwa/out/_next/static/{x6mjI6vqOkVVu0jFuDkz1 → LZcstaO9_NXypPFmujolv}/_ssgManifest.js +0 -0
@@ -7,12 +7,15 @@ import { effectiveInstanceRoot, prodInstanceRoot } from '../../src/instance/reso
7
7
  import { listSchedules } from '../../src/release-loop/release-schedule.js';
8
8
  import { devVersion, listChecklist } from '../../src/release-loop/checklist.js';
9
9
  import { DecisionLedger } from '../../src/decisions/decision-ledger.js';
10
+ import { selectProact1Lite, type ProactSignals } from '../../src/decisions/proact1-lite.js';
10
11
  import { trafficTick, seatOfTree } from '../../src/loops/orchestrator/traffic.js';
11
- import { defaultListHarnessProcesses } from '../../src/harness/harness-cli-command.js';
12
+ import { defaultListHarnessProcesses, githubDraftSweepAdapters } from '../../src/harness/harness-cli-command.js';
12
13
  import { getUserConfig, ORCHESTRATOR_DEFAULTS } from '../../src/user-config.js';
13
14
  import { loopEvent } from '../../src/loops/observe.js';
14
- import { sendOutbound } from '../../src/domains/outbound-alert.js';
15
+ import { kindRouteTarget, mainHomeTarget } from '../../src/domains/telegram-kind-route.js';
16
+ import { recordOutbound } from '../../src/domains/outbound-alert.js';
15
17
  import { listSelfDevRuns, runSummaryLine, selfDevRunsDir } from '../../src/self-dev/run-store.js';
18
+ import { runDraftSweep } from '../../src/self-dev/draft-sweep.js';
16
19
 
17
20
  export type Section<T> = { status: 'ok'; value: T } | { status: 'unreadable'; reason: string };
18
21
  export type Landing = { title: string; seat: string; mergedAt: string; prNumber?: number };
@@ -32,7 +35,10 @@ export type DailyReview = { markdown: string; header: string; risks: Risk[]; sec
32
35
  const repo = join(import.meta.dir, '../..');
33
36
  const kstDay = (date: Date) => new Date(date.getTime() + 9 * 3_600_000).toISOString().slice(0, 10);
34
37
  const reasonOf = (error: unknown) => error instanceof Error ? error.message : String(error);
35
- const unreadable = <T>(reason: string): Section<T> => ({ status: 'unreadable', reason });
38
+ const shortReason = (reason: string) => reason.split(/\r?\n/, 1)[0]!.trim().slice(0, 200);
39
+ const unreadable = <T>(reason: string): Section<T> => ({ status: 'unreadable', reason: shortReason(reason) });
40
+ const adArticle = (title: string, body: string) => /토토|카지노|배팅|베팅|입플|슬롯|도박|성인|바카라|스포츠.?토토|성인물|야동/i.test(`${title} ${body}`);
41
+ const cleanNews = (items: News[]) => items.filter(item => !adArticle(item.title, item.implication));
36
42
  const record = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
37
43
  const rows = (value: unknown): Record<string, unknown>[] => Array.isArray(value) ? value.map(record) : [];
38
44
  const string = (value: unknown): string => typeof value === 'string' ? value : '';
@@ -69,18 +75,44 @@ export function collectRunSummaries(now: Date, root = effectiveInstanceRoot(), l
69
75
  }
70
76
 
71
77
  /** 기본은 어제 KST 00:00–24:00, 창을 주면 그 안에 실제 병합된 PR만 센다. */
72
- export async function collectLandings(now: Date, window?: { from: Date; to: Date }): Promise<Landing[]> {
78
+ export async function collectLandings(now: Date, window?: { from: Date; to: Date }, source?: { repoRoot?: string; repoName?: string; stateRoot?: string }): Promise<Landing[]> {
79
+ const configuredTree = source?.repoRoot ?? getUserConfig().ops?.cronRepoRoot;
80
+ const tree = configuredTree || repo;
81
+ const remote = source?.repoName ?? (() => {
82
+ if (!existsSync(join(tree, '.git'))) return null;
83
+ const result = spawnSync('git', ['-C', tree, 'remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 5_000 });
84
+ const path = result.status === 0 ? /(?:github\.com[:/])([^/\s]+\/[^/\s]+)\s*$/.exec(result.stdout)?.[1] : null;
85
+ return path?.replace(/\.git$/, '') ?? null;
86
+ })();
87
+ if (!remote) throw new Error('저장소 미지정');
73
88
  const yesterday = kstDay(new Date(now.getTime() - 86_400_000));
74
89
  const from = window?.from ?? new Date(`${yesterday}T00:00:00+09:00`);
75
90
  const to = window?.to ?? new Date(from.getTime() + 86_400_000);
76
91
  // GitHub's merged: date search is UTC; include the UTC date containing KST midnight, then filter precisely below.
77
92
  const searchDay = window ? from.toISOString().slice(0, 10) : yesterday;
78
- const data = cli('gh', 'pr', 'list', '--state', 'merged', '--search', `merged:>=${searchDay}`, '--limit', '500', '--json', 'number,title,mergedAt');
93
+ const data = cli('gh', 'pr', 'list', '--repo', remote, '--state', 'merged', '--search', `merged:>=${searchDay}`, '--limit', '500', '--json', 'number,title,mergedAt');
79
94
  if (!Array.isArray(data)) throw new Error('병합 목록 형식 오류');
80
95
  // 주간 창은 잘린 목록을 정확한 합계로 취급할 수 없다. 데일리 기본 경로는 그대로 둔다.
81
96
  if (window && data.length >= 500) throw new Error('병합 목록 500건 조회 상한 도달 · 주간 착지 집계 불완전');
97
+ const seatByPr = new Map<number, string>();
98
+ // A PR number is local to its repository. A bare prNumber cannot establish which repository it belongs to.
99
+ const targetRepo = remote.replace(/\.git$/i, '').toLowerCase();
100
+ // Newest checkpoint wins when a PR appears in more than one run; titles are not seat evidence.
101
+ for (const run of listSelfDevRuns(selfDevRunsDir(source?.stateRoot ?? effectiveInstanceRoot()))) {
102
+ if (!['OP', 'TC', 'MK', 'UX'].includes(run.seat ?? '')) continue;
103
+ for (const result of run.results) {
104
+ let url: URL;
105
+ try { url = new URL(result.prUrl ?? ''); } catch { continue; }
106
+ if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') continue;
107
+ const match = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(url.pathname);
108
+ if (!match || `${match[1]}/${match[2]}`.toLowerCase() !== targetRepo) continue;
109
+ const number = Number(match[3]);
110
+ if (result.prNumber !== undefined && result.prNumber !== number) continue;
111
+ if (Number.isSafeInteger(number) && number > 0 && !seatByPr.has(number)) seatByPr.set(number, run.seat!);
112
+ }
113
+ }
82
114
  return rows(data).filter(r => typeof r.title === 'string' && typeof r.mergedAt === 'string' && Number.isFinite(Date.parse(r.mergedAt as string)) && Date.parse(r.mergedAt as string) >= from.getTime() && Date.parse(r.mergedAt as string) < to.getTime())
83
- .map(r => ({ title: string(r.title), mergedAt: string(r.mergedAt), seat: /^\[(OP|TC|MK|UX)\]/.exec(string(r.title))?.[1] ?? '미분류',
115
+ .map(r => ({ title: string(r.title), mergedAt: string(r.mergedAt), seat: typeof r.number === 'number' ? seatByPr.get(r.number) ?? '미분류' : '미분류',
84
116
  ...(typeof r.number === 'number' ? { prNumber: r.number } : {}) }))
85
117
  .sort((a, b) => b.mergedAt.localeCompare(a.mergedAt) || sortNames(a.title, b.title));
86
118
  }
@@ -154,6 +186,7 @@ export async function collectNews(): Promise<News[]> {
154
186
  for (const result of rows(data.results)) for (const item of rows(result.items)) {
155
187
  const url = string(item.url);
156
188
  if (!/^https?:\/\//.test(url) || seen.has(url)) continue;
189
+ if (adArticle(string(item.title), string(item.text))) continue;
157
190
  seen.add(url);
158
191
  found.push({ title: string(item.title) || string(item.text).slice(0, 100) || url, url,
159
192
  implication: `${query}: ${string(item.text).replace(/\s+/g, ' ').slice(0, 120) || '원문 확인 필요'}` });
@@ -185,25 +218,33 @@ export function composeDaily(parts: DailyParts, now: Date): DailyReview {
185
218
  if (parts.grid.status === 'ok') for (const seat of parts.grid.value) if (seat.idle) risks.push({ name: seat.name, score: 1, reason: '노는 자리' });
186
219
  risks.sort((a, b) => b.score - a.score || sortNames(a.name, b.name));
187
220
  const top = risks.slice(0, 5);
188
- const unread = <T>(section: Section<T>) => section.status === 'unreadable' ? `못 읽음 · ${section.reason}` : '';
189
- const land = parts.landings.status === 'ok' ? `어제 착지 ${parts.landings.value.length}건` : `어제 착지 ${unread(parts.landings)}`;
221
+ const unread = <T>(section: Section<T>) => section.status === 'unreadable' ? `못 읽음 · ${shortReason(section.reason)}` : '';
222
+ const seenPr = new Set<number>();
223
+ const landings = parts.landings.status === 'ok' ? [...parts.landings.value]
224
+ .sort((a, b) => b.mergedAt.localeCompare(a.mergedAt) || sortNames(a.title, b.title))
225
+ .filter(item => {
226
+ if (item.prNumber === undefined) return true;
227
+ if (seenPr.has(item.prNumber)) return false;
228
+ seenPr.add(item.prNumber);
229
+ return true;
230
+ }) : [];
231
+ const land = parts.landings.status === 'ok' ? `어제 착지 ${landings.length}건` : `어제 착지 ${unread(parts.landings)}`;
190
232
  const rel = parts.release.status === 'ok' ? `판 ${parts.release.value.version} ${parts.release.value.green}/${parts.release.value.total}` : `판 ${unread(parts.release)}`;
191
233
  const action = top.length ? `${top[0]!.name} (${top[0]!.reason}) 확인` : '못 읽은 절을 확인하고 오늘 첫 착지를 고른다';
192
- const header = [`S: ${land} · ${rel}`, `C: ${top[0] ? `${top[0].name} (${top[0].score}점 · ${top[0].reason})` : '확인된 위험 없음(못 읽은 절은 별도 확인)'}`, 'Q: 오늘 무엇을 먼저 할 것인가?', `A: ${action}`].join('\n');
234
+ const header = [`S: ${land} · ${rel}`.slice(0, 200), `C: ${top[0] ? `${top[0].name} (${top[0].score}점 · ${top[0].reason})` : '확인된 위험 없음(못 읽은 절은 별도 확인)'}`, 'Q: 오늘 무엇을 먼저 할 것인가?', `A: ${action}`].join('\n');
193
235
  const section = <T>(key: keyof DailyParts, value: Section<T>, text: () => string) => `## ${labels[key]}\n${value.status === 'unreadable' ? unread(value) : text()}`;
194
236
  const runs = parts.landings.runSummaries ?? [];
195
237
  const landingText = (() => {
196
- if (parts.landings.status === 'unreadable') {
197
- const yesterdayRuns = runs.filter(run => run.updatedYesterday);
198
- return `${unread(parts.landings)}${yesterdayRuns.length ? `\n하니스 런\n${yesterdayRuns.map(run => run.line).join('\n')}` : ''}`;
199
- }
200
- const list = parts.landings.value;
238
+ if (parts.landings.status === 'unreadable') return `못 읽음 · ${shortReason(parts.landings.reason).slice(0, 200 - '못 읽음 · '.length)}`;
239
+ const list = landings;
201
240
  const counts = [...new Set(list.map(i => i.seat))].sort(sortNames).map(seat => `${seat} ${list.filter(i => i.seat === seat).length}`).join(' · ');
202
241
  const byPr = new Map(runs.flatMap(run => run.prNumbers.map(number => [number, run.line] as const)));
203
- const shown = list.slice(0, 5);
242
+ const shown = list.slice(0, 10);
204
243
  const listed = shown.map(i => i.prNumber !== undefined && byPr.has(i.prNumber) ? byPr.get(i.prNumber)! : `- ${i.title}`).join('\n') || '착지 없음';
205
- const withoutLanding = runs.filter(run => run.updatedYesterday && !shown.some(i => i.prNumber !== undefined && run.prNumbers.includes(i.prNumber)));
206
- return `총 ${list.length}건 · 자리별 ${counts || '없음'}\n${listed}${withoutLanding.length ? `\n하니스 런\n${withoutLanding.map(run => run.line).join('\n')}` : ''}`;
244
+ const omitted = list.length - shown.length;
245
+ const withoutLanding = runs.filter(run => run.updatedYesterday && !list.some(i => i.prNumber !== undefined && run.prNumbers.includes(i.prNumber)));
246
+ const shownRuns = withoutLanding.slice(0, 10);
247
+ return `총 ${list.length}건 · 자리별 ${counts || '없음'}\n${listed}${omitted ? `\n외 ${omitted}건` : ''}${withoutLanding.length ? `\n하니스 런\n${shownRuns.map(run => run.line).join('\n')}${withoutLanding.length > 10 ? `\n외 ${withoutLanding.length - 10}개` : ''}` : ''}`;
207
248
  })();
208
249
  const markdown = [
209
250
  `# 데일리 리뷰 — ${kstDay(now)} (KST)`, header,
@@ -222,68 +263,109 @@ export function composeDaily(parts: DailyParts, now: Date): DailyReview {
222
263
  return { markdown, header, risks: top, sections: Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, v.status])) as DailyReview['sections'] };
223
264
  }
224
265
 
225
- type DeliveryReceipt = 'sent' | 'not-sent' | 'unknown';
266
+ export type DailySendRequest = { channel: 'telegram'; chatId: number; botToken: string; kind: 'ops-report'; text: string };
267
+ export type DailySendConfirmation = { chatId: number; messageId: number };
268
+ type DeliveryReceipt = DailySendConfirmation | 'not-sent' | 'unknown';
269
+ type DeliveryResult = { sent: boolean; sendError: string | null; deliveryState: string; target: string; recordError?: string | null };
226
270
 
227
- /** An outbound success is the only evidence for 'sent'. The sender has no durable
228
- * idempotency API; an interrupted attempt is never silently treated as success
229
- * or automatically retried without an authoritative negative receipt. */
230
- function outboundReceipt(file: string, key: string): DeliveryReceipt {
231
- const root = dirname(dirname(dirname(file)));
232
- const path = join(root, ...(root === prodInstanceRoot() ? ['memory'] : []), 'surface_events.db');
233
- if (!existsSync(path)) return 'unknown';
271
+ // The final transport checks Telegram's returned recipient and message, not sendOutbound's
272
+ // boolean (which can also mean "queued" or "accepted by daemon").
273
+ function sendTelegram(request: DailySendRequest): DailySendConfirmation | null {
274
+ const quote = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
275
+ const config = [
276
+ `url = ${quote(`https://api.telegram.org/bot${request.botToken}/sendMessage`)}`,
277
+ 'request = "POST"',
278
+ `data-urlencode = ${quote(`chat_id=${request.chatId}`)}`,
279
+ `data-urlencode = ${quote(`text=${request.text}`)}`,
280
+ ].join('\n') + '\n';
281
+ const response = spawnSync('curl', ['-sS', '-m', '25', '--config', '-'],
282
+ { input: config, encoding: 'utf8', timeout: 30_000, maxBuffer: 2_000_000 });
283
+ if (response.error || response.status !== 0) return null;
234
284
  try {
235
- const db = new Database(path, { readonly: true, create: false });
236
- try {
237
- const row = db.prepare("SELECT 1 FROM events WHERE direction='outbound' AND kind='ops-report' AND instr(text, ?) > 0 LIMIT 1").get(`RHYTHM-DAILY:${key}`);
238
- return row ? 'sent' : 'unknown'; // Absence in a fail-soft memory store is NOT a negative receipt.
239
- } finally { db.close(); }
240
- } catch { return 'unknown'; }
285
+ const body = record(JSON.parse(response.stdout));
286
+ const message = record(body.result);
287
+ const chat = record(message.chat);
288
+ return body.ok === true && chat.id === request.chatId && Number.isSafeInteger(message.message_id) && (message.message_id as number) > 0
289
+ ? { chatId: request.chatId, messageId: message.message_id as number } : null;
290
+ } catch { return null; }
241
291
  }
242
292
 
243
- function sendReview(file: string, text: string, send: (text: string, kind: string) => boolean,
244
- receipt: (key: string) => DeliveryReceipt = key => outboundReceipt(file, key)): { sent: boolean; sendError: string | null } {
245
- const key = file.slice(file.lastIndexOf('/') + 1).replace(/\.md$/, ''); // KST date, not changing content
293
+ function recipient(deps: DailyDeps): { chatId: number; botToken: string } | null {
294
+ const target = deps.target ?? (() => {
295
+ const config = getUserConfig();
296
+ return config.telegram.channels?.length ? kindRouteTarget(config, 'ops-report') : mainHomeTarget(config);
297
+ })();
298
+ return target && Number.isSafeInteger(target.chatId) && target.botToken.trim() ? target : null;
299
+ }
300
+
301
+ function sendReview(file: string, text: string, target: { chatId: number; botToken: string },
302
+ send: (request: DailySendRequest) => DailySendConfirmation | null,
303
+ receipt: (key: string) => DeliveryReceipt,
304
+ recordDelivery?: (text: string, kind: 'ops-report') => void): DeliveryResult {
305
+ const key = file.slice(file.lastIndexOf('/') + 1).replace(/\.md$/, '');
306
+ const label = `telegram:${target.chatId}`;
246
307
  const db = new Database(join(dirname(file), 'delivery.sqlite'));
247
308
  try {
248
- db.run("CREATE TABLE IF NOT EXISTS deliveries (day TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('pending','unknown','sent')))");
309
+ db.run("CREATE TABLE IF NOT EXISTS deliveries (day TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('pending','unknown','sent')), chat_id TEXT, message_id TEXT)");
310
+ const cols = db.prepare('PRAGMA table_info(deliveries)').all() as { name: string }[];
311
+ if (!cols.some(c => c.name === 'chat_id')) db.run('ALTER TABLE deliveries ADD COLUMN chat_id TEXT');
312
+ if (!cols.some(c => c.name === 'message_id')) db.run('ALTER TABLE deliveries ADD COLUMN message_id TEXT');
249
313
  db.run("INSERT OR IGNORE INTO deliveries(day, state) VALUES (?, 'pending')", [key]);
250
- const state = (db.prepare('SELECT state FROM deliveries WHERE day = ?').get(key) as { state: string }).state;
251
- if (state === 'sent') return { sent: false, sendError: null };
252
- if (state === 'unknown') {
314
+ const state = db.prepare('SELECT state, chat_id, message_id FROM deliveries WHERE day = ?').get(key) as { state: string; chat_id: string | null; message_id: string | null };
315
+ if (state.state === 'sent') {
316
+ if (state.chat_id === String(target.chatId) && state.message_id && Number(state.message_id) > 0)
317
+ return { sent: false, sendError: null, deliveryState: 'already-sent', target: label };
318
+ return { sent: false, sendError: '수신자 확인 불가 · 기존 발송 기록 확인 필요', deliveryState: 'unknown', target: 'unknown' };
319
+ }
320
+ if (state.state === 'unknown') {
253
321
  const verdict = receipt(key);
254
- if (verdict === 'sent') {
255
- db.run("UPDATE deliveries SET state = 'sent' WHERE day = ?", [key]);
256
- return { sent: false, sendError: null };
322
+ if (typeof verdict === 'object' && verdict.chatId === target.chatId && Number.isSafeInteger(verdict.messageId) && verdict.messageId > 0) {
323
+ db.run("UPDATE deliveries SET state = 'sent', chat_id = ?, message_id = ? WHERE day = ?", [String(target.chatId), String(verdict.messageId), key]);
324
+ return { sent: false, sendError: null, deliveryState: 'receipt-sent', target: label };
257
325
  }
258
- if (verdict !== 'not-sent') return { sent: false, sendError: `발송 결과 불명 · RHYTHM-DAILY:${key} 영수증 확인 필요` };
326
+ if (verdict !== 'not-sent') return { sent: false, sendError: `발송 결과 불명 · RHYTHM-DAILY:${key} 영수증 확인 필요`, deliveryState: 'unknown', target: 'unknown' };
259
327
  db.run("UPDATE deliveries SET state = 'pending' WHERE day = ? AND state = 'unknown'", [key]);
260
328
  }
261
- // Atomic claim across concurrent runs. A process dying before this write made no send.
262
329
  if (!db.prepare("UPDATE deliveries SET state = 'unknown' WHERE day = ? AND state = 'pending'").run(key).changes)
263
- return { sent: false, sendError: `발송 결과 불명 · RHYTHM-DAILY:${key} 영수증 확인 필요` };
330
+ return { sent: false, sendError: `발송 결과 불명 · RHYTHM-DAILY:${key} 영수증 확인 필요`, deliveryState: 'unknown', target: 'unknown' };
264
331
  try {
265
- if (!send(`${text}\nRHYTHM-DAILY:${key}`, 'ops-report')) return { sent: false, sendError: '발송 실패 · 결과 불명 (영수증 확인 필요)' };
266
- db.run("UPDATE deliveries SET state = 'sent' WHERE day = ?", [key]);
267
- return { sent: true, sendError: null };
268
- } catch (error) { return { sent: false, sendError: `발송 결과 불명 · ${reasonOf(error)}` }; }
269
- } catch (error) { return { sent: false, sendError: `발송 상태 못 읽음 · ${reasonOf(error)}` }; }
332
+ const request: DailySendRequest = { channel: 'telegram', chatId: target.chatId, botToken: target.botToken,
333
+ kind: 'ops-report', text: `${text}\nRHYTHM-DAILY:${key}` };
334
+ const confirmation = send(request);
335
+ if (!confirmation || confirmation.chatId !== target.chatId || !Number.isSafeInteger(confirmation.messageId) || confirmation.messageId <= 0)
336
+ return { sent: false, sendError: '발송 결과 불명 · 수신자/메시지 영수증 확인 필요', deliveryState: 'unknown', target: 'unknown' };
337
+ db.run("UPDATE deliveries SET state = 'sent', chat_id = ?, message_id = ? WHERE day = ?", [String(confirmation.chatId), String(confirmation.messageId), key]);
338
+ let recordError: string | null = null;
339
+ try {
340
+ if (recordDelivery) recordDelivery(request.text, request.kind);
341
+ else if (send === sendTelegram && process.env.NODE_ENV !== 'test') recordOutbound(request.text, request.kind);
342
+ } catch (error) { recordError = `보조 기록 실패 · ${shortReason(reasonOf(error))}`; }
343
+ return { sent: true, sendError: null, deliveryState: 'sent', target: label, recordError };
344
+ } catch (error) { return { sent: false, sendError: `발송 결과 불명 · ${shortReason(reasonOf(error))}`, deliveryState: 'unknown', target: 'unknown' }; }
345
+ } catch (error) { return { sent: false, sendError: `발송 상태 못 읽음 · ${shortReason(reasonOf(error))}`, deliveryState: 'unknown', target: 'unknown' }; }
270
346
  finally { db.close(); }
271
347
  }
272
348
 
273
- export type DailyDeps = { now?: () => Date; root?: string; vaultRoot?: string | null; sendEnabled?: boolean;
349
+ export type DailyDeps = { now?: () => Date; root?: string; vaultRoot?: string | null; sendEnabled?: boolean; repoRoot?: string; repoName?: string;
274
350
  landings?: (now: Date) => Promise<Landing[]>; release?: () => Promise<Release>; loops?: () => Promise<Loop[]>;
275
351
  grid?: () => Promise<Seat[]>; decisions?: () => Promise<Pending[]>; news?: () => Promise<News[]>;
276
- send?: (text: string, kind: string) => boolean; receipt?: (key: string) => DeliveryReceipt;
352
+ target?: { chatId: number; botToken: string };
353
+ send?: (request: DailySendRequest) => DailySendConfirmation | null; receipt?: (key: string) => DeliveryReceipt;
354
+ recordDelivery?: (text: string, kind: 'ops-report') => void;
277
355
  log?: (category: string, event: string, data: Record<string, unknown>) => void;
356
+ proactSignals?: () => Promise<ProactSignals>;
357
+ proactPreviouslySuggested?: ReadonlySet<string>;
278
358
  print?: (line: string) => void };
279
359
 
280
360
  // 볼트 사본은 필수 산출물이다 — 쓰기 실패·설정 못 읽음은 vaultFatal 로 올려 그래프가 done 으로 가지 않게 한다.
281
- // 볼트 미설정·격리 인스턴스 건너뜀은 «의도»라 fatal 이 아니다.
361
+ // 볼트 미설정·격리 인스턴스의 외부 볼트 건너뜀은 «의도»라 fatal 이 아니다.
282
362
  function copyToVault(markdown: string, now: Date, deps: DailyDeps): { vaultError: string | null; vaultFatal: boolean } {
283
363
  let vaultRoot: string | null | undefined = deps.vaultRoot;
284
364
  if (vaultRoot === undefined) try {
285
- vaultRoot = string(record(getUserConfig().raw.obsidian).vaultRoot) || null;
286
- if (vaultRoot && effectiveInstanceRoot() !== prodInstanceRoot()) return { vaultError: '격리 인스턴스 — 운영 볼트 사본 건너뜀', vaultFatal: false };
365
+ const config = getUserConfig();
366
+ const obsidian = record(config.raw.obsidian);
367
+ vaultRoot = string(obsidian.vault) || string(obsidian.vaultRoot) || config.obsidian.vault || null;
368
+ if (vaultRoot && effectiveInstanceRoot() !== prodInstanceRoot() && !vaultRoot.startsWith(`${effectiveInstanceRoot()}/`)) return { vaultError: '격리 인스턴스 — 운영 볼트 사본 건너뜀', vaultFatal: false };
287
369
  } catch (e) { return { vaultError: reasonOf(e), vaultFatal: true }; }
288
370
  if (!vaultRoot) return { vaultError: null, vaultFatal: false };
289
371
  try {
@@ -295,10 +377,12 @@ function copyToVault(markdown: string, now: Date, deps: DailyDeps): { vaultError
295
377
 
296
378
  export async function runDaily(options: { dryRun?: boolean; noNews?: boolean; stage?: 'collect' | 'compose' | 'deliver' } = {}, deps: DailyDeps = {}) {
297
379
  const now = (deps.now ?? (() => new Date()))();
380
+ const receiptKey = `RHYTHM-DAILY:${kstDay(now)}`;
298
381
  if (options.stage === 'compose' || options.stage === 'deliver') {
299
382
  const file = join(deps.root ?? effectiveInstanceRoot(), 'rhythm', 'daily', `${kstDay(now)}.md`);
300
383
  if (!existsSync(file)) throw new Error(`수집 파일 없음: ${file}`);
301
384
  const markdown = readFileSync(file, 'utf8');
385
+ const suggestionBlock = markdown.split('## PROACT1-LITE 제안\n')[1]?.split('\n\n## ')[0]?.trim() ?? '';
302
386
  const header = markdown.split('\n').slice(2, 6).join('\n');
303
387
  if (!header.startsWith('S: ') || !markdown.includes('## ⑥ 위험 톱 5')) throw new Error(`리뷰 파일 형식 오류: ${file}`);
304
388
  const riskBlock = markdown.split('## ⑥ 위험 톱 5\n')[1]!.split('\n\n## ')[0]!;
@@ -310,31 +394,51 @@ export async function runDaily(options: { dryRun?: boolean; noNews?: boolean; st
310
394
  const sections = Object.fromEntries(Object.entries(labels).map(([key, label]) => [key,
311
395
  markdown.split(`## ${label}\n`)[1]?.split('\n\n## ')[0]?.startsWith('못 읽음 ·') ? 'unreadable' : 'ok'])) as DailyReview['sections'];
312
396
  sections.news = markdown.split('## ⑦ 외부 동향\n')[1]?.startsWith('못 읽음 ·') ? 'unreadable' : 'ok';
313
- const tick = { sections, risks: risks.length, sent: false };
397
+ const tick: Record<string, unknown> = { sections, risks: risks.length, sent: false };
314
398
  const vault = options.stage === 'compose' && !options.dryRun ? copyToVault(markdown, now, deps) : { vaultError: null, vaultFatal: false };
315
399
  let sent = false;
400
+ let deliveryState = options.stage === 'compose' ? 'pending' : options.dryRun ? 'dry-run' : 'send-disabled';
401
+ let chars = 0;
402
+ let target = 'unknown';
316
403
  let sendError: string | null = null;
404
+ let recordError: string | null = null;
317
405
  let enabled = deps.sendEnabled === true;
318
406
  if (deps.sendEnabled === undefined && options.stage === 'deliver' && !options.dryRun) {
319
407
  try { enabled = record(record(record(getUserConfig().raw.loops).rhythm).daily).send === true; }
320
- catch (error) { sendError = `발송 설정 못 읽음 · ${reasonOf(error)}`; }
408
+ catch (error) { sendError = `발송 설정 못 읽음 · ${shortReason(reasonOf(error))}`; deliveryState = 'unknown'; }
321
409
  }
322
410
  if (options.stage === 'deliver' && !options.dryRun && enabled) {
323
- const delivery = sendReview(file, `${header}\n\n## 위험 톱 5\n${riskBlock}\n\n${file}`, deps.send ?? sendOutbound, deps.receipt);
411
+ const text = `${header}\n\n## 위험 톱 5\n${riskBlock}${suggestionBlock ? `\n\n## PROACT1-LITE 제안\n${suggestionBlock}` : ''}\n\n${file}`;
412
+ chars = `${text}\n${receiptKey}`.length;
413
+ let resolved: ReturnType<typeof recipient> = null;
414
+ try { resolved = recipient(deps); }
415
+ catch (error) { sendError = `수신자 설정 못 읽음 · ${shortReason(reasonOf(error))}`; }
416
+ const delivery = resolved ? sendReview(file, text, resolved, deps.send ?? sendTelegram, deps.receipt ?? (() => 'unknown'), deps.recordDelivery)
417
+ : { sent: false, sendError: '수신자 미설정', deliveryState: 'no-recipient', target: 'unknown' };
418
+ target = delivery.target;
324
419
  sent = delivery.sent;
325
- sendError = delivery.sendError;
420
+ sendError = sendError ?? delivery.sendError;
421
+ recordError = delivery.recordError ?? null;
422
+ deliveryState = delivery.deliveryState;
326
423
  }
327
424
  tick.sent = sent;
425
+ const status = vault.vaultFatal || Object.values(sections).includes('unreadable') ? 'degraded'
426
+ : deliveryState === 'pending' ? 'pending'
427
+ : ['sent', 'already-sent', 'receipt-sent'].includes(deliveryState) ? 'ok' : 'degraded';
428
+ const deliveryLine = `${options.stage} ${status} · target=${target} · ${deliveryState} · sent=${sent} · chars=${chars} · receipt=${receiptKey}${recordError ? ` · ${recordError}` : ''}`;
328
429
  if (options.stage === 'deliver') {
329
- if (deps.log) deps.log('loop.rhythm-daily', 'tick', tick);
330
- else loopEvent('rhythm-daily', 'tick', tick);
430
+ Object.assign(tick, { status, deliveryState, target, chars, receiptKey, deliveryLine, recordError });
431
+ if (!options.dryRun) {
432
+ if (deps.log) deps.log('loop.rhythm-daily', 'tick', tick);
433
+ else loopEvent('rhythm-daily', 'tick', tick);
434
+ }
331
435
  }
332
- return { file, markdown, header, risks, sections, sent, ...vault, sendError };
436
+ return { file, markdown, header, risks, sections, sent, ...vault, sendError, recordError, status, deliveryState, deliveryLine };
333
437
  }
334
438
  const safe = async <T>(fetch: () => Promise<T>): Promise<Section<T>> => {
335
439
  try { return { status: 'ok', value: await fetch() }; } catch (e) { return unreadable(reasonOf(e)); }
336
440
  };
337
- const landings = await safe(() => (deps.landings ?? collectLandings)(now));
441
+ const landings = await safe(() => deps.landings ? deps.landings(now) : collectLandings(now, undefined, { repoRoot: deps.repoRoot, repoName: deps.repoName, stateRoot: deps.root }));
338
442
  const parts: DailyParts = {
339
443
  landings: { ...landings, runSummaries: collectRunSummaries(now, deps.root ?? effectiveInstanceRoot(),
340
444
  landings.status === 'ok' ? landings.value.flatMap(item => item.prNumber === undefined ? [] : [item.prNumber]) : []) },
@@ -342,40 +446,123 @@ export async function runDaily(options: { dryRun?: boolean; noNews?: boolean; st
342
446
  loops: await safe(deps.loops ?? collectLoops),
343
447
  grid: await safe(deps.grid ?? collectGrid),
344
448
  decisions: await safe(deps.decisions ?? collectDecisions),
345
- news: options.noNews ? unreadable('건너뜀 (--no-news)') : await safe(deps.news ?? collectNews),
449
+ news: options.noNews ? unreadable('건너뜀 (--no-news)') : await safe(async () => cleanNews(await (deps.news ?? collectNews)())),
346
450
  };
347
451
  const review = composeDaily(parts, now);
452
+ const root = deps.root ?? effectiveInstanceRoot();
453
+ const observed = await safe(async (): Promise<ProactSignals> => {
454
+ if (deps.proactSignals) return deps.proactSignals();
455
+ const signals: { -readonly [K in keyof ProactSignals]: ProactSignals[K] } = {};
456
+ try {
457
+ const schedules = listSchedules(root);
458
+ const yellow = schedules.flatMap(schedule => {
459
+ try { return listChecklist(schedule.version, root).items.filter(item => item.status === 'yellow')
460
+ .map(item => ({ version: schedule.version, item })); }
461
+ catch { return []; }
462
+ });
463
+ signals.schedules = schedules;
464
+ signals.yellow = yellow;
465
+ } catch { /* source unreadable */ }
466
+ try { signals.decisions = new DecisionLedger({ stateDir: root }).list({ status: 'open' }); } catch { /* source unreadable */ }
467
+ try {
468
+ const remote = deps.repoName ?? (deps.root ? undefined : (() => {
469
+ const tree = deps.repoRoot ?? getUserConfig().ops?.cronRepoRoot ?? repo;
470
+ const result = spawnSync('git', ['-C', tree, 'remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 5_000 });
471
+ return result.status === 0 ? /(?:github\.com[:/])([^/\s]+\/[^/\s]+)\s*$/.exec(result.stdout)?.[1]?.replace(/\.git$/, '') : undefined;
472
+ })());
473
+ if (remote) {
474
+ const inventory = cli('gh', 'pr', 'list', '--repo', remote, '--state', 'open', '--limit', '500', '--json', 'number,title,isDraft');
475
+ if (Array.isArray(inventory) && inventory.length < 500) {
476
+ const drafts = rows(inventory).filter(pr => pr.isDraft === true && typeof pr.number === 'number' && typeof pr.title === 'string')
477
+ .map(pr => ({ number: pr.number as number, title: pr.title as string }));
478
+ // draft 정리 판정은 열린 draft 전수에 GitHub 호출을 돌려 10-05 운영에서 600초 넘게 끝나지 않았다(크론 수집 노드가 멎는다).
479
+ // 그래서 기본은 끈다 — loops.rhythm.daily.draftSweep=true 일 때만 돈다. 오래된 draft 정리는 DRAFT-TRIAGE 몫.
480
+ let sweepEnabled = false;
481
+ try { sweepEnabled = record(record(record(getUserConfig().raw.loops).rhythm).daily).draftSweep === true; } catch { /* config unreadable → off */ }
482
+ if (!sweepEnabled) loopEvent('rhythm-daily', 'draft-sweep-skipped', { drafts: drafts.length, reason: 'disabled-by-default' });
483
+ if (drafts.length && sweepEnabled) {
484
+ const sweep = await runDraftSweep({ repository: remote, apply: false, now, adapters: githubDraftSweepAdapters() });
485
+ signals.draftAssessment = { drafts, sweep };
486
+ }
487
+ }
488
+ }
489
+ } catch { /* source unreadable */ }
490
+ return signals;
491
+ });
492
+ const signals: ProactSignals = observed.status === 'ok' ? observed.value : {};
493
+ const previousFile = join(root, 'rhythm', 'daily', `${kstDay(now)}.md`);
494
+ const retained = existsSync(previousFile) ? readFileSync(previousFile, 'utf8').split('## PROACT1-LITE 제안\n')[1]?.split('\n\n## ')[0]?.trim() ?? '' : '';
495
+ const previouslySuggested = new Set(deps.proactPreviouslySuggested ?? []);
496
+ // 같은 제안은 하루 한 번이 아니라 «지난 7일 안에 한 번»: 오늘 ⊕ 앞선 6일 리뷰 파일의 제안 표지를 모두 읽는다.
497
+ const history = [retained];
498
+ for (let back = 1; back < 7; back++) {
499
+ const file = join(root, 'rhythm', 'daily', `${kstDay(new Date(now.getTime() - back * 86_400_000))}.md`);
500
+ if (!existsSync(file)) continue;
501
+ try { history.push(readFileSync(file, 'utf8').split('## PROACT1-LITE 제안\n')[1]?.split('\n\n## ')[0] ?? ''); } catch { /* unreadable day is skipped, not treated as empty history */ }
502
+ }
503
+ for (const line of history.join('\n').split('\n')) {
504
+ const key = /^- .* \[proact:([^\]]+)\]$/.exec(line)?.[1];
505
+ if (key) previouslySuggested.add(key);
506
+ }
507
+ const suggestions = selectProact1Lite({ ...signals,
508
+ ...(parts.loops.status === 'ok' ? { loops: parts.loops.value.map(loop => ({ name: loop.name, state: loop.status, scope: loop.scope })) } : {}),
509
+ }, { now, previouslySuggested });
510
+ const newSuggestions = suggestions.slice(0, Math.max(0, 3 - retained.split('\n').filter(line => line.startsWith('- ')).length))
511
+ .map(suggestion => `- ${suggestion.title} — ${suggestion.reason} (${suggestion.source}) [proact:${suggestion.key}]`).join('\n');
512
+ const recorded = [retained, newSuggestions].filter(Boolean).join('\n');
513
+ if (recorded) review.markdown += `\n## PROACT1-LITE 제안\n${recorded}\n`;
348
514
  const file = join(deps.root ?? effectiveInstanceRoot(), 'rhythm', 'daily', `${kstDay(now)}.md`);
349
- mkdirSync(dirname(file), { recursive: true });
350
- writeFileSync(file, review.markdown);
351
- const { vaultError, vaultFatal } = copyToVault(review.markdown, now, deps);
515
+ if (!options.dryRun) {
516
+ mkdirSync(dirname(file), { recursive: true });
517
+ writeFileSync(file, review.markdown);
518
+ }
519
+ const { vaultError, vaultFatal } = options.dryRun ? { vaultError: null, vaultFatal: false } : copyToVault(review.markdown, now, deps);
352
520
  let enabled = deps.sendEnabled === true;
353
521
  let sendError: string | null = null;
522
+ let deliveryState = options.stage === 'collect' ? 'pending' : options.dryRun ? 'dry-run' : 'send-disabled';
354
523
  if (deps.sendEnabled === undefined && !options.dryRun && options.stage !== 'collect') {
355
524
  try { enabled = record(record(record(getUserConfig().raw.loops).rhythm).daily).send === true; }
356
- catch (error) { sendError = `발송 설정 못 읽음 · ${reasonOf(error)}`; }
525
+ catch (error) { sendError = `발송 설정 못 읽음 · ${shortReason(reasonOf(error))}`; deliveryState = 'unknown'; }
357
526
  }
358
527
  let sent = false;
528
+ let recordError: string | null = null;
529
+ let chars = 0;
530
+ let target = 'unknown';
359
531
  if (!options.dryRun && options.stage !== 'collect' && enabled) {
360
- const delivery = sendReview(file, `${review.header}\n\n## 위험 톱 5\n${review.risks.map((r, i) => `${i + 1}. ${r.name} — ${r.score}점 · ${r.reason}`).join('\n') || '확인된 위험 없음'}\n\n${file}`, deps.send ?? sendOutbound, deps.receipt);
532
+ const text = `${review.header}\n\n## 위험 톱 5\n${review.risks.map((r, i) => `${i + 1}. ${r.name} — ${r.score}점 · ${r.reason}`).join('\n') || '확인된 위험 없음'}${recorded ? `\n\n## PROACT1-LITE 제안\n${recorded}` : ''}\n\n${file}`;
533
+ chars = `${text}\n${receiptKey}`.length;
534
+ let resolved: ReturnType<typeof recipient> = null;
535
+ try { resolved = recipient(deps); }
536
+ catch (error) { sendError = `수신자 설정 못 읽음 · ${shortReason(reasonOf(error))}`; }
537
+ const delivery = resolved ? sendReview(file, text, resolved, deps.send ?? sendTelegram, deps.receipt ?? (() => 'unknown'), deps.recordDelivery)
538
+ : { sent: false, sendError: '수신자 미설정', deliveryState: 'no-recipient', target: 'unknown' };
539
+ target = delivery.target;
361
540
  sent = delivery.sent;
362
- sendError = delivery.sendError;
541
+ sendError = sendError ?? delivery.sendError;
542
+ recordError = delivery.recordError ?? null;
543
+ deliveryState = delivery.deliveryState;
363
544
  }
545
+ const status = vaultFatal || Object.values(review.sections).includes('unreadable') ? 'degraded'
546
+ : deliveryState === 'pending' ? 'pending'
547
+ : ['sent', 'already-sent', 'receipt-sent'].includes(deliveryState) ? 'ok' : 'degraded';
548
+ const deliveryLine = `${options.stage === 'collect' ? 'collect' : 'deliver'} ${status} · target=${target} · ${deliveryState} · sent=${sent} · chars=${chars} · receipt=${receiptKey}${recordError ? ` · ${recordError}` : ''}`;
364
549
  if (options.stage !== 'collect') {
365
- const tick = { sections: review.sections, risks: review.risks.length, sent };
366
- if (deps.log) deps.log('loop.rhythm-daily', 'tick', tick);
367
- else loopEvent('rhythm-daily', 'tick', tick);
550
+ const tick = { sections: review.sections, risks: review.risks.length, sent, status, deliveryState, target, chars, receiptKey, deliveryLine, recordError };
551
+ if (!options.dryRun) {
552
+ if (deps.log) deps.log('loop.rhythm-daily', 'tick', tick);
553
+ else loopEvent('rhythm-daily', 'tick', tick);
554
+ }
368
555
  }
369
- return { file, ...review, sent, vaultError, vaultFatal, sendError };
556
+ return { file, ...review, sent, vaultError, vaultFatal, sendError, recordError, status, deliveryState, deliveryLine };
370
557
  }
371
558
 
372
559
  export async function main(args: string[] = process.argv.slice(2), deps: DailyDeps = {}): Promise<Awaited<ReturnType<typeof runDaily>>> {
373
560
  const stage = process.env.ELANOUS_GRAPH_DIR && ['collect', 'compose', 'deliver'].includes(args[0] ?? '') ? args.shift() as 'collect' | 'compose' | 'deliver' : undefined;
374
561
  for (const arg of args) if (!['--dry-run', '--no-news', '--json'].includes(arg)) throw new Error(`알 수 없는 옵션: ${arg}`);
375
562
  const result = await runDaily({ dryRun: args.includes('--dry-run'), noNews: args.includes('--no-news'), stage }, deps);
376
- (deps.print ?? console.log)(args.includes('--json') ? JSON.stringify(result) : `${result.markdown}\n파일: ${result.file} · sent=${result.sent}`);
377
- // 그래프 노드는 종료 코드로만 갈린다 — 미발송·볼트 사본 실패를 성공으로 내보내면 그래프가 done 으로 간다.
378
- if (stage === 'compose' && result.vaultFatal) throw new Error(`볼트 사본 실패 · ${result.vaultError}`);
563
+ (deps.print ?? console.log)(args.includes('--json') ? JSON.stringify(result) : `${result.markdown}\n파일: ${result.file} · ${result.deliveryLine}`);
564
+ // 발송 설정 OFF 는 degraded 로 기록하고 done 으로 진행한다. 실제 발송 실패·볼트 사본 실패는 그래프를 멈춘다.
565
+ if ((stage === 'compose' || !stage) && result.vaultFatal) throw new Error(`볼트 사본 실패 · ${result.vaultError}`);
379
566
  if (stage === 'deliver' && result.sendError) throw new Error(`발송 실패 · ${result.sendError}`);
380
567
  return result;
381
568
  }
@@ -7,13 +7,15 @@ import { effectiveInstanceRoot, prodInstanceRoot } from '../../src/instance/reso
7
7
  import { getUserConfig } from '../../src/user-config.js';
8
8
  import { loopEvent } from '../../src/loops/observe.js';
9
9
  import { sendOutbound } from '../../src/domains/outbound-alert.js';
10
- import { collectLandings, collectRelease, collectDecisions, type Landing, type Release, type Pending, type Section } from './daily.js';
10
+ import { listSchedules } from '../../src/release-loop/release-schedule.js';
11
+ import { listChecklist } from '../../src/release-loop/checklist.js';
12
+ import { collectLandings, collectDecisions, type Landing, type Release, type Pending, type Section } from './daily.js';
11
13
 
12
14
  type Issue = { id: string; title: string; owner: string; due: string; status: 'open' | 'done'; at: string; by: string };
13
15
  type WeeklyParts = { landings: Section<Landing[]>; release: Section<Release>; issues: Section<Issue[]>;
14
16
  followThrough: Section<{ issue: Issue; outcome: 'done' | '기한 뒤 완료' | '기한 넘김' | '진행'; doneAt?: string }[]>;
15
17
  decisions: Section<Pending[]>; news: Section<{ title: string; url: string }[]> };
16
- export type WeeklyDeps = { now?: () => Date; root?: string; vaultRoot?: string | null; sendEnabled?: boolean;
18
+ export type WeeklyDeps = { now?: () => Date; root?: string; vaultRoot?: string | null; sendEnabled?: boolean; repoRoot?: string; repoName?: string;
17
19
  landings?: (now: Date, window: { from: Date; to: Date }) => Promise<Landing[]>; release?: () => Promise<Release>;
18
20
  decisions?: () => Promise<Pending[]>; send?: (text: string, kind: string) => boolean;
19
21
  receipt?: (key: string) => 'sent' | 'not-sent' | 'unknown';
@@ -60,6 +62,20 @@ function appendIssue(root: string, row: Issue) {
60
62
  mkdirSync(dirname(file), { recursive: true });
61
63
  appendFileSync(file, `${JSON.stringify(row)}\n`);
62
64
  }
65
+ async function collectWeeklyRelease(now: Date, root: string): Promise<Release> {
66
+ if (!existsSync(join(root, 'release', 'features.sqlite'))) throw new Error('판 원장 없음');
67
+ const schedules = listSchedules(root).sort((a, b) => a.cutAt.localeCompare(b.cutAt));
68
+ const index = schedules.findIndex(schedule => Date.parse(schedule.cutAt) >= now.getTime());
69
+ if (index < 0) throw new Error('남은 판 일정 없음');
70
+ const current = schedules[index]!;
71
+ const items = listChecklist(current.version, root).items;
72
+ const next = schedules[index + 1];
73
+ const nextItems = next ? listChecklist(next.version, root).items : null;
74
+ return { version: current.version, green: items.filter(i => i.status === 'green').length, total: items.length,
75
+ nextVersion: next?.version ?? null, nextGreen: nextItems ? nextItems.filter(i => i.status === 'green').length : null,
76
+ nextTotal: nextItems?.length ?? null, cutAt: current.cutAt,
77
+ red: items.filter(i => i.status === 'red').map(i => ({ name: `${i.id} ${i.title}` })) };
78
+ }
63
79
  function readDailyNews(root: string, from: Date, to: Date): Section<{ title: string; url: string }[]> {
64
80
  const found = new Map<string, { title: string; url: string }>();
65
81
  const failures: string[] = [];
@@ -67,7 +83,7 @@ function readDailyNews(root: string, from: Date, to: Date): Section<{ title: str
67
83
  for (let at = from.getTime(); at < to.getTime(); at += DAY) {
68
84
  const day = kstDate(new Date(at));
69
85
  const file = join(root, 'rhythm', 'daily', `${day}.md`);
70
- if (!existsSync(file)) { failures.push(`${day} 데일리 없음`); continue; }
86
+ if (!existsSync(file)) continue;
71
87
  files++;
72
88
  try {
73
89
  const text = readFileSync(file, 'utf8');
@@ -83,7 +99,8 @@ function readDailyNews(root: string, from: Date, to: Date): Section<{ title: str
83
99
  } catch (e) { failures.push(`${day} ${reasonOf(e)}`); }
84
100
  }
85
101
  const news = [...found.values()].slice(0, 10);
86
- if (failures.length) return { status: 'unreadable', reason: `${files ? failures.join('; ') : '데일리 없음'}${news.length ? `\n부분 수집 (완전한 주간 집계 아님):\n${news.map(n => `- ${n.title} — ${n.url}`).join('\n')}` : ''}` };
102
+ if (!files) return { status: 'unreadable', reason: '데일리 없음' };
103
+ if (failures.length) return { status: 'unreadable', reason: `${failures.join('; ')}${news.length ? `\n부분 수집 (완전한 주간 집계 아님):\n${news.map(n => `- ${n.title} — ${n.url}`).join('\n')}` : ''}` };
87
104
  return { status: 'ok', value: news };
88
105
  }
89
106
  const labels: Record<keyof WeeklyParts, string> = {
@@ -133,8 +150,10 @@ function vaultCopy(markdown: string, key: string, deps: WeeklyDeps) {
133
150
  let root = deps.vaultRoot;
134
151
  if (root === undefined) {
135
152
  try {
136
- root = String(object(getUserConfig().raw.obsidian).vaultRoot || '') || null;
137
- if (root && effectiveInstanceRoot() !== prodInstanceRoot()) return { vaultError: '격리 인스턴스 — 운영 볼트 사본 건너뜀', vaultFatal: false };
153
+ const config = getUserConfig();
154
+ const obsidian = object(config.raw.obsidian);
155
+ root = (typeof obsidian.vault === 'string' && obsidian.vault) || (typeof obsidian.vaultRoot === 'string' && obsidian.vaultRoot) || config.obsidian.vault || null;
156
+ if (root && effectiveInstanceRoot() !== prodInstanceRoot() && !root.startsWith(`${effectiveInstanceRoot()}/`)) return { vaultError: '격리 인스턴스 — 운영 볼트 사본 건너뜀', vaultFatal: false };
138
157
  } catch (e) { return { vaultError: reasonOf(e), vaultFatal: true }; }
139
158
  }
140
159
  if (!root) return { vaultError: null, vaultFatal: false };
@@ -181,6 +200,8 @@ export async function runWeekly(options: { dryRun?: boolean; stage?: 'collect' |
181
200
  const now = (deps.now ?? (() => new Date()))();
182
201
  const root = deps.root ?? effectiveInstanceRoot();
183
202
  const { from, to, key } = weekOf(now);
203
+ const newsTo = new Date(`${kstDate(now)}T00:00:00+09:00`);
204
+ const newsFrom = new Date(newsTo.getTime() - 7 * DAY);
184
205
  const file = join(root, 'rhythm', 'weekly', `${key}.md`);
185
206
  let markdown: string;
186
207
  let header: string;
@@ -205,8 +226,8 @@ export async function runWeekly(options: { dryRun?: boolean; stage?: 'collect' |
205
226
  };
206
227
  const issues = await safe(async () => issueHistory(root));
207
228
  const parts: WeeklyParts = {
208
- landings: await safe(() => (deps.landings ?? collectLandings)(now, { from, to })),
209
- release: await safe(deps.release ?? collectRelease),
229
+ landings: await safe(() => deps.landings ? deps.landings(now, { from, to }) : collectLandings(now, { from, to }, { repoRoot: deps.repoRoot, repoName: deps.repoName })),
230
+ release: await safe(deps.release ?? (() => collectWeeklyRelease(now, root))),
210
231
  issues: issues.status === 'ok' ? { status: 'ok', value: [...latest(issues.value).values()].filter(i => i.status === 'open') } : issues,
211
232
  followThrough: issues.status === 'ok' ? { status: 'ok', value: [...new Set([
212
233
  ...[...latest(issues.value, from.getTime()).values()].filter(i => i.status === 'open').map(i => i.id),
@@ -219,7 +240,7 @@ export async function runWeekly(options: { dryRun?: boolean; stage?: 'collect' |
219
240
  return { issue, outcome: Date.parse(issue.due) < now.getTime() ? '기한 넘김' as const : '진행' as const };
220
241
  }) } : issues,
221
242
  decisions: await safe(deps.decisions ?? collectDecisions),
222
- news: readDailyNews(root, from, to),
243
+ news: readDailyNews(root, newsFrom, newsTo),
223
244
  };
224
245
  const versions = await safe(async () => publishedVersions(root, from, to));
225
246
  if (versions.status === 'unreadable') {