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,125 @@
1
+ import { appendFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { effectiveInstanceRoot } from '../instance/resolve.js';
4
+ import type { LaunchPlan } from './launch.js';
5
+ import type { ScheduledDecision } from './triage.js';
6
+
7
+ // Planning estimates, not invoices: one harness run per launch, one Pod-hour per run.
8
+ // No model/token or runtime telemetry is present in the shadow decision line.
9
+ const ESTIMATED_LLM_USD_PER_LAUNCH = 2;
10
+ const ESTIMATED_POD_USD_PER_LAUNCH = 1;
11
+ const CATEGORY = 'steward.readiness';
12
+ const EVENT = 'shadow-tick';
13
+
14
+ export interface ShadowTick {
15
+ at: string;
16
+ decisions: Array<{ issue: string; rung: ScheduledDecision['rung']; disposition: ScheduledDecision['disposition'] }>;
17
+ actions: Array<{ issue: string; kind: 'launch' | 'hitl'; llmUsd: number; podUsd: number }>;
18
+ }
19
+
20
+ function ledgerPath(root: string): string { return join(root, 'steward', 'readiness.jsonl'); }
21
+
22
+ /** Called by the shadow schedule after its launch/HITL candidates have been selected. */
23
+ export function recordShadowTick(root: string, rows: ScheduledDecision[], plan: LaunchPlan, now: Date = new Date()): ShadowTick {
24
+ const tick: ShadowTick = {
25
+ at: now.toISOString(),
26
+ decisions: rows.map(({ issue, rung, disposition }) => ({ issue, rung, disposition })),
27
+ actions: [
28
+ ...plan.launches.map(({ issue }) => ({ issue: issue.identifier, kind: 'launch' as const, llmUsd: ESTIMATED_LLM_USD_PER_LAUNCH, podUsd: ESTIMATED_POD_USD_PER_LAUNCH })),
29
+ ...plan.hitl.map(({ issue }) => ({ issue: issue.identifier, kind: 'hitl' as const, llmUsd: 0, podUsd: 0 })),
30
+ ],
31
+ };
32
+ appendFileSync(ledgerPath(root), `${JSON.stringify(tick)}\n`);
33
+ return tick;
34
+ }
35
+
36
+ export interface ReadinessReport {
37
+ since: string;
38
+ until: string;
39
+ ticks: number;
40
+ judgments: number;
41
+ distribution: Record<string, number>;
42
+ actions: Array<ShadowTick['actions'][number] & { at: string }>;
43
+ budgetGate: { estimatedLlmUsd: number; estimatedPodUsd: number; estimatedTotalUsd: number; basis: string; verdict: 'estimate-only' };
44
+ hitlGate: { requiringHuman: number };
45
+ source: { decisions: string; logs: string };
46
+ }
47
+
48
+ /** Snapshot-only reader: no CardStore, stage, config setter, logger or write-capable DB handle. */
49
+ export function stewardReadiness(root: string, sinceHours = 24, now: Date = new Date()): ReadinessReport {
50
+ if (!Number.isFinite(sinceHours) || sinceHours <= 0) throw new Error('--since must be a positive duration such as 24h');
51
+ const since = new Date(now.getTime() - sinceHours * 3_600_000);
52
+ if (!Number.isFinite(since.getTime())) throw new Error('--since is out of range');
53
+ const path = ledgerPath(root);
54
+ let text: string;
55
+ try { text = readFileSync(path, 'utf8'); }
56
+ catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
58
+ text = '';
59
+ }
60
+ const ticks: ShadowTick[] = [];
61
+ for (const [index, line] of text.split('\n').entries()) {
62
+ if (!line.trim()) continue;
63
+ let parsed: ShadowTick;
64
+ try { parsed = JSON.parse(line) as ShadowTick; }
65
+ catch { throw new Error(`Invalid steward shadow decision line ${index + 1}`); }
66
+ if (!parsed || typeof parsed.at !== 'string' || !Number.isFinite(Date.parse(parsed.at)) ||
67
+ !Array.isArray(parsed.decisions) || !parsed.decisions.every(row => row && typeof row.issue === 'string' &&
68
+ [0, 1, 2, 3, 4, 5, 'hitl'].includes(row.rung) && ['now', 'wait', 'hitl'].includes(row.disposition)) ||
69
+ !Array.isArray(parsed.actions) || !parsed.actions.every(action => action && typeof action.issue === 'string' &&
70
+ ['launch', 'hitl'].includes(action.kind) && Number.isFinite(action.llmUsd) && action.llmUsd >= 0 &&
71
+ Number.isFinite(action.podUsd) && action.podUsd >= 0))
72
+ throw new Error(`Invalid steward shadow decision line ${index + 1}`);
73
+ const at = Date.parse(parsed.at);
74
+ if (at >= since.getTime() && at <= now.getTime()) ticks.push(parsed);
75
+ }
76
+ const distribution: Record<string, number> = {};
77
+ const actions = ticks.flatMap(tick => tick.actions.map(action => ({ at: tick.at, ...action })));
78
+ for (const row of ticks.flatMap(tick => tick.decisions)) {
79
+ const key = `${row.disposition} / rung ${row.rung}`;
80
+ distribution[key] = (distribution[key] ?? 0) + 1;
81
+ }
82
+ const estimatedLlmUsd = actions.reduce((sum, action) => sum + action.llmUsd, 0);
83
+ const estimatedPodUsd = actions.reduce((sum, action) => sum + action.podUsd, 0);
84
+ return {
85
+ since: since.toISOString(), until: now.toISOString(), ticks: ticks.length,
86
+ judgments: ticks.reduce((sum, tick) => sum + tick.decisions.length, 0), distribution, actions,
87
+ budgetGate: { estimatedLlmUsd, estimatedPodUsd, estimatedTotalUsd: estimatedLlmUsd + estimatedPodUsd,
88
+ basis: 'Planning assumption per launch: LLM $2 + Pod $1 (one Pod-hour); not metered usage or budget approval', verdict: 'estimate-only' },
89
+ hitlGate: { requiringHuman: actions.filter(action => action.kind === 'hitl').length },
90
+ source: { decisions: path, logs: `elanous logs --category ${CATEGORY} --event ${EVENT} --since ${sinceHours}h` },
91
+ };
92
+ }
93
+
94
+ export function formatStewardReadiness(report: ReadinessReport): string {
95
+ return [
96
+ `Steward shadow readiness | ${report.since} → ${report.until}`,
97
+ `Ticks | ${report.ticks}`,
98
+ `Judgments | ${report.judgments}`,
99
+ `Judgment distribution | ${Object.entries(report.distribution).map(([kind, count]) => `${kind}: ${count}`).join(' · ') || 'none'}`,
100
+ `If live, actions | ${report.actions.map(a => `${a.at} ${a.kind} ${a.issue}`).join(' · ') || 'none'}`,
101
+ `Budget gate | LLM ~$${report.budgetGate.estimatedLlmUsd} · Pod ~$${report.budgetGate.estimatedPodUsd} · total ~$${report.budgetGate.estimatedTotalUsd} (estimate only; not approval)`,
102
+ `HITL gate | ${report.hitlGate.requiringHuman} human confirmations`,
103
+ `Estimate basis | ${report.budgetGate.basis}`,
104
+ `Shadow decisions | ${report.source.decisions}`,
105
+ `First-class logs | ${report.source.logs}`,
106
+ ].join('\n');
107
+ }
108
+
109
+ export function parseReadinessSince(value: string): number {
110
+ if (!/^(?:\d+)(?:\.\d+)?h$/.test(value)) throw new Error('--since must be a positive duration such as 24h');
111
+ const hours = Number(value.slice(0, -1));
112
+ if (!Number.isFinite(hours) || hours <= 0) throw new Error('--since must be a positive duration such as 24h');
113
+ return hours;
114
+ }
115
+
116
+ export function runStewardReadinessCli(opts: { since: string; json?: boolean }, root = effectiveInstanceRoot()): number {
117
+ try {
118
+ const report = stewardReadiness(root, parseReadinessSince(opts.since));
119
+ console.log(opts.json ? JSON.stringify(report) : formatStewardReadiness(report));
120
+ return 0;
121
+ } catch (error) {
122
+ console.error(`steward readiness: ${error instanceof Error ? error.message : String(error)}`);
123
+ return 1;
124
+ }
125
+ }
@@ -0,0 +1,75 @@
1
+ // HQ-SEAT — steward «구조 모드»(rescue): after an HQ move the seat sessions are gone and the steward keeps
2
+ // the line live, but only for the four reversible, internal actions below (OP 10-04 10:22 · 대표 directive).
3
+ import { debug } from '../debug/log.js';
4
+
5
+ export type StewardMode = 'off' | 'shadow' | 'live' | 'rescue';
6
+
7
+ /** The whole rescue allow-list: launch queued goals · harvest finished runs · internal stuck alerts · decision cards. */
8
+ export const RESCUE_ALLOWED = ['launch', 'harvest', 'alert', 'decision-card'] as const;
9
+ export type RescueAllowedAction = typeof RESCUE_ALLOWED[number];
10
+ export type RescueAction = RescueAllowedAction | 'publish' | 'release-run' | 'external-post' | 'delete' | 'config-change' | 'git-force';
11
+
12
+ /** Shared, fail-closed policy for rescue and seat-loop live-safe decisions. */
13
+ export function isRescueAllowedAction(action: string): action is RescueAllowedAction {
14
+ return (RESCUE_ALLOWED as readonly string[]).includes(action);
15
+ }
16
+
17
+ /** Launch semantics of a steward mode: rescue launches like live. */
18
+ export function launchModeOf(mode: StewardMode): 'off' | 'shadow' | 'live' {
19
+ return mode === 'rescue' ? 'live' : mode;
20
+ }
21
+
22
+ /**
23
+ * Rescue-mode action gate. Outside rescue the steward's own mode rules decide (returns true).
24
+ * In rescue only RESCUE_ALLOWED pass; every decision is observed as steward.rescue/<action> or steward.rescue/refused.
25
+ */
26
+ export function rescueAllows(mode: StewardMode | undefined, action: RescueAction, data: Record<string, unknown> = {}): boolean {
27
+ if (mode !== 'rescue') return true;
28
+ const allowed = isRescueAllowedAction(action);
29
+ try { debug.log('steward.rescue', allowed ? action : 'refused', { action, ...data }); } catch { /* observation is fail-soft */ }
30
+ return allowed;
31
+ }
32
+
33
+ export interface StewardModeStore {
34
+ read: () => StewardMode;
35
+ write: (mode: StewardMode) => void;
36
+ }
37
+
38
+ const MODES: readonly StewardMode[] = ['off', 'shadow', 'live', 'rescue'];
39
+
40
+ export function parseStewardMode(value: string): StewardMode | null {
41
+ return (MODES as readonly string[]).includes(value) ? value as StewardMode : null;
42
+ }
43
+
44
+ /** `elanous steward mode [<mode>]` — HQ promotion sets rescue; a reattaching human seat sets shadow. */
45
+ export async function runStewardModeCli(value: string | undefined, opts: { json?: boolean }, store?: StewardModeStore): Promise<number> {
46
+ const s = store ?? await defaultModeStore();
47
+ const before = s.read();
48
+ if (value === undefined) {
49
+ console.log(opts.json ? JSON.stringify({ mode: before }) : before);
50
+ return 0;
51
+ }
52
+ const mode = parseStewardMode(value);
53
+ if (!mode) { console.error(`steward mode: unknown mode ${JSON.stringify(value)} (off|shadow|live|rescue)`); return 2; }
54
+ s.write(mode);
55
+ const after = s.read();
56
+ try { debug.log('steward.rescue', 'mode-set', { before, after }); } catch { /* observation is fail-soft */ }
57
+ if (after !== mode) { console.error(`steward mode: saved value did not stick (read ${after})`); return 1; }
58
+ console.log(opts.json ? JSON.stringify({ before, mode: after }) : `steward mode ${before} → ${after}`);
59
+ return 0;
60
+ }
61
+
62
+ async function defaultModeStore(): Promise<StewardModeStore> {
63
+ const uc = await import('../user-config.js');
64
+ return {
65
+ read: () => (uc.getUserConfig().loops?.steward?.mode ?? 'shadow') as StewardMode,
66
+ write: (mode) => {
67
+ const cfg = uc.getUserConfig();
68
+ uc.backupUserConfig(uc.userConfigPath());
69
+ const loops = { ...(cfg.loops ?? {}) };
70
+ loops.steward = { ...(loops.steward ?? {}), mode };
71
+ uc.saveUserConfig({ ...cfg, loops });
72
+ uc.reloadUserConfig();
73
+ },
74
+ };
75
+ }
@@ -1,10 +1,13 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { launchModeOf, rescueAllows } from './rescue.js';
2
4
  import { join } from 'node:path';
3
5
  import { fetchLinearIssues } from '../connectors/linear.js';
4
6
  import { getSecretAsync } from '../nexus/config/secrets/index.js';
5
7
  import { getUserConfig } from '../user-config.js';
6
8
  import { emitDecision } from '../live/detail-switch.js';
7
9
  import { debug, redactSecretText } from '../debug/log.js';
10
+ import { loopEvent } from '../loops/observe.js';
8
11
  import { effectiveInstanceRoot } from '../instance/resolve.js';
9
12
  import { redactSecrets } from '../task-cards/card-store.js';
10
13
  import { directiveHash } from './directive.js';
@@ -33,7 +36,8 @@ export interface TriageDecision {
33
36
  }
34
37
  export interface ScheduledDecision extends TriageDecision { disposition: 'now' | 'wait' | 'hitl' }
35
38
  export interface StewardSettings {
36
- mode?: 'off' | 'shadow' | 'live'; linearTeam?: string;
39
+ /** `rescue` (HQ-SEAT): live launches/harvest/alerts/decision cards only — see ./rescue.ts. */
40
+ mode?: 'off' | 'shadow' | 'live' | 'rescue'; linearTeam?: string;
37
41
  roles?: Record<string, { maxConcurrent?: number }>;
38
42
  budget?: number;
39
43
  tracks?: Record<string, string>;
@@ -62,11 +66,14 @@ export interface StewardDeps {
62
66
  stageOwnedByCommand?: boolean;
63
67
  }
64
68
 
69
+ /** rescue launches/harvests like live (HQ-SEAT). */
70
+ const liveLike = (mode: StewardSettings['mode']): boolean => mode !== undefined && launchModeOf(mode) === 'live';
71
+
65
72
  export function stewardSettings(): StewardSettings {
66
73
  return getUserConfig().loops?.steward ?? { mode: 'shadow' };
67
74
  }
68
75
 
69
- function effectiveStewardMode(settings: StewardSettings): 'off' | 'shadow' | 'live' {
76
+ function effectiveStewardMode(settings: StewardSettings): 'off' | 'shadow' | 'live' | 'rescue' {
70
77
  return settings.mode ?? settings.launch ?? 'shadow';
71
78
  }
72
79
 
@@ -115,6 +122,9 @@ export async function triageIssues(issues: TriageIssue[], judge?: NonNullable<St
115
122
  // Inventory is shared by every classification in this batch; do not spawn --help per issue.
116
123
  let inventory: ReturnType<typeof installedCapabilities> | undefined;
117
124
  const signal = (result: TriageDecision): void => {
125
+ try { debug.log('steward.triage', 'decision', {
126
+ kind: String(result.rung), target: result.issue, wouldAct: false,
127
+ }); } catch { /* logging must not change the judgment */ }
118
128
  decide({ kind: result.rung === 'hitl' ? 'ESCALATE' : 'ROUTE', what: result.issue, reason: result.why,
119
129
  purpose: 'steward triage', target: result.owner ? `${result.rung} · ${result.owner}` : String(result.rung), refs: { issue: result.issue } });
120
130
  };
@@ -279,6 +289,9 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
279
289
  const fetchFn = deps.fetch ?? fetch;
280
290
  const skip = skippedPath(root);
281
291
  if (stage !== 'sync' && existsSync(skip)) return;
292
+ const runId = (graphRunId() ?? process.env.ELANOUS_RUN_ID?.trim()) || `steward-stage-${randomUUID()}`;
293
+ let outcome: 'ok' | 'fail' = 'fail';
294
+ try {
282
295
  const key = await (deps.getSecret ?? getSecretAsync)('connector.linear.apiKey');
283
296
  if (!key) throw new Error('connector.linear.apiKey missing');
284
297
  if (stage === 'sync') {
@@ -368,6 +381,9 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
368
381
  ? { ...row, rung: 'hitl' as const, disposition: 'hitl' as const, hitlReason: 'other' as const, why: 'working-backwards draft invalid' }
369
382
  : row);
370
383
  }
384
+ for (const row of rows) try { debug.log('steward.schedule', 'decision', {
385
+ kind: row.disposition, target: row.issue, wouldAct: row.disposition === 'now' && liveLike(settings.mode),
386
+ }); } catch { /* logging must not change the schedule */ }
371
387
  writeFileSync(schedule, JSON.stringify(rows));
372
388
  const ledger = readLaunchLedger(root);
373
389
  const launchDeps: LaunchDeps = { root, settings, ledger, command: deps.launchCommand, spawn: deps.spawnLaunch, gate: deps.launchGate };
@@ -384,6 +400,11 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
384
400
  const entry = launch(item, launchDeps);
385
401
  recordLaunchOnCard(item.issue, entry, cardDeps);
386
402
  }
403
+ if (settings.mode === 'shadow') {
404
+ const { recordShadowTick } = await import('./readiness.js');
405
+ const tick = recordShadowTick(root, rows, plan, (deps.now ?? (() => new Date()))());
406
+ debug.log('steward.readiness', 'shadow-tick', tick);
407
+ }
387
408
  } else {
388
409
  const rows = JSON.parse(readFileSync(schedule, 'utf8')) as ScheduledDecision[];
389
410
  const path = reportPath(root);
@@ -422,7 +443,7 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
422
443
  const issue = issues.find(item => item.identifier === outcome.issue) ??
423
444
  { identifier: outcome.issue, title: outcome.title, ref: '', body: '' };
424
445
  recordOutcomeOnCard(issue, outcome, { root, store: deps.cardStore, now: deps.now });
425
- notifyOutcome(outcome, { root, ledger, shadow: settings.mode !== 'live', ...deps.outcomeNotify });
446
+ notifyOutcome(outcome, { root, ledger, shadow: !(settings.mode === 'live' || settings.mode === 'rescue' && rescueAllows(settings.mode, 'alert', { issue: outcome.issue })), ...deps.outcomeNotify });
426
447
  const prefix = ['telegram', 'pwa', 'tui', 'cli'].includes(outcome.source) ? `${outcome.source} · ` : '';
427
448
  await (deps.sendDigest ?? sendStewardDigest)(`${prefix}${issue.title} · PR ${outcome.prNumber ?? '없음'} · ${outcome.status}`);
428
449
  outcome.reported = true;
@@ -430,11 +451,11 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
430
451
  }
431
452
  const justCollected = new Set(outcomes.map(outcome => outcome.issue));
432
453
  for (const outcome of Object.values(ledger.launches)) {
433
- if ((settings.mode !== 'live' && outcome.status === 'shadow' && !outcome.notified) ||
434
- (settings.mode === 'live' && !justCollected.has(outcome.issue) && outcome.reported && outcome.notified !== 'shadow') ||
454
+ if ((!liveLike(settings.mode) && outcome.status === 'shadow' && !outcome.notified) ||
455
+ (liveLike(settings.mode) && !justCollected.has(outcome.issue) && outcome.reported && outcome.notified !== 'shadow') ||
435
456
  // A finished run whose PR waits for merge still owes the wish's chat its PR link (once; failures retry).
436
- (settings.mode === 'live' && outcome.awaitingMerge === true && !outcome.reported && outcome.notified !== 'sent')) {
437
- notifyOutcome(outcome, { root, ledger, shadow: settings.mode !== 'live', ...deps.outcomeNotify });
457
+ (liveLike(settings.mode) && outcome.awaitingMerge === true && !outcome.reported && outcome.notified !== 'sent')) {
458
+ notifyOutcome(outcome, { root, ledger, shadow: !(settings.mode === 'live' || settings.mode === 'rescue' && rescueAllows(settings.mode, 'alert', { issue: outcome.issue })), ...deps.outcomeNotify });
438
459
  }
439
460
  }
440
461
  const day = at.slice(0, 10);
@@ -445,11 +466,17 @@ export async function runStewardStage(stage: 'sync' | 'triage' | 'schedule' | 'r
445
466
  writeFileSync(path, JSON.stringify(state));
446
467
  }
447
468
  }
469
+ outcome = 'ok';
470
+ } finally {
471
+ try { loopEvent('steward', 'tick', { runId, stage, outcome, reason: outcome,
472
+ sourceRef: 'graphs/steward/steward.yaml', profile: settings.mode }); }
473
+ catch { /* Observation must not change the stage outcome. */ }
474
+ }
448
475
  }
449
476
 
450
477
  async function sendStewardDigest(text: string): Promise<void> {
451
478
  const { deliver } = await import('../domains/outbound-alert.js');
452
- if (!deliver(text, 'digest')) throw new Error('steward digest delivery failed');
479
+ if (!deliver(text, 'ops-report')) throw new Error('steward digest delivery failed');
453
480
  }
454
481
 
455
482
  export interface StewardCommandDeps extends StewardDeps {
@@ -543,13 +570,25 @@ export async function runStewardStageCommand(stage: StewardStage, deps: StewardC
543
570
  }, stage === 'sync' ? () => { skipOverlappingRun(root, currentOpen(root, stage)); return 0 as const; } : undefined);
544
571
  }
545
572
 
573
+ export async function runStewardNodeCommand(
574
+ stage: StewardStage,
575
+ deps: StewardCommandDeps = {},
576
+ registerSink: () => Promise<unknown> = async () => (await import('../domains/standalone-log-sink.js')).registerStandaloneLogSink('steward'),
577
+ ): Promise<0 | 1> {
578
+ try { await registerSink(); }
579
+ catch (error) {
580
+ console.error(`steward ${stage}: log sink registration failed — ${redactSecretText(error instanceof Error ? error.message : String(error))}`);
581
+ }
582
+ return runStewardStageCommand(stage, deps);
583
+ }
584
+
546
585
  if (import.meta.main) {
547
586
  const stage = process.argv[2];
548
587
  if (!['sync', 'triage', 'schedule', 'report'].includes(stage ?? '')) {
549
588
  console.error('steward: expected sync|triage|schedule|report');
550
589
  process.exitCode = 2;
551
590
  } else {
552
- runStewardStageCommand(stage as StewardStage).then(code => { process.exitCode = code; }).catch((error: unknown) => {
591
+ void runStewardNodeCommand(stage as StewardStage).then(code => { process.exitCode = code; }).catch((error: unknown) => {
553
592
  console.error(`steward ${stage}: ${redactSecretText(error instanceof Error ? error.message : String(error))}`);
554
593
  process.exitCode = 1;
555
594
  });
@@ -1,11 +1,15 @@
1
1
  import type { Command } from 'commander';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { scanWishFolder } from '../intake-plane/wish-folder.js';
4
+ import { LINEAR_WISH_KEY_MISSING, scanLinearWishes } from '../intake-plane/linear-wish.js';
5
+ import type { fetchLinearIssues } from '../connectors/linear.js';
4
6
  import { CardStore, type TaskCard } from './card-store.js';
5
7
 
6
8
  export interface CardCliDeps {
7
9
  createStore?: () => CardStore;
8
10
  write?: (text: string) => void;
11
+ getApiKey?: () => Promise<string | undefined>;
12
+ fetchIssues?: typeof fetchLinearIssues;
9
13
  }
10
14
 
11
15
  function formatCard(card: TaskCard): string {
@@ -84,5 +88,79 @@ export function registerCardCommand(program: Command, deps: CardCliDeps = {}): C
84
88
  }
85
89
  });
86
90
 
91
+ card.command('intake-scan')
92
+ .description('Scan Wish notes then Linear wishes in one scheduled invocation')
93
+ .option('--dir <path>', 'Wish folder path')
94
+ .option('--team <key>', 'Linear team key (omit to skip Linear)')
95
+ .option('--label <name>', 'Wish label or title prefix', 'wish')
96
+ .option('--json', 'Output JSON')
97
+ .action(async (options: { dir?: string; team?: string; label: string; json?: boolean }) => {
98
+ const result = {
99
+ folder: { added: 0, updated: 0, skipped: 0 },
100
+ linear: { added: 0, duplicate: 0, failed: 0 },
101
+ skipped: [] as string[],
102
+ failed: [] as string[],
103
+ };
104
+ let store: CardStore | undefined;
105
+ try {
106
+ const dir = resolve(options.dir ?? join(process.env.OBSIDIAN_VAULT_ROOT ?? '', '00. Inbox', '00. Wish'));
107
+ store = createStore();
108
+ result.folder = scanWishFolder({ dir, store });
109
+ } catch {
110
+ result.failed.push('Wish 폴더 스캔 실패');
111
+ } finally {
112
+ try { store?.close(); } catch {
113
+ if (!result.failed.includes('Wish 폴더 스캔 실패')) result.failed.push('Wish 폴더 스캔 실패');
114
+ }
115
+ }
116
+
117
+ store = undefined;
118
+ if (!options.team?.trim()) {
119
+ result.skipped.push('Linear 팀 미지정');
120
+ } else {
121
+ try {
122
+ store = createStore();
123
+ result.linear = await scanLinearWishes({ teamKey: options.team, label: options.label, store,
124
+ deps: { getApiKey: deps.getApiKey, fetchIssues: deps.fetchIssues } });
125
+ if (result.linear.failed > 0) result.failed.push(`Linear 항목 ${result.linear.failed}건 실패`);
126
+ } catch (error) {
127
+ if (error instanceof Error && error.message === LINEAR_WISH_KEY_MISSING) {
128
+ result.skipped.push('Linear 키 없음');
129
+ } else {
130
+ result.failed.push('Linear 스캔 실패');
131
+ }
132
+ } finally {
133
+ try { store?.close(); } catch {
134
+ if (!result.failed.includes('Linear 스캔 실패')) result.failed.push('Linear 스캔 실패');
135
+ }
136
+ }
137
+ }
138
+
139
+ write(options.json ? `${JSON.stringify(result)}\n` :
140
+ `폴더 추가 ${result.folder.added} · Linear 추가 ${result.linear.added} · 건너뜀(${result.skipped.join(', ') || '없음'})${result.failed.length ? ` · 실패(${result.failed.join(', ')})` : ''}\n`);
141
+ if (result.failed.length) process.exitCode = 1;
142
+ });
143
+
144
+ card.command('linear-scan')
145
+ .description('Create wish cards from open Linear issues')
146
+ .requiredOption('--team <key>', 'Linear team key')
147
+ .option('--label <name>', 'Wish label or title prefix', 'wish')
148
+ .option('--json', 'Output JSON')
149
+ .action(async (options: { team: string; label: string; json?: boolean }) => {
150
+ let store: CardStore | undefined;
151
+ try {
152
+ store = createStore();
153
+ const result = await scanLinearWishes({ teamKey: options.team, label: options.label, store,
154
+ deps: { getApiKey: deps.getApiKey, fetchIssues: deps.fetchIssues } });
155
+ write(options.json ? `${JSON.stringify(result)}\n` : `추가 ${result.added} · 중복 ${result.duplicate} · 실패 ${result.failed}\n`);
156
+ } catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ process.stderr.write(`${message.split(/\r?\n/, 1)[0]}\n`);
159
+ process.exitCode = message === LINEAR_WISH_KEY_MISSING ? 2 : 1;
160
+ } finally {
161
+ store?.close();
162
+ }
163
+ });
164
+
87
165
  return card;
88
166
  }
@@ -5,9 +5,9 @@ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { dlopen } from 'bun:ffi';
8
- import { hostLeaseBaseDir } from '../../pod-lease/host-lease.js';
8
+ import { hostLeaseBaseDir, type PendingJobIdentity } from '../../pod-lease/host-lease.js';
9
9
 
10
- /** Persisted on a Job admitted through the host lease; the file lease is released after apply. */
10
+ /** Persisted on a Job admitted through the host lease; the file lease remains until its Pod is observed Running or terminal. */
11
11
  export const POD_HOST_LEASE_ANNOTATION = 'elanous.dev/host-lease-admitted';
12
12
 
13
13
  // `get pods --all-namespaces -o json` on a busy cluster is several MB — the default spawnSync
@@ -25,6 +25,8 @@ export interface PodLeaseMember {
25
25
  capacity: number;
26
26
  running: number | null;
27
27
  pending: number | null;
28
+ /** Job names of Pending harness Pods already included in pending (used to avoid counting the same Job's host lease twice). */
29
+ pendingJobs?: PendingJobIdentity[];
28
30
  /** Running harness Pods whose Jobs predate host lease admission. */
29
31
  unleasedRunning: number | null;
30
32
  memoryLimitBytes: number | null;
@@ -52,6 +54,8 @@ export interface PoolLeaseRecommendation {
52
54
  accountSlots: number | null;
53
55
  running: number | null;
54
56
  pending: number | null;
57
+ /** Names of Pending Jobs already counted in pending. */
58
+ pendingJobs?: PendingJobIdentity[];
55
59
  /** Absent only for injected legacy status fixtures; real measurements always report a number or null. */
56
60
  unleasedRunning?: number | null;
57
61
  }
@@ -294,6 +298,7 @@ export function measurePoolLease(members: readonly PodPoolMember[], deps: { kube
294
298
  }
295
299
  if (!jobsValid) reasons.push('cluster jobs: missing name/labels');
296
300
  let running = 0, pending = 0, unleasedRunning = 0, limits = 0, limitsValid = true, reservationsValid = nodeItems !== null && nodeNames.size === nodeItems.length;
301
+ const pendingJobs = new Map<string, PendingJobIdentity>();
297
302
  let unknownPhase = false;
298
303
  const seenPods = new Set<string>();
299
304
  for (const pod of podItems) {
@@ -314,8 +319,14 @@ export function measurePoolLease(members: readonly PodPoolMember[], deps: { kube
314
319
  const spec = object(pod.spec);
315
320
  let limit: number | null = null;
316
321
  if (harness) {
317
- if (phase === 'Pending') pending++;
318
- else {
322
+ if (phase === 'Pending') {
323
+ pending++;
324
+ const jobName = labels?.['elanous.job'] ?? labels?.['job-name'];
325
+ if (typeof jobName === 'string' && jobName && typeof metadata.namespace === 'string' && metadata.namespace) {
326
+ const identity = { context: member.context, namespace: metadata.namespace, job: jobName };
327
+ pendingJobs.set(JSON.stringify(identity), identity);
328
+ }
329
+ } else {
319
330
  running++;
320
331
  const jobName = labels?.['elanous.job'] ?? labels?.['job-name'];
321
332
  if (typeof jobName !== 'string' || !leasedJobs.has(jobName)) unleasedRunning++;
@@ -333,7 +344,7 @@ export function measurePoolLease(members: readonly PodPoolMember[], deps: { kube
333
344
  if (nodeFree.has(nodeName)) nodeFree.set(nodeName, nodeFree.get(nodeName)! - Math.max(requested, limit ?? 0));
334
345
  }
335
346
  if (unknownPhase) reasons.push('cluster pods: missing/Unknown phase');
336
- if (jobsValid && !unknownPhase) { result.running = running; result.pending = pending; result.unleasedRunning = unleasedRunning; }
347
+ if (jobsValid && !unknownPhase) { result.running = running; result.pending = pending; result.pendingJobs = [...pendingJobs.values()]; result.unleasedRunning = unleasedRunning; }
337
348
  if (limitsValid && jobsValid && !unknownPhase) result.memoryLimitBytes = limits;
338
349
  else if (!limitsValid) reasons.push('cluster pods: Running Pod memory limit missing/invalid');
339
350
  if (reservationsValid && limitsValid && jobsValid && !unknownPhase) result.availableMemoryByNodeBytes = [...nodeFree.values()];
@@ -366,6 +377,7 @@ export function recommendConcurrency(measure: PoolLeaseMeasure, options: { capac
366
377
  if (!Number.isSafeInteger(options.capacity) || options.capacity < 0) return { ...empty, reason: '측정 불가: capacity' };
367
378
  const running = measure.members.reduce((sum, m) => sum + m.running!, 0);
368
379
  const pending = measure.members.reduce((sum, m) => sum + m.pending!, 0);
380
+ const pendingJobs = measure.members.flatMap((m) => m.pendingJobs ?? []);
369
381
  const unleasedRunning = measure.members.reduce((sum, m) => sum + m.unleasedRunning!, 0);
370
382
  // Pool-wide free = sum of per-member free slots: a member over its cap counts as full, never negative,
371
383
  // so it cannot zero another member's free slot (and its unleased Pods only consume its own free slots).
@@ -393,5 +405,5 @@ export function recommendConcurrency(measure: PoolLeaseMeasure, options: { capac
393
405
  const recommended = Math.max(0, Math.min(capacitySlots, memoryBound) - eligibleUnleasedRunning);
394
406
  const limitedBy = memoryBound < capacitySlots ? 'memory' : 'capacity';
395
407
  if (unleasedRunning > 0) debug.log('pod-lease', 'unleased', { unleasedRunning, running, recommended });
396
- return { recommended, limitedBy, reason: measure.members.some((m) => m.reason === 'dns') ? 'dns' : null, capacitySlots, memorySlots, liteMemorySlots, placeableSlots, accountSlots, running, pending, unleasedRunning };
408
+ return { recommended, limitedBy, reason: measure.members.some((m) => m.reason === 'dns') ? 'dns' : null, capacitySlots, memorySlots, liteMemorySlots, placeableSlots, accountSlots, running, pending, pendingJobs, unleasedRunning };
397
409
  }
@@ -19,7 +19,7 @@ import { join, resolve } from 'node:path';
19
19
  import { debug } from '../../debug/log.js';
20
20
  import { PodLeaseAdmission, type PodLeasePredecessor, type PodLeaseRelease } from '../../pod-lease/admission.js';
21
21
  import { predecessorState, type PredecessorState } from '../../pod-lease/dependency-state.js';
22
- import { HostPoolLease } from '../../pod-lease/host-lease.js';
22
+ import { HostPoolLease, leaseHasPendingPod } from '../../pod-lease/host-lease.js';
23
23
  import { measurePoolLease, recommendConcurrency, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
24
24
 
25
25
  export interface PodPoolMember {
@@ -143,6 +143,10 @@ export function genericConfigPodPool(config: { pod?: { pool?: string }; harness?
143
143
  }
144
144
 
145
145
  /** 노드 자리 배분 — 우선순위 순서로 첫 빈 자리. 순수(시각·대기는 호출자가). */
146
+ export function podPoolHostLease(members: readonly PodPoolMember[]): HostPoolLease {
147
+ return new HostPoolLease([...members.map((m) => `${m.context}@${m.sshHost ?? ''}`)].sort().join(','));
148
+ }
149
+
146
150
  export class PodPoolScheduler {
147
151
  private readonly inflight = new Map<string, number>();
148
152
  private readonly admission: PodLeaseAdmission;
@@ -157,7 +161,7 @@ export class PodPoolScheduler {
157
161
  constructor(readonly members: readonly PodPoolMember[], options: { status?: () => PoolLeaseRecommendation | Promise<PoolLeaseRecommendation>; kubectl?: PoolKubectl; dns?: (context: string) => PoolDnsProbe; pollMs?: number; hostLease?: HostPoolLease; dependencyMerged?: (after: PodLeasePredecessor, signal?: AbortSignal) => PredecessorState | boolean | Promise<PredecessorState | boolean> } = {}) {
158
162
  this.pollMs = options.pollMs ?? 15_000;
159
163
  // One lease directory per pool (contexts, not caps) — every CLI process launching into the same clusters shares it.
160
- this.hostLease = options.hostLease ?? new HostPoolLease([...members.map((m) => `${m.context}@${m.sshHost ?? ''}`)].sort().join(','));
164
+ this.hostLease = options.hostLease ?? podPoolHostLease(members);
161
165
  const measureStatus = options.status ?? (() => {
162
166
  const measure = measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) });
163
167
  return recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 });
@@ -166,9 +170,13 @@ export class PodPoolScheduler {
166
170
  this.admission = new PodLeaseAdmission({ status: async () => {
167
171
  const measured = await measureStatus();
168
172
  this.lastRaw = measured;
169
- // Launches admitted by other processes on this host but not yet visible as Jobs hold free slots too.
170
- const others = measured.recommended === null ? 0 : this.hostLease.othersPending();
171
- const decision = measured.recommended === null ? measured : { ...measured, recommended: Math.max(0, measured.recommended - others) };
173
+ const pendingJobs = measured.pendingJobs ?? [];
174
+ const ownStart = this.hostLease.selfStart();
175
+ const transferId = process.env.ELANOUS_POD_RESERVED_LEASE;
176
+ const transferIsOther = !!transferId && this.hostLease.reservationIsOther(transferId);
177
+ const others = measured.recommended === null ? 0 : this.hostLease.live().filter((r) =>
178
+ (r.pid !== process.pid || r.startedAt !== ownStart) && !leaseHasPendingPod(r, pendingJobs)).length;
179
+ const decision = measured.recommended === null ? measured : { ...measured, recommended: Math.max(0, measured.recommended - others + (transferIsOther ? 1 : 0)) };
172
180
  // An unknown reading cannot erase permits already granted: on recovery,
173
181
  // the first healthy recommendation must still account for their slots.
174
182
  if (decision.recommended === null) return decision;
@@ -181,19 +189,28 @@ export class PodPoolScheduler {
181
189
  }, ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged: options.dependencyMerged ?? ((after, signal) => predecessorState(after, {}, signal)) });
182
190
  }
183
191
  /** FIFO gate within this process, then a host lease so independent CLI processes cannot over-admit the same pool. */
184
- async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease & { applied: () => void }> {
192
+ async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease & { applied: (job: string, context: string, namespace: string) => void; observed: () => void }> {
185
193
  const local = await this.acquireLocalAdmission(signal, after);
186
194
  try {
187
195
  for (;;) {
188
196
  if (signal?.aborted) throw new Error('pod lease admission aborted');
189
197
  const free = this.lastRaw?.recommended ?? null;
190
- // Same check as the local gate, but atomic across processes: other unapplied leases must stay under the free slots.
191
- const lease = free !== null && free > 0 ? this.hostLease.tryReserve((others) => others < free) : null;
198
+ // Same check as the local gate, but atomic across processes: other authoring and pre-Running leases hold slots.
199
+ const reservedId = process.env.ELANOUS_POD_RESERVED_LEASE;
200
+ const transferred = reservedId ? this.hostLease.claim(reservedId) : null;
201
+ if (reservedId && !transferred) throw new Error('pod lease: authoring reservation could not be transferred');
202
+ if (transferred) delete process.env.ELANOUS_POD_RESERVED_LEASE;
203
+ const pendingJobs = this.lastRaw?.pendingJobs ?? [];
204
+ const lease = transferred ?? (free !== null && free > 0 ? this.hostLease.tryReserve((others) =>
205
+ others - this.hostLease.live().filter((r) => leaseHasPendingPod(r, pendingJobs) &&
206
+ (r.pid !== process.pid || r.startedAt !== this.hostLease.selfStart())).length < free) : null);
192
207
  if (lease) {
193
208
  let hostReleased = false;
194
209
  const releaseHost = () => { if (!hostReleased) { hostReleased = true; lease(); } };
195
- // Once the Job is applied the cluster measurement counts it — the host lease would double count.
196
- return Object.assign(() => { releaseHost(); local(); }, { applied: releaseHost });
210
+ return Object.assign(() => { releaseHost(); local(); }, {
211
+ applied: (job: string, context: string, namespace: string) => lease.applied(job, context, namespace),
212
+ observed: releaseHost,
213
+ });
197
214
  }
198
215
  await new Promise<void>((resolve, reject) => {
199
216
  const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, this.pollMs);