elanous 0.2.11 → 0.2.12

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 (246) 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/1147-466ec15afacc1a65.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/{1394-556de6d71bbdb0fb.js → 1394-ee3795b112907122.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/3869-3cd46526879f454c.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/7758-0b018a9e4fc56200.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8605-ea5bee9dfae512c7.js → 8605-0259994816604c49.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/approvals/page-cb169620945d1952.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-6a061bd97f0b9398.js → page-cefe8169e36cf62a.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/board/{page-cb95a7432c9ee744.js → page-d2e35228bc497119.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-b12b6d2f8d2d1aea.js → page-5c05b0da4bc1392c.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/bots/{page-b9efe868bfd36255.js → page-e0ccdb576812b523.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/chat/{page-7c97dcdfe2e37a60.js → page-813c4a52c9d05ca8.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/consult/page-4731f2c215358937.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/control/{page-5721537ca8a3598c.js → page-a022b257950f39e6.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-b654240a572e4c48.js → page-a2ffc717e04b8b0a.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/editor/{page-b9750abc897508c3.js → page-b59ee4e70fc18fcf.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/field/page-c172dabd796eb7a3.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/inside/page-1efd0cb9e0ed9d13.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/intake/{page-c8b1ccda6ba7ae98.js → page-d5dc7ff69a37b4cf.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/live/{page-2a9932ddb62d76bd.js → page-1d6a533ce5a6efcb.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/market/{page-9688f5b4f1ff950f.js → page-dfca9b7d2de0b135.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-793fa536b3a6ef37.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-35414e0a007b4082.js → page-6dd3bfbbde2a5c2c.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-e7a529e0507ac86c.js → not-found-490653e8d4cc8852.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-e001823c35c9dd67.js → page-77447356baad75d2.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-3a24072b35470157.js → page-6d604227e1664b71.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-0be8d4b2ced75ba0.js → page-4048543b4b7ad5e0.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-d37cb0d52f9b6bd5.js → page-4c3a3126abed551a.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-686da0df9bf23e5c.js → page-64b0aecb1ca75e75.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-53f80edc6b8dc07c.js → page-db14d0be7f1c460c.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/setup/{page-239c17b911b84e72.js → page-9547b5cec7cf76bf.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-1218753c82d585f6.js → page-2cddeb9e2aeedc55.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/term/{page-4b5935e67531efb5.js → page-e297866051595d81.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/today/{page-3285bc87caf1eaa5.js → page-baba2e047872fb0f.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/trace/{page-ad553b0e06505ecd.js → page-e895da4ec9cac345.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/page-2e9e2fb01c9da1c9.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-d62ceb13411ebba4.js +1 -0
  54. package/apps/pwa/out/_next/static/css/128fb7fb670b2fbf.css +1 -0
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +14 -14
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +14 -14
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +14 -14
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +14 -14
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +14 -14
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +14 -14
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +14 -14
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +14 -14
  71. package/apps/pwa/out/design-check/index.html +1 -1
  72. package/apps/pwa/out/design-check/index.txt +14 -14
  73. package/apps/pwa/out/editor/index.html +1 -1
  74. package/apps/pwa/out/editor/index.txt +14 -14
  75. package/apps/pwa/out/exec/index.html +1 -1
  76. package/apps/pwa/out/exec/index.txt +14 -14
  77. package/apps/pwa/out/field/index.html +1 -1
  78. package/apps/pwa/out/field/index.txt +14 -14
  79. package/apps/pwa/out/healthz +1 -1
  80. package/apps/pwa/out/index.html +1 -1
  81. package/apps/pwa/out/index.txt +14 -14
  82. package/apps/pwa/out/inside/index.html +1 -1
  83. package/apps/pwa/out/inside/index.txt +14 -14
  84. package/apps/pwa/out/intake/index.html +1 -1
  85. package/apps/pwa/out/intake/index.txt +14 -14
  86. package/apps/pwa/out/live/index.html +1 -1
  87. package/apps/pwa/out/live/index.txt +14 -14
  88. package/apps/pwa/out/market/index.html +1 -1
  89. package/apps/pwa/out/market/index.txt +14 -14
  90. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  91. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  92. package/apps/pwa/out/morning/index.html +1 -1
  93. package/apps/pwa/out/morning/index.txt +14 -14
  94. package/apps/pwa/out/observatory/index.html +1 -1
  95. package/apps/pwa/out/observatory/index.txt +14 -14
  96. package/apps/pwa/out/ops/checklist/index.html +1 -1
  97. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  98. package/apps/pwa/out/ops/release/index.html +1 -1
  99. package/apps/pwa/out/ops/release/index.txt +14 -14
  100. package/apps/pwa/out/ops/seats/index.html +1 -1
  101. package/apps/pwa/out/ops/seats/index.txt +14 -14
  102. package/apps/pwa/out/outputs/index.html +1 -1
  103. package/apps/pwa/out/outputs/index.txt +14 -14
  104. package/apps/pwa/out/reflection/index.html +1 -1
  105. package/apps/pwa/out/reflection/index.txt +15 -15
  106. package/apps/pwa/out/scheduler/index.html +1 -1
  107. package/apps/pwa/out/scheduler/index.txt +14 -14
  108. package/apps/pwa/out/sessions/index.html +1 -1
  109. package/apps/pwa/out/sessions/index.txt +14 -14
  110. package/apps/pwa/out/settings/devices/index.html +1 -1
  111. package/apps/pwa/out/settings/devices/index.txt +14 -14
  112. package/apps/pwa/out/settings/index.html +1 -1
  113. package/apps/pwa/out/settings/index.txt +14 -14
  114. package/apps/pwa/out/setup/done/index.html +1 -1
  115. package/apps/pwa/out/setup/done/index.txt +14 -14
  116. package/apps/pwa/out/setup/index.html +1 -1
  117. package/apps/pwa/out/setup/index.txt +14 -14
  118. package/apps/pwa/out/share/index.html +1 -1
  119. package/apps/pwa/out/share/index.txt +14 -14
  120. package/apps/pwa/out/showroom/index.html +1 -1
  121. package/apps/pwa/out/showroom/index.txt +14 -14
  122. package/apps/pwa/out/tasks/index.html +1 -1
  123. package/apps/pwa/out/tasks/index.txt +14 -14
  124. package/apps/pwa/out/term/index.html +1 -1
  125. package/apps/pwa/out/term/index.txt +14 -14
  126. package/apps/pwa/out/today/index.html +1 -1
  127. package/apps/pwa/out/today/index.txt +14 -14
  128. package/apps/pwa/out/trace/index.html +1 -1
  129. package/apps/pwa/out/trace/index.txt +14 -14
  130. package/apps/pwa/out/vault/index.html +1 -1
  131. package/apps/pwa/out/vault/index.txt +14 -14
  132. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  133. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  134. package/apps/pwa/out/workflows/index.html +1 -1
  135. package/apps/pwa/out/workflows/index.txt +14 -14
  136. package/apps/pwa/out/workspace/index.html +1 -1
  137. package/apps/pwa/out/workspace/index.txt +14 -14
  138. package/apps/pwa/out/worktrees/index.html +1 -1
  139. package/apps/pwa/out/worktrees/index.txt +14 -14
  140. package/graphs/context/nightly-condense.yaml +21 -0
  141. package/graphs/context/recipes.yaml +12 -0
  142. package/graphs/demo/inside-seed.yaml +20 -0
  143. package/graphs/demo/recipes.yaml +12 -0
  144. package/package.json +1 -1
  145. package/plugins/card-followup/examples/sample-card.png +0 -0
  146. package/scripts/lib/telegram-inject-args.ts +31 -0
  147. package/scripts/measure-pod-memory-by-goal.ts +130 -0
  148. package/scripts/release-loop/auto-approve-node.ts +1 -1
  149. package/scripts/release-loop/checklist-gate-node.ts +2 -1
  150. package/scripts/release-loop/gate-node.ts +8 -2
  151. package/scripts/release-loop/ops-upgrade-node.ts +47 -6
  152. package/scripts/release-loop/publish-node.ts +4 -1
  153. package/scripts/release-loop/version-node.ts +13 -3
  154. package/scripts/telegram-inject.ts +14 -6
  155. package/scripts/test-diet/remeasure-list.ts +112 -0
  156. package/scripts/ux/demo-rehearsal.ts +17 -13
  157. package/scripts/ux/demo-seed.ts +108 -0
  158. package/scripts/ux/lib/rehearsal-verdict.ts +12 -5
  159. package/src/agent-mission/driver.ts +138 -16
  160. package/src/agent-mission/pty-decision.ts +1 -1
  161. package/src/autopilot/pty-control-loop.ts +4 -1
  162. package/src/chat/index.ts +1 -0
  163. package/src/cli/persona-cli.ts +17 -14
  164. package/src/cli/pod-cli.ts +17 -0
  165. package/src/cli/pod-memory-advice.ts +35 -0
  166. package/src/cli/release-cli.ts +46 -6
  167. package/src/context-bus/context-now-surfaces.ts +17 -0
  168. package/src/context-bus/context-now.ts +2 -2
  169. package/src/context-bus/coord-events.ts +2 -2
  170. package/src/context-bus/nightly-condense.ts +153 -0
  171. package/src/context-bus/session-events.ts +35 -0
  172. package/src/dashboard/dashboard-status-lines.ts +2 -0
  173. package/src/dashboard/index.ts +8 -0
  174. package/src/dashboard/slash-runtime/dashboard-handlers.ts +24 -0
  175. package/src/domains/outbound-alert.ts +99 -31
  176. package/src/domains/outbound-log-probe.ts +8 -0
  177. package/src/execution-loop/launch-gate.ts +17 -8
  178. package/src/field-note/telegram.ts +142 -0
  179. package/src/graph-runner/graph-grow-llm.ts +23 -13
  180. package/src/graph-runner/graph-grow.ts +5 -2
  181. package/src/graph-runner/runner.ts +12 -8
  182. package/src/harness/harness-cli-command.ts +138 -16
  183. package/src/hooks/consult-intake.ts +153 -0
  184. package/src/hooks/receiver.ts +78 -1
  185. package/src/index.ts +33 -9
  186. package/src/intake-plane/discord-seat-work.ts +28 -2
  187. package/src/intake-plane/wish-card.ts +33 -0
  188. package/src/llm/difficulty.ts +1 -1
  189. package/src/maturity/feature-maturity.ts +2 -1
  190. package/src/nexus/api/http-server.ts +31 -8
  191. package/src/nexus/api/outbound-report.ts +40 -7
  192. package/src/nexus/api/personas.ts +95 -53
  193. package/src/nexus/api/task-cards-api.ts +19 -0
  194. package/src/nexus/api/workflow-history.ts +88 -0
  195. package/src/nexus/api/workflows.ts +52 -0
  196. package/src/nexus/outbound/delivery-ledger.ts +17 -8
  197. package/src/nexus/outbound/router.ts +8 -3
  198. package/src/persona/presets.ts +8 -1
  199. package/src/plugins/maker/market-bundle.ts +2 -1
  200. package/src/release-loop/checklist.ts +15 -3
  201. package/src/release-loop/feature-store.ts +24 -10
  202. package/src/release-loop/landed-but-yellow.ts +48 -0
  203. package/src/seat-loop/seat-loop.ts +168 -7
  204. package/src/self-dev/draft-sweep.ts +44 -10
  205. package/src/self-dev/draft-triage-rules.ts +54 -2
  206. package/src/self-implement/budget-gate.ts +29 -12
  207. package/src/self-implement/gate-candidate-ranking.ts +60 -0
  208. package/src/self-implement/gate-scope.ts +9 -8
  209. package/src/self-implement/headless-elanous-driver.ts +7 -2
  210. package/src/self-implement/orchestrator.ts +15 -0
  211. package/src/self-implement/test-seams.ts +1 -0
  212. package/src/task-orchestrator/surfaces/pod-command-job.ts +14 -10
  213. package/src/task-orchestrator/surfaces/self-implement-pod.ts +8 -0
  214. package/src/telegram-commands.ts +21 -0
  215. package/src/telegram-project-command.ts +78 -4
  216. package/src/telegram.ts +32 -5
  217. package/src/user-config.ts +8 -4
  218. package/src/version/packed-revision.json +1 -1
  219. package/apps/pwa/out/_next/static/chunks/2310-7a59f043f6db207e.js +0 -1
  220. package/apps/pwa/out/_next/static/chunks/2548-6e316138d347cefe.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/3869-a39d1d9921e10d43.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/507-5acd98bd6dab2d8a.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/5445-745eb795c5963d79.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/approvals/page-1dcdc58fc2de2454.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/consult/page-8fd36984f549270a.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/exec/page-4cd1de371f8ad048.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/field/page-d99f89657828421c.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/inside/page-50cb2a0eb15e6c83.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/layout-d6b1d156fb95e74b.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-6b15dd6456c5df09.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-371423d1a573d64b.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4b764b125339ac17.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cdf8e4b01067bcf1.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/page-94c82eb0ae5abb32.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4dc614f3ce043e63.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d02c782fc792e2af.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/share/page-dc6a79fb003f746c.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/showroom/page-0fffb84cafdc9da6.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/vault/page-fca096e3e860306f.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-9ebfa78f0d9885c3.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/workflows/page-3dc5f21f7e39622d.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/workspace/page-c6da2b468454532d.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-93bc70e36f595414.js +0 -1
  244. package/apps/pwa/out/_next/static/css/543f56bcb1f79fca.css +0 -1
  245. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_buildManifest.js +0 -0
  246. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_ssgManifest.js +0 -0
@@ -69,7 +69,7 @@ function safeSegment(value: string): string {
69
69
  return value;
70
70
  }
71
71
 
72
- type CommandRecipe = { command: string; timeout_ms?: number };
72
+ type CommandRecipe = { command: string; dry_run_command?: string; timeout_ms?: number };
73
73
  type Recipe = CommandRecipe | { approval: string };
74
74
 
75
75
  /** 접두 없는 recipe 가 카탈로그 역할이고, 그래프 옆 recipes.yaml 에 같은 키의 `{ command }` 가 있을 때만 cmd 처럼 실행한다. */
@@ -117,10 +117,13 @@ function recipesFor(path: string, recipeSource: string): Record<string, Recipe>
117
117
  recipes[id] = { approval: item.approval };
118
118
  } else {
119
119
  if (typeof item.command !== 'string' || !item.command.trim() ||
120
- (item.timeout_ms !== undefined && (!Number.isSafeInteger(item.timeout_ms) || (item.timeout_ms as number) <= 0))) {
120
+ (item.timeout_ms !== undefined && (!Number.isSafeInteger(item.timeout_ms) || (item.timeout_ms as number) <= 0)) ||
121
+ (item.dry_run_command !== undefined && (typeof item.dry_run_command !== 'string' || !item.dry_run_command.trim()))) {
121
122
  throw new Error(`invalid recipe: ${id}`);
122
123
  }
123
- recipes[id] = { command: item.command, ...(item.timeout_ms === undefined ? {} : { timeout_ms: item.timeout_ms as number }) };
124
+ recipes[id] = { command: item.command,
125
+ ...(item.dry_run_command === undefined ? {} : { dry_run_command: item.dry_run_command as string }),
126
+ ...(item.timeout_ms === undefined ? {} : { timeout_ms: item.timeout_ms as number }) };
124
127
  }
125
128
  }
126
129
  return recipes;
@@ -590,7 +593,7 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
590
593
  let exit: number | null = null;
591
594
  let error: string | undefined;
592
595
  let output: unknown;
593
- if (command && !state.dryRun && !resumingCompleted) {
596
+ if (command && (!state.dryRun || command.dry_run_command) && !resumingCompleted) {
594
597
  const outputs: Record<string, unknown> = Object.create(null);
595
598
  // 구조 산출(마지막 JSON 줄)이 있으면 그것을, 없으면 원문을 준다 — 다음 노드가 판단을 «값»으로 받는다.
596
599
  for (const previous of state.nodes) outputs[previous.nodeId] = lastJsonObject(previous.output) ?? previous.output ?? null;
@@ -635,9 +638,10 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
635
638
  debug.log('graph-runner', 'child-universe-pinned', { graphId, runId, node: current, configDir: env.ELANOUS_CONFIG_DIR, stateDir: env.ELANOUS_STATE_DIR });
636
639
  }
637
640
  env.ELANOUS_GRAPH_CONTEXT = contextPath;
641
+ env.ELANOUS_GRAPH_DRY_RUN = state.dryRun ? '1' : '0';
638
642
  env.ELANOUS_GRAPH_DIR = dirname(resolve(path));
639
643
  const ctx = { arguments: '', artifactsDir: dirname(statePath), outputs: {}, resolvedProvider: undefined, resolvedModel: undefined, toolPolicy: {}, env } as NodeExecContext;
640
- const result = await executeBashNode({ id: current, type: 'bash', bash: command.command, ...(command.timeout_ms ? { idle_timeout: command.timeout_ms } : {}) } as BashNode, ctx, { runBash } as WorkflowDeps);
644
+ const result = await executeBashNode({ id: current, type: 'bash', bash: state.dryRun ? command.dry_run_command! : command.command, ...(command.timeout_ms ? { idle_timeout: command.timeout_ms } : {}) } as BashNode, ctx, { runBash } as WorkflowDeps);
641
645
  output = result.output;
642
646
  ok = result.ok;
643
647
  exit = code;
@@ -654,14 +658,14 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
654
658
  if (approval && !state.dryRun && !command) {
655
659
  output = JSON.stringify({ outcome: ok ? 'approved' : 'rejected', ...(state.pending?.decidedBy === undefined ? {} : { decidedBy: state.pending.decidedBy }), decidedAt: state.pending?.decidedAt ?? null });
656
660
  }
657
- state.nodes.push({ nodeId: current, ok, exit, executed: !!command && !state.dryRun, ...((command || approval) && !state.dryRun && output !== undefined ? { output } : {}), ...(error ? { error } : {}),
661
+ state.nodes.push({ nodeId: current, ok, exit, executed: !!command && (!state.dryRun || !!command.dry_run_command), ...((command || approval) && (!state.dryRun || !!command?.dry_run_command) && output !== undefined ? { output } : {}), ...(error ? { error } : {}),
658
662
  ...(approval && state.pending ? { ...(state.pending.decidedBy === undefined ? {} : { decidedBy: state.pending.decidedBy }), decidedAt: state.pending.decidedAt } : {}) });
659
663
  }
660
664
  if (approval) {
661
665
  delete state.pending;
662
666
  delete state.approvalSourceHash;
663
667
  }
664
- const reported = command && !state.dryRun ? lastJsonObject(resumingCompleted ? completed?.output : output)?.outcome : undefined;
668
+ const reported = command && (!state.dryRun || command.dry_run_command) ? lastJsonObject(resumingCompleted ? completed?.output : output)?.outcome : undefined;
665
669
  const namedOutcome = typeof reported === 'string' ? reported : undefined;
666
670
  const seconds = Number(((performance.now() - startedAt) / 1000).toFixed(2));
667
671
  console.error(`[graph] ${current} ${ok ? 'ok' : 'fail'} (${seconds.toFixed(2)}s)`);
@@ -709,7 +713,7 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
709
713
  }
710
714
  persist();
711
715
  }
712
- current = nextNode(graph.edges, current, state.dryRun ? 'ok' : ok ? 'ok' : 'fail', state.dryRun ? undefined : namedOutcome);
716
+ current = nextNode(graph.edges, current, state.dryRun && !command?.dry_run_command ? 'ok' : ok ? 'ok' : 'fail', namedOutcome);
713
717
  if (current !== undefined) publishInsideEvent({ kind: 'edge', graphId, runId, from: fromNodeId, to: current });
714
718
  if (current === undefined) {
715
719
  state.status = 'failed';
@@ -24,7 +24,7 @@ import { debug } from '../debug/log.js';
24
24
  import { dispatchTask, type DispatchTaskInput, type DispatchTaskDeps } from '../execution-loop/dispatch-task.js';
25
25
  import { launchRequestId, preLaunchGate, type PreLaunchGateDeps } from '../execution-loop/launch-gate.js';
26
26
  import { PR_LABELS } from '../github/pr-labels.js';
27
- import { CODEX_PROVIDER, GROK_PROVIDER, decideBudget, readBudgetInputsLive, type BudgetDecision, type BudgetInputs } from '../self-implement/budget-gate.js';
27
+ import { CODEX_PROVIDER, GROK_PROVIDER, decideBudget, decideLaunchBudget, readBudgetInputsLive, type BudgetDecision, type BudgetInputs } from '../self-implement/budget-gate.js';
28
28
  import { DEFAULT_FALLBACK_CHAIN } from '../oauth/fallback-chain.js';
29
29
  import { installDeliverableVerifyCliCommand, type InstallDeliverableVerifyCliDeps } from './deliverable-verify-cli.js';
30
30
  import { getUserConfig } from '../user-config.js';
@@ -338,6 +338,8 @@ function resolveLaunchSubstrate(opts: HarnessSubstrateOpts): ResolvedHarnessSubs
338
338
  }
339
339
  type PodExit = { status: number | null; signal?: string | null };
340
340
  type PodExitReason = 'signal' | 'human-stop' | 'supervisor' | 'no-launch' | 'unknown';
341
+ /** GitHub lists at most this many files for one pull request; a list that reaches it is not provably complete. */
342
+ export const GITHUB_PR_FILES_LIST_CAP = 3000;
341
343
  const STOPPING_SUPERVISOR_VERDICTS = new Set(['UNCONVERGEABLE', 'CONTRACT-CONFLICT']);
342
344
  // Ledger events that only exist once the child actually ran — any of them rules out «never reached the Pod».
343
345
  const LAUNCH_EVIDENCE_EVENTS = new Set(['job-applied', 'pod-child-run', 'reviewed', 'rework-budget', 'pr-opened']);
@@ -578,8 +580,12 @@ const LAUNCH_BUDGET_PROVIDER: Readonly<Record<string, string>> = { 'codex-rotate
578
580
  export function harnessLaunchBudgetDecision(
579
581
  inputs: BudgetInputs,
580
582
  opts: { readonly childLlmProvider?: string; readonly childLlmModel?: string } = {},
581
- ): { readonly decision: BudgetDecision; readonly warning?: string } {
583
+ ): { readonly decision: BudgetDecision; readonly warning?: string; readonly unmeasuredProvider?: string } {
582
584
  const explicit = opts.childLlmProvider?.trim();
585
+ const judged = (selected: BudgetInputs) => {
586
+ const { decision, unmeasuredProvider } = decideLaunchBudget(selected);
587
+ return { decision, ...(unmeasuredProvider ? { unmeasuredProvider, warning: `budget: ${unmeasuredProvider} usage unmeasured — launching` } : {}) };
588
+ };
583
589
  if (explicit) {
584
590
  if (explicit !== CODEX_PROVIDER && explicit !== GROK_PROVIDER) {
585
591
  return {
@@ -588,13 +594,14 @@ export function harnessLaunchBudgetDecision(
588
594
  };
589
595
  }
590
596
  const chain = [{ provider: explicit, ...(opts.childLlmModel ? { model: opts.childLlmModel } : {}) }];
591
- return { decision: decideBudget({ ...inputs, preference: { ...inputs.preference, chain } }) };
597
+ return judged({ ...inputs, preference: { ...inputs.preference, chain } });
592
598
  }
593
- if (inputs.preference.chain.length > 0) return { decision: decideBudget(inputs) };
599
+ if (inputs.preference.chain.length > 0) return judged(inputs);
594
600
  const chain = DEFAULT_FALLBACK_CHAIN.map((step) => ({ provider: LAUNCH_BUDGET_PROVIDER[step] ?? step }));
601
+ const result = judged({ ...inputs, preference: { ...inputs.preference, chain } });
595
602
  return {
596
- decision: decideBudget({ ...inputs, preference: { ...inputs.preference, chain } }),
597
- warning: `budget: no chain in this universe's config — judged with the code default ${DEFAULT_FALLBACK_CHAIN.join(',')}`,
603
+ ...result,
604
+ warning: `budget: no chain in this universe's config — judged with the code default ${DEFAULT_FALLBACK_CHAIN.join(',')}${result.warning ? ` · ${result.warning}` : ''}`,
598
605
  };
599
606
  }
600
607
 
@@ -602,10 +609,13 @@ async function readHarnessLaunchBudget(
602
609
  opts: { readonly childLlmProvider?: string; readonly childLlmModel?: string } = {},
603
610
  ): Promise<BudgetDecision | 'unknown'> {
604
611
  try {
605
- const { decision, warning } = harnessLaunchBudgetDecision(await readBudgetInputsLive(), opts);
612
+ const { decision, warning, unmeasuredProvider } = harnessLaunchBudgetDecision(await readBudgetInputsLive(), opts);
606
613
  if (warning) {
607
614
  console.error(`⚠️ launch gate: ${warning}`);
608
- try { debug.log('execution-loop.launch-gate', 'budget-chain-defaulted', { warning, action: decision.action, provider: decision.provider }); } catch { /* observation is fail-soft */ }
615
+ try {
616
+ if (unmeasuredProvider) debug.log('execution-loop.launch-gate', 'budget-unmeasured', { provider: unmeasuredProvider });
617
+ if (!opts.childLlmProvider?.trim() && warning.includes('code default')) debug.log('execution-loop.launch-gate', 'budget-chain-defaulted', { warning, action: decision.action, provider: decision.provider });
618
+ } catch { /* observation is fail-soft */ }
609
619
  }
610
620
  return decision;
611
621
  }
@@ -644,8 +654,8 @@ async function dispatchHarnessAskSay(
644
654
  }
645
655
  goalId ??= launchRequestId(identity);
646
656
  // Under bun test with no injected gate deps, skip the live reads (≈5 s of codex rotation + run scans) —
647
- // CLI wiring tests time out on them. Gate tests inject deps and still run the gate (0.2.11 gate 10-03).
648
- if (process.env.NODE_ENV === 'test' && Object.keys(gate).length === 0) {
657
+ // CLI wiring tests time out on them. Gate tests inject deps or opt in with ELANOUS_LAUNCH_GATE_LIVE=1.
658
+ if (process.env.NODE_ENV === 'test' && Object.keys(gate).length === 0 && process.env.ELANOUS_LAUNCH_GATE_LIVE !== '1') {
649
659
  try { debug.log('execution-loop.launch-gate', 'skipped-test-env', { goalId }); } catch { /* observation is fail-soft */ }
650
660
  await dispatch(resolved);
651
661
  return;
@@ -1577,10 +1587,13 @@ type GithubPull = {
1577
1587
  draft: boolean;
1578
1588
  state: 'open' | 'closed';
1579
1589
  head: { ref: string };
1590
+ base?: { ref: string };
1580
1591
  labels: Array<{ name: string }>;
1581
1592
  created_at: string;
1582
1593
  updated_at?: string;
1583
1594
  merged_at: string | null;
1595
+ body?: string | null;
1596
+ merge_commit_sha?: string | null;
1584
1597
  };
1585
1598
 
1586
1599
  type GhExecute = (args: string[]) => string;
@@ -1629,9 +1642,25 @@ export function draftSweepRunStatus(ledgerMatches: readonly RunLedgerMatch[], ru
1629
1642
  ['completed', 'failed', 'cancelled', 'abandoned'].includes(terminal) ? terminal : undefined);
1630
1643
  if (status && status !== 'unknown') statuses.add(status);
1631
1644
  }
1645
+ if (statuses.has('running')) return 'running';
1646
+ if (statuses.has('probable-running')) return 'probable-running';
1632
1647
  return statuses.size === 1 ? [...statuses][0] : undefined;
1633
1648
  }
1634
1649
 
1650
+ function currentDraftSweepRunId(matches: readonly RunLedgerMatch[], repository: string, number: number): string | undefined {
1651
+ const owners = matches.flatMap((match) => {
1652
+ const events = match.entries.filter((entry) => entry.event === 'pr-opened' && entry.data.number === number);
1653
+ if (events.length === 0 || events.some((entry) => ledgerPrRepository(entry.data, number) !== repository)) return [];
1654
+ const times = events.map((entry) => Date.parse(entry.timestamp ?? ''));
1655
+ return [{ runId: match.runId, time: times.every(Number.isFinite) ? Math.max(...times) : NaN }];
1656
+ });
1657
+ if (owners.length === 1) return owners[0]!.runId;
1658
+ if (owners.some((owner) => !Number.isFinite(owner.time))) return undefined;
1659
+ owners.sort((a, b) => b.time - a.time);
1660
+ if (owners.length > 1 && owners[0]!.time === owners[1]!.time) return undefined;
1661
+ return owners[0]?.runId;
1662
+ }
1663
+
1635
1664
  type DraftSweepGitExecute = (cwd: string, args: string[]) => Pick<ReturnType<typeof runGitCommand>, 'status' | 'stdout' | 'stderr'>;
1636
1665
 
1637
1666
  function githubRemoteRepository(url: string): string | undefined {
@@ -1669,24 +1698,117 @@ export function githubDraftSweepAdapters(execute: GhExecute = executeGh, git: Dr
1669
1698
  if (!cache.has(repository)) cache.set(repository, inventory(repository, state));
1670
1699
  return cache.get(repository)!;
1671
1700
  };
1701
+ // GitHub stops listing a pull request's files at 3,000, and the cut looks like a normal last page.
1702
+ // A list that reaches the cap cannot prove it is complete, so it is reported as unknown (undefined):
1703
+ // a draft without a provable file list never qualifies for the all-files-landed close.
1704
+ const filesFor = (repository: string, number: number): string[] | undefined => {
1705
+ const files: string[] = [];
1706
+ for (let page = 1; page <= 100; page++) {
1707
+ const batch = ghJson<Array<{ filename: string }>>(['api', `repos/${repository}/pulls/${number}/files?per_page=100&page=${page}`], execute);
1708
+ if (!Array.isArray(batch) || batch.length > 100 || batch.some((file) => typeof file.filename !== 'string')) throw new Error('Incomplete PR file inventory');
1709
+ files.push(...batch.map((file) => file.filename));
1710
+ if (files.length >= GITHUB_PR_FILES_LIST_CAP) {
1711
+ debug.log('drafts.cleanup', 'file-inventory-capped', { number, listed: files.length });
1712
+ return undefined;
1713
+ }
1714
+ if (batch.length < 100) return files;
1715
+ }
1716
+ throw new Error('PR file inventory exceeded pagination limit');
1717
+ };
1718
+ const latestFileChangesFor = (draft: SweepDraft, repository: string): Record<string, string> | undefined => {
1719
+ if (!draft.changedFiles?.length) return undefined;
1720
+ const changes: Record<string, string> = {};
1721
+ for (let page = 1; page <= 100; page++) {
1722
+ const batch = ghJson<Array<{ sha: string; commit: { committer: { date: string } } }>>(
1723
+ ['api', `repos/${repository}/pulls/${draft.number}/commits?per_page=100&page=${page}`], execute);
1724
+ if (!Array.isArray(batch) || batch.length > 100) throw new Error('Incomplete draft commit inventory');
1725
+ for (const commit of batch) {
1726
+ const changedAt = commit.commit?.committer?.date;
1727
+ if (!commit.sha || !changedAt || !Number.isFinite(Date.parse(changedAt))) return undefined;
1728
+ const detail = ghJson<{ files?: Array<{ filename: string; previous_filename?: string }> }>(
1729
+ ['api', `repos/${repository}/commits/${commit.sha}?per_page=100`], execute);
1730
+ // GitHub truncates very large commit file lists; never infer coverage from a partial list.
1731
+ if (!Array.isArray(detail.files) || detail.files.length >= 100) return undefined;
1732
+ for (const file of detail.files) {
1733
+ for (const name of [file.filename, file.previous_filename]) {
1734
+ if (!name || !draft.changedFiles.includes(name)) continue;
1735
+ if (!changes[name] || Date.parse(changes[name]) < Date.parse(changedAt)) changes[name] = changedAt;
1736
+ }
1737
+ }
1738
+ }
1739
+ if (batch.length < 100) return draft.changedFiles.every((file) => changes[file]) ? changes : undefined;
1740
+ }
1741
+ return undefined;
1742
+ };
1743
+ const landingCommentsFor = (repository: string, number: number): string[] => {
1744
+ const comments: string[] = [];
1745
+ for (let page = 1; page <= 100; page++) {
1746
+ const batch = ghJson<Array<{ body?: string }>>(['api', `repos/${repository}/issues/${number}/comments?per_page=100&page=${page}`], execute);
1747
+ if (!Array.isArray(batch) || batch.length > 100) throw new Error('Incomplete landing comment inventory');
1748
+ comments.push(...batch.filter((comment) => /\blanding-verified\b/i.test(comment.body ?? '')).map((comment) => comment.body!));
1749
+ if (batch.length < 100) return comments;
1750
+ }
1751
+ throw new Error('Landing comment inventory exceeded pagination limit');
1752
+ };
1672
1753
  let ledgerMatches: readonly RunLedgerMatch[] | undefined;
1673
1754
  let running: ReadonlyMap<string, string> | undefined;
1674
- const runStatusFor = (repository: string, number: number): string | undefined => {
1755
+ const runStatusFor = (draft: SweepDraft, repository: string): string | undefined => {
1675
1756
  ledgerMatches ??= listRunLedgers().matches;
1676
1757
  running ??= new Map(queryRunningRuns().entries.map((entry) => [entry.runId, entry.status]));
1677
- return draftSweepRunStatus(ledgerMatches, running, repository, number);
1758
+ if (draft.runId) {
1759
+ const match = ledgerMatches.find((entry) => entry.runId === draft.runId);
1760
+ return match ? draftSweepRunStatus([match], running, repository, draft.number) : undefined;
1761
+ }
1762
+ return draftSweepRunStatus(ledgerMatches, running, repository, draft.number);
1678
1763
  };
1679
1764
  return {
1680
1765
  listDrafts: async (page, perPage, repository): Promise<SweepDraft[]> =>
1681
1766
  (await listed(open, repository, 'open')).filter((pr) => pr.draft)
1682
1767
  .slice((page - 1) * perPage, page * perPage)
1683
1768
  .map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref,
1684
- labels: pr.labels.map((item) => item.name), createdAt: pr.created_at, ...(pr.updated_at ? { updatedAt: pr.updated_at } : {}) })),
1769
+ labels: pr.labels.map((item) => item.name), createdAt: pr.created_at, body: pr.body ?? undefined,
1770
+ changedFiles: filesFor(repository, pr.number), ...(pr.updated_at ? { updatedAt: pr.updated_at } : {}),
1771
+ runId: currentDraftSweepRunId(ledgerMatches ??= listRunLedgers().matches, repository, pr.number) })),
1685
1772
  listMerged: async (page, perPage, repository): Promise<SweepMergedPr[]> =>
1686
- (await listed(closed, repository, 'closed')).filter((pr) => pr.merged_at !== null)
1773
+ (await listed(closed, repository, 'closed')).filter((pr) => pr.merged_at !== null && pr.base?.ref === 'main')
1687
1774
  .slice((page - 1) * perPage, page * perPage)
1688
- .map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref })),
1689
- getRunStatus: async (draft, repository) => runStatusFor(repository, draft.number),
1775
+ .map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref, body: pr.body ?? undefined,
1776
+ mergedAt: pr.merged_at ?? undefined, changedFiles: filesFor(repository, pr.number),
1777
+ mergeCommitMessage: pr.merge_commit_sha ? ghJson<{ commit: { message: string } }>(
1778
+ ['api', `repos/${repository}/commits/${pr.merge_commit_sha}`], execute).commit.message : undefined,
1779
+ landingVerifiedComments: landingCommentsFor(repository, pr.number) })),
1780
+ getRunStatus: async (draft, repository) => runStatusFor(draft, repository),
1781
+ getLatestFileChanges: async (draft, repository) => latestFileChangesFor(draft, repository),
1782
+ hasFinalRunResult: async (draft, repository) => {
1783
+ ledgerMatches ??= listRunLedgers().matches;
1784
+ const ownedRuns = new Set(ledgerMatches.filter((match) => {
1785
+ const prEvents = match.entries.filter((entry) => entry.event === 'pr-opened' && entry.data.number === draft.number);
1786
+ return prEvents.length > 0 && prEvents.every((entry) => ledgerPrRepository(entry.data, draft.number) === repository);
1787
+ }).map((match) => match.runId));
1788
+ if (ownedRuns.size === 0) return false;
1789
+ // A previous owner's final does not establish finality for a later owner of this PR.
1790
+ if (draft.runId && !ownedRuns.has(draft.runId)) return false;
1791
+ if (!draft.runId && ownedRuns.size > 1) return false;
1792
+ const currentOwners = draft.runId ? new Set([draft.runId]) : ownedRuns;
1793
+ const path = logsDbPath();
1794
+ if (!existsSync(path)) return false;
1795
+ const store = LogStore.openReadOnly(path);
1796
+ try {
1797
+ let beforeId: number | undefined;
1798
+ for (;;) {
1799
+ const rows = store.query({ exactCategories: ['self-implement.result'], events: ['final'], grep: String(draft.number),
1800
+ limit: 1_000, ...(beforeId === undefined ? {} : { beforeId }) });
1801
+ for (const row of rows) {
1802
+ try {
1803
+ const data = JSON.parse(row.data ?? '{}') as { runId?: string; prNumber?: number };
1804
+ if (data.prNumber === draft.number && data.runId && currentOwners.has(data.runId)) return true;
1805
+ } catch { /* A malformed result cannot establish finality. */ }
1806
+ }
1807
+ if (rows.length < 1_000) return false;
1808
+ beforeId = rows.at(-1)!.id;
1809
+ }
1810
+ } finally { store.close(); }
1811
+ },
1690
1812
  listLiveBranches: async (repository) => {
1691
1813
  const cwd = process.cwd();
1692
1814
  const remote = git(cwd, ['config', '--get', 'remote.origin.url']);
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export const CONSULT_MAX_BYTES = 8 * 1024;
6
+ export const CONSULT_QUEUE_MAX_AGE_MS = 7 * 24 * 3600_000;
7
+
8
+ export interface ConsultInput {
9
+ name: string;
10
+ org?: string;
11
+ kind: 'company' | 'personal';
12
+ interest: 'A' | 'B';
13
+ contact: string;
14
+ consent: true;
15
+ }
16
+
17
+ export interface QueuedConsult { id: string; receivedAt: string; consult: ConsultInput }
18
+
19
+ const singleLine = (value: string): string => value.trim().replace(/[\r\n\u2028\u2029]+/g, ' ');
20
+
21
+ /** The Primary accepts optional org and requires nonblank name/contact, kind, interest and explicit consent. */
22
+ export function validateConsult(body: unknown): { ok: true; consult: ConsultInput } | { ok: false; field: string } {
23
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, field: 'body' };
24
+ const b = body as Record<string, unknown>;
25
+ if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 256) return { ok: false, field: 'name' };
26
+ if (b.org !== undefined && (typeof b.org !== 'string' || !b.org.trim() || b.org.length > 256)) return { ok: false, field: 'org' };
27
+ if (b.kind !== 'company' && b.kind !== 'personal') return { ok: false, field: 'kind' };
28
+ if (b.interest !== 'A' && b.interest !== 'B') return { ok: false, field: 'interest' };
29
+ if (typeof b.contact !== 'string' || !b.contact.trim() || b.contact.length > 512) return { ok: false, field: 'contact' };
30
+ if (b.consent !== true) return { ok: false, field: 'consent' };
31
+ return { ok: true, consult: { name: singleLine(b.name), ...(typeof b.org === 'string' ? { org: singleLine(b.org) } : {}),
32
+ kind: b.kind, interest: b.interest, contact: b.contact.trim(), consent: true } };
33
+ }
34
+
35
+ /** Sliding windows; neither rejected hits nor invalid submissions consume quota. */
36
+ export class ConsultLimiter {
37
+ private readonly ips = new Map<string, number[]>();
38
+ private daily: number[] = [];
39
+ constructor(private readonly now: () => number = Date.now) {}
40
+
41
+ admit(ip: string): number | null {
42
+ const t = this.now();
43
+ const hits = (this.ips.get(ip) ?? []).filter(x => t - x < 600_000);
44
+ this.daily = this.daily.filter(x => t - x < 86_400_000);
45
+ const wait = hits.length >= 3 ? 600_000 - (t - hits[0]!) :
46
+ this.daily.length >= 200 ? 86_400_000 - (t - this.daily[0]!) : null;
47
+ if (wait !== null) { this.ips.set(ip, hits); return Math.max(1, Math.ceil(wait / 1000)); }
48
+ hits.push(t);
49
+ this.ips.set(ip, hits);
50
+ this.daily.push(t);
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Atomic JSONL rewrite on every mutation, with a private file and directory. */
56
+ export class ConsultQueue {
57
+ readonly path: string;
58
+ constructor(root: string, private readonly log: (event: string, data: Record<string, unknown>) => void = () => {}) {
59
+ this.path = join(root, 'hooks', 'consult-queue.jsonl');
60
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
61
+ }
62
+
63
+ private read(): QueuedConsult[] {
64
+ try { return readFileSync(this.path, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line) as QueuedConsult); }
65
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
66
+ }
67
+
68
+ private write(items: QueuedConsult[]): void {
69
+ const temp = `${this.path}.${randomUUID()}.tmp`;
70
+ try {
71
+ const fd = openSync(temp, 'wx', 0o600);
72
+ try { writeSync(fd, items.map(item => JSON.stringify(item)).join('\n') + (items.length ? '\n' : '')); fsyncSync(fd); }
73
+ finally { closeSync(fd); }
74
+ renameSync(temp, this.path);
75
+ } finally { rmSync(temp, { force: true }); }
76
+ }
77
+
78
+ enqueue(item: QueuedConsult): void { this.write([...this.read(), item]); }
79
+ entries(now: number = Date.now()): QueuedConsult[] {
80
+ const items = this.read();
81
+ const kept = items.filter(item => Number.isFinite(Date.parse(item.receivedAt)) && now - Date.parse(item.receivedAt) <= CONSULT_QUEUE_MAX_AGE_MS);
82
+ if (kept.length !== items.length) { this.write(kept); this.log('dropped', { expired: items.length - kept.length }); }
83
+ return kept;
84
+ }
85
+ count(): number { return existsSync(this.path) ? this.read().length : 0; }
86
+ delivered(id: string): void { this.write(this.read().filter(item => item.id !== id)); }
87
+ }
88
+
89
+ /** Accept only a validated body; responses and logs never contain user-provided values. */
90
+ export async function handleConsultPost(request: Request, deps: {
91
+ limiter: ConsultLimiter; queue: ConsultQueue; now?: () => number; ip?: string;
92
+ log?: (event: string, data: Record<string, unknown>) => void; wake?: () => void;
93
+ }): Promise<Response> {
94
+ const log = deps.log ?? (() => {});
95
+ const declared = Number(request.headers.get('content-length') ?? '0');
96
+ if (declared > CONSULT_MAX_BYTES) { log('rejected', { reason: 'too-large' }); return Response.json({ error: 'too-large' }, { status: 413 }); }
97
+ const chunks: Uint8Array[] = [];
98
+ let size = 0;
99
+ const reader = request.body?.getReader();
100
+ try {
101
+ if (reader) {
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ size += value.byteLength;
106
+ if (size > CONSULT_MAX_BYTES) {
107
+ await reader.cancel().catch(() => {});
108
+ log('rejected', { reason: 'too-large' });
109
+ return Response.json({ error: 'too-large' }, { status: 413 });
110
+ }
111
+ chunks.push(value);
112
+ }
113
+ }
114
+ } catch {
115
+ log('rejected', { reason: 'body' });
116
+ return Response.json({ error: 'invalid', field: 'body' }, { status: 400 });
117
+ } finally { reader?.releaseLock(); }
118
+ let body: unknown;
119
+ try {
120
+ const raw = new Uint8Array(size);
121
+ let offset = 0;
122
+ for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.byteLength; }
123
+ body = JSON.parse(new TextDecoder().decode(raw));
124
+ }
125
+ catch { log('rejected', { reason: 'body' }); return Response.json({ error: 'invalid', field: 'body' }, { status: 400 }); }
126
+ const checked = validateConsult(body);
127
+ if (!checked.ok) { log('rejected', { reason: checked.field }); return Response.json({ error: 'invalid', field: checked.field }, { status: 400 }); }
128
+ const ip = deps.ip ?? 'unknown';
129
+ const wait = deps.limiter.admit(ip);
130
+ if (wait !== null) {
131
+ log('rejected', { reason: 'rate-limited' });
132
+ return Response.json({ error: 'rate-limited', retryAfterSec: wait }, { status: 429, headers: { 'Retry-After': String(wait) } });
133
+ }
134
+ const id = randomUUID();
135
+ try { deps.queue.enqueue({ id, receivedAt: new Date((deps.now ?? Date.now)()).toISOString(), consult: checked.consult }); }
136
+ catch { log('rejected', { reason: 'queue-unavailable' }); return Response.json({ error: 'queue-unavailable' }, { status: 503 }); }
137
+ log('received', { queued: true, kind: checked.consult.kind, interest: checked.consult.interest,
138
+ nameLength: checked.consult.name.length, contactLength: checked.consult.contact.length, orgLength: checked.consult.org?.length ?? 0 });
139
+ deps.wake?.();
140
+ return Response.json({ id }, { status: 202 });
141
+ }
142
+
143
+ /** Forward in arrival order; a failure leaves that item and all following items queued. */
144
+ export async function drainConsults(queue: ConsultQueue, forward: (item: QueuedConsult) => Promise<boolean>, now: number = Date.now()): Promise<{ delivered: number; failed: number }> {
145
+ let delivered = 0;
146
+ let failed = 0;
147
+ for (const item of queue.entries(now)) {
148
+ let ok = false;
149
+ try { ok = await forward(item); } catch { ok = false; }
150
+ if (ok) { queue.delivered(item.id); delivered++; } else { failed++; break; }
151
+ }
152
+ return { delivered, failed };
153
+ }
@@ -7,6 +7,7 @@ import { HookQueue, type QueuedHook } from './queue.js';
7
7
  import { recordGithubShadow } from './github-shadow.js';
8
8
  import { toExternalTask, verifyWebhook, type HookProvider } from './providers.js';
9
9
  import { drainReports, handleReportPost, ReportLimiter, ReportQueue, type ReceivedReport } from './error-report.js';
10
+ import { ConsultLimiter, ConsultQueue, drainConsults, handleConsultPost, type QueuedConsult } from './consult-intake.js';
10
11
  import { effectiveInstanceRoot } from '../instance/resolve.js';
11
12
 
12
13
  export interface HookSecrets {
@@ -27,6 +28,11 @@ export interface HookReceiverOptions {
27
28
  wakeSeat?: HookWake;
28
29
  /** ER2 — forward one queued error report to the Primary; default = POST /v1/reports/ingest over the tailnet. */
29
30
  forwardReport?: (item: ReceivedReport) => Promise<boolean>;
31
+ /** CS1 — forward a queued consultation to the Primary's consult ledger. */
32
+ forwardConsult?: (item: QueuedConsult) => Promise<boolean>;
33
+ /** CS1 — the public consult route is a write path from the open internet, so it stays closed (404) until
34
+ * `hooks.consult.enabled === true` in user config (대표 decision D-20261003-09). Tests pass it directly. */
35
+ consultEnabled?: boolean;
30
36
  /** GET-only public metadata lookup; failure forces human review. */
31
37
  githubFetch?: typeof fetch;
32
38
  }
@@ -53,11 +59,66 @@ async function forwardReportToPrimary(item: ReceivedReport): Promise<boolean> {
53
59
  return response.ok;
54
60
  }
55
61
 
62
+ async function forwardConsultToPrimary(item: QueuedConsult): Promise<boolean> {
63
+ const config = JSON.parse(readFileSync(userConfigPath(), 'utf8')) as { hooks?: { primaryTokenRef?: string; primaryUrl?: unknown } };
64
+ const ref = config.hooks?.primaryTokenRef;
65
+ if (!ref) throw new Error('primary token reference missing');
66
+ const token = await getSecretAsync(ref);
67
+ if (!token) throw new Error('primary token missing');
68
+ const response = await fetch(new URL('/v1/consult-requests', hooksPrimaryUrl(config)), {
69
+ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
70
+ body: JSON.stringify(item.consult), signal: AbortSignal.timeout(10_000),
71
+ });
72
+ return response.ok;
73
+ }
74
+
75
+ /** Raw read like the report forwarder: only an explicit `hooks.consult.enabled: true` opens the route. */
76
+ function consultRouteEnabled(): boolean {
77
+ try {
78
+ const config = JSON.parse(readFileSync(userConfigPath(), 'utf8')) as { hooks?: { consult?: { enabled?: unknown } } };
79
+ return config.hooks?.consult?.enabled === true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function consultOrigin(origin: string | null): string | null {
86
+ if (!origin) return null;
87
+ try {
88
+ const url = new URL(origin);
89
+ if (url.protocol !== 'https:' || url.origin !== origin) return null;
90
+ return url.hostname === 'elanous.ai' || url.hostname === 'www.elanous.ai' ||
91
+ /^[a-z0-9-]+\.vercel\.app$/i.test(url.hostname) ? origin : null;
92
+ } catch { return null; }
93
+ }
94
+
56
95
  export function startHookReceiver(options: HookReceiverOptions): { url: string; stop: () => void; queue: HookQueue } {
57
96
  const queue = new HookQueue(options.root);
58
97
  const reportQueue = new ReportQueue(options.root ?? effectiveInstanceRoot(), (event, data) => debug.log('hooks.reports', event, data));
59
98
  const reportLimiter = new ReportLimiter(options.now ?? Date.now);
60
99
  const forwardReport = options.forwardReport ?? forwardReportToPrimary;
100
+ const consultEnabled = options.consultEnabled ?? consultRouteEnabled();
101
+ debug.log('hooks.consult', consultEnabled ? 'route-open' : 'route-closed', {});
102
+ const consultQueue = new ConsultQueue(options.root ?? effectiveInstanceRoot(), (_event, data) =>
103
+ debug.log('hooks.consult', 'rejected', { reason: 'expired', ...data }));
104
+ const consultLimiter = new ConsultLimiter(options.now ?? Date.now);
105
+ const forwardConsult = options.forwardConsult ?? forwardConsultToPrimary;
106
+ let consultFailures = 0;
107
+ let consultNextAt = 0;
108
+ let consultsDraining = false;
109
+ async function drainConsultQueue(): Promise<void> {
110
+ if (stopped || consultsDraining || Date.now() < consultNextAt) return;
111
+ consultsDraining = true;
112
+ try {
113
+ const result = await drainConsults(consultQueue, forwardConsult, (options.now ?? Date.now)());
114
+ if (result.delivered) debug.log('hooks.consult', 'forwarded', { queued: consultQueue.count(), delivered: result.delivered });
115
+ if (result.failed) {
116
+ consultFailures++;
117
+ consultNextAt = Date.now() + Math.min(300_000, Math.max(1, options.retryBaseMs ?? 1000) * 2 ** Math.min(consultFailures - 1, 20));
118
+ debug.log('hooks.consult', 'forward-failed', { queued: consultQueue.count(), reason: 'delivery-failed' });
119
+ } else consultFailures = 0;
120
+ } finally { consultsDraining = false; }
121
+ }
61
122
  let reportFailures = 0;
62
123
  let reportNextAt = 0;
63
124
  let reportsDraining = false;
@@ -111,18 +172,34 @@ export function startHookReceiver(options: HookReceiverOptions): { url: string;
111
172
  }
112
173
  } finally {
113
174
  void drainReportQueue();
175
+ void drainConsultQueue();
114
176
  draining = false;
115
177
  if (!stopped) timer = setTimeout(() => { void drain(); }, Math.min(1000, options.retryBaseMs ?? 1000));
116
178
  }
117
179
  }
118
180
  const server = Bun.serve({ hostname: options.host ?? '127.0.0.1', port: options.port,
119
- async fetch(request) {
181
+ async fetch(request, server) {
120
182
  const url = new URL(request.url);
121
183
  if (request.method === 'GET' && url.pathname === '/hooks/health')
122
184
  return Response.json({ ok: true, queued: queue.count(), reportsQueued: reportQueue.count() });
123
185
  if (request.method === 'POST' && url.pathname === '/v1/reports')
124
186
  return handleReportPost(request, { limiter: reportLimiter, queue: reportQueue, ...(options.now ? { now: options.now } : {}),
125
187
  log: (event, data) => debug.log('hooks.reports', event, data), wake: () => { reportNextAt = 0; void drainReportQueue(); } });
188
+ if (consultEnabled && url.pathname === '/v1/consult' && (request.method === 'POST' || request.method === 'OPTIONS')) {
189
+ const origin = consultOrigin(request.headers.get('origin'));
190
+ if (!origin) {
191
+ debug.log('hooks.consult', 'rejected', { reason: 'origin' });
192
+ return Response.json({ error: 'forbidden' }, { status: 403 });
193
+ }
194
+ const headers = { 'Access-Control-Allow-Origin': origin, Vary: 'Origin',
195
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
196
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers });
197
+ const response = await handleConsultPost(request, { limiter: consultLimiter, queue: consultQueue, ...(options.now ? { now: options.now } : {}),
198
+ ip: server.requestIP(request)?.address ?? 'unknown',
199
+ log: (event, data) => debug.log('hooks.consult', event, data), wake: () => { consultNextAt = 0; void drainConsultQueue(); } });
200
+ for (const [key, value] of Object.entries(headers)) response.headers.set(key, value);
201
+ return response;
202
+ }
126
203
  if (request.method !== 'POST' || !/^\/hooks\/(linear|asana|github)$/.test(url.pathname)) return new Response('Not Found', { status: 404 });
127
204
  const provider = url.pathname.slice('/hooks/'.length) as HookProvider;
128
205
  const reject = (reason: string) => {
package/src/index.ts CHANGED
@@ -3035,15 +3035,39 @@ contextCmd.command('hooks').description('Claude Code 맥락 훅 설정').command
3035
3035
  const { claudeHooksSettings } = await import('./context-bus/claude-hooks-install.js');
3036
3036
  console.log(claudeHooksSettings(REPOSITORY_ROOT));
3037
3037
  });
3038
- contextCmd.command('emit')
3039
- .requiredOption('--kind <kind>', 'claimed|done|asked|guide-changed')
3040
- .requiredOption('--summary <line>', '한 줄 요약')
3041
- .requiredOption('--source <link>', '출처 링크')
3042
- .action(async (opts: { kind: string; summary: string; source: string }) => {
3043
- if (!['claimed', 'done', 'asked', 'guide-changed'].includes(opts.kind)) throw new Error('context emit --kind must be claimed|done|asked|guide-changed');
3044
- const { recordExternalEvent } = await import('./context-bus/external-events.js');
3045
- recordExternalEvent({ origin: 'claude-code', kind: opts.kind as import('./context-bus/external-events.js').ContextHookKind,
3046
- summary: opts.summary, source: opts.source });
3038
+ contextCmd.command('emit [sessionKind]')
3039
+ .description('외부 에이전트 세션 이벤트: emit <kind> --seat <자리> --text <한 줄> [--ref <PR|칸>]')
3040
+ .option('--seat <seat>', 'OP|TC|MK|UX')
3041
+ .option('--text <line>', '한 줄 이벤트')
3042
+ .option('--ref <PR|cell>', '연결 PR 또는 칸')
3043
+ .option('--kind <kind>', '기존 Claude Code 훅 이벤트 종류')
3044
+ .option('--summary <line>', '기존 훅 한 줄 요약')
3045
+ .option('--source <link>', '기존 훅 출처 링크')
3046
+ .action(async (sessionKind: string | undefined, opts: { seat?: string; text?: string; ref?: string; kind?: string; summary?: string; source?: string }) => {
3047
+ try {
3048
+ if (sessionKind !== undefined) {
3049
+ if (opts.seat === undefined || opts.text === undefined || opts.kind !== undefined || opts.summary !== undefined || opts.source !== undefined) {
3050
+ throw new Error('context emit <kind> requires --seat and --text (not hook options)');
3051
+ }
3052
+ const { emitSessionEvent, SESSION_EVENT_KINDS } = await import('./context-bus/session-events.js');
3053
+ if (!SESSION_EVENT_KINDS.includes(sessionKind as import('./context-bus/session-events.js').SessionEventKind)) {
3054
+ throw new Error(`unknown context session kind: ${sessionKind}`);
3055
+ }
3056
+ emitSessionEvent({ kind: sessionKind as import('./context-bus/session-events.js').SessionEventKind,
3057
+ seat: opts.seat, text: opts.text, ...(opts.ref === undefined ? {} : { ref: opts.ref }) });
3058
+ } else {
3059
+ if (opts.seat !== undefined || opts.text !== undefined || opts.ref !== undefined || !opts.kind || opts.summary === undefined || opts.source === undefined) {
3060
+ throw new Error('context emit hook requires --kind, --summary and --source');
3061
+ }
3062
+ if (!['claimed', 'done', 'asked', 'guide-changed'].includes(opts.kind)) throw new Error(`unknown context hook kind: ${opts.kind}`);
3063
+ const { recordExternalEvent } = await import('./context-bus/external-events.js');
3064
+ recordExternalEvent({ origin: 'claude-code', kind: opts.kind as import('./context-bus/external-events.js').ContextHookKind,
3065
+ summary: opts.summary, source: opts.source });
3066
+ }
3067
+ } catch (error) {
3068
+ console.error(`context emit: ${error instanceof Error ? error.message : String(error)}`);
3069
+ process.exitCode = 2;
3070
+ }
3047
3071
  });
3048
3072
 
3049
3073
  const coordCmd = program.command('coord').description('조율 채널 맥락 원장');