elanous 0.2.12 → 0.2.13

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 (289) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1394-ee3795b112907122.js → 1394-931c9827067b2276.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{3992-2e522a8ac5f2afde.js → 3992-fe5c06d6b31f8b25.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/5250-c78ca295585bbf95.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/6511-ff8360be3d65aaa4.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/6588-ba380869b67c4fa7.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{7758-0b018a9e4fc56200.js → 7758-85c2b53f731da024.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/{8605-0259994816604c49.js → 8605-9da4678f8a310d18.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/9906-e7b8b5310a169dfe.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/approvals/{page-cb169620945d1952.js → page-e5b4e1b256f41076.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-cefe8169e36cf62a.js → page-e49b9b76c565a0b1.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/board/page-b4f032c893b21cc9.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/botlab/page-f14987d5850a31e0.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/bots/page-b433308aad13ceb6.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/chat/{page-813c4a52c9d05ca8.js → page-4c1dbb21861beab2.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/consult/{page-4731f2c215358937.js → page-c85b816e3b269dbb.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/control/{page-a022b257950f39e6.js → page-f6bc66b919b2ea7d.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-a2ffc717e04b8b0a.js → page-60603c1e056c9b25.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/editor/{page-b59ee4e70fc18fcf.js → page-a0ea8f5a2341ee6b.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/exec/page-45c5bf04b4a605e6.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/field/{page-c172dabd796eb7a3.js → page-36c6c4183353ac9e.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/inside/{page-1efd0cb9e0ed9d13.js → page-cb2eb0fd809980da.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/intake/{page-d5dc7ff69a37b4cf.js → page-31012264da8a341f.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/layout-50309aeb0bdd554a.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/live/page-bc00e964e855f4df.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/market/{page-dfca9b7d2de0b135.js → page-0696ee3fac9fe56c.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-793fa536b3a6ef37.js → page-66c27608f651b866.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/morning/{page-6dd3bfbbde2a5c2c.js → page-6ddd2109b7fc3a80.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/not-found-4e50b7298c1f69a5.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-77447356baad75d2.js → page-7a2bf830038a2264.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-1f327f18694fabca.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-e5cef2fe6e4028df.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cf596128d2b420ba.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-6d604227e1664b71.js → page-f672536f14f0969f.js} +1 -1
  36. package/apps/pwa/out/_next/static/chunks/app/page-b52365662232db89.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-4048543b4b7ad5e0.js → page-e7a6c80f288b1948.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-4c3a3126abed551a.js → page-29a709616df9b050.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-64b0aecb1ca75e75.js → page-fee842d7c2151129.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-78b40a344ed22f24.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/settings/page-75d199f448171014.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-a011dd5b0bff29c5.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/setup/{page-9547b5cec7cf76bf.js → page-d92e32d51ab5157e.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/share/page-5f21aed6932ef375.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/showroom/page-1e1a53946804debf.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-2cddeb9e2aeedc55.js → page-a1833cd43d832601.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/term/{page-e297866051595d81.js → page-63d14a4d8fe1e1c1.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/today/{page-baba2e047872fb0f.js → page-2a511d320f762e06.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/trace/{page-e895da4ec9cac345.js → page-1c3e03f88dac1e21.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/vault/page-c4d5289c2ea97a89.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-b91f8952deaeaf27.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/workflows/page-71b43a2588489987.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-2e9e2fb01c9da1c9.js → page-2b256feed331546f.js} +1 -1
  54. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-d62ceb13411ebba4.js → page-505bb96d69a8622c.js} +1 -1
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +13 -13
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +13 -13
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +13 -13
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +13 -13
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +13 -13
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +13 -13
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +13 -13
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +13 -13
  71. package/apps/pwa/out/design-check/index.html +1 -1
  72. package/apps/pwa/out/design-check/index.txt +13 -13
  73. package/apps/pwa/out/editor/index.html +1 -1
  74. package/apps/pwa/out/editor/index.txt +13 -13
  75. package/apps/pwa/out/exec/index.html +1 -1
  76. package/apps/pwa/out/exec/index.txt +13 -13
  77. package/apps/pwa/out/field/index.html +1 -1
  78. package/apps/pwa/out/field/index.txt +13 -13
  79. package/apps/pwa/out/healthz +1 -1
  80. package/apps/pwa/out/index.html +1 -1
  81. package/apps/pwa/out/index.txt +13 -13
  82. package/apps/pwa/out/inside/index.html +1 -1
  83. package/apps/pwa/out/inside/index.txt +13 -13
  84. package/apps/pwa/out/intake/index.html +1 -1
  85. package/apps/pwa/out/intake/index.txt +13 -13
  86. package/apps/pwa/out/live/index.html +1 -1
  87. package/apps/pwa/out/live/index.txt +13 -13
  88. package/apps/pwa/out/market/index.html +1 -1
  89. package/apps/pwa/out/market/index.txt +13 -13
  90. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  91. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  92. package/apps/pwa/out/morning/index.html +1 -1
  93. package/apps/pwa/out/morning/index.txt +13 -13
  94. package/apps/pwa/out/observatory/index.html +1 -1
  95. package/apps/pwa/out/observatory/index.txt +13 -13
  96. package/apps/pwa/out/ops/checklist/index.html +1 -1
  97. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  98. package/apps/pwa/out/ops/release/index.html +1 -1
  99. package/apps/pwa/out/ops/release/index.txt +13 -13
  100. package/apps/pwa/out/ops/seats/index.html +1 -1
  101. package/apps/pwa/out/ops/seats/index.txt +13 -13
  102. package/apps/pwa/out/outputs/index.html +1 -1
  103. package/apps/pwa/out/outputs/index.txt +13 -13
  104. package/apps/pwa/out/reflection/index.html +1 -1
  105. package/apps/pwa/out/reflection/index.txt +14 -14
  106. package/apps/pwa/out/scheduler/index.html +1 -1
  107. package/apps/pwa/out/scheduler/index.txt +13 -13
  108. package/apps/pwa/out/sessions/index.html +1 -1
  109. package/apps/pwa/out/sessions/index.txt +13 -13
  110. package/apps/pwa/out/settings/devices/index.html +1 -1
  111. package/apps/pwa/out/settings/devices/index.txt +13 -13
  112. package/apps/pwa/out/settings/index.html +1 -1
  113. package/apps/pwa/out/settings/index.txt +13 -13
  114. package/apps/pwa/out/setup/done/index.html +1 -1
  115. package/apps/pwa/out/setup/done/index.txt +13 -13
  116. package/apps/pwa/out/setup/index.html +1 -1
  117. package/apps/pwa/out/setup/index.txt +13 -13
  118. package/apps/pwa/out/share/index.html +1 -1
  119. package/apps/pwa/out/share/index.txt +13 -13
  120. package/apps/pwa/out/showroom/index.html +1 -1
  121. package/apps/pwa/out/showroom/index.txt +13 -13
  122. package/apps/pwa/out/tasks/index.html +1 -1
  123. package/apps/pwa/out/tasks/index.txt +13 -13
  124. package/apps/pwa/out/term/index.html +1 -1
  125. package/apps/pwa/out/term/index.txt +13 -13
  126. package/apps/pwa/out/today/index.html +1 -1
  127. package/apps/pwa/out/today/index.txt +13 -13
  128. package/apps/pwa/out/trace/index.html +1 -1
  129. package/apps/pwa/out/trace/index.txt +13 -13
  130. package/apps/pwa/out/vault/index.html +1 -1
  131. package/apps/pwa/out/vault/index.txt +13 -13
  132. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  133. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  134. package/apps/pwa/out/workflows/index.html +1 -1
  135. package/apps/pwa/out/workflows/index.txt +13 -13
  136. package/apps/pwa/out/workspace/index.html +1 -1
  137. package/apps/pwa/out/workspace/index.txt +13 -13
  138. package/apps/pwa/out/worktrees/index.html +1 -1
  139. package/apps/pwa/out/worktrees/index.txt +13 -13
  140. package/examples/teams/personas/backend.yaml +4 -0
  141. package/examples/teams/personas/pm.yaml +4 -0
  142. package/examples/teams/personas/qa.yaml +4 -0
  143. package/examples/teams/small-product.yaml +20 -0
  144. package/graphs/lecture/lecture-note.yaml +28 -0
  145. package/graphs/lecture/recipes.yaml +17 -0
  146. package/package.json +2 -1
  147. package/scripts/context-hooks/emit.ts +36 -14
  148. package/scripts/coord-channel-watch.sh +1 -0
  149. package/scripts/discord-seat-probe.ts +144 -0
  150. package/scripts/hq/hq-standby.sh +65 -0
  151. package/scripts/hq/standby-promote.ts +173 -0
  152. package/scripts/hq/standby-role.ts +59 -0
  153. package/scripts/hq/standby-snapshot.ts +226 -0
  154. package/scripts/hq/standby-verify.ts +219 -0
  155. package/scripts/lecture-note/node.ts +256 -0
  156. package/scripts/release-loop/gate-node.ts +53 -15
  157. package/scripts/release-loop/ops-upgrade-node.ts +17 -5
  158. package/scripts/test-deterministic.ts +53 -3
  159. package/skills/omni-crawl/SKILL.md +16 -16
  160. package/skills/omni-crawl/package-lock.json +1 -544
  161. package/skills/omni-crawl/package.json +3 -4
  162. package/skills/omni-crawl/scripts/main.ts +13 -13
  163. package/skills/omni-crawl/scripts/monitor.ts +8 -8
  164. package/skills/omni-crawl/src/apify.ts +13 -2
  165. package/skills/omni-crawl/src/capture.ts +6 -0
  166. package/skills/omni-crawl/src/registry.ts +2 -2
  167. package/skills/omni-crawl/src/stdout-json.ts +4 -0
  168. package/skills/omni-digest/SKILL.md +10 -11
  169. package/skills/omni-digest/package-lock.json +0 -545
  170. package/skills/omni-digest/package.json +2 -5
  171. package/skills/omni-digest/scripts/main.ts +3 -3
  172. package/skills/omni-digest/src/delegate.ts +5 -5
  173. package/skills/omni-digest/src/fetch.ts +2 -2
  174. package/skills/omni-digest/src/media.ts +25 -11
  175. package/skills/omni-digest/src/visual.ts +3 -2
  176. package/skills/omni-market/scripts/main.ts +1 -1
  177. package/skills/omni-market/scripts/stdout-json.ts +4 -0
  178. package/src/agent/identity-env.ts +11 -0
  179. package/src/agent-mission/driver.ts +12 -7
  180. package/src/agent-mission/duo.ts +94 -0
  181. package/src/agent-mission/pty-decision.ts +1 -1
  182. package/src/autopilot/track-agent-shadow.ts +2 -1
  183. package/src/autopilot/track-agent.ts +4 -1
  184. package/src/boot/daemon-prompt-request.ts +3 -0
  185. package/src/boot/daemon-prompt-turn.ts +6 -2
  186. package/src/boot/daemon-runtime.ts +48 -16
  187. package/src/cli/decisions-cli.ts +51 -19
  188. package/src/cli/outputs-cli.ts +92 -0
  189. package/src/cli/persona-cli.ts +29 -0
  190. package/src/cli/pod-cli.ts +10 -3
  191. package/src/cli/pr-cli.ts +15 -1
  192. package/src/cli/release-cli.ts +108 -28
  193. package/src/context-bus/condense.ts +194 -0
  194. package/src/context-bus/context-day.ts +82 -0
  195. package/src/context-bus/context-now-surfaces.ts +15 -2
  196. package/src/context-bus/long-term-memory.ts +118 -22
  197. package/src/context-bus/nightly-condense.ts +1 -1
  198. package/src/decisions/decision-cards.ts +13 -7
  199. package/src/decisions/decision-ledger.ts +43 -17
  200. package/src/graph-kinds/registry.ts +2 -2
  201. package/src/graph-runner/graph-approval-card.ts +2 -2
  202. package/src/graph-runner/graph-grow.ts +64 -11
  203. package/src/graph-runner/graph-tick.ts +9 -2
  204. package/src/graph-runner/runner.ts +81 -17
  205. package/src/harness/harness-cli-command.ts +27 -1
  206. package/src/harness/harness-queue-child.ts +37 -0
  207. package/src/harness/harness-queue.ts +306 -0
  208. package/src/hooks/github-shadow.ts +72 -2
  209. package/src/hooks/receiver.ts +4 -2
  210. package/src/hq/hq.ts +318 -0
  211. package/src/hq/lease.ts +210 -0
  212. package/src/hq/ledger-write-target.ts +41 -0
  213. package/src/index.ts +199 -7
  214. package/src/intake-plane/discord-seat-work.ts +13 -6
  215. package/src/intake-plane/linear-wish.ts +44 -0
  216. package/src/intake-plane/persona-answer.ts +5 -0
  217. package/src/intake-plane/telegram-seat-work.ts +13 -6
  218. package/src/intake-plane/wish-card.ts +1 -1
  219. package/src/loops/checker.ts +178 -0
  220. package/src/loops/loop-cli.ts +37 -3
  221. package/src/loops/observe.ts +49 -0
  222. package/src/loops/orchestrator/overlap.ts +141 -0
  223. package/src/loops/orchestrator/traffic.ts +150 -0
  224. package/src/loops/package/verify.ts +125 -0
  225. package/src/loops/verdict.ts +22 -0
  226. package/src/maturity/feature-maturity.ts +3 -3
  227. package/src/memory.ts +37 -4
  228. package/src/nexus/api/card-followup-route.ts +118 -0
  229. package/src/nexus/api/decisions-route.ts +68 -0
  230. package/src/nexus/api/http-server.ts +47 -0
  231. package/src/nexus/api/personas.ts +2 -1
  232. package/src/nexus/api/skill-exec-route.ts +97 -0
  233. package/src/persona/loader.ts +18 -0
  234. package/src/persona/persona-todo.ts +14 -9
  235. package/src/persona/presets.ts +2 -1
  236. package/src/persona/team.ts +105 -0
  237. package/src/persona/types.ts +4 -0
  238. package/src/pod-lease/host-lease.ts +130 -20
  239. package/src/release-loop/landed-but-yellow.ts +12 -0
  240. package/src/seat-dispatch/seat-ask.ts +57 -12
  241. package/src/seat-loop/seat-loop.ts +90 -16
  242. package/src/seat-loop/seat-pack.ts +127 -0
  243. package/src/self-dev/ask-launch-flow.ts +3 -0
  244. package/src/self-implement/l8-merge-queue.ts +157 -1
  245. package/src/steward/launch.ts +5 -1
  246. package/src/steward/readiness.ts +125 -0
  247. package/src/steward/rescue.ts +75 -0
  248. package/src/steward/triage.ts +48 -9
  249. package/src/task-cards/card-cli.ts +78 -0
  250. package/src/task-orchestrator/surfaces/pod-lease.ts +18 -6
  251. package/src/task-orchestrator/surfaces/pod-pool.ts +27 -10
  252. package/src/task-orchestrator/surfaces/self-implement-pod.ts +38 -8
  253. package/src/telegram-dispatch-forward.ts +1 -1
  254. package/src/telegram-fabric-plan.ts +90 -0
  255. package/src/telegram-project-command.ts +10 -4
  256. package/src/telegram.ts +5 -0
  257. package/src/tool-runtime/elanous-control-runtimes.ts +2 -1
  258. package/src/user-config.ts +94 -10
  259. package/src/version/packed-revision.json +1 -1
  260. package/src/workflow-runtime/executor.ts +4 -0
  261. package/src/workflow-runtime/node-catalog.ts +13 -0
  262. package/src/workflow-runtime/nodes/knowledge.ts +38 -0
  263. package/src/workflow-runtime/schema.ts +17 -1
  264. package/src/workflow-runtime/types.ts +13 -1
  265. package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/9906-482c6619b3fdda20.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/board/page-d2e35228bc497119.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/botlab/page-5c05b0da4bc1392c.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/bots/page-e0ccdb576812b523.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/live/page-1d6a533ce5a6efcb.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/not-found-490653e8d4cc8852.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/settings/page-db14d0be7f1c460c.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +0 -1
  288. /package/apps/pwa/out/_next/static/{-6XrAoD3eU7NyMHAE4xPS → D51l8jUBplJrIpWvENsDK}/_buildManifest.js +0 -0
  289. /package/apps/pwa/out/_next/static/{-6XrAoD3eU7NyMHAE4xPS → D51l8jUBplJrIpWvENsDK}/_ssgManifest.js +0 -0
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bun
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { homedir } from 'node:os';
7
+ import { debug } from '../../src/debug/log.js';
8
+ import { streamLLM } from '../../src/llm.js';
9
+ import { unified } from 'unified';
10
+ import remarkParse from 'remark-parse';
11
+ import remarkGfm from 'remark-gfm';
12
+ import remarkRehype from 'remark-rehype';
13
+ import rehypeSanitize from 'rehype-sanitize';
14
+ import rehypeStringify from 'rehype-stringify';
15
+
16
+ export type Stage = 'collect' | 'draft' | 'render' | 'html' | 'pdf';
17
+ export interface Input { name: string; kind: 'pdf' | 'image' | 'markdown'; text: string }
18
+ export interface Counts { inputs: number; diagrams: number; rendered: number; html: 'yes' | 'no'; pdf: 'yes' | 'skipped' }
19
+ export interface StageResult extends Counts { outcome: 'ok' | 'skipped' }
20
+ export interface StageOptions {
21
+ llm?: (prompt: string) => Promise<string>;
22
+ toolsPath?: string;
23
+ referenceRoot?: string;
24
+ }
25
+
26
+ const ownRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../skills');
27
+ const kinds: Record<string, Input['kind']> = {
28
+ '.pdf': 'pdf', '.md': 'markdown', '.png': 'image', '.jpg': 'image', '.jpeg': 'image', '.webp': 'image',
29
+ };
30
+ const mermaid = /^```mermaid\s*\n([\s\S]*?)^```\s*$/gm;
31
+ const escapeHtml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
32
+
33
+ function pathStatus(path: string): ReturnType<typeof lstatSync> | undefined {
34
+ try { return lstatSync(path); }
35
+ catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ function outputDir(folder: string): string {
42
+ if (!lstatSync(folder).isDirectory()) throw new Error(`강의 폴더가 아니다: ${folder}`);
43
+ const dir = join(resolve(folder), 'lecture-note');
44
+ const status = pathStatus(dir);
45
+ if (status && (status.isSymbolicLink() || !status.isDirectory())) throw new Error('lecture-note 출력 폴더가 안전하지 않다');
46
+ mkdirSync(dir, { recursive: true });
47
+ return dir;
48
+ }
49
+
50
+ function artifact(dir: string, name: string): string {
51
+ const path = join(dir, name);
52
+ const status = pathStatus(path);
53
+ if (status && (status.isSymbolicLink() || !status.isFile())) throw new Error(`출력 파일이 안전하지 않다: ${path}`);
54
+ return path;
55
+ }
56
+
57
+ function binary(name: string, path = process.env.PATH ?? ''): string | undefined {
58
+ for (const part of path.split(':').filter(Boolean)) {
59
+ const at = join(part, name);
60
+ if (existsSync(at) && statSync(at).isFile()) return at;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ function run(bin: string, args: string[], dir: string, timeout: number): ReturnType<typeof spawnSync> {
66
+ return spawnSync(bin, args, { cwd: dir, encoding: 'utf8', timeout, maxBuffer: 20 * 1024 * 1024,
67
+ env: { ...process.env, HOME: dir, XDG_CACHE_HOME: dir, XDG_CONFIG_HOME: dir, UV_CACHE_DIR: dir, PYTHONDONTWRITEBYTECODE: '1' } });
68
+ }
69
+
70
+ function readInputs(dir: string): Input[] {
71
+ const value: unknown = JSON.parse(readFileSync(join(dir, 'inputs.json'), 'utf8'));
72
+ if (!Array.isArray(value) || !value.every((item) => item && typeof item.name === 'string' && typeof item.kind === 'string' && typeof item.text === 'string')) {
73
+ throw new Error('inputs.json 형식 오류');
74
+ }
75
+ return value as Input[];
76
+ }
77
+
78
+ function counts(dir: string): Counts {
79
+ const inputs = existsSync(join(dir, 'inputs.json')) ? readInputs(dir).length : 0;
80
+ const notes = existsSync(join(dir, 'notes.md')) ? readFileSync(join(dir, 'notes.md'), 'utf8') : '';
81
+ const diagrams = [...notes.matchAll(mermaid)].length;
82
+ const rendered = [...Array(diagrams).keys()].filter(i => existsSync(join(dir, `diagram-${i + 1}.png`))).length;
83
+ return { inputs, diagrams, rendered, html: existsSync(join(dir, 'notes.html')) ? 'yes' : 'no', pdf: existsSync(join(dir, 'notes.pdf')) ? 'yes' : 'skipped' };
84
+ }
85
+
86
+ export function collect(folder: string, options: StageOptions = {}): StageResult {
87
+ const dir = outputDir(folder);
88
+ const files: Input[] = [];
89
+ const pdfText = binary('pdftotext', options.toolsPath);
90
+ const sources = resolve(folder, 'sources/common');
91
+ const scan = [{ base: folder, prefix: '' }, ...(pathStatus(sources)?.isDirectory() && !pathStatus(sources)?.isSymbolicLink()
92
+ ? [{ base: sources, prefix: 'sources/common/' }] : [])];
93
+ for (const { base, prefix } of scan) for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
94
+ const kind = kinds[extname(entry.name).toLowerCase()];
95
+ if (!entry.isFile() || !kind) continue;
96
+ const at = join(base, entry.name);
97
+ const text = kind === 'markdown' ? readFileSync(at, 'utf8') : kind === 'image' ? '이미지 — 시각 자료 (텍스트 추출 없음)' :
98
+ pdfText ? (() => {
99
+ const result = run(pdfText, ['-layout', at, '-'], dir, 60000);
100
+ return result.status === 0 ? String(result.stdout) : 'PDF 텍스트 없음 — pdftotext 추출 실패';
101
+ })() : 'PDF 텍스트 없음 — pdftotext 없음';
102
+ files.push({ name: prefix + entry.name, kind, text });
103
+ }
104
+ writeFileSync(artifact(dir, 'inputs.json'), JSON.stringify(files, null, 2) + '\n');
105
+ return { ...counts(dir), outcome: 'ok' };
106
+ }
107
+
108
+ const instructions = `강의 자료로 종합 디지털 강의 노트 Markdown만 작성하라.
109
+ 강의 진행 순서를 유지하고 손필기/슬라이드의 시각 정보와 요약의 텍스트를 통합하라. 읽지 못한 이미지는 추측하지 말고 출처를 표시하라.
110
+ 다이어그램은 Mermaid 우선으로 해당 설명 위치에 \`\`\`mermaid 코드블록으로 내장하라 (곡선·자유형은 코드로 억지 변환하지 않는다).
111
+ 비교·분류는 표로, 실제 사례는 별도 사례 박스로, 핵심 원칙은 인용문으로 표시하라.
112
+ 수식은 KaTeX 인라인 \\( ... \\) 형식, 강조 수식은 > \\( ... \\) 형식으로 쓰고 $$ 블록 수식이나 볼드/코드 수식은 쓰지 마라.
113
+ 강의 개요 바로 아래에 해당 주차 신규 용어집 표(| 약어 | 영문 | 한국어 | 정의 |)를 배치하라.
114
+ 목차와 각 헤딩 위의 영문 앵커를 만들고, 맨 끝에 다이어그램 목록 부록을 둬라. 근거 없는 내용은 만들어내지 마라.`;
115
+
116
+ export async function draft(folder: string, options: StageOptions = {}): Promise<StageResult> {
117
+ const dir = outputDir(folder);
118
+ const inputs = readInputs(dir);
119
+ if (!inputs.length) throw new Error('강의 자료가 없다');
120
+ const prompt = `${instructions}\n\n자료:\n${inputs.map(i => `### ${i.name} (${i.kind})\n${i.text}`).join('\n\n')}`;
121
+ const ask = options.llm ?? ((request: string) => streamLLM([{ role: 'user', content: request }], () => {}));
122
+ const notes = (await ask(prompt)).trim();
123
+ if (!notes) throw new Error('LLM 초안이 비었다');
124
+ writeFileSync(artifact(dir, 'notes.md'), notes + '\n');
125
+ return { ...counts(dir), outcome: 'ok' };
126
+ }
127
+
128
+ export function render(folder: string, options: StageOptions = {}): StageResult {
129
+ const dir = outputDir(folder);
130
+ const notes = readFileSync(join(dir, 'notes.md'), 'utf8');
131
+ const renderer = join(options.referenceRoot ?? ownRoot, 'diagram-master/references/engines/mermaid/render_mermaid.py');
132
+ const uv = binary('uv', options.toolsPath);
133
+ let unavailable = 0;
134
+ for (const [i, match] of [...notes.matchAll(mermaid)].entries()) {
135
+ const png = artifact(dir, `diagram-${i + 1}.png`);
136
+ if (existsSync(png)) rmSync(png);
137
+ if (!uv || !existsSync(renderer)) { unavailable++; continue; }
138
+ const mmd = artifact(dir, `diagram-${i + 1}.mmd`);
139
+ writeFileSync(mmd, match[1]!);
140
+ const result = run(uv, ['run', 'python', renderer, mmd, '--output', png], dir, 120000);
141
+ if (result.status !== 0 || !existsSync(png)) {
142
+ if (existsSync(png)) rmSync(png);
143
+ throw new Error(`Mermaid 렌더 실패 (diagram-${i + 1}, exit=${result.status}): ${result.error?.message ?? result.stderr ?? result.stdout ?? 'PNG 없음'}`);
144
+ }
145
+ }
146
+ if (unavailable) console.log(`렌더러 없음: ${unavailable}`);
147
+ return { ...counts(dir), outcome: 'ok' };
148
+ }
149
+
150
+ function markdownHtml(text: string): string {
151
+ let suffix = 0;
152
+ while (text.includes(`LECTUREMATHOPEN${suffix}`) || text.includes(`LECTUREMATHCLOSE${suffix}`)) suffix++;
153
+ const open = `LECTUREMATHOPEN${suffix}`;
154
+ const close = `LECTUREMATHCLOSE${suffix}`;
155
+ const protectedText = text.replace(/\\\(/g, open).replace(/\\\)/g, close);
156
+ return String(unified().use(remarkParse).use(remarkGfm)
157
+ .use(remarkRehype).use(rehypeSanitize).use(rehypeStringify).processSync(protectedText))
158
+ .replaceAll(open, '\\(').replaceAll(close, '\\)');
159
+ }
160
+
161
+ export function html(folder: string, options: StageOptions = {}): StageResult {
162
+ const dir = outputDir(folder);
163
+ const notes = readFileSync(join(dir, 'notes.md'), 'utf8');
164
+ const template = readFileSync(join(options.referenceRoot ?? ownRoot, 'lecture-note-digitizer/references/html-template.md'), 'utf8');
165
+ const css = template.match(/```css\n([\s\S]*?)\n```/)?.[1];
166
+ if (!css) throw new Error('HTML 템플릿 CSS 없음');
167
+ let n = 0;
168
+ const withoutAppendix = notes.replace(/\n(?:---\n\n)?(?:<a id="appendix"><\/a>\n)?#+ 부록: 다이어그램 목록[\s\S]*$/u, '');
169
+ const anchored = withoutAppendix.replace(/^<a id="([a-zA-Z0-9-]+)"><\/a>$/gm, (_match, id: string) => `\n\nLECTURE_ANCHOR_${id}_END\n\n`);
170
+ const body = markdownHtml(anchored.replace(mermaid, (_block, source: string) => {
171
+ const png = `diagram-${++n}.png`;
172
+ return existsSync(join(dir, png)) ? `\n\n![diagram-${n}](${png})\n\n` : `\n\n\`\`\`mermaid\n${source}\`\`\`\n\n`;
173
+ })).replace(/<p><img src="(diagram-\d+\.png)" alt="(diagram-\d+)"><\/p>/g,
174
+ '<div class="diagram-container"><img src="$1" alt="$2"></div>')
175
+ .replace(/<p>LECTURE_ANCHOR_([a-zA-Z0-9-]+)_END<\/p>/g, '<a id="$1"></a>');
176
+ const title = notes.match(/^# (.+)$/m)?.[1] ?? '강의 노트';
177
+ const sections: string[] = [];
178
+ let heading = 0;
179
+ const numbered = body.replace(/<(h[12])>(.*?)<\/\1>/g, (_m, tag: string, label: string) => {
180
+ const id = `sec-${++heading}`;
181
+ sections.push(`<li><a href="#${id}">${label}</a></li>`);
182
+ return `<${tag} id="${id}">${label}</${tag}>`;
183
+ });
184
+ const page = `<!DOCTYPE html>\n<html lang="ko"><head><meta charset="UTF-8"><title>${escapeHtml(title)}</title>` +
185
+ '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">' +
186
+ '<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>' +
187
+ '<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:\'\\\\(\',right:\'\\\\)\',display:false}]});"></script>' +
188
+ `<style>${css}</style></head><body><div class="cover"><h1>${escapeHtml(title)}</h1></div>` +
189
+ `<div class="toc"><h2>목차</h2><ol>${sections.join('\n')}</ol></div><div class="section">${numbered}</div></body></html>\n`;
190
+ writeFileSync(artifact(dir, 'notes.html'), page);
191
+ return { ...counts(dir), outcome: 'ok' };
192
+ }
193
+
194
+ export function pdf(folder: string, options: StageOptions = {}): StageResult {
195
+ const dir = outputDir(folder);
196
+ const source = artifact(dir, 'notes.html');
197
+ if (!existsSync(source)) throw new Error('notes.html 없음');
198
+ const converter = join(options.referenceRoot ?? ownRoot, 'lecture-note-digitizer/references/convert_to_pdf.py');
199
+ const python = binary('python3', options.toolsPath);
200
+ const target = artifact(dir, 'notes.pdf');
201
+ if (existsSync(target)) throw new Error('기존 notes.pdf 를 덮어쓰지 않는다');
202
+ const result = python && existsSync(converter) ? run(python, [converter, source], dir, 120000) : undefined;
203
+ const engineMissing = !result || (result.status !== 0 && !result.error &&
204
+ (/^ModuleNotFoundError: No module named ['"]playwright['"]$/m.test(String(result.stderr)) ||
205
+ /BrowserType\.launch: Executable doesn't exist at/.test(String(result.stderr))));
206
+ if (engineMissing) {
207
+ if (existsSync(target)) rmSync(target);
208
+ console.log('PDF 엔진 없음 — HTML 이 산출물');
209
+ return { ...counts(dir), pdf: 'skipped', outcome: 'skipped' };
210
+ }
211
+ if (result && (result.status !== 0 || !existsSync(target))) {
212
+ if (existsSync(target)) rmSync(target);
213
+ throw new Error(`PDF 변환 실패 (exit=${result.status}): ${result.error?.message ?? result.stderr ?? result.stdout ?? 'PDF 없음'}`);
214
+ }
215
+ return { ...counts(dir), outcome: 'ok' };
216
+ }
217
+
218
+ export async function runStage(stage: Stage, folder: string, options: StageOptions = {}): Promise<StageResult> {
219
+ const result = stage === 'collect' ? collect(folder, options) : stage === 'draft' ? await draft(folder, options) :
220
+ stage === 'render' ? render(folder, options) : stage === 'html' ? html(folder, options) : pdf(folder, options);
221
+ debug.log('lecture.note', stage, { inputs: result.inputs, diagrams: result.diagrams, rendered: result.rendered, pdf: result.pdf });
222
+ return result;
223
+ }
224
+
225
+ function folderFromContext(): string {
226
+ const arg = process.argv.indexOf('--folder');
227
+ if (arg >= 0) {
228
+ const folder = process.argv[arg + 1];
229
+ if (!folder) throw new Error('--folder 값 없음');
230
+ return resolve(folder.replace(/^~(?=\/)/, homedir()));
231
+ }
232
+ const context = process.env.ELANOUS_GRAPH_CONTEXT;
233
+ if (!context) throw new Error('input.folder 필요');
234
+ const data: unknown = JSON.parse(context.trim().startsWith('{') ? context : readFileSync(context, 'utf8'));
235
+ const folder = (data as { input?: { folder?: unknown } })?.input?.folder;
236
+ if (typeof folder !== 'string' || !folder.trim()) throw new Error('input.folder 필요');
237
+ const expanded = folder.replace(/^~(?=\/)/, homedir());
238
+ if (!isAbsolute(expanded)) throw new Error('input.folder 절대 경로 필요');
239
+ return resolve(expanded);
240
+ }
241
+
242
+ if (import.meta.main) {
243
+ const stage = process.argv[2];
244
+ if (!['collect', 'draft', 'render', 'html', 'pdf'].includes(stage ?? '')) throw new Error('stage: collect|draft|render|html|pdf');
245
+ let folder = '';
246
+ try {
247
+ folder = folderFromContext();
248
+ const result = await runStage(stage as Stage, folder);
249
+ console.log(`lecture-note ${folder} inputs=${result.inputs} diagrams=${result.diagrams} rendered=${result.rendered} html=${result.html} pdf=${result.pdf}`);
250
+ console.log(JSON.stringify({ outcome: result.outcome }));
251
+ } catch (error) {
252
+ console.error(error);
253
+ console.log(JSON.stringify({ outcome: 'fail', reason: error instanceof Error ? error.message : String(error) }));
254
+ process.exitCode = 1;
255
+ }
256
+ }
@@ -18,7 +18,7 @@ import { POD_BUN_CACHE_HOST_PATH, parseInstallSeconds, podBunCacheVolume } from
18
18
  import { PodPoolScheduler, parsePodPool, checkPodPool } from '../../src/task-orchestrator/surfaces/pod-pool.js';
19
19
  import { defaultKubectl } from '../../src/task-orchestrator/surfaces/self-implement-pod.js';
20
20
 
21
- interface CommandResult { rc: number; output: string; passedIds?: string[] }
21
+ interface CommandResult { rc: number; output: string; passedIds?: string[]; stalledEnv?: Array<{ file: string; reason: 'no-output' }> }
22
22
  export interface GateRunner {
23
23
  command(cmd: string, args: string[], cwd: string): Promise<CommandResult>;
24
24
  localCommand(cmd: string, args: string[], cwd: string): Promise<CommandResult>;
@@ -50,6 +50,7 @@ export interface GateResult {
50
50
  fixed: number;
51
51
  knownEnv: number;
52
52
  knownEnvCleared: string[];
53
+ stalledEnv: Array<{ file: string; reason: 'no-output'; local: 'passed' | 'failed' }>;
53
54
  baselineSource?: 'ledger' | 'instance' | 'cut-logs' | 'swept';
54
55
  durationMs: number;
55
56
  error?: string;
@@ -322,13 +323,15 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
322
323
  const ran = clean && lastMatch(clean, /Ran (\d+) tests? across (\d+) files?/);
323
324
  const passes = clean === undefined ? NaN : summaryCount(clean, 'pass');
324
325
  const fails = clean === undefined ? NaN : summaryCount(clean, 'fail');
326
+ const errors = clean === undefined ? NaN : summaryCount(clean, 'error');
325
327
  let reason: ShardReason | undefined;
326
328
  let namedFailures: number | undefined;
327
329
  let unattributedDetail: string | undefined;
328
330
  if (jobFailed || (jobExitCode !== undefined && jobExitCode !== 0) || (rc !== undefined && rc !== 0 && rc !== 1)) reason = 'job-failed';
329
331
  else if (!clean || rc === undefined) reason = 'no-output';
330
332
  else if (!ran || !Number.isFinite(passes) || !Number.isFinite(fails)
331
- || Number(ran[2]) === 0 || Number(ran[2]) > paths.length || Number(ran[1]) === 0) reason = 'incomplete';
333
+ || Number(ran[2]) === 0 || Number(ran[2]) > paths.length
334
+ || (Number(ran[1]) === 0 && !(errors > 0))) reason = 'incomplete';
332
335
  else {
333
336
  try {
334
337
  const attributed = failuresOf({ rc, output: clean }, `pod-${shard} shard`);
@@ -400,7 +403,7 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
400
403
  return { runs: children.flatMap((child) => child.runs), stalled: children.flatMap((child) => child.stalled) };
401
404
  }
402
405
  return { runs: [{ output: clean!, rc: rc!, pass: passes, fail: fails,
403
- errors: Number.isNaN(summaryCount(clean!, 'error')) ? 0 : summaryCount(clean!, 'error'),
406
+ errors: Number.isNaN(errors) ? 0 : errors,
404
407
  ran: Number(ran![1]), files: Number(ran![2]), passedIds: junit ? junitPassedIds(junit) : [] }], stalled: [] };
405
408
  };
406
409
  // 모든 조각이 끝난 뒤 판정한다 — 한 조각의 예외로 먼저 돌아가면 다른 Pod 가 도는 채로 정리가 시작된다(#22002 리뷰 R3).
@@ -413,11 +416,12 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
413
416
  const total = measured.reduce((sum, run) => ({ pass: sum.pass + run.pass, fail: sum.fail + run.fail,
414
417
  errors: sum.errors + run.errors, ran: sum.ran + run.ran, files: sum.files + run.files }),
415
418
  { pass: 0, fail: 0, errors: 0, ran: 0, files: 0 });
416
- if (stalledShards.length) throw new StalledPodShards(stalledShards, total);
417
- if (total.ran === 0 && total.errors === 0) throw new Error('sweep incomplete: no tests ran');
419
+ if (stalledShards.some((item) => item.reason !== 'no-output' || item.files.length !== 1)) throw new StalledPodShards(stalledShards, total);
420
+ if (total.ran === 0 && total.errors === 0 && !stalledShards.length) throw new Error('sweep incomplete: no tests ran');
418
421
  const outputs = measured.map((run) => run.output.replace(/(?:^|\n)\s*\d+ (?:pass|fail|errors?)\s*(?=\n|$)/g, '\n').replace(/Ran \d+ tests? across \d+ files?\.?/g, ''));
419
422
  return { rc: total.fail + total.errors ? 1 : 0, output: outputs.join('\n') + `\n${total.pass} pass\n${total.fail} fail\n${total.errors} errors\nRan ${total.ran} tests across ${total.files} files.\n`,
420
- passedIds: [...new Set(measured.flatMap((run) => run.passedIds))].sort() };
423
+ passedIds: [...new Set(measured.flatMap((run) => run.passedIds))].sort(),
424
+ stalledEnv: stalledShards.flatMap((item) => item.files.map((file) => ({ file, reason: 'no-output' as const }))) };
421
425
  };
422
426
  return {
423
427
  command,
@@ -550,7 +554,7 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
550
554
  return baselineCommit(root, version);
551
555
  };
552
556
  const repo = resolve(opts.repo ?? process.cwd());
553
- const result: GateResult = { outcome: 'error', commit: opts.commit, introduced: [], preexisting: 0, fixed: 0, knownEnv: 0, knownEnvCleared: [], durationMs: 0 };
557
+ const result: GateResult = { outcome: 'error', commit: opts.commit, introduced: [], preexisting: 0, fixed: 0, knownEnv: 0, knownEnvCleared: [], stalledEnv: [], durationMs: 0 };
554
558
  let work: string | undefined;
555
559
  let baseSnapshot = false;
556
560
  let cutFailures: SweepFailures | undefined;
@@ -601,7 +605,38 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
601
605
  for (const dir of [cutTree, join(cutTree, 'apps/pwa')]) check(await runner.command('bun', ['install'], dir), `bun install ${dir}`);
602
606
  const cutRun = await runner.sweep(cutTree, join(root, 'release', opts.version, 'gate-logs', 'cut'),
603
607
  opts.pod ? { ...opts.pod, durationSource: join(ledger, 'release', opts.baselineVersion, 'gate-logs', 'cut') } : undefined);
604
- const cut = failuresOf(cutRun, 'cut sweep');
608
+ const localStalled = async (run: CommandResult, tree: string, commit: string, label: string) => {
609
+ const stalled = run.stalledEnv ?? [];
610
+ const noCompletedShard = stalled.length && run.rc === 0
611
+ && /(?:^|\n)0 pass\n0 fail\n0 errors\nRan 0 tests across 0 files\./.test(run.output);
612
+ const measured: SweepFailures = noCompletedShard ? { failures: [], errors: [] } : failuresOf(run, `${label} sweep`);
613
+ if (!stalled.length) return measured;
614
+ let hostTree = tree;
615
+ const local: GateResult['stalledEnv'] = [];
616
+ try {
617
+ if (opts.remote) {
618
+ hostTree = join(mkdtempSync(join(tmpdir(), 'release-gate-local-')), 'cut');
619
+ check(await runner.localCommand('git', ['clone', '--quiet', '--shared', '--no-checkout', repo, hostTree], repo), 'host cut clone');
620
+ check(await runner.localCommand('git', ['checkout', '--quiet', '--detach', commit], hostTree), 'host cut checkout');
621
+ for (const dir of [hostTree, join(hostTree, 'apps/pwa')]) check(await runner.localCommand('bun', ['install'], dir), `host bun install ${dir}`);
622
+ }
623
+ for (const { file, reason } of stalled) {
624
+ if (!/^(?:[\w.-]+\/)+[\w.-]+\.test\.tsx?$/.test(file) || file.split('/').includes('..')) throw new Error(`unsafe test path: ${file}`);
625
+ const isolated = await runner.localCommand('bun', ['run', 'test:deterministic', asPath(file)], hostTree);
626
+ const parsed = failuresOf(isolated, `${label} host isolated ${file}`);
627
+ if ([...parsed.failures, ...parsed.errors].some((id) => fileOf(id) !== file)) throw new Error(`host isolated run attributed to another file: ${file}`);
628
+ measured.failures.push(...parsed.failures);
629
+ measured.errors.push(...parsed.errors);
630
+ local.push({ file, reason, local: parsed.failures.length || parsed.errors.length ? 'failed' : 'passed' });
631
+ }
632
+ } finally {
633
+ if (opts.remote && hostTree !== tree) rmSync(dirname(hostTree), { recursive: true, force: true });
634
+ }
635
+ result.stalledEnv.push(...local);
636
+ debug.log('release-loop.gate', 'pod-shard-stalled-env', { files: stalled.map(({ file, reason }) => ({ file, reason })), local });
637
+ return measured;
638
+ };
639
+ const cut = await localStalled(cutRun, cutTree, opts.commit, 'cut');
605
640
  cutFailures = cut;
606
641
  const baseSha = opts.baselineCommit ?? baselineReleaseCommit(opts.baselineVersion);
607
642
  const cached = cachedBaseline(opts.baselineVersion, baseSha);
@@ -630,22 +665,24 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
630
665
  // hour of local baseline after a 23-minute Pod cut).
631
666
  const baseRun = await runner.sweep(await getBaseTree(), join(root, 'release', opts.version, 'gate-logs', 'baseline'),
632
667
  opts.pod ? { ...opts.pod, durationSource: join(ledger, 'release', opts.baselineVersion, 'gate-logs', 'cut') } : undefined);
633
- baseline = failuresOf(baseRun, 'baseline sweep');
668
+ baseline = await localStalled(baseRun, baseTree!, baseSha, 'baseline');
634
669
  }
635
670
  for (const id of [...baseline.failures, ...baseline.errors, ...cut.failures, ...cut.errors]) fileOf(id);
636
671
  const known = opts.pod ? loadEnvKnownFailures(repo) : new Set<string>();
637
672
  const cutIds = [...cut.failures, ...cut.errors];
673
+ const cutHostFailed = new Set(cutRun.stalledEnv?.map(({ file }) => file) ?? []);
638
674
  const baselineIds = [...baseline.failures, ...baseline.errors];
639
675
  const countedCut = splitKnownEnv(cutIds, known);
640
676
  const countedBaseline = splitKnownEnv(baselineIds, known);
641
677
  result.knownEnv = countedCut.knownEnv.length;
642
- result.knownEnvCleared = [...new Set(cutRun.passedIds ?? [])].filter((id) => known.has(id) && !cutIds.includes(id)).sort();
678
+ result.knownEnvCleared = [...new Set(cutRun.passedIds ?? [])].filter((id) => known.has(id) && !cutIds.includes(id) && !cutRun.stalledEnv?.some(({ file }) => fileOf(id) === file)).sort();
643
679
  const diff = diffFailures(countedCut.counted, countedBaseline.counted);
644
680
  result.fixed = diff.fixed.length;
645
681
  result.preexisting = diff.common.length;
646
682
  for (const file of new Set(diff.newFailures.map(fileOf))) {
647
- const isolated = await runner.command('bun', ['run', 'test:deterministic', asPath(file)], cutTree);
648
- const isolatedCut = failuresOf(isolated, `cut isolated ${file}`);
683
+ const isolatedCut = cutHostFailed.has(file)
684
+ ? { failures: cut.failures.filter((id) => fileOf(id) === file), errors: cut.errors.filter((id) => fileOf(id) === file) }
685
+ : failuresOf(await runner.command('bun', ['run', 'test:deterministic', asPath(file)], cutTree), `cut isolated ${file}`);
649
686
  const reproduced = new Set([...isolatedCut.failures, ...isolatedCut.errors]);
650
687
  const candidates = diff.newFailures.filter((id) => fileOf(id) === file && reproduced.has(id));
651
688
  if (candidates.length === 0) continue;
@@ -690,7 +727,7 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
690
727
  if (opts.remote) runner.remoteMirror = undefined;
691
728
  result.durationMs = Date.now() - start;
692
729
  }
693
- if (cutFailures && !result.stalledShards?.length) {
730
+ if (cutFailures && !result.stalledShards?.length && !result.stalledEnv.length) {
694
731
  try {
695
732
  for (const location of roots) {
696
733
  const path = failureFile(location, opts.version);
@@ -714,7 +751,8 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
714
751
  export function graphGateResult(result: GateResult, graph: boolean) {
715
752
  return { ...result, ...(graph && result.outcome === 'regression' ? { outcome: 'fail' as const } : {}),
716
753
  verdict: result.outcome === 'ok' ? 'pass' as const : 'fail' as const,
717
- summary: (result.outcome === 'ok' ? `새 회귀 ${result.introduced.length} · 기존 ${result.preexisting} · 고침 ${result.fixed}` : `게이트 ${result.outcome}: ${result.error ?? result.introduced.length + ' new regressions'}`) + ` · 환경 알려진 실패 ${result.knownEnv}` };
754
+ summary: (result.outcome === 'ok' ? `새 회귀 ${result.introduced.length} · 기존 ${result.preexisting} · 고침 ${result.fixed}` : `게이트 ${result.outcome}: ${result.error ?? result.introduced.length + ' new regressions'}`) + ` · 환경 알려진 실패 ${result.knownEnv}`
755
+ + (result.stalledEnv.length ? ` · 환경 멈춤 ${result.stalledEnv.length} (Pod 밖 통과 ${result.stalledEnv.filter((item) => item.local === 'passed').length})` : '') };
718
756
  }
719
757
 
720
758
  export function parseOptions(args: string[], env: NodeJS.ProcessEnv): GateOptions | 'help' {
@@ -772,7 +810,7 @@ if (import.meta.main) {
772
810
  version = opts.version;
773
811
  result = await judgeGate(opts);
774
812
  } catch (error) {
775
- result = { outcome: 'error', commit: '', introduced: [], preexisting: 0, fixed: 0, knownEnv: 0, knownEnvCleared: [], durationMs: Date.now() - start, error: String(error) };
813
+ result = { outcome: 'error', commit: '', introduced: [], preexisting: 0, fixed: 0, knownEnv: 0, knownEnvCleared: [], stalledEnv: [], durationMs: Date.now() - start, error: String(error) };
776
814
  }
777
815
  if (result.error) console.error(result.error);
778
816
  debug.log('release-loop.gate', 'result', { version, outcome: result.outcome });
@@ -7,7 +7,7 @@ import { errorResult, finishNode, lastResult, nodeOutput, readGraphContext, runC
7
7
 
8
8
  interface HostResult { host: string; ok: boolean; before: string; after: string; error?: string; skipped?: string; hooks?: string; restart?: 'no-service' }
9
9
  /** Where the internal feed is packed from: a checkout of the release tag, never the run's working tree. */
10
- export interface FeedSource { checkout: string; commit: string; cleanup: () => void }
10
+ export interface FeedSource { checkout: string; commit: string; deps: 'linked' | 'installed'; cleanup: () => void }
11
11
  interface OpsUpgradeDeps { exists?: (path: string) => boolean; installSource?: () => string | null; feedSource?: (version: string) => FeedSource }
12
12
 
13
13
  /** Detached worktree at refs/tags/v<version> with the PWA built — the feed ships what was released (10-04 0.2.11:
@@ -26,16 +26,28 @@ export function tagFeedSource(run: CommandRunner, repo: string = process.cwd()):
26
26
  try {
27
27
  const added = run('git', ['worktree', 'add', '--detach', tree, commit], repo);
28
28
  if (added.status !== 0) throw new Error(`태그 체크아웃 실패: ${reason(added)}`);
29
- symlinkSync(join(repo, 'node_modules'), join(tree, 'node_modules'));
30
- if (existsSync(join(repo, 'apps/pwa/node_modules'))) symlinkSync(join(repo, 'apps/pwa/node_modules'), join(tree, 'apps/pwa/node_modules'));
29
+ const sameDeps = ['bun.lock', 'apps/pwa/bun.lock', 'package.json', 'apps/pwa/package.json'].every((file) => {
30
+ try { return readFileSync(join(repo, file), 'utf8') === readFileSync(join(tree, file), 'utf8'); }
31
+ catch { return false; }
32
+ });
33
+ const deps: FeedSource['deps'] = sameDeps ? 'linked' : 'installed';
34
+ if (sameDeps) {
35
+ symlinkSync(join(repo, 'node_modules'), join(tree, 'node_modules'));
36
+ if (existsSync(join(repo, 'apps/pwa/node_modules'))) symlinkSync(join(repo, 'apps/pwa/node_modules'), join(tree, 'apps/pwa/node_modules'));
37
+ } else {
38
+ for (const cwd of [tree, join(tree, 'apps/pwa')]) {
39
+ const installed = run('bun', ['install', '--frozen-lockfile'], cwd, 900_000);
40
+ if (installed.status !== 0) throw new Error(`태그 의존성 설치 실패: ${reason(installed)}`);
41
+ }
42
+ }
31
43
  // The feed carries the PWA build (apps/pwa/out is ignored, so a fresh tag checkout has none until built).
32
44
  const built = run('bun', ['bin/elanous.mjs', 'nexus', 'build'], tree, 900_000);
33
45
  if (built.status !== 0) throw new Error(`태그 PWA 빌드 실패: ${reason(built)}`);
46
+ return { checkout: tree, commit, deps, cleanup };
34
47
  } catch (error) {
35
48
  cleanup();
36
49
  throw error;
37
50
  }
38
- return { checkout: tree, commit, cleanup };
39
51
  };
40
52
  }
41
53
 
@@ -96,7 +108,7 @@ export function runOpsUpgrade(run: CommandRunner = runCommand, deps: OpsUpgradeD
96
108
  const data = lastResult(feed);
97
109
  if (feed.status !== 0 || data?.ok !== true || data.version !== version) feedError = `내부 피드 발행 실패: ${feed.status === 0 && data?.ok === true ? `판 불일치 (${String(data.version)})` : reason(feed)}`;
98
110
  else if (data.commit !== source.commit) feedError = `내부 피드 발행 실패: 묶은 커밋 ${String(data.commit).slice(0, 12)} ≠ 태그 ${source.commit.slice(0, 12)}`;
99
- debug.log('release-loop.ops-upgrade', 'feed-source', { version, commit: source.commit, source: `refs/tags/v${version}`, packed: data?.commit, pwaBuild: data?.pwaBuild });
111
+ debug.log('release-loop.ops-upgrade', 'feed-source', { version, commit: source.commit, source: `refs/tags/v${version}`, deps: source.deps, packed: data?.commit, pwaBuild: data?.pwaBuild });
100
112
  }
101
113
  } catch (error) { feedError = `내부 피드 발행 실패: ${firstLine(String(error))}`; }
102
114
  finally { source?.cleanup(); }
@@ -31,8 +31,9 @@
31
31
  // reports the failure.
32
32
 
33
33
  import { mkdtempSync, rmSync } from 'node:fs';
34
+ import { spawnSync } from 'node:child_process';
34
35
  import { tmpdir } from 'node:os';
35
- import { join } from 'node:path';
36
+ import { join, resolve } from 'node:path';
36
37
  // The predicate lives in its own side-effect-free module so it can be tested:
37
38
  // importing THIS file used to spawn the suite at load time. The runner is now
38
39
  // behind `import.meta.main` / `runDeterministicTests()`, but the filter stays
@@ -56,6 +57,8 @@ const HARNESS_TEST_ENV_KEYS = [
56
57
  // Discovery: rg -n "process\.env\.ELANOUS_(HOST|RUN|ORIGIN|SUPERVISOR|PARENT)" src
57
58
  // These are ambient execution identities, not inputs to a deterministic test.
58
59
  // debug.log also attributes SUBSTRATE and ARM_ID to every plain-object record.
60
+ const GIT_LOCATION_ENV_KEYS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR'] as const;
61
+
59
62
  const EXECUTION_ORIGIN_ENV_KEYS = [
60
63
  'ELANOUS_HOST_ID',
61
64
  'ELANOUS_RUN_ID',
@@ -120,6 +123,7 @@ export type DeterministicRunnerDeps = {
120
123
  childGraceMs?: number;
121
124
  killProcessGroup?: KillProcessGroup;
122
125
  getProcessGroupId?: GetProcessGroupId;
126
+ gitConfig?: (cwd: string, args: string[]) => string;
123
127
  };
124
128
 
125
129
  export function isLivePid(pid: number): boolean {
@@ -269,11 +273,22 @@ export async function terminateDirectChild(
269
273
  reportVisible(`failed to terminate child pid=${pid}`, report);
270
274
  }
271
275
 
276
+ function gitConfig(cwd: string, args: string[]): string {
277
+ const env = { ...process.env };
278
+ for (const key of GIT_LOCATION_ENV_KEYS) delete env[key];
279
+ const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
280
+ if (result.error || (result.status !== 0 && !(result.status === 1 && args.includes('--get-all') && !result.stderr))) {
281
+ throw new Error(`git ${args.join(' ')} failed: ${result.error?.message ?? result.stderr}`);
282
+ }
283
+ return result.stdout.trim();
284
+ }
285
+
272
286
  export function prepareIsolatedTestEnv(sourceEnv: NodeJS.ProcessEnv, testRoot: string): NodeJS.ProcessEnv {
273
287
  const env = Object.fromEntries(
274
288
  Object.entries(sourceEnv).filter(([key]) => !isCredentialKey(key)
275
289
  && !HARNESS_TEST_ENV_KEYS.includes(key as typeof HARNESS_TEST_ENV_KEYS[number])
276
- && !EXECUTION_ORIGIN_ENV_KEYS.includes(key as typeof EXECUTION_ORIGIN_ENV_KEYS[number])),
290
+ && !EXECUTION_ORIGIN_ENV_KEYS.includes(key as typeof EXECUTION_ORIGIN_ENV_KEYS[number])
291
+ && !GIT_LOCATION_ENV_KEYS.includes(key as typeof GIT_LOCATION_ENV_KEYS[number])),
277
292
  );
278
293
  env.HOME = testRoot;
279
294
  // userConfigPath() warns whenever XDG_CONFIG_HOME is set, including when
@@ -392,6 +407,38 @@ export async function runDeterministicTests(deps: DeterministicRunnerDeps = {}):
392
407
  const report = deps.report ?? console.error;
393
408
  const spawn = deps.spawn ?? defaultSpawn;
394
409
  const argv = deps.argv ?? process.argv.slice(2);
410
+ const cwd = deps.cwd ?? process.cwd();
411
+ const config = deps.gitConfig ?? gitConfig;
412
+ const commonDir = config(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
413
+ const configFile = join(resolve(cwd, commonDir), 'config');
414
+ const bareArgs = ['config', '--file', configFile];
415
+ const readBare = () => config(cwd, [...bareArgs, '--get-all', 'core.bare']);
416
+ const bareBefore = readBare();
417
+ let bareChanged = false;
418
+ let bareChecked = false;
419
+ const checkBare = (): void => {
420
+ bareChecked = true;
421
+ let bareAfter = '<unreadable>';
422
+ const visible = (values: string) => values ? values.replaceAll('\n', ',') : '<unset>';
423
+ try {
424
+ bareAfter = readBare();
425
+ if (bareAfter === bareBefore) return;
426
+ bareChanged = true;
427
+ const originalValues = bareBefore ? bareBefore.split('\n') : [];
428
+ if (originalValues.length > 0) {
429
+ config(cwd, [...bareArgs, '--replace-all', 'core.bare', originalValues[0]!]);
430
+ for (const value of originalValues.slice(1)) config(cwd, [...bareArgs, '--add', 'core.bare', value]);
431
+ } else if (bareAfter) {
432
+ config(cwd, [...bareArgs, '--unset-all', 'core.bare']);
433
+ }
434
+ const restored = readBare();
435
+ if (restored !== bareBefore) throw new Error(`expected ${visible(bareBefore)}, got ${visible(restored)}`);
436
+ report(`[test-deterministic] core.bare changed ${visible(bareBefore)} -> ${visible(bareAfter)}; restored ${visible(bareBefore)}; failing run`);
437
+ } catch (error) {
438
+ bareChanged = true;
439
+ report(`[test-deterministic] core.bare changed ${visible(bareBefore)} -> ${visible(bareAfter)}; restore failed: ${formatError(error)}`);
440
+ }
441
+ };
395
442
  // ⛔ 사람이 «경로»를 직접 준 창에서는 아무것도 빼지 않는다 — 그 창은 「이것만 돌려라」다.
396
443
  const explicitPaths = argv.some((arg) => !arg.startsWith('-'));
397
444
  const cdpPatterns = explicitPaths ? [] : (deps.deriveCdpPatterns ?? deriveCdpTestPatterns)();
@@ -451,6 +498,7 @@ export async function runDeterministicTests(deps: DeterministicRunnerDeps = {}):
451
498
  });
452
499
  cleanupTemporaryRoot(testRoot, { rmSync: deps.rmSync, report });
453
500
  rootCleanupAttempted = true;
501
+ checkBare();
454
502
  gate?.dispose();
455
503
  gate = undefined;
456
504
  if (deps.killSelf) {
@@ -465,7 +513,8 @@ export async function runDeterministicTests(deps: DeterministicRunnerDeps = {}):
465
513
  }
466
514
 
467
515
  cleanupRoot();
468
- return outcome.code ?? 1;
516
+ checkBare();
517
+ return bareChanged ? 1 : (outcome.code ?? 1);
469
518
  } catch (error) {
470
519
  if (child) {
471
520
  try {
@@ -484,6 +533,7 @@ export async function runDeterministicTests(deps: DeterministicRunnerDeps = {}):
484
533
  } finally {
485
534
  gate?.dispose();
486
535
  cleanupRoot();
536
+ if (!bareChecked) checkBare();
487
537
  }
488
538
  }
489
539