elanous 0.2.5 → 0.2.6

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 (300) hide show
  1. package/README.md +5 -4
  2. package/apps/pwa/out/404/index.html +1 -1
  3. package/apps/pwa/out/404.html +1 -1
  4. package/apps/pwa/out/_next/static/chunks/1169-696263ac7af8c4d4.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/{7527.943753b6ed829f09.js → 1883.c91a2648af4842fd.js} +1 -1
  6. package/apps/pwa/out/_next/static/chunks/1947-9d64872eeb9cfdec.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/2808-dc06482994b40f70.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8918-482b30ce2a8ebf4c.js → 2984-977cfa339fcd65a5.js} +3 -3
  9. package/apps/pwa/out/_next/static/chunks/{4853-35fdbe6bdbcdc0fc.js → 3165-95515ca5f299a120.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/3190-e9282753e7ebde85.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/{5391-8793f88b03b30161.js → 3995-bee512a7063036ce.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/{9704-3221c102623c8d73.js → 4240-7153881b0b377306.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/4259-99614149577e77cb.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/4283-329eabeaf233e6cd.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/4328-cb5058b1acc2de5e.js +25 -0
  16. package/apps/pwa/out/_next/static/chunks/4801-ecee6ef62b349b18.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/5110-8058791dd25a2e14.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/5329.8b7e4bca684b6eec.js +5 -0
  19. package/apps/pwa/out/_next/static/chunks/5330-c41e9670f594c38f.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/5423-e95fd125d1794349.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/5478-b5eae07dc79ea619.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/5787-96ee6642b9a661e5.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/6448-79d72368589f98bc.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/6522-9866c002b5531836.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/6729-b4a558b7aa891e0c.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/6969-cb03f9410b7d2e03.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/7007-3b9861091bb8dd21.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/{5348-bbf838e119661eee.js → 763-7d0e1913b5074e98.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/{8617.465ca22a319c95b3.js → 7633.0654fe6ea259c1ee.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/7930-8dc7e20fc6fc19ca.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/8071-187edbc22a86741f.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/8198-d4101ad70cb9a579.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/{9238.2cd5f47f4bf7a2c3.js → 8250.4a2c5553a07cdf47.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/8293-123b91e7b0330dc0.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/8296-d6d5d87dfa911d41.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/8305-603963c33ee8eec3.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/8612-4a0066f20951b8fd.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/{4daa2798-1363d725c79f3593.js → 8d0d9faf-97887ff99713b84e.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/9181-cf4e1b61c4783f92.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/{f6f3850d-7f324c6566002113.js → 91ff6dda-3b33d42d20657429.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/9262-26c2ecbd5a67e4a2.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/9328-45abdebce4fbecb7.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/9355-dbc67205adf8745f.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/9763.255b90dd69875689.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/9933-66d31ec25c0231af.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-bd06fb55f4da0381.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/approvals/page-4e586142da455a25.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-889ad5cddcf45b0d.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/board/page-47ba401d56572c27.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0b54f9c83458e038.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/bots/page-4fcc296ef6c8ed90.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/chat/page-fe9fda494a87f4e5.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/control/page-f9642e25ce129590.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e1063e8ba5f3e2e7.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/editor/page-9d0df30027d8aa9d.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/healthz/route-bd06fb55f4da0381.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/intake/page-d97adf2c9455b8d0.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/layout-236b80d202110362.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/live/page-21a30f35f1a276ad.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/market/page-b1a5be357c4fa25d.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-1705a60303f7f573.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/morning/page-76b337c11ad0e237.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/not-found-7c0f85fbb56f64c6.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/observatory/page-65ca16b20617b44d.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/page-e7f9b8fc29411c1d.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/reflection/page-92ea6213d1aeec04.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a913a005aacb5476.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/sessions/page-2d78804a40dfdef6.js +1 -0
  69. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-08a99148e780ac00.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/settings/page-deb674e7ed36f815.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-49f8de0534d192d7.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/setup/layout-bd06fb55f4da0381.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/setup/page-ba162a28d2176e18.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/share/page-1ff1a37b550ab41a.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/showroom/page-ccaa698f56c0fbdb.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/tasks/page-53b944082de1d7db.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/term/page-9b70adf1552789bf.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/trace/page-30d482aa63241fe8.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/vault/page-aecf786f54651d8a.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-6ab9b62c7b6e08f2.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/workflows/page-08949735455e0c9a.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/workspace/page-a1479005234a46c2.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-84cd419494e16b0f.js → page-c3890e90bd232732.js} +1 -1
  84. package/apps/pwa/out/_next/static/chunks/{06ef2eba-d1b99ac6d2ea663c.js → beb5cf10-9adcc10b83930090.js} +1 -1
  85. package/apps/pwa/out/_next/static/chunks/framework-d68d41c88e05c801.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/main-app-11c63971b7a9593b.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/main-d09452353bd0d9cc.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/pages/_app-184d727cebeca767.js +1 -0
  89. package/apps/pwa/out/_next/static/chunks/pages/_error-21bbbbac99b720b1.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/{webpack-2bf59c720fe9542c.js → webpack-37b8bf78a3c6eec5.js} +1 -1
  91. package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_buildManifest.js +1 -1
  92. package/apps/pwa/out/approvals/index.html +1 -1
  93. package/apps/pwa/out/approvals/index.txt +21 -21
  94. package/apps/pwa/out/autopilot/index.html +1 -1
  95. package/apps/pwa/out/autopilot/index.txt +21 -21
  96. package/apps/pwa/out/board/index.html +1 -1
  97. package/apps/pwa/out/board/index.txt +21 -21
  98. package/apps/pwa/out/botlab/index.html +1 -1
  99. package/apps/pwa/out/botlab/index.txt +20 -20
  100. package/apps/pwa/out/bots/index.html +1 -1
  101. package/apps/pwa/out/bots/index.txt +20 -20
  102. package/apps/pwa/out/chat/index.html +1 -1
  103. package/apps/pwa/out/chat/index.txt +20 -20
  104. package/apps/pwa/out/control/index.html +1 -1
  105. package/apps/pwa/out/control/index.txt +21 -21
  106. package/apps/pwa/out/design-check/index.html +1 -1
  107. package/apps/pwa/out/design-check/index.txt +21 -21
  108. package/apps/pwa/out/editor/index.html +1 -1
  109. package/apps/pwa/out/editor/index.txt +21 -21
  110. package/apps/pwa/out/healthz +1 -1
  111. package/apps/pwa/out/icons/elanous-16.png +0 -0
  112. package/apps/pwa/out/icons/elanous-180.png +0 -0
  113. package/apps/pwa/out/icons/elanous-192.png +0 -0
  114. package/apps/pwa/out/icons/elanous-32.png +0 -0
  115. package/apps/pwa/out/icons/elanous-512.png +0 -0
  116. package/apps/pwa/out/index.html +1 -1
  117. package/apps/pwa/out/index.txt +20 -20
  118. package/apps/pwa/out/intake/index.html +1 -1
  119. package/apps/pwa/out/intake/index.txt +21 -21
  120. package/apps/pwa/out/live/index.html +1 -1
  121. package/apps/pwa/out/live/index.txt +21 -21
  122. package/apps/pwa/out/market/index.html +1 -1
  123. package/apps/pwa/out/market/index.txt +20 -20
  124. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  125. package/apps/pwa/out/missions/placeholder/index.txt +20 -20
  126. package/apps/pwa/out/morning/index.html +1 -1
  127. package/apps/pwa/out/morning/index.txt +21 -21
  128. package/apps/pwa/out/observatory/index.html +1 -1
  129. package/apps/pwa/out/observatory/index.txt +21 -21
  130. package/apps/pwa/out/reflection/index.html +1 -1
  131. package/apps/pwa/out/reflection/index.txt +21 -21
  132. package/apps/pwa/out/scheduler/index.html +1 -1
  133. package/apps/pwa/out/scheduler/index.txt +21 -21
  134. package/apps/pwa/out/sessions/index.html +1 -1
  135. package/apps/pwa/out/sessions/index.txt +20 -20
  136. package/apps/pwa/out/settings/devices/index.html +1 -1
  137. package/apps/pwa/out/settings/devices/index.txt +21 -21
  138. package/apps/pwa/out/settings/index.html +1 -1
  139. package/apps/pwa/out/settings/index.txt +21 -21
  140. package/apps/pwa/out/setup/done/index.html +1 -1
  141. package/apps/pwa/out/setup/done/index.txt +21 -21
  142. package/apps/pwa/out/setup/index.html +1 -1
  143. package/apps/pwa/out/setup/index.txt +21 -21
  144. package/apps/pwa/out/share/index.html +1 -1
  145. package/apps/pwa/out/share/index.txt +21 -21
  146. package/apps/pwa/out/showroom/index.html +1 -1
  147. package/apps/pwa/out/showroom/index.txt +20 -20
  148. package/apps/pwa/out/tasks/index.html +1 -1
  149. package/apps/pwa/out/tasks/index.txt +21 -21
  150. package/apps/pwa/out/term/index.html +1 -1
  151. package/apps/pwa/out/term/index.txt +21 -21
  152. package/apps/pwa/out/trace/index.html +1 -1
  153. package/apps/pwa/out/trace/index.txt +21 -21
  154. package/apps/pwa/out/vault/index.html +1 -1
  155. package/apps/pwa/out/vault/index.txt +21 -21
  156. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  157. package/apps/pwa/out/workflows/chat-ui/index.txt +21 -21
  158. package/apps/pwa/out/workflows/index.html +1 -1
  159. package/apps/pwa/out/workflows/index.txt +21 -21
  160. package/apps/pwa/out/workspace/index.html +1 -1
  161. package/apps/pwa/out/workspace/index.txt +21 -21
  162. package/apps/pwa/out/worktrees/index.html +1 -1
  163. package/apps/pwa/out/worktrees/index.txt +21 -21
  164. package/graphs/release/recipes.yaml +3 -0
  165. package/graphs/release/release-loop.yaml +3 -1
  166. package/package.json +1 -1
  167. package/plugins/card-followup/README.md +33 -0
  168. package/plugins/card-followup/examples/card-sample.json +7 -0
  169. package/plugins/card-followup/graphs/card-followup.yaml +16 -0
  170. package/plugins/card-followup/graphs/recipes.yaml +12 -0
  171. package/plugins/card-followup/graphs/run-step.ts +172 -0
  172. package/plugins/card-followup/plugin.json +25 -0
  173. package/plugins/geo-check/README.md +13 -0
  174. package/plugins/geo-check/examples/brand-sample.json +8 -0
  175. package/plugins/geo-check/graphs/geo-check.yaml +20 -0
  176. package/plugins/geo-check/graphs/recipes.yaml +18 -0
  177. package/plugins/geo-check/graphs/run-step.ts +177 -0
  178. package/plugins/geo-check/plugin.json +24 -0
  179. package/plugins/job-coach/README.md +30 -0
  180. package/scripts/coord-tracks.json +2 -2
  181. package/scripts/daemon-port-baseline.txt +1 -2
  182. package/scripts/lib/android-test-device.ts +32 -0
  183. package/scripts/release-loop/checklist-gate-node.ts +35 -0
  184. package/scripts/release-loop/gate-diff.ts +29 -0
  185. package/scripts/release-loop/gate-node.ts +46 -13
  186. package/scripts/release-loop/shard-plan.ts +47 -0
  187. package/scripts/ux/record-page.ts +4 -1
  188. package/skills/explainer-video/SKILL.md +18 -0
  189. package/skills/explainer-video/engine/brand/elanous-mark-on-dark.svg +1 -1
  190. package/skills/explainer-video/engine/build.mjs +1 -1
  191. package/skills/explainer-video/engine/reel.mjs +128 -0
  192. package/skills/explainer-video/engine/reel.sh +8 -0
  193. package/skills/explainer-video/engine/vo.ts +58 -16
  194. package/src/autopilot/mission-origin.ts +20 -4
  195. package/src/autopilot/mission-phase-track.ts +11 -1
  196. package/src/autopilot/track-agent.ts +3 -3
  197. package/src/autopilot/track-courier.ts +2 -2
  198. package/src/cli/nexus-ios-bind.ts +25 -8
  199. package/src/cli/phone-link.ts +117 -0
  200. package/src/cli/plugin-cli.ts +27 -0
  201. package/src/cli/private-ledger-commands.ts +31 -0
  202. package/src/cli/release-cli.ts +14 -11
  203. package/src/domains/outbound-alert.ts +5 -5
  204. package/src/field/field-media.ts +245 -0
  205. package/src/index.ts +18 -24
  206. package/src/nexus/api/field-uploads.ts +143 -0
  207. package/src/nexus/api/graph-approvals.ts +50 -1
  208. package/src/nexus/api/harness-api.ts +219 -5
  209. package/src/nexus/api/http-server.ts +9 -0
  210. package/src/notify/llm-budget-alert.ts +119 -0
  211. package/src/plugins/maker/plugin-maker.ts +208 -0
  212. package/src/relay/channel.ts +184 -0
  213. package/src/relay/mux.ts +182 -0
  214. package/src/relay/test-vectors.json +29 -0
  215. package/src/release-loop/checklist.ts +28 -2
  216. package/src/seat-address/seat-address.ts +34 -0
  217. package/src/self-implement/run-ledger-compact.ts +1 -1
  218. package/src/session/index.ts +23 -10
  219. package/src/task-orchestrator/surfaces/pod-bun-cache.ts +18 -0
  220. package/src/task-orchestrator/surfaces/pod-command-job.ts +12 -3
  221. package/src/telegram.ts +108 -1
  222. package/src/user-config.ts +4 -0
  223. package/src/version/packed-revision.json +1 -1
  224. package/src/video-pipeline/recipes/explainer.ts +213 -0
  225. package/src/video-pipeline/walk-line.ts +3 -2
  226. package/apps/pwa/out/_next/static/chunks/1413-7179a49332db5a80.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/150-25c277137a9638ab.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/1640-bc99ec23bbe29d12.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/1751.50b96ea9c37b49b6.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/2300-de059d0ab2a16630.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/2684-05b0a81e75298c16.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/3026-2ed7e317d9b02102.js +0 -25
  233. package/apps/pwa/out/_next/static/chunks/3611-77496ada38cd6358.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/3717-68957c4dc9ceec90.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/3929-46ba9af6f9d895cc.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/4015-62c5a3744698a8da.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/4217-3c4317a7905f9eeb.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/4240-3230f230fae2d7d5.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/4271-e53e075ebd48593b.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/4377.3b125fef79576dfd.js +0 -5
  241. package/apps/pwa/out/_next/static/chunks/4440-18bb160d95819f4e.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/5007-6a3b30e23ff68a6e.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/5244-12b424c42070f5ff.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/5611-df00af3e4e4a61bb.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/5890-dca2321773be829a.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/6123-37c9dc57ddaa7ab0.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/6588-b7975304a302ab91.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/6604-c03961962b6273b6.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/7109-cf8c87e968e83a65.js +0 -1
  250. package/apps/pwa/out/_next/static/chunks/7178-30f77877525a2efb.js +0 -1
  251. package/apps/pwa/out/_next/static/chunks/7262-871563fc9663730b.js +0 -1
  252. package/apps/pwa/out/_next/static/chunks/7294-f0eeebb92aa93459.js +0 -1
  253. package/apps/pwa/out/_next/static/chunks/7429-881af44deed6958f.js +0 -1
  254. package/apps/pwa/out/_next/static/chunks/764-a01e858525a190c0.js +0 -1
  255. package/apps/pwa/out/_next/static/chunks/7868-e35d5f7985ecd3f1.js +0 -1
  256. package/apps/pwa/out/_next/static/chunks/8320-51484af7b01fdfa5.js +0 -1
  257. package/apps/pwa/out/_next/static/chunks/9131-52f5fad51cceca04.js +0 -1
  258. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-70c404c7bb6d7f0f.js +0 -1
  259. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3ffd80166b1050aa.js +0 -1
  260. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f89e9a303515a033.js +0 -1
  261. package/apps/pwa/out/_next/static/chunks/app/board/page-4f403cd060442c95.js +0 -1
  262. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0ac5d053f0444d21.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/bots/page-4bcc63e272ae8205.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/chat/page-880734bd07d62882.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/control/page-72bd384898d88955.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1018bbc9586ecae5.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/editor/page-de86f8c38076dd7b.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/healthz/route-70c404c7bb6d7f0f.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/intake/page-cd6447196d8bafe6.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/layout-e733630c47fa102b.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/live/page-d1c1a8357b161875.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/market/page-60c514128e81d787.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8b637b7e325e13c1.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/morning/page-bccce0babceae30f.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/not-found-63301d11dbe73836.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/observatory/page-a8cf0dc472060db2.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/page-02fba54881ccfa49.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/reflection/page-d5e8e90f0038f8de.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-b1ef6824f494466a.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/sessions/page-d589bb0f233b1009.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-c9bbaebef4cda059.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/settings/page-3639ce7916b92fed.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-7450ae7478955653.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/setup/layout-70c404c7bb6d7f0f.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/setup/page-b41ca4abfd618452.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/share/page-b333dd79ebee3fa9.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/showroom/page-8be1227492793655.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6e03e9371b552e0d.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/term/page-911411e9fca18d03.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/trace/page-792170e649b37088.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/vault/page-c40463466937bc91.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-509c798b80384536.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/workflows/page-1cac2aa21d77c417.js +0 -1
  294. package/apps/pwa/out/_next/static/chunks/app/workspace/page-9626799d9b4a2cd1.js +0 -1
  295. package/apps/pwa/out/_next/static/chunks/framework-361487d2da603917.js +0 -1
  296. package/apps/pwa/out/_next/static/chunks/main-app-607f5beb94cb2e07.js +0 -1
  297. package/apps/pwa/out/_next/static/chunks/main-d0222d219e29dddf.js +0 -1
  298. package/apps/pwa/out/_next/static/chunks/pages/_app-a26980a31126decc.js +0 -1
  299. package/apps/pwa/out/_next/static/chunks/pages/_error-edb2b37decf23e4d.js +0 -1
  300. /package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_ssgManifest.js +0 -0
@@ -0,0 +1,245 @@
1
+ // 현장(field) 업로드 폴더 계약 — HTTP 입구(`/v1/field/uploads`)와 텔레그램 `#현장` 이 «같은» 저장 함수를 쓴다.
2
+ //
3
+ // 폴더 계약(2026-09-30 합의 · 소비자 = reel 파이프라인):
4
+ // <configDir>/field/<event>/<ISO 기본형 타임스탬프>-<device>-<원래 이름>
5
+ // 타임스탬프 = 클라이언트가 준 촬영 시각(있으면) · 없으면 업로드 시각 · UTC · `20261015T193012Z`(ISO 8601 기본형 —
6
+ // 파일 이름에 콜론을 넣지 않으려고 기본형을 쓴다. 사전순 = 시간순).
7
+ // 배치가 끝나면 `<folder>/.ready` 를 한 줄(= 폴더의 미디어 파일 수)로 덮어쓴다.
8
+
9
+ import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
12
+
13
+ export const FIELD_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
14
+ export const FIELD_MAX_FILE_BYTES = 100 * 1024 * 1024; // 폰 현장 사진·짧은 영상 — 데몬 전역 요청 한도를 과하게 올리지 않으려고 낮게(2026-09-30)
15
+ export const FIELD_MAX_REQUEST_BYTES = 250 * 1024 * 1024;
16
+ export const FIELD_READY_FILE = '.ready';
17
+ const MAX_ORIGINAL_NAME = 80;
18
+
19
+ /** 허용 MIME → 대표 확장자. heif 는 heic 의 별칭으로 받는다. */
20
+ const MIME_EXT: Record<string, string> = {
21
+ 'image/jpeg': 'jpg',
22
+ 'image/jpg': 'jpg',
23
+ 'image/png': 'png',
24
+ 'image/heic': 'heic',
25
+ 'image/heif': 'heic',
26
+ 'image/webp': 'webp',
27
+ 'video/mp4': 'mp4',
28
+ 'video/quicktime': 'mov',
29
+ };
30
+ const EXT_MIME: Record<string, string> = {
31
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', heic: 'image/heic', heif: 'image/heif',
32
+ webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime',
33
+ };
34
+
35
+ export type FieldUploadErrorCode = 'bad-slug' | 'unsupported-media-type' | 'too-large' | 'no-files' | 'bad-request';
36
+
37
+ export class FieldUploadError extends Error {
38
+ constructor(readonly code: FieldUploadErrorCode, message: string) {
39
+ super(message);
40
+ this.name = 'FieldUploadError';
41
+ }
42
+ get status(): number {
43
+ if (this.code === 'unsupported-media-type') return 415;
44
+ if (this.code === 'too-large') return 413;
45
+ return 400;
46
+ }
47
+ }
48
+
49
+ export interface FieldMediaInput {
50
+ originalName: string;
51
+ mimeType?: string;
52
+ bytes: Uint8Array;
53
+ /** 촬영 시각(ISO 문자열·epoch ms). 못 읽으면 업로드 시각. */
54
+ capturedAt?: string | number | Date;
55
+ }
56
+
57
+ export interface FieldSaveOptions {
58
+ /** config 뿌리(`~/.elanous` 또는 격리 뿌리). 폴더 = `<rootDir>/field/<event>`. */
59
+ rootDir: string;
60
+ event: string;
61
+ device: string;
62
+ now?: () => Date;
63
+ /** 사람이 쓴 한 줄(텔레그램 캡션·앱 입력칸) — 있으면 `captions.txt` 에 «<첫 파일 이름> | <글>» 로 덧붙인다(reel 자막 · MK 2026-09-30). */
64
+ caption?: string;
65
+ }
66
+
67
+ export interface FieldSaveResult {
68
+ event: string;
69
+ dir: string;
70
+ saved: Array<{ name: string; bytes: number }>;
71
+ /** 저장 «후» 폴더의 미디어 파일 수(= `.ready` 에 쓴 값). */
72
+ count: number;
73
+ }
74
+
75
+ export function isFieldSlug(value: unknown): value is string {
76
+ return typeof value === 'string' && FIELD_SLUG_RE.test(value);
77
+ }
78
+
79
+ export function fieldEventDir(rootDir: string, event: string): string {
80
+ if (!isFieldSlug(event)) throw new FieldUploadError('bad-slug', 'event slug must match ^[a-z0-9][a-z0-9._-]{0,63}$');
81
+ return join(rootDir, 'field', event);
82
+ }
83
+
84
+ function extOf(name: string): string {
85
+ const dot = name.lastIndexOf('.');
86
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
87
+ }
88
+
89
+ /** 허용 MIME 이면 정규화한 MIME, 아니면 null. MIME 이 비었거나 octet-stream 이면 확장자로 추정한다. */
90
+ export function resolveFieldMime(mimeType: string | undefined, originalName: string): string | null {
91
+ const mime = (mimeType ?? '').split(';')[0]!.trim().toLowerCase();
92
+ if (mime && mime !== 'application/octet-stream') return MIME_EXT[mime] ? mime : null;
93
+ return EXT_MIME[extOf(originalName)] ?? null;
94
+ }
95
+
96
+ /** basename · `[A-Za-z0-9._-]` 외는 `_` · 앞 점 제거 · 최대 80자(확장자 보존) · 확장자가 MIME 과 안 맞으면 붙인다. */
97
+ export function sanitizeFieldName(originalName: string, mime?: string): string {
98
+ const base = originalName.split(/[\\/]/).pop() ?? '';
99
+ let name = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_').replace(/^\.+/, '');
100
+ const wantExt = mime ? MIME_EXT[mime] : undefined;
101
+ const ext = extOf(name);
102
+ if (wantExt && EXT_MIME[ext] === undefined) name = name ? `${name}.${wantExt}` : `media.${wantExt}`;
103
+ if (!name) name = 'media';
104
+ if (name.length > MAX_ORIGINAL_NAME) {
105
+ const e = extOf(name);
106
+ const suffix = e && e.length < 10 ? `.${e}` : '';
107
+ name = name.slice(0, MAX_ORIGINAL_NAME - suffix.length) + suffix;
108
+ }
109
+ return name;
110
+ }
111
+
112
+ /** `20261015T193012Z` — ISO 8601 기본형 UTC(초 단위). */
113
+ export function fieldTimestamp(capturedAt: FieldMediaInput['capturedAt'], now: Date): string {
114
+ let at: Date | null = null;
115
+ if (capturedAt instanceof Date) at = capturedAt;
116
+ else if (typeof capturedAt === 'number') at = new Date(capturedAt);
117
+ else if (typeof capturedAt === 'string' && capturedAt.trim()) {
118
+ const trimmed = capturedAt.trim();
119
+ at = /^\d{10,13}$/.test(trimmed) ? new Date(Number(trimmed) * (trimmed.length === 10 ? 1000 : 1)) : new Date(trimmed);
120
+ }
121
+ if (!at || Number.isNaN(at.getTime())) at = now;
122
+ return at.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
123
+ }
124
+
125
+ function isMediaFile(name: string): boolean {
126
+ return !name.startsWith('.') && EXT_MIME[extOf(name)] !== undefined;
127
+ }
128
+
129
+ export function listFieldMedia(dir: string): Array<{ name: string; bytes: number }> {
130
+ let names: string[];
131
+ try { names = readdirSync(dir); }
132
+ catch (error) {
133
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
134
+ throw error;
135
+ }
136
+ const out: Array<{ name: string; bytes: number }> = [];
137
+ for (const name of names.sort()) {
138
+ if (!isMediaFile(name)) continue;
139
+ try {
140
+ const st = statSync(join(dir, name));
141
+ if (st.isFile()) out.push({ name, bytes: st.size });
142
+ } catch { /* 사이에 지워졌다 */ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function atomicWrite(dir: string, name: string, bytes: Uint8Array | string): void {
148
+ const tmp = join(dir, `.tmp-${randomUUID()}`);
149
+ try {
150
+ writeFileSync(tmp, bytes);
151
+ renameSync(tmp, join(dir, name));
152
+ } catch (error) {
153
+ try { unlinkSync(tmp); } catch { /* 없음 */ }
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ /** `.ready` 를 «지금» 폴더의 미디어 파일 수로 덮어쓴다. */
159
+ export function refreshFieldReady(dir: string): number {
160
+ const count = listFieldMedia(dir).length;
161
+ atomicWrite(dir, FIELD_READY_FILE, `${count}\n`);
162
+ return count;
163
+ }
164
+
165
+ /** 검증(전부) → 쓰기(전부, 원자적) → `.ready` 갱신. 하나라도 검증에 걸리면 아무것도 안 쓴다. */
166
+ export function saveFieldMedia(inputs: FieldMediaInput[], opts: FieldSaveOptions): FieldSaveResult {
167
+ const dir = fieldEventDir(opts.rootDir, opts.event);
168
+ if (!isFieldSlug(opts.device)) throw new FieldUploadError('bad-slug', 'device slug must match ^[a-z0-9][a-z0-9._-]{0,63}$');
169
+ if (inputs.length === 0) throw new FieldUploadError('no-files', 'at least one file is required');
170
+ const now = (opts.now ?? (() => new Date()))();
171
+ let total = 0;
172
+ const planned = inputs.map((input) => {
173
+ const mime = resolveFieldMime(input.mimeType, input.originalName);
174
+ if (!mime) throw new FieldUploadError('unsupported-media-type', 'only jpeg/png/heic/webp images and mp4/mov videos are accepted');
175
+ const size = input.bytes.byteLength;
176
+ if (size === 0) throw new FieldUploadError('bad-request', 'empty file');
177
+ if (size > FIELD_MAX_FILE_BYTES) throw new FieldUploadError('too-large', 'a file exceeds 100 MB');
178
+ total += size;
179
+ if (total > FIELD_MAX_REQUEST_BYTES) throw new FieldUploadError('too-large', 'the request exceeds 250 MB');
180
+ const name = `${fieldTimestamp(input.capturedAt, now)}-${opts.device}-${sanitizeFieldName(input.originalName, mime)}`;
181
+ return { name, bytes: input.bytes };
182
+ });
183
+
184
+ mkdirSync(dir, { recursive: true });
185
+ const taken = new Set<string>();
186
+ const saved: FieldSaveResult['saved'] = [];
187
+ for (const item of planned) {
188
+ const name = uniqueName(dir, item.name, item.bytes.byteLength, taken);
189
+ taken.add(name);
190
+ atomicWrite(dir, name, item.bytes);
191
+ saved.push({ name, bytes: item.bytes.byteLength });
192
+ }
193
+ const line = normalizeFieldCaption(opts.caption);
194
+ if (line && saved[0]) appendFileSync(join(dir, FIELD_CAPTIONS_FILE), `${saved[0].name} | ${line}\n`);
195
+ const count = refreshFieldReady(dir);
196
+ return { event: opts.event, dir, saved, count };
197
+ }
198
+
199
+ /** 같은 이름·같은 크기면 재전송으로 보고 덮는다(중복 방지). 크기가 다르면 `-1`,`-2`… 를 붙인다. */
200
+ function uniqueName(dir: string, name: string, size: number, taken: Set<string>): string {
201
+ const dot = name.lastIndexOf('.');
202
+ const stem = dot > 0 ? name.slice(0, dot) : name;
203
+ const ext = dot > 0 ? name.slice(dot) : '';
204
+ for (let i = 0; ; i += 1) {
205
+ const candidate = i === 0 ? name : `${stem}-${i}${ext}`;
206
+ if (taken.has(candidate)) continue;
207
+ let existing: number | null = null;
208
+ try { existing = statSync(join(dir, candidate)).size; } catch { existing = null; }
209
+ if (existing === null || existing === size) return candidate;
210
+ }
211
+ }
212
+
213
+ // ── 텔레그램 캡션 문법 ──────────────────────────────────────────────
214
+
215
+ export const FIELD_CAPTION_TAG = '#현장';
216
+
217
+ /** 캡션에 독립 토큰 `#현장` 이 있으면 `{ event? }`, 없으면 null.
218
+ * `#현장 <slug>` — 바로 다음 토큰을 소문자로 바꿔 슬러그 규칙에 맞으면 행사 이름, 아니면 기본 행사. */
219
+ export function parseFieldCaption(caption: string | undefined | null): { event?: string; text?: string } | null {
220
+ if (!caption) return null;
221
+ const tokens = caption.trim().split(/\s+/);
222
+ const at = tokens.indexOf(FIELD_CAPTION_TAG);
223
+ if (at < 0) return null;
224
+ const next = tokens[at + 1]?.toLowerCase();
225
+ const hasEvent = !!next && isFieldSlug(next);
226
+ // 태그·행사 이름을 뺀 나머지가 «글»(자막) — 태그 앞에 쓴 글도 같이.
227
+ const rest = tokens.filter((_, i) => i !== at && !(hasEvent && i === at + 1)).join(' ');
228
+ const text = normalizeFieldCaption(rest);
229
+ return { ...(hasEvent ? { event: next } : {}), ...(text ? { text } : {}) };
230
+ }
231
+
232
+ export const FIELD_CAPTIONS_FILE = 'captions.txt';
233
+
234
+ /** 자막 한 줄 — 줄바꿈은 공백으로, `|` 는 구분자라 전각 `|` 로, 280자까지. 비면 빈 값. */
235
+ export function normalizeFieldCaption(value: string | undefined | null): string {
236
+ if (typeof value !== 'string') return '';
237
+ return value.replace(/[\r\n]+/g, ' ').replace(/\|/g, '|').replace(/\s+/g, ' ').trim().slice(0, 280);
238
+ }
239
+
240
+ /** 기본 행사 = 유효한 config 값 · 없으면 `field-<로컬 YYYY-MM-DD>`. */
241
+ export function defaultFieldEvent(configured: string | undefined, now: Date = new Date()): string {
242
+ if (configured && isFieldSlug(configured)) return configured;
243
+ const pad = (n: number) => String(n).padStart(2, '0');
244
+ return `field-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
245
+ }
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { CLI_HARNESS_DOGFOOD_ENTRANCE, CLI_HARNESS_ORCHESTRATE_ENTRANCE, describ
26
26
  import { collectCommandEntrances, renderCommandEntrances } from './self-dev/entrance-inventory.js';
27
27
  import { isGoalAuthorFileName } from './self-implement/goal-document.js';
28
28
  import { DEV_PIPELINE_SINK_SURFACE } from './self-implement/self-cli-sink-surface.js';
29
+ import { registerPrivateLedgerCommands } from './cli/private-ledger-commands.js';
29
30
  import { applyTestStateDirFlagFromArgv } from './cli/test-state-dir-flag.js';
30
31
  import { applyTestFlagFromArgv, observeTestFlagOwnership, uncoveredTestFlagPaths, staleTestFlagPaths } from './cli/test-flag.js';
31
32
  import { registerPtyTakeoverCommands } from './cli/pty-takeover-cli.js';
@@ -5429,30 +5430,7 @@ program.command('ad [input...]')
5429
5430
  });
5430
5431
 
5431
5432
  registerDocsCommands(program);
5432
- // Private local decision ledger follows the same JS-only dispatch path as directives.
5433
- if (existsSync(resolve(import.meta.dir, 'cli/decisions-cli.ts')) || existsSync(resolve(import.meta.dir, 'cli/decisions-cli.js'))) {
5434
- program.command('decisions').description('대표 결정 로컬 원장').allowUnknownOption().allowExcessArguments(true)
5435
- .action(async (_opts: unknown, command: Command) => {
5436
- try { await (await import('./domains/standalone-log-sink.js')).registerStandaloneLogSink('decisions'); }
5437
- catch { /* Logging must not prevent recording a decision. */ }
5438
- const privateDecisionsModule: string = './cli/decisions-cli.js';
5439
- const { registerDecisionsCommands } = await import(privateDecisionsModule) as { registerDecisionsCommands: (program: Command) => void };
5440
- const privateProgram = new Command();
5441
- registerDecisionsCommands(privateProgram);
5442
- await privateProgram.parseAsync(['decisions', ...command.args], { from: 'user' });
5443
- });
5444
- }
5445
- // Private command is absent from the public export; JS-only distributions still register it.
5446
- if (existsSync(resolve(import.meta.dir, 'cli/directives-cli.ts')) || existsSync(resolve(import.meta.dir, 'cli/directives-cli.js'))) {
5447
- program.command('directives').description('대표 지시 로컬 색인·검색').allowUnknownOption().allowExcessArguments(true)
5448
- .action(async (_opts: unknown, command: Command) => {
5449
- const privateDirectivesModule: string = './cli/directives-cli.js';
5450
- const { registerDirectivesCommands } = await import(privateDirectivesModule) as { registerDirectivesCommands: (program: Command) => void };
5451
- const privateProgram = new Command();
5452
- registerDirectivesCommands(privateProgram);
5453
- await privateProgram.parseAsync(['directives', ...command.args], { from: 'user' });
5454
- });
5455
- }
5433
+ registerPrivateLedgerCommands(program);
5456
5434
  registerFleetCommands(program);
5457
5435
 
5458
5436
  // ── ops (운영 관측 — 지금 뭐 도나·이상 없나·상태 전이) ──
@@ -9935,6 +9913,22 @@ nexusCmd
9935
9913
  }
9936
9914
  });
9937
9915
 
9916
+ // 휴대폰 앱 온보딩 — 링크 하나(⊕ QR)로 이 Mac 의 넥서스에 붙인다(대표 2026-09-30 · 아이폰 첫 화면 단순화).
9917
+ program
9918
+ .command('phone')
9919
+ .description('Connect the phone app to this Mac')
9920
+ .command('link')
9921
+ .description('Print a one-tap connect link (and a QR code) for the Elanous phone app')
9922
+ .option('--simulator', 'Use the loopback address (the simulator on this Mac) instead of the tailnet address — `--local` is taken by the global remote flag')
9923
+ .option('--android [serial]', 'Hand the link straight to a USB-connected Android phone (adb reverse + am start · the token is never printed)')
9924
+ .option('--temp', 'Use a short-lived token instead of the owner token')
9925
+ .option('--ttl <duration>', 'Lifetime for --temp (max 24h)', '24h')
9926
+ .option('--no-qr', 'Do not print a QR code')
9927
+ .action(async (opts: { simulator?: boolean; android?: boolean | string; temp?: boolean; ttl: string; qr: boolean }) => {
9928
+ const { runPhoneLink } = await import('./cli/phone-link.js');
9929
+ process.exitCode = await runPhoneLink({ local: opts.simulator, android: opts.android, temp: opts.temp, ttl: opts.ttl, qr: opts.qr });
9930
+ });
9931
+
9938
9932
  // ── ACP — Agent Client Protocol (claude-code, codex, ...) ──
9939
9933
  const tokenCmd = program
9940
9934
  .command('token')
@@ -0,0 +1,143 @@
1
+ // 현장 업로드 입구 — POST/GET /v1/field/uploads (owner 전용).
2
+ // 저장 규칙은 src/field/field-media.ts 가 유일한 정본이고 텔레그램 `#현장` 도 같은 함수를 쓴다.
3
+
4
+ import { debug } from '../../debug/log.js';
5
+ import { getElanousConfigDir } from '../../elanous-config-dir.js';
6
+ import {
7
+ FIELD_MAX_FILE_BYTES,
8
+ FIELD_MAX_REQUEST_BYTES,
9
+ FieldUploadError,
10
+ fieldEventDir,
11
+ isFieldSlug,
12
+ listFieldMedia,
13
+ saveFieldMedia,
14
+ type FieldMediaInput,
15
+ } from '../../field/field-media.js';
16
+ import { jsonResponse } from './json-response.js';
17
+
18
+ export const FIELD_UPLOADS_PATH = '/v1/field/uploads';
19
+
20
+ /** 멀티파트 경계·헤더 여유. */
21
+ const MULTIPART_SLACK_BYTES = 4 * 1024 * 1024;
22
+ /** JSON 본문은 base64(×4/3) 라 더 크다. */
23
+ const JSON_MAX_BODY_BYTES = Math.ceil((FIELD_MAX_REQUEST_BYTES * 4) / 3) + MULTIPART_SLACK_BYTES;
24
+ /** Bun.serve 의 `maxRequestBodySize`(기본 128 MB) — 이 입구의 JSON 상한까지 받도록 서버가 이 값을 쓴다. */
25
+ export const FIELD_SERVER_MAX_BODY_BYTES = JSON_MAX_BODY_BYTES;
26
+
27
+ interface FieldUploadsDeps {
28
+ authorize?: (request: Request) => boolean;
29
+ /** config 뿌리 — 기본 `getElanousConfigDir()`(데몬의 다른 저장소와 같은 해석). 시험은 temp 를 준다. */
30
+ rootDir?: () => string;
31
+ now?: () => Date;
32
+ }
33
+
34
+ function errorResponse(error: FieldUploadError): Response {
35
+ return jsonResponse({ ok: false, error: error.code, reason: error.message }, error.status);
36
+ }
37
+
38
+ function parseCapturedAtList(values: FormDataEntryValue[]): Array<string | undefined> {
39
+ const strings = values.filter((v): v is string => typeof v === 'string');
40
+ if (strings.length === 1 && strings[0]!.trim().startsWith('[')) {
41
+ try {
42
+ const arr = JSON.parse(strings[0]!) as unknown;
43
+ if (Array.isArray(arr)) return arr.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : undefined));
44
+ } catch { /* 배열이 아니면 단일 값으로 */ }
45
+ }
46
+ return strings;
47
+ }
48
+
49
+ async function readMultipart(req: Request): Promise<FieldMediaInput[]> {
50
+ let form: FormData;
51
+ try { form = await req.formData(); }
52
+ catch { throw new FieldUploadError('bad-request', 'invalid multipart body'); }
53
+ const files = form.getAll('file').filter((v): v is File => typeof v !== 'string');
54
+ const captured = parseCapturedAtList(form.getAll('capturedAt'));
55
+ let total = 0;
56
+ const inputs: FieldMediaInput[] = [];
57
+ for (const [i, file] of files.entries()) {
58
+ if (file.size > FIELD_MAX_FILE_BYTES) throw new FieldUploadError('too-large', 'a file exceeds 100 MB');
59
+ total += file.size;
60
+ if (total > FIELD_MAX_REQUEST_BYTES) throw new FieldUploadError('too-large', 'the request exceeds 250 MB');
61
+ inputs.push({
62
+ originalName: file.name || 'media',
63
+ mimeType: file.type,
64
+ bytes: new Uint8Array(await file.arrayBuffer()),
65
+ ...(captured[i] ? { capturedAt: captured[i] } : {}),
66
+ });
67
+ }
68
+ return inputs;
69
+ }
70
+
71
+ async function readJson(req: Request): Promise<FieldMediaInput[]> {
72
+ let body: unknown;
73
+ try { body = await req.json(); }
74
+ catch { throw new FieldUploadError('bad-request', 'JSON body required'); }
75
+ const files = body && typeof body === 'object' && !Array.isArray(body) ? (body as { files?: unknown }).files : undefined;
76
+ if (!Array.isArray(files)) throw new FieldUploadError('bad-request', 'files[] required');
77
+ return files.map((raw) => {
78
+ const f = (raw ?? {}) as Record<string, unknown>;
79
+ if (typeof f.name !== 'string' || typeof f.dataBase64 !== 'string') {
80
+ throw new FieldUploadError('bad-request', 'each file needs name and dataBase64');
81
+ }
82
+ // base64 길이로 먼저 거른다 — 파일 상한(FIELD_MAX_FILE_BYTES)을 넘는 것을 디코드하지 않는다.
83
+ if (Math.floor((f.dataBase64.length * 3) / 4) > FIELD_MAX_FILE_BYTES + 3) {
84
+ throw new FieldUploadError('too-large', 'a file exceeds 100 MB');
85
+ }
86
+ const capturedAt = typeof f.capturedAt === 'string' || typeof f.capturedAt === 'number' ? f.capturedAt : undefined;
87
+ return {
88
+ originalName: f.name,
89
+ ...(typeof f.mimeType === 'string' ? { mimeType: f.mimeType } : {}),
90
+ bytes: new Uint8Array(Buffer.from(f.dataBase64, 'base64')),
91
+ ...(capturedAt !== undefined ? { capturedAt } : {}),
92
+ };
93
+ });
94
+ }
95
+
96
+ export async function handleFieldUploads(req: Request, deps: FieldUploadsDeps = {}): Promise<Response> {
97
+ const url = new URL(req.url);
98
+ if (url.pathname !== FIELD_UPLOADS_PATH) return jsonResponse({ error: 'not-found' }, 404);
99
+ if (!deps.authorize?.(req)) return jsonResponse({ error: 'unauthorized' }, 401);
100
+ const rootDir = (deps.rootDir ?? getElanousConfigDir)();
101
+ const event = url.searchParams.get('event') ?? '';
102
+ if (!isFieldSlug(event)) return errorResponse(new FieldUploadError('bad-slug', 'event slug must match ^[a-z0-9][a-z0-9._-]{0,63}$'));
103
+
104
+ if (req.method === 'GET') {
105
+ const files = listFieldMedia(fieldEventDir(rootDir, event));
106
+ debug.log('field.upload', 'listed', { count: files.length });
107
+ return jsonResponse({ event, count: files.length, files });
108
+ }
109
+ if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405);
110
+
111
+ const device = url.searchParams.get('device') ?? '';
112
+ if (!isFieldSlug(device)) return errorResponse(new FieldUploadError('bad-slug', 'device slug must match ^[a-z0-9][a-z0-9._-]{0,63}$'));
113
+
114
+ const contentType = (req.headers.get('content-type') ?? '').toLowerCase();
115
+ const isMultipart = contentType.startsWith('multipart/form-data');
116
+ const isJson = contentType.startsWith('application/json');
117
+ if (!isMultipart && !isJson) {
118
+ return errorResponse(new FieldUploadError('unsupported-media-type', 'use multipart/form-data or application/json'));
119
+ }
120
+ const declared = Number(req.headers.get('content-length') ?? '');
121
+ const bodyCap = isJson ? JSON_MAX_BODY_BYTES : FIELD_MAX_REQUEST_BYTES + MULTIPART_SLACK_BYTES;
122
+ if (Number.isFinite(declared) && declared > bodyCap) {
123
+ debug.log('field.upload', 'refused', { reason: 'too-large', declaredBytes: declared });
124
+ return errorResponse(new FieldUploadError('too-large', 'the request exceeds 250 MB'));
125
+ }
126
+
127
+ try {
128
+ const inputs = isMultipart ? await readMultipart(req) : await readJson(req);
129
+ const caption = url.searchParams.get('caption') ?? undefined;
130
+ const result = saveFieldMedia(inputs, { rootDir, event, device, ...(caption ? { caption } : {}), ...(deps.now ? { now: deps.now } : {}) });
131
+ debug.log('field.upload', 'saved', {
132
+ surface: 'http', files: result.saved.length, bytes: result.saved.reduce((n, s) => n + s.bytes, 0), count: result.count,
133
+ });
134
+ return jsonResponse({ ok: true, event, saved: result.saved, count: result.count });
135
+ } catch (error) {
136
+ if (error instanceof FieldUploadError) {
137
+ debug.log('field.upload', 'refused', { surface: 'http', reason: error.code });
138
+ return errorResponse(error);
139
+ }
140
+ debug.log('field.upload', 'failed', { surface: 'http' });
141
+ throw error;
142
+ }
143
+ }
@@ -18,7 +18,54 @@ interface GraphApprovalItem {
18
18
  recent: Array<{ nodeId: string; ok: boolean; outcome?: string; summary?: string }>;
19
19
  }
20
20
 
21
+ /** One decision this daemon applied through `POST /v1/graph-approvals/:graphId/:runId`. */
22
+ interface GraphApprovalDecided {
23
+ graphId: string;
24
+ runId: string;
25
+ nodeId: string;
26
+ decision: 'approved' | 'rejected';
27
+ decidedAt: string;
28
+ }
29
+
30
+ export const GRAPH_DECISION_RING_CAPACITY = 100;
31
+ export const GRAPH_DECISION_RING_MAX_AGE_MS = 30 * 60 * 1000;
32
+
33
+ /**
34
+ * Recent decisions, in memory. The undecided list drops a run the moment it is decided, so
35
+ * another device (Fold ↔ iPhone) could only see «gone» — this ring lets it say «승인됨/거절됨».
36
+ * ⚠️ In-memory: a daemon restart forgets it, and a decision made outside this process
37
+ * (`elanous graph approve` CLI) is not recorded — clients keep a «decided elsewhere» fallback.
38
+ */
39
+ export function createGraphDecisionRing(opts: { capacity?: number; maxAgeMs?: number; now?: () => number } = {}) {
40
+ const capacity = opts.capacity ?? GRAPH_DECISION_RING_CAPACITY;
41
+ const maxAgeMs = opts.maxAgeMs ?? GRAPH_DECISION_RING_MAX_AGE_MS;
42
+ const now = opts.now ?? Date.now;
43
+ let entries: Array<{ atMs: number; record: GraphApprovalDecided }> = [];
44
+ const prune = () => {
45
+ const cutoff = now() - maxAgeMs;
46
+ entries = entries.filter((entry) => entry.atMs > cutoff).slice(-capacity);
47
+ };
48
+ return {
49
+ record(decision: Omit<GraphApprovalDecided, 'decidedAt'>): void {
50
+ const atMs = now();
51
+ entries.push({ atMs, record: { ...decision, decidedAt: new Date(atMs).toISOString() } });
52
+ prune();
53
+ },
54
+ /** Newest first, pruned by age and capacity. */
55
+ list(): GraphApprovalDecided[] {
56
+ prune();
57
+ return entries.map((entry) => entry.record).reverse();
58
+ },
59
+ };
60
+ }
61
+
62
+ export type GraphDecisionRing = ReturnType<typeof createGraphDecisionRing>;
63
+
64
+ const defaultDecisionRing = createGraphDecisionRing();
65
+
21
66
  interface GraphApprovalsDeps {
67
+ /** Injected in tests; production shares one per-process ring. */
68
+ decisions?: GraphDecisionRing;
22
69
  authorize?: (request: Request) => boolean;
23
70
  /** Credential the request carries, checked independently of same-origin (`bearerCredential`).
24
71
  * A decision needs a bearer token: same-origin alone is not trusted because behind
@@ -90,7 +137,8 @@ export async function handleGraphApprovals(req: Request, deps: GraphApprovalsDep
90
137
  if (!deps.authorize?.(req)) return jsonResponse({ error: 'unauthorized' }, 401);
91
138
  if (pathname === GRAPH_APPROVALS_PATH) {
92
139
  if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
93
- return jsonResponse({ items: list(deps.root ?? effectiveInstanceRoot()) });
140
+ // ⭐ `items` is unchanged; `decided` is additive (newest first) for other devices' cards.
141
+ return jsonResponse({ items: list(deps.root ?? effectiveInstanceRoot()), decided: (deps.decisions ?? defaultDecisionRing).list() });
94
142
  }
95
143
  if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405);
96
144
  const authReason = deps.authReason?.(req);
@@ -120,6 +168,7 @@ export async function handleGraphApprovals(req: Request, deps: GraphApprovalsDep
120
168
  if (error instanceof Error && error.message.startsWith('run is not awaiting an undecided approval:')) return jsonResponse({ error: 'already-decided' }, 409);
121
169
  throw error;
122
170
  }
171
+ (deps.decisions ?? defaultDecisionRing).record({ graphId, runId, nodeId, decision });
123
172
  debug.log('approvals.graph', 'decided', { graphId, runId, nodeId, decision, by: `pwa:${authReason}` });
124
173
  return jsonResponse({ graphId, runId, decision });
125
174
  }