elanous 0.2.16 → 0.2.17

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 (228) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1307-b46d45b705e8c9ae.js → 1307-aac7332576c4d6fc.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{6877-7c6208705c284124.js → 6877-fe94e76a5841ee9b.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/8401-4ce1c42c8d89903f.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/899-f41be85e8a680651.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3143b07a817d1da9.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3cb52e5ea8fc2fa5.js → page-17ba086ef82d339b.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/app/board/page-7eb661ff23af7da2.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/botlab/page-6b8444914da74ca6.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/bots/{page-482c5dbde699947c.js → page-6e5c3a867557298b.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/ceo/{page-1e962b31d1f11081.js → page-7110f732f45bf944.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/chat/{page-8c2d9526164f5d09.js → page-9a41d65355fc25b9.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/consult/page-5e77cd1802567c89.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/control/page-41afec0b255ed2bb.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-c53598cfce6156f2.js → page-4187666132fc98c4.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/editor/{page-13a63953f53c6a23.js → page-09328d499162bae4.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/exec/{page-5d3c8429d04f3831.js → page-29ff3a8f84f03b6a.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/field/{page-3193bdc0bbab047a.js → page-80ec7401bc83f511.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/inside/{page-76270efaee638f17.js → page-ab93b240f687d69a.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/intake/{page-6107ca8ddd5e3bdd.js → page-fc7d86467291ae38.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/{layout-dce686e582c23937.js → layout-de43a186e5ba5683.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/live/{page-d3d53b146416e422.js → page-8b08cafad27759c9.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/loops/page-0e2d6985fb42cd2f.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/market/page-13e4108999f58d13.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-e8296ee284aa1293.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-4167348049166852.js → page-043a9111e74f6b24.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-de52e8fdf9095f7a.js → not-found-23a4601ceb7f452e.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/page-1ed1324b1dc36754.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-ca3952cd78a83ca2.js → page-10d958605a77b3bd.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-5b042736aa113756.js → page-166745e51ac0c7b0.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-acb749f1e432bcf3.js → page-a04260b8ccc968d4.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/page-7a1d85e121065cc8.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/page-1f50e3d4e13435f4.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1ebfe049c640e171.js → page-12916918988ac97e.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-d8a3347a9d0c3f4a.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/page-a9117d2cd3093d36.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-31fa1ce4c4aff9ae.js → page-5a64cb5208c6a8fd.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-0f5cd57ca928f42b.js → page-1407ec83ea64c1f9.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-abe8e3b74bea1ffc.js → page-1d824510df025d18.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/setup/page-6e878ebbe1737d00.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/share/{page-d28573d6792b4275.js → page-7f49fe645d5132f7.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-59f03fda76c03d22.js → page-81a4e7f8dbfe74cb.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-d751de5f8062ff5e.js → page-27d71f131acd9320.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/term/page-3ab59d19913b200e.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/today/page-bf85b2f5bf077925.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/trace/page-424659bacfb8cdd7.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/vault/page-ab32a8f522a60371.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-d4aef67abf264874.js → page-fc2df261f8a2c7df.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-1ea64acadd704ace.js → page-09461366af82ef13.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-3544f154c87b43ee.js → page-5d4fef9ccd7d406b.js} +1 -1
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-30c2073bfc21c004.js → page-ea90a8ac73ce2749.js} +1 -1
  54. package/apps/pwa/out/approvals/index.html +1 -1
  55. package/apps/pwa/out/approvals/index.txt +13 -13
  56. package/apps/pwa/out/autopilot/index.html +1 -1
  57. package/apps/pwa/out/autopilot/index.txt +13 -13
  58. package/apps/pwa/out/board/index.html +1 -1
  59. package/apps/pwa/out/board/index.txt +13 -13
  60. package/apps/pwa/out/botlab/index.html +1 -1
  61. package/apps/pwa/out/botlab/index.txt +13 -13
  62. package/apps/pwa/out/bots/index.html +1 -1
  63. package/apps/pwa/out/bots/index.txt +13 -13
  64. package/apps/pwa/out/ceo/index.html +1 -1
  65. package/apps/pwa/out/ceo/index.txt +13 -13
  66. package/apps/pwa/out/chat/index.html +1 -1
  67. package/apps/pwa/out/chat/index.txt +13 -13
  68. package/apps/pwa/out/consult/index.html +1 -1
  69. package/apps/pwa/out/consult/index.txt +13 -13
  70. package/apps/pwa/out/control/index.html +1 -1
  71. package/apps/pwa/out/control/index.txt +13 -13
  72. package/apps/pwa/out/design-check/index.html +1 -1
  73. package/apps/pwa/out/design-check/index.txt +13 -13
  74. package/apps/pwa/out/editor/index.html +1 -1
  75. package/apps/pwa/out/editor/index.txt +13 -13
  76. package/apps/pwa/out/exec/index.html +1 -1
  77. package/apps/pwa/out/exec/index.txt +13 -13
  78. package/apps/pwa/out/field/index.html +1 -1
  79. package/apps/pwa/out/field/index.txt +13 -13
  80. package/apps/pwa/out/healthz +1 -1
  81. package/apps/pwa/out/index.html +1 -1
  82. package/apps/pwa/out/index.txt +13 -13
  83. package/apps/pwa/out/inside/index.html +1 -1
  84. package/apps/pwa/out/inside/index.txt +13 -13
  85. package/apps/pwa/out/intake/index.html +1 -1
  86. package/apps/pwa/out/intake/index.txt +13 -13
  87. package/apps/pwa/out/live/index.html +1 -1
  88. package/apps/pwa/out/live/index.txt +13 -13
  89. package/apps/pwa/out/loops/index.html +1 -1
  90. package/apps/pwa/out/loops/index.txt +13 -13
  91. package/apps/pwa/out/market/index.html +1 -1
  92. package/apps/pwa/out/market/index.txt +13 -13
  93. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  94. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  95. package/apps/pwa/out/morning/index.html +1 -1
  96. package/apps/pwa/out/morning/index.txt +13 -13
  97. package/apps/pwa/out/observatory/index.html +1 -1
  98. package/apps/pwa/out/observatory/index.txt +13 -13
  99. package/apps/pwa/out/ops/checklist/index.html +1 -1
  100. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  101. package/apps/pwa/out/ops/release/index.html +1 -1
  102. package/apps/pwa/out/ops/release/index.txt +13 -13
  103. package/apps/pwa/out/ops/seats/index.html +1 -1
  104. package/apps/pwa/out/ops/seats/index.txt +13 -13
  105. package/apps/pwa/out/outputs/index.html +1 -1
  106. package/apps/pwa/out/outputs/index.txt +13 -13
  107. package/apps/pwa/out/reflection/index.html +1 -1
  108. package/apps/pwa/out/reflection/index.txt +14 -14
  109. package/apps/pwa/out/scheduler/index.html +1 -1
  110. package/apps/pwa/out/scheduler/index.txt +13 -13
  111. package/apps/pwa/out/sessions/index.html +1 -1
  112. package/apps/pwa/out/sessions/index.txt +13 -13
  113. package/apps/pwa/out/settings/devices/index.html +1 -1
  114. package/apps/pwa/out/settings/devices/index.txt +13 -13
  115. package/apps/pwa/out/settings/index.html +1 -1
  116. package/apps/pwa/out/settings/index.txt +13 -13
  117. package/apps/pwa/out/setup/done/index.html +1 -1
  118. package/apps/pwa/out/setup/done/index.txt +13 -13
  119. package/apps/pwa/out/setup/index.html +1 -1
  120. package/apps/pwa/out/setup/index.txt +13 -13
  121. package/apps/pwa/out/share/index.html +1 -1
  122. package/apps/pwa/out/share/index.txt +13 -13
  123. package/apps/pwa/out/showroom/index.html +1 -1
  124. package/apps/pwa/out/showroom/index.txt +13 -13
  125. package/apps/pwa/out/tasks/index.html +1 -1
  126. package/apps/pwa/out/tasks/index.txt +13 -13
  127. package/apps/pwa/out/term/index.html +1 -1
  128. package/apps/pwa/out/term/index.txt +13 -13
  129. package/apps/pwa/out/today/index.html +1 -1
  130. package/apps/pwa/out/today/index.txt +13 -13
  131. package/apps/pwa/out/trace/index.html +1 -1
  132. package/apps/pwa/out/trace/index.txt +13 -13
  133. package/apps/pwa/out/vault/index.html +1 -1
  134. package/apps/pwa/out/vault/index.txt +13 -13
  135. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  136. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  137. package/apps/pwa/out/workflows/index.html +1 -1
  138. package/apps/pwa/out/workflows/index.txt +13 -13
  139. package/apps/pwa/out/workspace/index.html +1 -1
  140. package/apps/pwa/out/workspace/index.txt +13 -13
  141. package/apps/pwa/out/worktrees/index.html +1 -1
  142. package/apps/pwa/out/worktrees/index.txt +13 -13
  143. package/graphs/intake/intake-daily.yaml +9 -2
  144. package/graphs/intake/news-sources.yaml +4 -0
  145. package/graphs/intake/recipes.yaml +6 -1
  146. package/package.json +1 -1
  147. package/scripts/__fixtures__/intake-news.xml +7 -0
  148. package/scripts/intake-news.ts +169 -0
  149. package/scripts/release-loop/docs-land-node.ts +69 -10
  150. package/scripts/release-loop/publish-node.ts +2 -2
  151. package/scripts/release-loop/release-docs.ts +134 -0
  152. package/scripts/release-loop/version-node.ts +7 -4
  153. package/scripts/release-story/draft.ts +42 -14
  154. package/src/agent-mission/driver.ts +53 -10
  155. package/src/autopilot/track-agent.ts +5 -3
  156. package/src/cli/autopilot-cli.ts +66 -0
  157. package/src/cli/decisions-cli.ts +4 -1
  158. package/src/cli/intake-cli.ts +1 -1
  159. package/src/cli/log-event-names.ts +1 -0
  160. package/src/cli/pr-cli.ts +12 -1
  161. package/src/cli/provider-cli.ts +14 -0
  162. package/src/cli/self-update-canary.ts +46 -0
  163. package/src/cli/self-update.ts +200 -1
  164. package/src/context-card/index.ts +45 -0
  165. package/src/context-card/task.ts +76 -0
  166. package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -0
  167. package/src/decisions/decision-ledger.ts +10 -1
  168. package/src/harness/harness-cli-command.ts +142 -10
  169. package/src/harness/harness-pod-dispatch.ts +11 -0
  170. package/src/harness/harness-queue.ts +86 -7
  171. package/src/harness/nested-elanous-policy.ts +23 -4
  172. package/src/hq/fence-wrapper.ts +123 -0
  173. package/src/hq/hq.ts +8 -0
  174. package/src/index.ts +57 -8
  175. package/src/intake-plane/digest.ts +39 -4
  176. package/src/nexus/api/grid.ts +61 -0
  177. package/src/nexus/api/http-server.ts +19 -1
  178. package/src/nexus/api/task-cards-api.ts +19 -0
  179. package/src/oauth/codex.ts +82 -2
  180. package/src/release-loop/landing-freeze.ts +18 -10
  181. package/src/seat-loop/seat-loop.ts +95 -31
  182. package/src/self-dev/ask-launch-flow.ts +26 -7
  183. package/src/self-dev/run-store.ts +5 -1
  184. package/src/self-dev/run-supervisor.ts +7 -1
  185. package/src/self-dev/supervisor-verdict-edges.ts +46 -0
  186. package/src/self-implement/frozen-merges.ts +25 -20
  187. package/src/self-implement/goal-author-clarification.ts +81 -0
  188. package/src/self-implement/goal-author-cli.ts +33 -5
  189. package/src/self-implement/goal-author.ts +88 -12
  190. package/src/self-implement/goal-clarification-escalation.ts +37 -1
  191. package/src/self-implement/goal-document-write.ts +28 -0
  192. package/src/self-implement/heal-intake.ts +4 -0
  193. package/src/self-implement/host-regate.ts +3 -1
  194. package/src/self-implement/orchestrator.ts +19 -1
  195. package/src/task-agent/actions.ts +118 -0
  196. package/src/task-agent/judge.ts +96 -0
  197. package/src/task-cards/card-store.ts +4 -1
  198. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +33 -4
  199. package/src/task-orchestrator/surfaces/pod-lease.ts +5 -2
  200. package/src/task-orchestrator/surfaces/pod-pool.ts +81 -13
  201. package/src/task-orchestrator/surfaces/self-implement-pod.ts +151 -29
  202. package/src/telegram-commands.ts +1 -1
  203. package/src/telegram-loops-command.ts +21 -14
  204. package/src/user-config.ts +25 -3
  205. package/src/version/packed-revision.json +1 -1
  206. package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +0 -1
  207. package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +0 -1
  208. package/apps/pwa/out/_next/static/chunks/9247-29d889b809288e65.js +0 -1
  209. package/apps/pwa/out/_next/static/chunks/app/approvals/page-79c3ed84baaedd03.js +0 -1
  210. package/apps/pwa/out/_next/static/chunks/app/board/page-a424ef80885d1743.js +0 -1
  211. package/apps/pwa/out/_next/static/chunks/app/botlab/page-c1e20d7d338713f1.js +0 -1
  212. package/apps/pwa/out/_next/static/chunks/app/consult/page-ffff6d819fbc6e0f.js +0 -1
  213. package/apps/pwa/out/_next/static/chunks/app/control/page-a0e2a6d1bfb52fc5.js +0 -1
  214. package/apps/pwa/out/_next/static/chunks/app/loops/page-1a61c7ca1ceb3ae7.js +0 -1
  215. package/apps/pwa/out/_next/static/chunks/app/market/page-51f811fa119308b6.js +0 -1
  216. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-dacff46049e1557e.js +0 -1
  217. package/apps/pwa/out/_next/static/chunks/app/observatory/page-36a7d994f4ca0299.js +0 -1
  218. package/apps/pwa/out/_next/static/chunks/app/outputs/page-cd78862707999911.js +0 -1
  219. package/apps/pwa/out/_next/static/chunks/app/page-54b9ef8afe6278fd.js +0 -1
  220. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-57924393e3e4e539.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/app/sessions/page-9e0ce6c8129fa641.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/app/setup/page-4364ef9f4e7c7f0f.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/app/term/page-fe1759f94003b32b.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/today/page-d64c984e855c8c68.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/trace/page-86914b0dee8ab7f8.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/vault/page-9afdee66b820c76e.js +0 -1
  227. /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_buildManifest.js +0 -0
  228. /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_ssgManifest.js +0 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * TASK-AGENT 판단부 — 슈퍼바이저 종료 어휘(+ PR·리뷰 관측) → «다음 한 수».
3
+ *
4
+ * RFC-task-agent-any-task-to-completion-2026-10-06 §A2 표를 순수 함수로 옮긴다.
5
+ * 부작용이 없다(발사·리뷰·착지는 실행부 `actions.ts` 의 몫). 수확 원천 = run-c9ad6d6d `chooseTaskAction`.
6
+ */
7
+ import type { SupervisorStopReason } from '../self-dev/run-supervisor.js';
8
+ import type { NextActionKind } from './actions.js';
9
+
10
+ export type TaskMove =
11
+ | 'propose-green'
12
+ | 'review'
13
+ | 'propose-land'
14
+ | 'narrow-relaunch'
15
+ | 'wait-retry'
16
+ | 'salvage-relaunch'
17
+ | 'alternative'
18
+ | 'decision-card'
19
+ | 'wait';
20
+
21
+ export interface TaskJudgeInput {
22
+ stopReason?: SupervisorStopReason;
23
+ prState?: 'OPEN' | 'MERGED' | 'CLOSED';
24
+ pr?: string | number;
25
+ /** `self review --json` 판정. 관측 못 했으면 비운다(«통과»로 읽지 않는다). */
26
+ review?: 'pass' | 'fail';
27
+ mustFix?: readonly string[];
28
+ /** 수마다 누적 실패 수. */
29
+ failures?: Partial<Record<TaskMove, number>>;
30
+ }
31
+
32
+ export interface TaskJudgement {
33
+ move: TaskMove;
34
+ stage: 'observing' | 'closing' | 'reviewing' | 'repairing' | 'waiting' | 'escalated';
35
+ reason: string;
36
+ /** 실행부(`executeNextAction`)가 바로 받는 수면 그 종류. 없으면 발사·대기 계열(실행부 밖). */
37
+ executorKind?: NextActionKind;
38
+ }
39
+
40
+ /** 같은 수가 이 횟수만큼 실패하면 다른 수로 넘어간다(RFC §A2 «같은 수 2번 실패»). */
41
+ export const SAME_MOVE_FAILURE_LIMIT = 2;
42
+
43
+ const EXECUTOR_KIND: Partial<Record<TaskMove, NextActionKind>> = {
44
+ 'propose-green': 'green',
45
+ review: 'review',
46
+ 'propose-land': 'land',
47
+ 'narrow-relaunch': 'retry',
48
+ 'wait-retry': 'retry',
49
+ 'salvage-relaunch': 'retry',
50
+ alternative: 'retry',
51
+ 'decision-card': 'decision',
52
+ };
53
+
54
+ function base(input: TaskJudgeInput): Omit<TaskJudgement, 'executorKind'> {
55
+ const stop = input.stopReason;
56
+ const merged = input.prState === 'MERGED' && input.pr !== undefined && input.pr !== '';
57
+ if (merged) {
58
+ return { move: 'propose-green', stage: 'closing', reason: `병합된 PR ${input.pr} 근거로 칸 green 제안` };
59
+ }
60
+ switch (stop) {
61
+ case 'harvestable-awaiting-human':
62
+ case 'needs-human':
63
+ if (input.review === 'pass') return { move: 'propose-land', stage: 'reviewing', reason: 'self review 통과 — 착지 제안' };
64
+ if (input.review === 'fail') {
65
+ return { move: 'narrow-relaunch', stage: 'repairing', reason: `must-fix 로 좁혀 재발사: ${(input.mustFix ?? []).join(' · ') || '(must-fix 비어 있음)'}` };
66
+ }
67
+ return { move: 'review', stage: 'reviewing', reason: '수확 가능 런 — self review 필요' };
68
+ case 'no-progress':
69
+ case 'decomposable-no-progress':
70
+ return { move: 'narrow-relaunch', stage: 'repairing', reason: `${stop} — 범위를 좁혀 한 번 재발사(판정선 유지)` };
71
+ case 'provider-exhausted':
72
+ case 'step-timeout':
73
+ return { move: 'wait-retry', stage: 'waiting', reason: `${stop} — 대기 후 재시도` };
74
+ case 'handed-off-to-salvage':
75
+ return { move: 'salvage-relaunch', stage: 'repairing', reason: '수확 브랜치 diff 를 가져와 이어 발사' };
76
+ case 'converged':
77
+ return { move: 'wait', stage: 'observing', reason: '완주했으나 PR 병합 근거 대기' };
78
+ case undefined:
79
+ return { move: 'wait', stage: 'observing', reason: '종료 관측 대기' };
80
+ default:
81
+ return { move: 'decision-card', stage: 'escalated', reason: `표에 없는 종료 어휘 ${stop} — 근거 붙여 사람 결정` };
82
+ }
83
+ }
84
+
85
+ /** 종료 어휘·관측에서 다음 한 수를 고른다. 같은 수 2회 실패 → 다른 수 → 그것도 실패 → 결정 카드. */
86
+ export function judgeNextMove(input: TaskJudgeInput): TaskJudgement {
87
+ const failures = input.failures ?? {};
88
+ let judged = base(input);
89
+ if ((failures.alternative ?? 0) >= 1) {
90
+ judged = { move: 'decision-card', stage: 'escalated', reason: `대안도 실패 — 런·PR·must-fix 근거로 사람 결정 (직전: ${judged.move})` };
91
+ } else if (judged.move !== 'wait' && judged.move !== 'decision-card' && (failures[judged.move] ?? 0) >= SAME_MOVE_FAILURE_LIMIT) {
92
+ judged = { move: 'alternative', stage: 'repairing', reason: `같은 수 ${SAME_MOVE_FAILURE_LIMIT}회 실패 — 다른 수 선택 (${judged.move})` };
93
+ }
94
+ const executorKind = EXECUTOR_KIND[judged.move];
95
+ return executorKind ? { ...judged, executorKind } : judged;
96
+ }
@@ -16,6 +16,7 @@ export interface TaskCard {
16
16
  goalId: string;
17
17
  title: string;
18
18
  status: CardStatus;
19
+ closedReason?: string;
19
20
  createdAt: string;
20
21
  sections: CardSection[];
21
22
  }
@@ -206,7 +207,7 @@ export class CardStore {
206
207
  if (card.status === 'closed') return card;
207
208
  this.append(id, { type: 'closed', createdAt: new Date().toISOString(),
208
209
  ...(reason === undefined ? {} : { reason: redactSecrets(reason.trim()) }) });
209
- return { ...card, status: 'closed' as const };
210
+ return { ...card, status: 'closed' as const, ...(reason === undefined ? {} : { closedReason: redactSecrets(reason.trim()) }) };
210
211
  }).immediate();
211
212
  }
212
213
 
@@ -216,8 +217,10 @@ export class CardStore {
216
217
  } | null;
217
218
  if (!row) return null;
218
219
  const events = readFileSync(cardEventsPath(id, this.root), 'utf8').trimEnd().split('\n').map((line) => JSON.parse(line) as CardEvent);
220
+ const closed = events.find((event): event is Extract<CardEvent, { type: 'closed' }> => event.type === 'closed');
219
221
  return {
220
222
  id: row.id, goalId: row.goal_id, title: row.title, status: row.status, createdAt: row.created_at,
223
+ ...(closed?.reason === undefined ? {} : { closedReason: closed.reason }),
221
224
  sections: events.filter((event): event is Extract<CardEvent, { type: 'section' }> => event.type === 'section')
222
225
  .map(({ key, owner, content, createdAt }) => ({ key, owner, content, createdAt })),
223
226
  };
@@ -1,3 +1,31 @@
1
+ /** Executor kinds of the dev child's `--json` result line (`src/index.ts` writeStdoutJson · DevExecutor.kind). */
2
+ const CHILD_RESULT_KINDS = new Set(['self', 'external']);
3
+
4
+ /** The dev child's terminal line nests the stage: {ok, kind, result:{stage, ok, node, outcome, …}}. Other JSON rows are not results. */
5
+ export function nestedPodResult(row: Record<string, unknown>): { stage: string; ok: boolean; error?: string } | null {
6
+ if (typeof row.kind !== 'string' || !CHILD_RESULT_KINDS.has(row.kind) || typeof row.ok !== 'boolean') return null;
7
+ if (!row.result || typeof row.result !== 'object' || Array.isArray(row.result)) return null;
8
+ const result = row.result as Record<string, unknown>;
9
+ if (typeof result.stage !== 'string' || typeof result.ok !== 'boolean') return null;
10
+ if (result.ok) return { stage: result.stage, ok: true };
11
+ const classification = result.abandonedClassification;
12
+ const detail = typeof result.supervisorVerdict === 'string' ? result.supervisorVerdict
13
+ : classification && typeof classification === 'object' && !Array.isArray(classification)
14
+ ? (classification as Record<string, unknown>).classification : undefined;
15
+ const reason = [result.node, result.outcome].filter((part): part is string => typeof part === 'string' && part.length > 0).join(' ');
16
+ const error = [reason || (typeof result.error === 'string' ? result.error : ''), typeof detail === 'string' && detail ? detail : ''].filter(Boolean).join(' · ');
17
+ return { stage: result.stage, ok: false, ...(error ? { error } : {}) };
18
+ }
19
+
20
+ /** A child terminal row: the legacy flat form {stage, ok, error?} (no kind, or a child kind) or the nested dev result. Any other kind is not a result. */
21
+ export function podTerminalRow(row: Record<string, unknown>): { stage: string; ok: boolean; error?: string } | null {
22
+ if (typeof row.kind === 'string' && !CHILD_RESULT_KINDS.has(row.kind)) return null;
23
+ if (typeof row.stage === 'string' && typeof row.ok === 'boolean') {
24
+ return { stage: row.stage, ok: row.ok, ...(typeof row.error === 'string' ? { error: row.error } : {}) };
25
+ }
26
+ return nestedPodResult(row);
27
+ }
28
+
1
29
  /** Extract a human-readable reason from a failed child Pod without treating transfer markers as child errors. */
2
30
  export function extractPodFailureReason(input: {
3
31
  logs: string;
@@ -12,7 +40,7 @@ export function extractPodFailureReason(input: {
12
40
  if (input.containerReason === 'OOMKilled') return 'OOMKilled: 컨테이너 메모리 한도 초과';
13
41
 
14
42
  const isBookkeeping = (line: string): boolean =>
15
- /^ELANOUS_(?:MEM|RUN_LEDGER|USAGE_ROLLUP|POD_)/u.test(line) || /^(?:at\s+\S|\.\.\.\s+\d+\s+more\b|cleanup\s+(?:complete|done|finished)\b)/iu.test(line);
43
+ /^(?:ELANOUS_(?:MEM|RUN_LEDGER|USAGE_ROLLUP|POD_)|\[graph\] )/u.test(line) || /^(?:at\s+\S|\.\.\.\s+\d+\s+more\b|cleanup\s+(?:complete|done|finished)\b)/iu.test(line);
16
44
  const isError = (line: string): boolean =>
17
45
  /\berror\b|\bfail(?:ed|ure)?\b|exception|fatal|denied|timed?\s*out|exceeded|overloaded|unavailable|exhausted|오류|실패/iu.test(line);
18
46
  const meaningful: Array<{ text: string; terminal: boolean }> = [];
@@ -24,10 +52,11 @@ export function extractPodFailureReason(input: {
24
52
  try { parsed = JSON.parse(trimmed); } catch { /* An ordinary line may begin with a brace. */ }
25
53
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
26
54
  const row = parsed as Record<string, unknown>;
27
- if (typeof row.ok === 'boolean' && typeof row.stage === 'string') {
55
+ const terminal = podTerminalRow(row);
56
+ if (terminal) {
28
57
  meaningful.length = 0;
29
- if (row.ok === false && typeof row.error === 'string') {
30
- const errorLines = row.error.split(/\r\n|\n|\r/u).map((part) => part.trim())
58
+ if (terminal.ok === false && typeof terminal.error === 'string') {
59
+ const errorLines = terminal.error.split(/\r\n|\n|\r/u).map((part) => part.trim())
31
60
  .filter((part) => part && !isBookkeeping(part));
32
61
  const errorLine = [...errorLines].reverse().find(isError) ?? errorLines.at(-1);
33
62
  if (errorLine) meaningful.push({ text: errorLine, terminal: true });
@@ -64,6 +64,9 @@ export interface PoolLeaseRecommendation {
64
64
  }
65
65
 
66
66
  /** Kubernetes quantity to bytes (memory) or millicores (CPU). */
67
+ /** Kubernetes memory quantity (`16Gi`, `2Gi`, `512Mi`) → bytes; null when unparseable. */
68
+ export function memoryQuantityBytes(raw: string): number | null { return quantity(raw, 'memory'); }
69
+
67
70
  function quantity(raw: string, unit: 'memory' | 'cpu'): number | null {
68
71
  const m = /^(\d+(?:\.\d+)?)(n|u|m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$/.exec(raw);
69
72
  if (!m) return null;
@@ -235,7 +238,7 @@ function runPoolDnsProbe(context: string, kubectl: PoolKubectl): PoolDnsProbe {
235
238
  }
236
239
 
237
240
  /** Each cluster reads resource reservations; DNS requires a bounded disposable Pod. */
238
- export function measurePoolLease(members: readonly PodPoolMember[], deps: { kubectl?: PoolKubectl; dns?: (context: string) => PoolDnsProbe } = {}): PoolLeaseMeasure {
241
+ export function measurePoolLease(members: readonly PodPoolMember[], deps: { kubectl?: PoolKubectl; dns?: (context: string) => PoolDnsProbe; /** Read-only snapshots must not create the disposable DNS Pod or update its cache. */ skipDnsProbe?: boolean } = {}): PoolLeaseMeasure {
239
242
  const kubectl = deps.kubectl ?? leaseKubectl;
240
243
  return { members: members.map((member) => {
241
244
  const base = ['--context', member.context, '--request-timeout=10s'];
@@ -354,7 +357,7 @@ export function measurePoolLease(members: readonly PodPoolMember[], deps: { kube
354
357
  else if (!reservationsValid && !unknownPhase) reasons.push('cluster reservations: node assignment or memory request/limit missing/invalid');
355
358
  }
356
359
  result.reason = reasons.length ? reasons.join('; ').slice(0, 240) : null;
357
- if (nodeItems?.length && jobItems && podItems) {
360
+ if (!deps.skipDnsProbe && nodeItems?.length && jobItems && podItems) {
358
361
  let dnsState: PoolDnsProbe = 'unknown';
359
362
  try { dnsState = (deps.dns ?? ((context) => probePoolDns(context, kubectl)))(member.context); } catch { /* probe could not be measured */ }
360
363
  if (dnsState === 'dns') { result.capacity = 0; result.availableMemoryByNodeBytes = []; result.reason = 'dns'; }
@@ -14,7 +14,8 @@
14
14
  * 풀을 안 주면 종전처럼 «현재 컨텍스트» 하나다(동작 불변).
15
15
  */
16
16
  import { spawn, spawnSync } from 'node:child_process';
17
- import { existsSync } from 'node:fs';
17
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
18
19
  import { join, resolve } from 'node:path';
19
20
  import { debug } from '../../debug/log.js';
20
21
  import { PodLeaseAdmission, type PodLeasePredecessor, type PodLeaseRelease } from '../../pod-lease/admission.js';
@@ -186,7 +187,8 @@ export class PodPoolScheduler {
186
187
  private readonly hostLease: HostPoolLease;
187
188
  private readonly pollMs: number;
188
189
  private readonly occupancy: () => Record<string, MemberOccupancy> | Promise<Record<string, MemberOccupancy>>;
189
- constructor(readonly members: readonly PodPoolMember[], options: { status?: () => PoolLeaseRecommendation | Promise<PoolLeaseRecommendation>; kubectl?: PoolKubectl; dns?: (context: string) => PoolDnsProbe; pollMs?: number; hostLease?: HostPoolLease; occupancy?: () => Record<string, MemberOccupancy> | Promise<Record<string, MemberOccupancy>>; dependencyMerged?: (after: PodLeasePredecessor, signal?: AbortSignal) => PredecessorState | boolean | Promise<PredecessorState | boolean> } = {}) {
190
+ private readonly memberMemory: () => Array<{ context: string; allocatableMemoryBytes: number | null }> | Promise<Array<{ context: string; allocatableMemoryBytes: number | null }>>;
191
+ constructor(readonly members: readonly PodPoolMember[], options: { status?: () => PoolLeaseRecommendation | Promise<PoolLeaseRecommendation>; kubectl?: PoolKubectl; dns?: (context: string) => PoolDnsProbe; pollMs?: number; hostLease?: HostPoolLease; memberMemory?: () => Array<{ context: string; allocatableMemoryBytes: number | null }> | Promise<Array<{ context: string; allocatableMemoryBytes: number | null }>>; occupancy?: () => Record<string, MemberOccupancy> | Promise<Record<string, MemberOccupancy>>; dependencyMerged?: (after: PodLeasePredecessor, signal?: AbortSignal) => PredecessorState | boolean | Promise<PredecessorState | boolean> } = {}) {
190
192
  this.pollMs = options.pollMs ?? 15_000;
191
193
  // One lease directory per pool (contexts, not caps) — every CLI process launching into the same clusters shares it.
192
194
  this.hostLease = options.hostLease ?? podPoolHostLease(members);
@@ -195,6 +197,9 @@ export class PodPoolScheduler {
195
197
  return recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 });
196
198
  });
197
199
  this.measure = async () => measureStatus();
200
+ // An injected status (tests, callers with their own measurement) without memberMemory never shells out to kubectl here.
201
+ this.memberMemory = options.memberMemory ?? (options.status ? (() => []) : () => measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }).members
202
+ .map((m: PodLeaseMember) => ({ context: m.context, allocatableMemoryBytes: m.allocatableMemoryBytes })));
198
203
  this.occupancy = options.occupancy ?? (() => measureMemberOccupancy(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }));
199
204
  this.admission = new PodLeaseAdmission({ status: async () => {
200
205
  const measured = await measureStatus();
@@ -267,6 +272,23 @@ export class PodPoolScheduler {
267
272
  };
268
273
  });
269
274
  }
275
+ /**
276
+ * LAUNCH-STALL (10-06): members whose total allocatable memory is below one Job's limit can never take it —
277
+ * waiting for them is an endless silent stall (minio 12.5GB vs a 16Gi standard Job). A member whose memory
278
+ * could not be read is not judged unfit (unknown ≠ too small).
279
+ */
280
+ async unfitMembers(limitBytes: number): Promise<Array<{ context: string; allocatableMemoryBytes: number }>> {
281
+ let rows: Array<{ context: string; allocatableMemoryBytes: number | null }>;
282
+ try { rows = await this.memberMemory(); } catch { return []; }
283
+ return rows.flatMap((r) => r.allocatableMemoryBytes !== null && r.allocatableMemoryBytes < limitBytes
284
+ ? [{ context: r.context, allocatableMemoryBytes: r.allocatableMemoryBytes }] : []);
285
+ }
286
+ /** Why admission is still waiting: the pool-wide lease measurement (null before any reading). Slot waits get their reason from tryAcquire's onEmpty. */
287
+ waitReason(): string | null {
288
+ const r = this.lastRaw;
289
+ if (!r) return null;
290
+ return `recommended=${r.recommended ?? 'unknown'} limitedBy=${r.limitedBy ?? '-'} memorySlots=${r.memorySlots ?? '?'}${r.reason ? ` reason=${r.reason}` : ''}`;
291
+ }
270
292
  admissionSnapshot(): ReturnType<PodLeaseAdmission['snapshot']> {
271
293
  return this.admission.snapshot();
272
294
  }
@@ -277,7 +299,7 @@ export class PodPoolScheduler {
277
299
  * A member whose occupancy could not be read falls back to inflight-only (never treated as zero occupied).
278
300
  * Ties keep the configured member order. Returns null when every readable member is full.
279
301
  */
280
- async tryAcquire(): Promise<PodPoolMember | null> {
302
+ async tryAcquire(skip?: ReadonlySet<string>, onEmpty?: (reason: string) => void): Promise<PodPoolMember | null> {
281
303
  let reading: Record<string, MemberOccupancy>;
282
304
  try { reading = await this.occupancy(); }
283
305
  catch (error) {
@@ -293,6 +315,8 @@ export class PodPoolScheduler {
293
315
  || (cand.measured !== best.measured ? cand.measured
294
316
  : cand.slots / cand.member.capacity > best.slots / best.member.capacity);
295
317
  for (const m of this.members) {
318
+ // LAUNCH-STALL: a member this launch cannot fit is skipped for this call only (other launches may fit it).
319
+ if (skip?.has(m.context)) continue;
296
320
  const inflight = this.inflight.get(m.context) ?? 0;
297
321
  const row = reading[m.context];
298
322
  const ok = !!row && row.occupied !== null;
@@ -304,7 +328,10 @@ export class PodPoolScheduler {
304
328
  const cand = { member: m, slots, measured: ok };
305
329
  if (better(cand)) best = cand;
306
330
  }
307
- if (!best) return null;
331
+ if (!best) {
332
+ onEmpty?.(`no free slot · free=${JSON.stringify(free)} · measured=${JSON.stringify(measured)}${skip?.size ? ` · skipped=${[...skip].join(',')}` : ''}`);
333
+ return null;
334
+ }
308
335
  this.inflight.set(best.member.context, (this.inflight.get(best.member.context) ?? 0) + 1);
309
336
  const share = Object.fromEntries(this.members.map((m) => [m.context, Math.round(Math.max(0, free[m.context] ?? 0) / m.capacity * 100) / 100]));
310
337
  debug.log('pod.pool', 'member-selected', { member: best.member.context, free, measured, share });
@@ -396,13 +423,14 @@ export function podImageBuildScript(cwd: string = process.cwd()): string | null
396
423
  return null;
397
424
  }
398
425
 
399
- type RemoteBuild = (host: string, cluster: string, registry?: string) => Promise<{ ok: boolean; detail: string }>;
426
+ /** packedTgz = 풀 동기화가 한 번 만든 패키지 묶음(있으면 build.sh 가 pack 을 다시 하지 않는다). */
427
+ type RemoteBuild = (host: string, cluster: string, registry?: string, packedTgz?: string) => Promise<{ ok: boolean; detail: string }>;
400
428
 
401
- function defaultRemoteBuild(script: string, image: string): RemoteBuild {
402
- return (host, cluster, registry) => new Promise((done) => {
403
- const env: NodeJS.ProcessEnv = { ...process.env, ELANOUS_BUILD_REMOTE: host, ELANOUS_L2_CLUSTER: cluster, ELANOUS_BUILD_IMAGE: image === 'elanous-harness-lite:local' ? 'lite' : 'full' };
404
- delete env.ELANOUS_BUILD_REGISTRY;
405
- if (registry) env.ELANOUS_BUILD_REGISTRY = registry;
429
+ /** 같은 트리의 패키지 묶음(tgz)을 «한 번» 만든다 — 멤버들이 같이 쓴다. cleanup 은 동기화가 끝나면 부른다. */
430
+ type PoolPack = () => Promise<{ ok: boolean; tgz?: string; detail: string; cleanup?: () => void }>;
431
+
432
+ function runBuildScript(script: string, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; detail: string }> {
433
+ return new Promise((done) => {
406
434
  const child = spawn('bash', [script], { env, stdio: ['ignore', 'pipe', 'pipe'] });
407
435
  let out = '';
408
436
  child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { out += d; });
@@ -411,18 +439,53 @@ function defaultRemoteBuild(script: string, image: string): RemoteBuild {
411
439
  });
412
440
  }
413
441
 
442
+ /**
443
+ * 🩸 2026-10-06: 멤버마다 build.sh 가 같은 트리에서 `bun pm pack` 을 «동시에» 돌렸다 — prepack 이 트리에 쓰는
444
+ * src/version/packed-revision.json 을 다른 쪽 postpack 이 지워 한쪽이 «⛔ pack» 으로 죽었다(node-c 가 매 발사 빠졌다).
445
+ * ⇒ 원격 멤버가 둘 이상이면 pack 은 여기서 한 번만 하고 그 tgz 를 나눠 준다.
446
+ */
447
+ function defaultPoolPack(script: string): PoolPack {
448
+ return async () => {
449
+ const dir = mkdtempSync(join(tmpdir(), 'elanous-pool-pack-'));
450
+ const tgz = join(dir, 'elanous.tgz');
451
+ const env: NodeJS.ProcessEnv = { ...process.env, ELANOUS_BUILD_PACK_ONLY: tgz };
452
+ delete env.ELANOUS_BUILD_PACKED_TGZ;
453
+ delete env.ELANOUS_BUILD_REMOTE;
454
+ const r = await runBuildScript(script, env);
455
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
456
+ if (!r.ok || !existsSync(tgz)) { cleanup(); return { ok: false, detail: r.detail }; }
457
+ return { ok: true, tgz, detail: r.detail, cleanup };
458
+ };
459
+ }
460
+
461
+ function defaultRemoteBuild(script: string, image: string): RemoteBuild {
462
+ return (host, cluster, registry, packedTgz) => {
463
+ const env: NodeJS.ProcessEnv = { ...process.env, ELANOUS_BUILD_REMOTE: host, ELANOUS_L2_CLUSTER: cluster, ELANOUS_BUILD_IMAGE: image === 'elanous-harness-lite:local' ? 'lite' : 'full' };
464
+ delete env.ELANOUS_BUILD_REGISTRY;
465
+ if (registry) env.ELANOUS_BUILD_REGISTRY = registry;
466
+ delete env.ELANOUS_BUILD_PACK_ONLY;
467
+ delete env.ELANOUS_BUILD_PACKED_TGZ;
468
+ if (packedTgz) env.ELANOUS_BUILD_PACKED_TGZ = packedTgz;
469
+ return runBuildScript(script, env);
470
+ };
471
+ }
414
472
  /**
415
473
  * 풀의 원격 노드들을 «동시에» 이 트리의 판으로 맞춘다 (09-25 개선).
416
474
  * 1순위 = 노드 «쪽에서» 빌드(build.sh ELANOUS_BUILD_REMOTE) — 빌드 재료(수십 MB)만 보내고 무거운 층은 그 노드의 캐시가 재사용한다.
417
475
  * 📏 커밋이 바뀐 뒤 두 노드를 올리는 데 벽시계 55초(종전: 다시 굽기 1분 40초 ⊕ 4GB 차례 전송 5분 48초).
418
476
  * 실패하면 = 종전의 통째 전송(syncPoolImage)으로 떨어진다.
419
477
  */
420
- export async function syncPoolImages(members: readonly PodPoolMember[], image: string, localCommit: string | null, deps: { run?: RemoteRun; remoteBuild?: RemoteBuild; buildScript?: string | null; ship?: typeof syncPoolImage; localSkillsDigest?: string | null; waitIntervalMs?: number; waitMaxMs?: number; now?: () => number; sleep?: (ms: number) => Promise<void>; inspect?: LocalImageInspect; transfer?: ImageShip } = {}): Promise<Map<string, PoolImageSync>> {
478
+ export async function syncPoolImages(members: readonly PodPoolMember[], image: string, localCommit: string | null, deps: { run?: RemoteRun; remoteBuild?: RemoteBuild; buildScript?: string | null; ship?: typeof syncPoolImage; localSkillsDigest?: string | null; waitIntervalMs?: number; waitMaxMs?: number; now?: () => number; sleep?: (ms: number) => Promise<void>; inspect?: LocalImageInspect; transfer?: ImageShip; pack?: PoolPack | null } = {}): Promise<Map<string, PoolImageSync>> {
421
479
  const run = deps.run ?? defaultRemoteRun;
422
480
  const script = deps.buildScript === undefined ? podImageBuildScript() : deps.buildScript;
423
481
  const remoteBuild = deps.remoteBuild ?? (script ? defaultRemoteBuild(script, image) : null);
424
482
  const ship = deps.ship ?? ((m: PodPoolMember, img: string, commit: string | null, remote: RemoteRun) => syncPoolImage(m, img, commit, { run: remote, inspect: deps.inspect, transfer: deps.transfer }));
425
483
  const results = new Map<string, PoolImageSync>();
484
+ // pack 한 번 ⊕ 나눠 쓰기 — 원격 멤버가 둘 이상일 때만(하나뿐이면 종전대로 build.sh 가 스스로 pack 한다).
485
+ // 주입된 remoteBuild(시험)에는 진짜 pack 을 붙이지 않는다 — pack 도 주입해야 쓴다.
486
+ const pack = deps.pack !== undefined ? deps.pack : (!deps.remoteBuild && script ? defaultPoolPack(script) : null);
487
+ const sharePack = pack && members.filter((m) => m.sshHost).length >= 2 ? pack : null;
488
+ let packed: ReturnType<PoolPack> | null = null;
426
489
  await Promise.all(members.map(async (m) => {
427
490
  const t0 = Date.now();
428
491
  if (!m.sshHost) { results.set(m.context, { ok: true, action: 'local', detail: 'this machine', ms: 0 }); return; }
@@ -437,7 +500,12 @@ export async function syncPoolImages(members: readonly PodPoolMember[], image: s
437
500
  return;
438
501
  }
439
502
  if (remoteBuild) {
440
- const b = await remoteBuild(m.sshHost, m.k3dCluster, m.registry);
503
+ // 같은 판이 아닌 멤버가 처음 여기 닿을 때 pack 한다(전부 fresh 면 pack 도 없다).
504
+ const shared = sharePack ? await (packed ??= sharePack()) : null;
505
+ if (shared && !shared.ok) debug.log('self-implement.pod', 'pool-image-pack-failed', { context: m.context, detail: shared.detail });
506
+ const b = shared && !shared.ok
507
+ ? { ok: false, detail: shared.detail }
508
+ : await remoteBuild(m.sshHost, m.k3dCluster, m.registry, shared?.tgz);
441
509
  const after = remoteImageCommit(m, image, run);
442
510
  const refAfter = registryImageRef(m, localCommit ?? after, run, image);
443
511
  // ⭐ 레지스트리 노드면 «이 판 커밋 태그가 레지스트리에 있나»로 판정한다 — Pod 는 그 커밋 태그를 pull 한다.
@@ -462,6 +530,6 @@ export async function syncPoolImages(members: readonly PodPoolMember[], image: s
462
530
  return;
463
531
  }
464
532
  results.set(m.context, { ...ship(m, image, localCommit, run), ms: Date.now() - t0 });
465
- }));
533
+ })).finally(async () => { if (packed) (await packed).cleanup?.(); });
466
534
  return results;
467
535
  }