elanous 0.2.21 → 0.2.22

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 (315) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/1307-08b716476f1d50af.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/{2489-ff55a35b202c0f94.js → 2489-d238d3e2eb5b2bf5.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/3857-5985236d9307f676.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/5633-d6852385000738ef.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/5977-5b9e8a45a5b0c823.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/8857-aa1cc4ac90af4d40.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/9247-55bcfbb0e8057f59.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/approvals/page-88459e0903da2803.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-d781be3e3a9cfd74.js → page-d85c43f46f58716b.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/board/{page-b0a80997903852d8.js → page-4e645600579ae952.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-2b1263165630d9c8.js → page-87edbfd19117823b.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/bots/page-7f0c2b7801ac6a54.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/ceo/page-bf1f030a9d1adffd.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/chat/{page-e2d6bed48ac20120.js → page-cbaf66b1e1804adf.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/consult/{page-111cabe4d1fd69ae.js → page-80ed511e02c81412.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/control/{page-544dd4bd4847eeb7.js → page-9f5610caaa06178c.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/decisions/{page-3ee191319bf97d87.js → page-ec8f3575e339de74.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/design-check/page-86d753e460f1a989.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/editor/page-4166a7d51cf0a57d.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/exec/{page-a449f1ed472b81c2.js → page-2a19c5cd3b562580.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/field/{page-9045094d6d3b5cd0.js → page-b6a09cde2e07521a.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/inside/{page-c06673596849ef45.js → page-f7eae1518ca0c353.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/intake/page-456a8573ad811381.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/{layout-9f81233f29dcd409.js → layout-8462d6a543cbb2d9.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/live/page-2974cdfb87c0f9c9.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/live-run/page-f927ffa25f3c86d1.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/loops/page-abd86cd832aaa011.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/market/page-79daf68f6256adaa.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8c3f168fc3f91939.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/morning/{page-7a12f61be4a579d1.js → page-786fb4504d0f2fa9.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/{not-found-c85b0589591a99c9.js → not-found-75209f14597dcb8f.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-725a4da02dd129eb.js → page-bf30affe3a85af7e.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-1187a44b7515434b.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-3d0910185a66ed54.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-f401ee1dd5a26e25.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/outputs/page-5dfe7604fd842784.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/{page-21bec4afeb2f0917.js → page-76cf838050a8d681.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-dc39ee7a36d26385.js → page-7ec8e289b4c14df6.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-84ae759f76053672.js → page-8c8ac17d52be2a79.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-3db58ddb61fe2be9.js → page-d359ab048d63a9d9.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-c5315e30f3528dc4.js → page-b8b345c880bf78d1.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/settings/{page-2fe368efb6de3b6a.js → page-f151cc10ad026940.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-9875f103057da433.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/setup/page-9789328af9a9aa62.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/share/{page-c69762987b751174.js → page-bf495a1e284f4e00.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/showroom/page-a263d94bca4be22a.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/tasks/page-3962eab8ec58d16e.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/term/page-9abcfac07e02a542.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/today/page-bee49f927c333abb.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/trace/page-bac2d56dcb02d955.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/vault/page-937cbcc13176295c.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d09495594f7f480d.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-7f8d7fcdf058a845.js → page-69cb93d1a2e57e0e.js} +1 -1
  56. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-22163556cd38bc88.js → page-72ec543928d67c3e.js} +1 -1
  57. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-82906eeda0186a7b.js +1 -0
  58. package/apps/pwa/out/_next/static/css/f2bd5257e6af88d8.css +1 -0
  59. package/apps/pwa/out/approvals/index.html +1 -1
  60. package/apps/pwa/out/approvals/index.txt +14 -14
  61. package/apps/pwa/out/autopilot/index.html +1 -1
  62. package/apps/pwa/out/autopilot/index.txt +14 -14
  63. package/apps/pwa/out/board/index.html +1 -1
  64. package/apps/pwa/out/board/index.txt +14 -14
  65. package/apps/pwa/out/botlab/index.html +1 -1
  66. package/apps/pwa/out/botlab/index.txt +14 -14
  67. package/apps/pwa/out/bots/index.html +1 -1
  68. package/apps/pwa/out/bots/index.txt +14 -14
  69. package/apps/pwa/out/ceo/index.html +1 -1
  70. package/apps/pwa/out/ceo/index.txt +14 -14
  71. package/apps/pwa/out/chat/index.html +1 -1
  72. package/apps/pwa/out/chat/index.txt +14 -14
  73. package/apps/pwa/out/consult/index.html +1 -1
  74. package/apps/pwa/out/consult/index.txt +14 -14
  75. package/apps/pwa/out/control/index.html +1 -1
  76. package/apps/pwa/out/control/index.txt +14 -14
  77. package/apps/pwa/out/decisions/index.html +1 -1
  78. package/apps/pwa/out/decisions/index.txt +14 -14
  79. package/apps/pwa/out/design-check/index.html +1 -1
  80. package/apps/pwa/out/design-check/index.txt +14 -14
  81. package/apps/pwa/out/editor/index.html +1 -1
  82. package/apps/pwa/out/editor/index.txt +14 -14
  83. package/apps/pwa/out/exec/index.html +1 -1
  84. package/apps/pwa/out/exec/index.txt +14 -14
  85. package/apps/pwa/out/field/index.html +1 -1
  86. package/apps/pwa/out/field/index.txt +14 -14
  87. package/apps/pwa/out/healthz +1 -1
  88. package/apps/pwa/out/index.html +1 -1
  89. package/apps/pwa/out/index.txt +14 -14
  90. package/apps/pwa/out/inside/index.html +1 -1
  91. package/apps/pwa/out/inside/index.txt +14 -14
  92. package/apps/pwa/out/intake/index.html +1 -1
  93. package/apps/pwa/out/intake/index.txt +14 -14
  94. package/apps/pwa/out/live/index.html +1 -1
  95. package/apps/pwa/out/live/index.txt +14 -14
  96. package/apps/pwa/out/live-run/index.html +1 -1
  97. package/apps/pwa/out/live-run/index.txt +14 -14
  98. package/apps/pwa/out/loops/index.html +1 -1
  99. package/apps/pwa/out/loops/index.txt +14 -14
  100. package/apps/pwa/out/market/index.html +1 -1
  101. package/apps/pwa/out/market/index.txt +14 -14
  102. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  103. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  104. package/apps/pwa/out/morning/index.html +1 -1
  105. package/apps/pwa/out/morning/index.txt +14 -14
  106. package/apps/pwa/out/observatory/index.html +1 -1
  107. package/apps/pwa/out/observatory/index.txt +14 -14
  108. package/apps/pwa/out/ops/checklist/index.html +1 -1
  109. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  110. package/apps/pwa/out/ops/release/index.html +1 -1
  111. package/apps/pwa/out/ops/release/index.txt +14 -14
  112. package/apps/pwa/out/ops/seats/index.html +1 -1
  113. package/apps/pwa/out/ops/seats/index.txt +14 -14
  114. package/apps/pwa/out/outputs/index.html +1 -1
  115. package/apps/pwa/out/outputs/index.txt +14 -14
  116. package/apps/pwa/out/reflection/index.html +1 -1
  117. package/apps/pwa/out/reflection/index.txt +15 -15
  118. package/apps/pwa/out/scheduler/index.html +1 -1
  119. package/apps/pwa/out/scheduler/index.txt +14 -14
  120. package/apps/pwa/out/sessions/index.html +1 -1
  121. package/apps/pwa/out/sessions/index.txt +14 -14
  122. package/apps/pwa/out/settings/devices/index.html +1 -1
  123. package/apps/pwa/out/settings/devices/index.txt +14 -14
  124. package/apps/pwa/out/settings/index.html +1 -1
  125. package/apps/pwa/out/settings/index.txt +14 -14
  126. package/apps/pwa/out/setup/done/index.html +1 -1
  127. package/apps/pwa/out/setup/done/index.txt +14 -14
  128. package/apps/pwa/out/setup/index.html +1 -1
  129. package/apps/pwa/out/setup/index.txt +14 -14
  130. package/apps/pwa/out/share/index.html +1 -1
  131. package/apps/pwa/out/share/index.txt +14 -14
  132. package/apps/pwa/out/showroom/index.html +1 -1
  133. package/apps/pwa/out/showroom/index.txt +14 -14
  134. package/apps/pwa/out/tasks/index.html +1 -1
  135. package/apps/pwa/out/tasks/index.txt +14 -14
  136. package/apps/pwa/out/term/index.html +1 -1
  137. package/apps/pwa/out/term/index.txt +14 -14
  138. package/apps/pwa/out/today/index.html +1 -1
  139. package/apps/pwa/out/today/index.txt +14 -14
  140. package/apps/pwa/out/trace/index.html +1 -1
  141. package/apps/pwa/out/trace/index.txt +14 -14
  142. package/apps/pwa/out/vault/index.html +1 -1
  143. package/apps/pwa/out/vault/index.txt +14 -14
  144. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  145. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  146. package/apps/pwa/out/workflows/index.html +1 -1
  147. package/apps/pwa/out/workflows/index.txt +14 -14
  148. package/apps/pwa/out/workspace/index.html +1 -1
  149. package/apps/pwa/out/workspace/index.txt +14 -14
  150. package/apps/pwa/out/worktrees/index.html +1 -1
  151. package/apps/pwa/out/worktrees/index.txt +14 -14
  152. package/graphs/catalog/catalog.yaml +1 -1
  153. package/graphs/drafts/recipes.yaml +2 -2
  154. package/graphs/test-diet/recipes.yaml +3 -0
  155. package/graphs/test-diet/test-diet.yaml +3 -1
  156. package/package.json +1 -1
  157. package/scripts/f-session-monitor.ts +69 -0
  158. package/scripts/hq/standby-snapshot.ts +7 -3
  159. package/scripts/knowledge-compress.ts +11 -0
  160. package/scripts/nightly-docops.ts +90 -3
  161. package/scripts/research-coo-admin-dates.ts +39 -0
  162. package/scripts/skills/public-check.ts +7 -2
  163. package/scripts/smart-merge/resync-shadow.ts +5 -3
  164. package/scripts/test-diet/lib.ts +81 -1
  165. package/scripts/test-diet/node.ts +48 -2
  166. package/scripts/verify-coo-admin-evidence.ts +83 -0
  167. package/src/acp/client.ts +4 -2
  168. package/src/agent/run-context.ts +4 -0
  169. package/src/agent-env/profile.ts +25 -5
  170. package/src/agent-mission/driver.ts +26 -12
  171. package/src/agent-mission/supervisor-metrics.ts +17 -2
  172. package/src/agent-substrate/acp-reviewer.ts +12 -5
  173. package/src/agent-substrate/pr-reviewer.ts +11 -4
  174. package/src/agent-substrate/review-intent.ts +102 -3
  175. package/src/autopilot/build/llm-conflict-merge.ts +22 -8
  176. package/src/autopilot/pr-manager.ts +18 -8
  177. package/src/away/release-watch.ts +47 -8
  178. package/src/briefing/brief-items.ts +104 -11
  179. package/src/chat/index.ts +1 -0
  180. package/src/cli/brief-cli.ts +20 -2
  181. package/src/cli/config-classify.ts +83 -0
  182. package/src/cli/control-cli.ts +14 -0
  183. package/src/cli/decisions-cli.ts +26 -16
  184. package/src/cli/logs-model-input.ts +1 -1
  185. package/src/cli/pr-cli.ts +65 -8
  186. package/src/cli/release-cli.ts +21 -4
  187. package/src/cli/tasks-cli.ts +108 -1
  188. package/src/connectors/types.ts +6 -6
  189. package/src/context-bus/context-now-surfaces.ts +18 -0
  190. package/src/context-bus/coord-events.ts +32 -4
  191. package/src/context-bus/long-term-memory.ts +23 -6
  192. package/src/context-bus/nightly-condense.ts +169 -21
  193. package/src/control/control-status.ts +87 -0
  194. package/src/control-plane/light-member.ts +5 -3
  195. package/src/control-plane/member.ts +172 -7
  196. package/src/core-turn/run-goal-loop.ts +22 -1
  197. package/src/dashboard/slash-runtime/dashboard-handlers.ts +9 -0
  198. package/src/decisions/decision-cards.ts +20 -1
  199. package/src/decisions/decision-ledger.ts +11 -8
  200. package/src/decisions/web-push-decision-cards.ts +19 -3
  201. package/src/discord-self-message.ts +2 -2
  202. package/src/domains/coo-admin-dates.evidence.json +876 -0
  203. package/src/domains/coo-admin-tool.ts +48 -14
  204. package/src/domains/ops-status.ts +53 -0
  205. package/src/domains/outbound-alert.ts +24 -1
  206. package/src/exec-requests/planner.ts +6 -0
  207. package/src/graph-kinds/registry.ts +6 -6
  208. package/src/graph-runner/graph-cli.ts +34 -2
  209. package/src/graph-runner/graph-run-api.ts +7 -0
  210. package/src/graph-runner/graph-tick.ts +73 -12
  211. package/src/graph-runner/runner.ts +43 -11
  212. package/src/graph-unify/wf-to-graph.ts +79 -0
  213. package/src/graph-wizard/generate.ts +34 -2
  214. package/src/graph-wizard/steps.ts +13 -0
  215. package/src/harness/harness-cli-command.ts +121 -23
  216. package/src/harness/harness-queue.ts +58 -14
  217. package/src/harness/launch-advice.ts +23 -0
  218. package/src/index.ts +36 -7
  219. package/src/knowledge/conclusion-compress.ts +93 -0
  220. package/src/knowledge/know-find.ts +9 -2
  221. package/src/knowledge/query.ts +20 -0
  222. package/src/llm.ts +26 -16
  223. package/src/loops/neighbor-runtime.ts +88 -0
  224. package/src/loops/orchestrator/author-depth.ts +29 -11
  225. package/src/loops/orchestrator/overlap.ts +7 -1
  226. package/src/loops/orchestrator/resync-shadow.ts +1 -1
  227. package/src/loops/orchestrator/tick.ts +35 -2
  228. package/src/loops/orchestrator/traffic.ts +2 -0
  229. package/src/loops/registry.ts +5 -0
  230. package/src/market/publish.ts +17 -1
  231. package/src/maturity/feature-maturity.ts +2 -1
  232. package/src/mss/logging/redaction.ts +17 -0
  233. package/src/nexus/api/draft-metrics.ts +22 -8
  234. package/src/nexus/api/graph-peer-edit.ts +77 -0
  235. package/src/nexus/api/graph-wizard-api.ts +9 -1
  236. package/src/nexus/api/graphs-api.ts +145 -7
  237. package/src/nexus/api/http-server.ts +93 -8
  238. package/src/nexus/api/plugins-market.ts +24 -0
  239. package/src/nexus/index.ts +3 -2
  240. package/src/oauth/claude-pty-maturity.ts +24 -0
  241. package/src/oauth/fallback-chain.ts +27 -14
  242. package/src/plugins/maker/market-bundle.ts +9 -0
  243. package/src/plugins/maker/plugin-maker.ts +9 -3
  244. package/src/plugins/maker/wizard-generate.ts +73 -9
  245. package/src/policy/llm-policy.ts +11 -6
  246. package/src/release-loop/checklist-board.ts +27 -0
  247. package/src/release-loop/checklist.ts +38 -0
  248. package/src/seat-address/seat-address.ts +1 -0
  249. package/src/seat-loop/seat-loop.ts +174 -20
  250. package/src/seat-loop/seat-report.ts +29 -1
  251. package/src/self-dev/dev-cli.ts +90 -8
  252. package/src/self-dev/dev-pipeline.ts +20 -1
  253. package/src/self-dev/draft-sweep.ts +45 -8
  254. package/src/self-dev/orchestrate.ts +3 -0
  255. package/src/self-dev/review-provider-fallback.ts +83 -0
  256. package/src/self-dev/run-supervisor.ts +5 -1
  257. package/src/self-implement/harness-policy-gates.ts +7 -5
  258. package/src/self-implement/host-regate.ts +28 -14
  259. package/src/self-implement/l8-merge-queue.ts +80 -8
  260. package/src/self-implement/orchestrator.ts +68 -27
  261. package/src/self-implement/seams.ts +5 -3
  262. package/src/self-implement/self-implement-cli.ts +4 -0
  263. package/src/shell-env-bootstrap.ts +19 -4
  264. package/src/steward/triage.ts +25 -8
  265. package/src/storage/file-lock.ts +33 -4
  266. package/src/task-agent/completion-evidence.ts +31 -3
  267. package/src/task-agent/cover.ts +61 -0
  268. package/src/task-agent/live-moves.ts +126 -3
  269. package/src/task-agent/shadow.ts +19 -4
  270. package/src/task-agent/task-hand.ts +2 -0
  271. package/src/task-orchestrator/surfaces/pod-pool.ts +138 -15
  272. package/src/task-orchestrator/surfaces/self-implement.ts +7 -0
  273. package/src/telegram-commands.ts +8 -0
  274. package/src/telegram-project-command.ts +6 -0
  275. package/src/telegram.ts +2 -2
  276. package/src/typecheck-ratchet.ts +2 -1
  277. package/src/user-config.ts +35 -4
  278. package/src/version/packed-revision.json +1 -1
  279. package/src/workflow-runtime/executor.ts +24 -2
  280. package/src/workflow-runtime/triggers/registry.ts +27 -0
  281. package/src/workflow-runtime/triggers/schedule-source.ts +30 -3
  282. package/apps/pwa/out/_next/static/chunks/1307-c97eba9fb72a9a5e.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/3857-995ea9f5ef33d71e.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/5977-59fedea50d13c762.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/8857-f1762140ef9a96b1.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/9247-7581825d511a64c0.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/9497-b0e758d1cd4ae8d7.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/approvals/page-937c92292a4ab4f0.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/bots/page-ea998f7f5d156bea.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/ceo/page-33c328b64ca508f8.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e4bcb2c503dab04b.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/editor/page-300897dcbbd5ed76.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/intake/page-103fa3191c1b2e08.js +0 -1
  294. package/apps/pwa/out/_next/static/chunks/app/live/page-be8b4615c919e36c.js +0 -1
  295. package/apps/pwa/out/_next/static/chunks/app/live-run/page-c685b104e0dfa609.js +0 -1
  296. package/apps/pwa/out/_next/static/chunks/app/loops/page-8d693331cfbb6add.js +0 -1
  297. package/apps/pwa/out/_next/static/chunks/app/market/page-cef4f30a4c22fae1.js +0 -1
  298. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-66ea1b54fc83fa15.js +0 -1
  299. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-8cc707bd09a497d9.js +0 -1
  300. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-90b55be1f15ce0e8.js +0 -1
  301. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cb16606e08be65db.js +0 -1
  302. package/apps/pwa/out/_next/static/chunks/app/outputs/page-16dc64b74ed6a40a.js +0 -1
  303. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-8d04132dd0ac5e10.js +0 -1
  304. package/apps/pwa/out/_next/static/chunks/app/setup/page-f23484de467c9ea3.js +0 -1
  305. package/apps/pwa/out/_next/static/chunks/app/showroom/page-5456692bda39aafb.js +0 -1
  306. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d75f7cd65845ce98.js +0 -1
  307. package/apps/pwa/out/_next/static/chunks/app/term/page-5603f5e568b25ce3.js +0 -1
  308. package/apps/pwa/out/_next/static/chunks/app/today/page-d2cc0f4e89f42028.js +0 -1
  309. package/apps/pwa/out/_next/static/chunks/app/trace/page-a6eb73b8a4840f1c.js +0 -1
  310. package/apps/pwa/out/_next/static/chunks/app/vault/page-c0a3f4d10edb83fa.js +0 -1
  311. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-6c0b88068c143f15.js +0 -1
  312. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-141436fd26dd56fc.js +0 -1
  313. package/apps/pwa/out/_next/static/css/e81df11490b4413b.css +0 -1
  314. /package/apps/pwa/out/_next/static/{MObU8ujkMciAKr6ljAV05 → aBi6EyMtLsrl7J8eH4kF7}/_buildManifest.js +0 -0
  315. /package/apps/pwa/out/_next/static/{MObU8ujkMciAKr6ljAV05 → aBi6EyMtLsrl7J8eH4kF7}/_ssgManifest.js +0 -0
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
4
5
  import { parse as parseYaml } from 'yaml';
5
6
  import { elanousStateRoot } from '../../autopilot/state-paths.js';
6
7
  import { debug } from '../../debug/log.js';
@@ -10,6 +11,7 @@ import { validateGraphYaml } from './graph-kinds.js';
10
11
  import { parseWizardSteps, readWizardSteps, withWizardSteps } from '../../graph-wizard/saved-steps.js';
11
12
  import type { WizardNodeStep } from '../../graph-wizard/steps.js';
12
13
  import { jsonResponse } from './json-response.js';
14
+ import { clearPeerEdit, peerEditRefusalBody, peerEditRunGate, readPeerEdit, writePeerEdit } from './graph-peer-edit.js';
13
15
 
14
16
  /** Declared graph ids only. A user string is never joined onto a filesystem path. */
15
17
  const GRAPH_ID = /^[a-z0-9-]+$/;
@@ -44,6 +46,93 @@ function mineGraphsDir(): string {
44
46
  return join(elanousStateRoot(), 'graphs');
45
47
  }
46
48
 
49
+ export type GraphPermission = 'view' | 'edit';
50
+ type GraphGrant = { recipient: string; permission: GraphPermission };
51
+ const TOKEN_PATTERN = /^eg_[A-Za-z0-9_-]{32,}$/;
52
+
53
+ function accessFile(dir: string, id: string): string {
54
+ return join(dir, '.access', `${id}.json`);
55
+ }
56
+ function tokenHash(token: string): string {
57
+ return createHash('sha256').update(token).digest('hex');
58
+ }
59
+ function accessGrants(dir: string, id: string): GraphGrant[] {
60
+ let text: string;
61
+ try { text = readFileSync(accessFile(dir, id), 'utf8'); }
62
+ catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
64
+ throw error;
65
+ }
66
+ const parsed: unknown = JSON.parse(text);
67
+ if (!Array.isArray(parsed) || !parsed.every((entry): entry is GraphGrant => entry !== null && typeof entry === 'object'
68
+ && typeof entry.recipient === 'string' && /^[a-f0-9]{64}$/.test(entry.recipient)
69
+ && (entry.permission === 'view' || entry.permission === 'edit'))) throw new Error('invalid graph access store');
70
+ return parsed;
71
+ }
72
+
73
+ /** Only a hash is persisted; no owner bearer or raw recipient credential goes into a graph/YAML. */
74
+ export function handleGraphAccess(pathname: string, req: Request, deps: GraphsApiDeps = {}): Response {
75
+ const match = /^\/v1\/graphs\/([^/]+)\/access$/.exec(pathname);
76
+ const id = match ? resolveId(match[1]!) : null;
77
+ if (!id) return jsonResponse({ error: 'bad_request' }, 400);
78
+ const owned = mineOwnership(id, deps);
79
+ if (owned instanceof Response) return owned;
80
+ if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
81
+ try { return jsonResponse({ grants: accessGrants(owned.mineDir, id) }); }
82
+ catch (error) { return saveFailed(id, error); }
83
+ }
84
+
85
+ export async function handleGraphAccessPut(pathname: string, req: Request, deps: GraphsApiDeps = {}): Promise<Response> {
86
+ const match = /^\/v1\/graphs\/([^/]+)\/access$/.exec(pathname);
87
+ const id = match ? resolveId(match[1]!) : null;
88
+ if (!id) return jsonResponse({ error: 'bad_request' }, 400);
89
+ const owned = mineOwnership(id, deps);
90
+ if (owned instanceof Response) return owned;
91
+ // Detail GET resolves a core id first; do not grant a token for a shadowed mine id.
92
+ if (indexByDeclaredId(deps.coreDir ?? defaultGraphsDir()).has(id)) return jsonResponse({ error: 'core-id-conflict', id }, 409);
93
+ let body: { recipient?: unknown; permission?: unknown };
94
+ try { body = await req.json() as typeof body; }
95
+ catch { return jsonResponse({ error: 'bad_request' }, 400); }
96
+ if (!body || typeof body.recipient !== 'string' || !TOKEN_PATTERN.test(body.recipient)
97
+ || (body.permission !== 'view' && body.permission !== 'edit')) {
98
+ return jsonResponse({ error: 'bad_request', reason: 'recipient access token and view/edit permission required' }, 400);
99
+ }
100
+ const recipient = tokenHash(body.recipient);
101
+ const dir = join(owned.mineDir, '.access');
102
+ try {
103
+ const grants = accessGrants(owned.mineDir, id).filter((grant) => grant.recipient !== recipient);
104
+ grants.push({ recipient, permission: body.permission });
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ const target = accessFile(owned.mineDir, id);
107
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
108
+ try {
109
+ writeFileSync(temp, JSON.stringify(grants), { mode: 0o600 });
110
+ renameSync(temp, target);
111
+ } finally { rmSync(temp, { force: true }); }
112
+ } catch (error) { return saveFailed(id, error); }
113
+ return jsonResponse({ recipient, permission: body.permission });
114
+ }
115
+
116
+ /** The peer token works only on this graph and the requested read/edit operation. */
117
+ export function graphAccessForRequest(pathname: string, method: string, token: string | null, deps: GraphsApiDeps = {}): boolean {
118
+ const match = /^\/v1\/graphs\/([^/]+)(?:\/(yaml))?$/.exec(pathname);
119
+ const id = match ? resolveId(match[1]!) : null;
120
+ if (!id || !token || !TOKEN_PATTERN.test(token)) return false;
121
+ if (method !== 'GET' && !(method === 'PUT' && match?.[2] === 'yaml')) return false;
122
+ try {
123
+ if (!indexByDeclaredId(deps.mineDir ?? mineGraphsDir()).has(id)) return false;
124
+ if (indexByDeclaredId(deps.coreDir ?? defaultGraphsDir()).has(id)) return false;
125
+ const wanted = tokenHash(token);
126
+ return accessGrants(deps.mineDir ?? mineGraphsDir(), id).some((grant) => {
127
+ const stored = Buffer.from(grant.recipient, 'hex');
128
+ return timingSafeEqual(stored, Buffer.from(wanted, 'hex')) && (method === 'GET' || grant.permission === 'edit');
129
+ });
130
+ } catch (error) {
131
+ debug.log('nexus.graphs', 'access-read-failed', { id, reason: String(error) }, { level: 'warn' });
132
+ return false;
133
+ }
134
+ }
135
+
47
136
  function resolveId(raw: string): string | null {
48
137
  let id: string;
49
138
  try { id = decodeURIComponent(raw); }
@@ -203,7 +292,10 @@ export function handleGraphsGet(pathname: string, dir = defaultGraphsDir(), deps
203
292
  }
204
293
 
205
294
  /** PUT writes only under the state-root graphs directory. Core YAML is never opened for write. */
206
- export async function handleGraphsPut(pathname: string, req: Request, deps: GraphsApiDeps = {}): Promise<Response> {
295
+ /** Who authorized a save: the owner, or a peer token holding an «edit» grant (raw token; only a hash prefix is kept). */
296
+ export type GraphSaveActor = { peerToken: string } | null;
297
+
298
+ export async function handleGraphsPut(pathname: string, req: Request, deps: GraphsApiDeps = {}, actor: GraphSaveActor = null): Promise<Response> {
207
299
  const match = /^\/v1\/graphs\/([^/]+)\/yaml$/.exec(pathname);
208
300
  if (!match) return jsonResponse({ error: 'not-found' }, 404);
209
301
  const id = resolveId(match[1]!);
@@ -229,11 +321,21 @@ export async function handleGraphsPut(pathname: string, req: Request, deps: Grap
229
321
  // Writes go only to <mine>/<id>.yaml; a hand-placed file under another name is not edited over HTTP.
230
322
  if (existing.file !== `${id}.yaml`) return jsonResponse({ error: 'file-name-mismatch', id, file: existing.file }, 409);
231
323
  let written: { version: string; previous: string };
232
- try { written = withWizardSteps(mineDir, id, steps, () => writeWithVersion(mineDir, id, existing, body.yaml as string)); }
324
+ const editedBy = actor ? `peer:${tokenHash(actor.peerToken).slice(0, 8)}` : null;
325
+ try {
326
+ written = withWizardSteps(mineDir, id, steps, () => {
327
+ // Peer save: the marker goes down «before» the bytes, so a crash in between still refuses the run.
328
+ if (editedBy) writePeerEdit(mineDir, id, { editedBy, version: 'pending', at: new Date().toISOString() });
329
+ const result = writeWithVersion(mineDir, id, existing, body.yaml as string);
330
+ if (editedBy) writePeerEdit(mineDir, id, { editedBy, version: result.version, at: new Date().toISOString() });
331
+ else clearPeerEdit(mineDir, id);
332
+ return result;
333
+ });
334
+ }
233
335
  catch (error) { return saveFailed(id, error); }
234
336
  const { version, previous } = written;
235
- debug.log('nexus.graphs', 'saved', { id, version, previous, steps: steps ? Object.keys(steps).length : 0 });
236
- return jsonResponse({ id, source: 'mine', editable: true, saved: true, version, previous });
337
+ debug.log('nexus.graphs', 'saved', { id, version, previous, steps: steps ? Object.keys(steps).length : 0, ...(editedBy ? { editedBy } : {}) });
338
+ return jsonResponse({ id, source: 'mine', editable: true, saved: true, version, previous, ...(editedBy ? { editedBy } : {}) });
237
339
  }
238
340
 
239
341
  const VERSION_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z(?:-\d{4})?$/;
@@ -408,8 +510,14 @@ export async function handleGraphsClone(pathname: string, req: Request, deps: Gr
408
510
  if (reservedId) return reservedId;
409
511
  const coreDir = deps.coreDir ?? defaultGraphsDir();
410
512
  const mineDir = deps.mineDir ?? mineGraphsDir();
411
- const source = indexByDeclaredId(coreDir).get(id) ?? indexByDeclaredId(mineDir).get(id);
513
+ const fromCore = indexByDeclaredId(coreDir).get(id);
514
+ const source = fromCore ?? indexByDeclaredId(mineDir).get(id);
412
515
  if (!source) return jsonResponse({ error: 'not-found', id }, 404);
516
+ // A clone would carry a peer's unapproved change into a graph with no marker — approve first.
517
+ if (!fromCore) {
518
+ const gate = peerEditRunGate(mineDir, id);
519
+ if (!gate.ok) return jsonResponse(peerEditRefusalBody(id, gate), 409);
520
+ }
413
521
  if (indexByDeclaredId(mineDir).has(body.newId) || indexByDeclaredId(coreDir).has(body.newId)) {
414
522
  return jsonResponse({ error: 'conflict', id: body.newId }, 409);
415
523
  }
@@ -422,9 +530,39 @@ export async function handleGraphsClone(pathname: string, req: Request, deps: Gr
422
530
  return jsonResponse({ id: body.newId, source: 'mine', editable: true, clonedFrom: id }, 201);
423
531
  }
424
532
 
425
- export async function handleGraphsMutation(pathname: string, req: Request, deps: GraphsApiDeps = {}): Promise<Response | null> {
533
+ /** POST /v1/graphs/<id>/approve {version} — owner only (the route never accepts a peer token). Approves the
534
+ * exact peer version the run refusal named; a newer peer save is a new version and needs a new approval. */
535
+ export async function handleGraphsApprove(pathname: string, req: Request, deps: GraphsApiDeps = {}): Promise<Response> {
536
+ const match = /^\/v1\/graphs\/([^/]+)\/approve$/.exec(pathname);
537
+ if (!match) return jsonResponse({ error: 'not-found' }, 404);
538
+ const id = resolveId(match[1]!);
539
+ if (!id) return jsonResponse({ error: 'bad_request', reason: 'invalid graph id' }, 400);
540
+ const owned = mineOwnership(id, deps);
541
+ if (owned instanceof Response) return owned;
542
+ let body: { version?: unknown };
543
+ try { body = await req.json() as { version?: unknown }; }
544
+ catch { return jsonResponse({ error: 'bad_request', reason: 'json body required' }, 400); }
545
+ if (typeof body?.version !== 'string' || !body.version) return jsonResponse({ error: 'bad_request', reason: 'version string required' }, 400);
546
+ let record;
547
+ try { record = readPeerEdit(owned.mineDir, id); }
548
+ catch (error) { return saveFailed(id, error); }
549
+ if (!record) return jsonResponse({ id, approved: true, version: body.version, pending: false });
550
+ if (record.version !== body.version) {
551
+ return jsonResponse({ error: 'version-mismatch', id, version: record.version, reason: '승인하려는 판이 최신 상대 변경과 다르다 — 다시 확인하라' }, 409);
552
+ }
553
+ const approved = { version: record.version, at: new Date().toISOString() };
554
+ try { writePeerEdit(owned.mineDir, id, { ...record, approved }); }
555
+ catch (error) { return saveFailed(id, error); }
556
+ debug.log('nexus.graphs', 'peer-edit-approved', { id, version: record.version, editedBy: record.editedBy });
557
+ return jsonResponse({ id, approved: true, version: record.version, editedBy: record.editedBy });
558
+ }
559
+
560
+ export async function handleGraphsMutation(pathname: string, req: Request, deps: GraphsApiDeps = {}, actor: GraphSaveActor = null): Promise<Response | null> {
561
+ // Only PUT …/yaml is open to a peer token; every other mutation is the owner's alone.
562
+ if (actor && !(req.method === 'PUT' && /^\/v1\/graphs\/[^/]+\/yaml$/.test(pathname))) return jsonResponse({ error: 'unauthorized' }, 401);
426
563
  if (req.method === 'POST' && pathname === '/v1/graphs') return handleGraphsCreate(req, deps);
427
- if (req.method === 'PUT' && /^\/v1\/graphs\/[^/]+\/yaml$/.test(pathname)) return handleGraphsPut(pathname, req, deps);
564
+ if (req.method === 'PUT' && /^\/v1\/graphs\/[^/]+\/yaml$/.test(pathname)) return handleGraphsPut(pathname, req, deps, actor);
565
+ if (req.method === 'POST' && /^\/v1\/graphs\/[^/]+\/approve$/.test(pathname)) return handleGraphsApprove(pathname, req, deps);
428
566
  if (req.method === 'POST' && /^\/v1\/graphs\/[^/]+\/clone$/.test(pathname)) return handleGraphsClone(pathname, req, deps);
429
567
  if (req.method === 'POST' && /^\/v1\/graphs\/[^/]+\/revert$/.test(pathname)) return handleGraphsRevert(pathname, req, deps);
430
568
  return null;
@@ -19,7 +19,9 @@ import { setInProcessOutbound } from '../../domains/outbound-alert.js';
19
19
  import { getUserConfig } from '../../user-config.js';
20
20
  import type { TelegramEvent } from '../../workflow-runtime/triggers/telegram-source.js';
21
21
  import { spawnSync } from 'node:child_process';
22
- import { isAbsolute } from 'node:path';
22
+ import { isAbsolute, join, dirname, relative } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { readdirSync, realpathSync, statSync } from 'node:fs';
23
25
  import { createProject, listProjects } from '../../project/project-store.js';
24
26
  import { handleOutputsGet, type OutputsDeps } from './outputs-route.js';
25
27
  import { buildGridData, type GridDeps } from './grid.js';
@@ -368,12 +370,12 @@ import {
368
370
  } from './workflows.js';
369
371
  import { handleTriggersSnapshot } from './triggers.js';
370
372
  import { handleWorkflowTemplatesList } from './workflow-templates.js';
371
- import { handleGraphVersionsGet, handleGraphsGet, handleGraphsMutation } from './graphs-api.js';
373
+ import { graphAccessForRequest, handleGraphAccess, handleGraphAccessPut, handleGraphVersionsGet, handleGraphsGet, handleGraphsMutation } from './graphs-api.js';
372
374
  import { handleGraphKindsGet, handleGraphsValidatePost } from './graph-kinds.js';
373
- import { handleGraphWizardPost } from './graph-wizard-api.js';
375
+ import { handleGraphWizardPost, handleGraphWizardPacksGet } from './graph-wizard-api.js';
374
376
  import { syncInstalledPluginNodes } from '../../graph-kinds/installed-plugin-nodes.js';
375
377
  import { elanousStateRoot } from '../../autopilot/state-paths.js';
376
- import { handlePluginsIndexGet, handlePluginsGet, handlePluginsMarketRefresh, handlePluginsInstall, handlePluginsRemove } from './plugins-market.js';
378
+ import { handlePluginsIndexGet, handlePluginsGet, handlePluginsMarketRefresh, handlePluginsInstall, handlePluginsRemove, handlePluginWizardList, handlePluginWizardSave } from './plugins-market.js';
377
379
  import { handlePluginsCredentialsGet, handlePluginsCredentialsPut } from './plugins-credentials-api.js';
378
380
  import {
379
381
  handleWorkflowsList,
@@ -489,6 +491,8 @@ export interface NexusHttpServerOpts {
489
491
  fabricPlans?: FabricPlansRouteOpts;
490
492
  /** GRAPH-WIZARD 시험 주입(LLM·템플릿 뿌리). 운영은 비워 둔다. */
491
493
  graphWizard?: import('../../graph-wizard/generate.js').GraphWizardDeps;
494
+ /** Roots the remote folder picker may browse (default: the daemon user's home). Tests inject a temp root. */
495
+ projectFolderRoots?: string[];
492
496
  /** Optional installer state root for isolated API consumers and tests. */
493
497
  pluginStateRoot?: string;
494
498
  outputs?: OutputsDeps;
@@ -846,6 +850,55 @@ export function handleProjectsGet(): Response {
846
850
  return jsonResponse({ projects: listProjects() });
847
851
  }
848
852
 
853
+ function isWithinRoot(path: string, root: string): boolean {
854
+ const rel = relative(root, path);
855
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
856
+ }
857
+
858
+ /**
859
+ * Remote folder picker: lists sub-folders under an allow-listed root only.
860
+ * Every path is resolved with realpath first, so `..` segments and symlinks cannot escape the roots;
861
+ * dot-folders are hidden and symlinked entries that point outside the roots are not listed.
862
+ */
863
+ export function handleProjectFoldersGet(req: Request, roots: string[] = [homedir()]): Response {
864
+ const allowed: string[] = [];
865
+ for (const root of roots) {
866
+ try { allowed.push(realpathSync(root)); } catch { /* a missing root grants nothing */ }
867
+ }
868
+ if (allowed.length === 0) return jsonResponse({ error: 'folder_unavailable' }, 404);
869
+ const requested = new URL(req.url).searchParams.get('path') ?? allowed[0]!;
870
+ if (!isAbsolute(requested) || requested.includes('\0')) return jsonResponse({ error: 'invalid_folder' }, 400);
871
+ let path: string;
872
+ try { path = realpathSync(requested); }
873
+ catch {
874
+ debug.log('nexus.projects', 'folders-unavailable', { reason: 'missing' });
875
+ return jsonResponse({ error: 'folder_unavailable' }, 404);
876
+ }
877
+ if (!allowed.some(root => isWithinRoot(path, root))) {
878
+ debug.log('nexus.projects', 'folders-refused', { reason: 'outside-roots' });
879
+ return jsonResponse({ error: 'outside_allowed_roots' }, 403);
880
+ }
881
+ try {
882
+ if (!statSync(path).isDirectory()) return jsonResponse({ error: 'not_directory' }, 400);
883
+ const folders = readdirSync(path, { withFileTypes: true })
884
+ .filter(entry => {
885
+ if (entry.name.startsWith('.')) return false;
886
+ if (entry.isDirectory()) return true;
887
+ if (!entry.isSymbolicLink()) return false;
888
+ try {
889
+ const target = realpathSync(join(path, entry.name));
890
+ return statSync(target).isDirectory() && allowed.some(root => isWithinRoot(target, root));
891
+ } catch { return false; }
892
+ })
893
+ .map(entry => ({ name: entry.name, path: join(path, entry.name) }))
894
+ .sort((a, b) => a.name.localeCompare(b.name));
895
+ const atRoot = allowed.includes(path);
896
+ return jsonResponse({ path, parent: atRoot ? null : dirname(path), folders });
897
+ } catch {
898
+ return jsonResponse({ error: 'folder_unavailable' }, 404);
899
+ }
900
+ }
901
+
849
902
  export async function handleProjectsPost(req: Request): Promise<Response> {
850
903
  let body: unknown;
851
904
  try { body = await req.json(); }
@@ -996,7 +1049,9 @@ export async function routeRequest(
996
1049
  const offered = req.headers.get('authorization');
997
1050
  const fallbackAuthorized = fallbackToken !== undefined && offered?.startsWith('Bearer ') === true
998
1051
  && compareTokenConstTime(offered.slice('Bearer '.length).trim(), fallbackToken);
999
- if (!(opts.metaApi ? checkAuth(req, opts.metaApi) : fallbackAuthorized)) {
1052
+ const graphPeerAuthorized = (method === 'GET' || method === 'PUT')
1053
+ && graphAccessForRequest(pathname, method, offered?.startsWith('Bearer ') ? offered.slice(7).trim() : null);
1054
+ if (!(opts.metaApi ? checkAuth(req, opts.metaApi) : fallbackAuthorized) && !graphPeerAuthorized) {
1000
1055
  const name = method === 'POST' && (pathname === '/v1/tasks' || pathname === '/v1/reports/ingest') && opts.metaApi
1001
1056
  ? matchIngestAuthorization(req)?.name
1002
1057
  : undefined;
@@ -1407,7 +1462,12 @@ export async function routeRequest(
1407
1462
  return handleGraphsValidatePost(req, opts.metaApi);
1408
1463
  }
1409
1464
 
1410
- // GRAPH-WIZARD — 말 → 그래프 YAML(저장 안 함). 다른 /v1/graphs 쓰기와 같은 인증.
1465
+ // GRAPH-WIZARD — 설치된 팩 목록과 말 → 그래프 YAML(저장 안 함). 같은 소유자 인증.
1466
+ if (method === 'GET' && pathname === '/v1/graphs/wizard/packs') {
1467
+ if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1468
+ if (!checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1469
+ return handleGraphWizardPacksGet(opts.graphWizard);
1470
+ }
1411
1471
  if (method === 'POST' && pathname === '/v1/graphs/wizard') {
1412
1472
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1413
1473
  if (!checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
@@ -1419,6 +1479,10 @@ export async function routeRequest(
1419
1479
  return handleFabricPlans(req, opts.fabricPlans);
1420
1480
  }
1421
1481
 
1482
+ if (pathname === '/v1/projects/folders' && method === 'GET') {
1483
+ if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1484
+ return handleProjectFoldersGet(req, opts.projectFolderRoots);
1485
+ }
1422
1486
  if (pathname === '/v1/projects' && (method === 'GET' || method === 'POST')) {
1423
1487
  if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1424
1488
  return method === 'GET' ? handleProjectsGet() : handleProjectsPost(req);
@@ -1515,6 +1579,10 @@ export async function routeRequest(
1515
1579
  ...opts.decisions,
1516
1580
  });
1517
1581
  }
1582
+ if (method === 'POST' && pathname === '/v1/plugins/wizard') {
1583
+ if (!opts.metaApi) return jsonResponse({ error: 'unauthorized' }, 401);
1584
+ return handlePluginWizardSave(req, opts.metaApi, opts.pluginStateRoot);
1585
+ }
1518
1586
  if (method === 'POST' && pathname === '/v1/plugins/install') {
1519
1587
  if (!opts.metaApi) return jsonResponse({ error: 'unauthorized' }, 401);
1520
1588
  return handlePluginsInstall(req, opts.metaApi, opts.pluginStateRoot);
@@ -1534,10 +1602,19 @@ export async function routeRequest(
1534
1602
  if (!opts.metaApi) return jsonResponse({ error: 'unauthorized' }, 401);
1535
1603
  return handlePluginsRemove(req, pluginRemove[1]!, opts.metaApi, opts.pluginStateRoot);
1536
1604
  }
1605
+ if (method === 'PUT' && /^\/v1\/graphs\/[^/]+\/access$/.test(pathname)) {
1606
+ if (!opts.metaApi || bearerCredential(req, opts.metaApi) === undefined) return jsonResponse({ error: 'unauthorized' }, 401);
1607
+ return handleGraphAccessPut(pathname, req);
1608
+ }
1537
1609
  if ((method === 'PUT' || method === 'POST') && (pathname.startsWith('/v1/graphs/') || pathname === '/v1/graphs')) {
1538
1610
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1539
- if (!checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1540
- const mutated = await handleGraphsMutation(pathname, req);
1611
+ const peerMayEdit = method === 'PUT' && graphAccessForRequest(pathname, method,
1612
+ req.headers.get('authorization')?.startsWith('Bearer ') ? req.headers.get('authorization')!.slice(7).trim() : null);
1613
+ const ownerAuthorized = checkAuth(req, opts.metaApi);
1614
+ if (!ownerAuthorized && !peerMayEdit) return jsonResponse({ error: 'unauthorized' }, 401);
1615
+ // A peer save is recorded on the graph so every run path refuses it until the owner approves (W9c run guard).
1616
+ const mutated = await handleGraphsMutation(pathname, req, {}, ownerAuthorized ? null
1617
+ : { peerToken: req.headers.get('authorization')!.slice(7).trim() });
1541
1618
  if (mutated) return mutated;
1542
1619
  }
1543
1620
  const cardClose = /^\/v1\/task-cards\/([^/]+)\/close$/.exec(pathname);
@@ -2829,6 +2906,10 @@ export async function routeRequest(
2829
2906
  // surface (`/v1/workflows` · `~/.elanous/workflows-runs/`) covers the same
2830
2907
  // user need.
2831
2908
 
2909
+ if (method === 'GET' && pathname === '/v1/plugins/wizard') {
2910
+ if (!opts.metaApi) return jsonResponse({ error: 'unauthorized' }, 401);
2911
+ return handlePluginWizardList(req, opts.metaApi, opts.pluginStateRoot);
2912
+ }
2832
2913
  const pluginCredentialsGet = /^\/v1\/plugins\/([^/]+)\/credentials$/.exec(pathname);
2833
2914
  if (method === 'GET' && pluginCredentialsGet) {
2834
2915
  if (!opts.metaApi) return jsonResponse({ error: 'unauthorized' }, 401);
@@ -2844,6 +2925,10 @@ export async function routeRequest(
2844
2925
  catch (error) { debug.log('graph.kinds', 'installed-sync-failed', { reason: String(error) }); }
2845
2926
  return handleGraphKindsGet(req, opts.metaApi);
2846
2927
  }
2928
+ if (method === 'GET' && /^\/v1\/graphs\/[^/]+\/access$/.test(pathname)) {
2929
+ if (!opts.metaApi || bearerCredential(req, opts.metaApi) === undefined) return jsonResponse({ error: 'unauthorized' }, 401);
2930
+ return handleGraphAccess(pathname, req);
2931
+ }
2847
2932
  // F-M1 read + P-F1 raw YAML. Writes live in the mutation block and never touch core graphs/.
2848
2933
  if (method === 'GET' && (pathname === '/v1/graphs' || /^\/v1\/graphs\/[^/]+$/.test(pathname) || /^\/v1\/graphs\/[^/]+\/yaml$/.test(pathname))) {
2849
2934
  return handleGraphsGet(pathname);
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { listWizardDrafts, saveWizardDraft, type WizardResearchDraft } from '../../plugins/maker/wizard-generate.js';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { elanousStateRoot } from '../../autopilot/state-paths.js';
4
5
  import { debug } from '../../debug/log.js';
@@ -120,6 +121,29 @@ export function handlePluginsIndexGet(req: Request, opts: MetaApiOpts, root?: st
120
121
  return jsonResponse(readMarketIndex(root, configuredTrustedKeys(configPath)));
121
122
  }
122
123
 
124
+ export function handlePluginWizardList(req: Request, opts: MetaApiOpts, root = elanousStateRoot()): Response {
125
+ if (!checkAuth(req, opts)) return jsonResponse({ error: 'unauthorized' }, 401);
126
+ return jsonResponse({ plugins: listWizardDrafts(join(root, 'plugins-local')) });
127
+ }
128
+
129
+ export async function handlePluginWizardSave(req: Request, opts: MetaApiOpts, root = elanousStateRoot()): Promise<Response> {
130
+ if (!checkAuth(req, opts)) return jsonResponse({ error: 'unauthorized' }, 401);
131
+ let body: unknown;
132
+ try { body = await req.json(); } catch { return jsonResponse({ error: 'bad_request' }, 400); }
133
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return jsonResponse({ error: 'bad_request' }, 400);
134
+ const input = body as { name?: unknown; draft?: unknown; regenerate?: unknown };
135
+ if (typeof input.name !== 'string' || !/^[a-z0-9][a-z0-9-]{1,31}$/.test(input.name) ||
136
+ !input.draft || typeof input.draft !== 'object' || Array.isArray(input.draft) ||
137
+ (input.regenerate !== undefined && typeof input.regenerate !== 'boolean')) return jsonResponse({ error: 'bad_request' }, 400);
138
+ try {
139
+ saveWizardDraft(join(root, 'plugins-local'), input.name, input.draft as WizardResearchDraft, input.regenerate === true);
140
+ return jsonResponse({ name: input.name, saved: true });
141
+ } catch (error) {
142
+ const reason = error instanceof Error ? error.message : 'invalid draft';
143
+ return jsonResponse({ error: 'invalid_wizard_draft', reason: /already exists|research draft/.test(reason) ? reason : 'invalid draft' }, 422);
144
+ }
145
+ }
146
+
123
147
  export function handlePluginsGet(req: Request, opts: MetaApiOpts, root?: string): Response {
124
148
  if (!checkAuth(req, opts)) return jsonResponse({ error: 'unauthorized' }, 401);
125
149
  return jsonResponse(listInstalledPlugins(root));
@@ -2743,11 +2743,12 @@ export async function runNexus(opts: RunNexusOptions = {}): Promise<RunNexusHand
2743
2743
  if ((getUserConfig().raw?.decisions as { cards?: unknown } | undefined)?.cards !== false && !isAutonomousRunContext()) {
2744
2744
  try {
2745
2745
  const { DecisionCardService } = await import('../decisions/decision-cards.js');
2746
- const { webPushDecisionTransport } = await import('../decisions/web-push-decision-cards.js');
2746
+ const { webPushDecisionTransport, webPushUrgency } = await import('../decisions/web-push-decision-cards.js');
2747
2747
  const { listSubscriptions } = await import('../web-push/subscriptions.js');
2748
2748
  const { DecisionLedger } = await import('../decisions/decision-ledger.js');
2749
2749
  const ledger = new DecisionLedger();
2750
- const service = new DecisionCardService({ transport: webPushDecisionTransport(ledger), ownerIds: [], ledger });
2750
+ const service = new DecisionCardService({ transport: webPushDecisionTransport(ledger), ownerIds: [], ledger,
2751
+ sendFilter: (entry, now) => webPushUrgency(entry, now) !== 'not-urgent', deferredReason: webPushUrgency });
2751
2752
  await service.tick();
2752
2753
  webPushDecisionTimer = setInterval(() => {
2753
2754
  void service.tick().catch((error: unknown) => {
@@ -0,0 +1,24 @@
1
+ export type ClaudePtyMaturity =
2
+ | { readonly mature: true }
3
+ | { readonly mature: false; readonly why: 'too-few-samples' | 'completion-below-70' | 'unknown-screen-above-10' | 'intervention-above-0.2' | 'sample-unreadable' };
4
+
5
+ export type ClaudePtyMaturitySample = {
6
+ readonly runCount: number;
7
+ readonly completedCount: number;
8
+ readonly unknownScreenCount: number;
9
+ readonly interventionCount: number;
10
+ };
11
+
12
+ /** 최근 런의 계수만 판정한다. 표본을 못 읽거나 계수가 불가능하면 성숙으로 추정하지 않는다. */
13
+ export function judgeClaudePtyMaturity(sample: ClaudePtyMaturitySample | null | undefined): ClaudePtyMaturity {
14
+ if (!sample || ![sample.runCount, sample.completedCount, sample.unknownScreenCount, sample.interventionCount]
15
+ .every((count) => Number.isSafeInteger(count) && count >= 0)
16
+ || sample.completedCount > sample.runCount || sample.unknownScreenCount > sample.runCount) {
17
+ return { mature: false, why: 'sample-unreadable' };
18
+ }
19
+ if (sample.runCount < 10) return { mature: false, why: 'too-few-samples' };
20
+ if (sample.completedCount / sample.runCount < 0.7) return { mature: false, why: 'completion-below-70' };
21
+ if (sample.unknownScreenCount / sample.runCount > 0.1) return { mature: false, why: 'unknown-screen-above-10' };
22
+ if (sample.interventionCount / sample.runCount > 0.2) return { mature: false, why: 'intervention-above-0.2' };
23
+ return { mature: true };
24
+ }
@@ -19,9 +19,10 @@
19
19
  // 지갑을 여는» 일이 없다. (2026-08-13 실측 · RESEARCH-grok-oauth-… §7b)
20
20
 
21
21
  import type { RotationCandidate } from './codex-account-rotation.js';
22
+ import type { ClaudePtyMaturity } from './claude-pty-maturity.js';
22
23
 
23
24
  /** 체인에 놓을 수 있는 칸. ⛔ 모르는 이름은 «조용히 건너뛰지 않고» 거부한다(오타 방어). */
24
- export const FALLBACK_STEPS = ['codex-rotate', 'grok'] as const;
25
+ export const FALLBACK_STEPS = ['codex-rotate', 'grok', 'claude-pty'] as const;
25
26
  export type FallbackStep = (typeof FALLBACK_STEPS)[number];
26
27
 
27
28
  /** 기본 체인 — ⛔⭐ **2026-08-20 에 `grok` 이 «기본»으로 들어왔다**(대표 지시).
@@ -75,8 +76,8 @@ export type RotationOutcome =
75
76
  export type FallbackDecision =
76
77
  /** codex 계정을 갈아탄다(종전 동작). */
77
78
  | { readonly action: 'codex-rotate'; readonly to: RotationCandidate }
78
- /** 백엔드를 통째로 바꾼다. 지금은 grok 뿐. */
79
- | { readonly action: 'switch-backend'; readonly backend: 'grok' }
79
+ /** 백엔드를 통째로 바꾼다. */
80
+ | { readonly action: 'switch-backend'; readonly backend: 'grok' | 'claude' }
80
81
  /** 아무것도 안 한다 — 이유를 «값으로» 남긴다. */
81
82
  | { readonly action: 'stay'; readonly why: StayReason };
82
83
 
@@ -96,7 +97,9 @@ export type StayReason =
96
97
  /** 다음 칸이 grok 인데 grok 자격이 «없다». */
97
98
  | 'grok-unavailable'
98
99
  /** grok 자격은 있는데 «잔량이 소진»됐다 — 자격 부재와 «다른 값»이다. */
99
- | 'grok-exhausted';
100
+ | 'grok-exhausted'
101
+ /** claude PTY 의 최근 표본이 성숙 관문을 통과하지 못했거나 읽히지 않는다. */
102
+ | 'claude-pty-immature';
100
103
 
101
104
  type FallbackRunPosition =
102
105
  | { readonly currentCredentialRateLimited?: false; readonly currentStep?: FallbackStep }
@@ -120,6 +123,8 @@ export type FallbackInput = {
120
123
  * (⇒ 그리고 그 사실은 관측에 값으로 남는다 — 「모르고 갔다」를 셀 수 있다.)
121
124
  */
122
125
  readonly grokQuota?: 'usable' | 'exhausted' | 'unknown';
126
+ /** 최근 claude PTY 표본의 순수 성숙 판정. 생략·못 읽음은 전환 불가. */
127
+ readonly claudePtyMaturity?: ClaudePtyMaturity;
123
128
  /**
124
129
  * `reset-credit-available` 에서 체인을 타지 않고 머문다.
125
130
  * 기본(생략·false)은 `no-candidate` 와 같이 `codex-rotate` 다음 칸을 찾는다.
@@ -148,12 +153,12 @@ export type FallbackInput = {
148
153
  export function decideFallback(input: FallbackInput): FallbackDecision {
149
154
  const {
150
155
  rotation, chain, currentStep, currentCredentialRateLimited = false,
151
- grokAvailable, grokQuota = 'unknown', stayOnResetCreditAvailable = false,
156
+ grokAvailable, grokQuota = 'unknown', claudePtyMaturity, stayOnResetCreditAvailable = false,
152
157
  } = input;
153
158
 
154
159
  if (currentCredentialRateLimited) {
155
160
  if (currentStep === undefined) return { action: 'stay', why: 'chain-exhausted' };
156
- return stepAfter(chain, currentStep, grokAvailable, grokQuota, 'chain-exhausted');
161
+ return stepAfter(chain, currentStep, grokAvailable, grokQuota, claudePtyMaturity, 'chain-exhausted');
157
162
  }
158
163
  if (rotation.reason === 'explicit') return { action: 'stay', why: 'explicit' };
159
164
  if (rotation.reason === 'not-reached' || (rotation.reason === 'credit-pace' && !('to' in rotation))) {
@@ -167,13 +172,13 @@ export function decideFallback(input: FallbackInput): FallbackDecision {
167
172
  // 회전이 답을 냈는데 체인이 codex-rotate 를 «빼» 놨다면 그 뜻을 존중하고
168
173
  // 다음 칸으로 간다(회전을 원치 않는 구성).
169
174
  if (chain.includes('codex-rotate')) return { action: 'codex-rotate', to: rotation.to };
170
- return stepAfterCodex(chain, grokAvailable, grokQuota, 'chain-exhausted');
175
+ return stepAfterCodex(chain, grokAvailable, grokQuota, claudePtyMaturity, 'chain-exhausted');
171
176
  }
172
177
 
173
178
  // no-candidate | disabled | reset-credit-available(기본) — codex 축이 끝났다. 다음 칸을 본다.
174
179
  // ⛔ 다음 칸이 «없을 때»의 이름만 고른다. 진행(stepAfterCodex)은 그대로다.
175
180
  const fallbackWhy = stayReasonWhenCodexAxisEnds(rotation.reason === 'credit-pace' ? 'no-candidate' : rotation.reason);
176
- return stepAfterCodex(chain, grokAvailable, grokQuota, fallbackWhy);
181
+ return stepAfterCodex(chain, grokAvailable, grokQuota, claudePtyMaturity, fallbackWhy);
177
182
  }
178
183
 
179
184
  /** codex 축이 끝났는데 체인 다음 칸도 없을 때 머무는 이유.
@@ -191,9 +196,10 @@ function stepAfterCodex(
191
196
  chain: readonly FallbackStep[],
192
197
  grokAvailable: boolean,
193
198
  grokQuota: 'usable' | 'exhausted' | 'unknown',
199
+ claudePtyMaturity: ClaudePtyMaturity | undefined,
194
200
  whyIfNone: StayReason,
195
201
  ): FallbackDecision {
196
- return stepAfter(chain, 'codex-rotate', grokAvailable, grokQuota, whyIfNone);
202
+ return stepAfter(chain, 'codex-rotate', grokAvailable, grokQuota, claudePtyMaturity, whyIfNone);
197
203
  }
198
204
 
199
205
  function stepAfter(
@@ -201,20 +207,27 @@ function stepAfter(
201
207
  currentStep: FallbackStep | undefined,
202
208
  grokAvailable: boolean,
203
209
  grokQuota: 'usable' | 'exhausted' | 'unknown',
210
+ claudePtyMaturity: ClaudePtyMaturity | undefined,
204
211
  whyIfNone: StayReason,
205
212
  ): FallbackDecision {
206
213
  const idx = currentStep === undefined ? -1 : chain.indexOf(currentStep);
207
214
  const rest = idx === -1 ? chain : chain.slice(idx + 1);
215
+ let unavailableWhy: StayReason | undefined;
208
216
  for (const step of rest) {
209
217
  if (step === 'grok') {
210
- // ⛔ 자격 «먼저» — 자격이 없으면 잔량을 물을 것도 없다(그리고 두 사유는 다른 값이다).
211
- if (!grokAvailable) return { action: 'stay', why: 'grok-unavailable' };
212
- // ⛔ 「모른다」는 통과시킨다 — 못 읽었다고 갈 곳을 없애지 않는다(위 계약 주석).
213
- if (grokQuota === 'exhausted') return { action: 'stay', why: 'grok-exhausted' };
218
+ // 자격 부재와 소진은 다른 값이다. 다음 칸이 있으면 그 칸을 살핀다.
219
+ if (!grokAvailable) { unavailableWhy = 'grok-unavailable'; continue; }
220
+ // 「모른다」는 통과시킨다 — 못 읽었다고 갈 곳을 없애지 않는다.
221
+ if (grokQuota === 'exhausted') { unavailableWhy = 'grok-exhausted'; continue; }
214
222
  return { action: 'switch-backend', backend: 'grok' };
215
223
  }
224
+ if (step === 'claude-pty') {
225
+ // 미성숙·못 읽음은 «건너뛴다» — 뒤에 다른 칸이 있으면 그 칸을 본다(없으면 이 사유로 머문다).
226
+ if (claudePtyMaturity?.mature !== true) { unavailableWhy = 'claude-pty-immature'; continue; }
227
+ return { action: 'switch-backend', backend: 'claude' };
228
+ }
216
229
  }
217
- return { action: 'stay', why: whyIfNone };
230
+ return { action: 'stay', why: unavailableWhy ?? whyIfNone };
218
231
  }
219
232
 
220
233
  /** 사람이 읽는 한 줄 — 관측·CLI 표면용. ⛔ 토큰·계정 홈은 넣지 않는다. */
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve, sep } from 'node:path';
6
6
  import { userConfigPath } from '../../user-config.js';
7
+ import { scanKnowledgePackDlp } from '../../mss/logging/redaction.js';
7
8
  import { loadPluginManifestFromDir } from '../core/manifest.js';
8
9
  import { LEAK_MARKERS, loadPrivateRedactions, privateIdentifierMarkers, scanLeaks } from '../../../scripts/public-export.js';
9
10
 
@@ -56,6 +57,14 @@ export function bundleInstalledWizardPlugin(installedPath: string, outputDir: st
56
57
  if (!redactions) throw new Error('private export redaction list unavailable; market bundle refused');
57
58
  const hits = scanLeaks(source, files, [...LEAK_MARKERS, ...privateIdentifierMarkers(redactions)]);
58
59
  if (hits.length) throw new Error(`market bundle leak: ${hits.map(hit => `${hit.file}:${hit.line} ${hit.marker}`).join(', ')}`);
60
+ const dlpHits: string[] = [];
61
+ for (const file of files) {
62
+ const text = readFileSync(join(source, file), 'utf8');
63
+ const reasons = scanKnowledgePackDlp(text);
64
+ if (text.includes('\u0000')) reasons.push('unscannable-content');
65
+ if (reasons.length) dlpHits.push(`${file}: ${reasons.join(', ')}`);
66
+ }
67
+ if (dlpHits.length) throw new Error(`market bundle DLP: ${dlpHits.join('; ')}`);
59
68
  const config: unknown = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : {};
60
69
  if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('invalid market signing config');
61
70
  const signing = (config as { market?: { signing?: { keyId?: unknown; privateKey?: unknown } } }).market?.signing;
@@ -146,7 +146,10 @@ export async function validatePluginDir(dir: string): Promise<string[]> {
146
146
  catch (error) { errors.push(`recipes.yaml 파싱 실패: ${String(error)}`); }
147
147
  if (!isRecord(recipes)) errors.push('recipes.yaml 은 맵이어야 한다');
148
148
  else for (const [id, recipe] of Object.entries(recipes)) {
149
- if (!isRecord(recipe) || typeof recipe.command !== 'string' || !recipe.command.trim() ||
149
+ if (!isRecord(recipe) ||
150
+ (!(typeof recipe.command === 'string' && recipe.command.trim()) && !(typeof recipe.approval === 'string' && recipe.approval.trim())) ||
151
+ (recipe.command !== undefined && (typeof recipe.command !== 'string' || !recipe.command.trim())) ||
152
+ (recipe.approval !== undefined && (typeof recipe.approval !== 'string' || !recipe.approval.trim())) ||
150
153
  (recipe.timeout_ms !== undefined && (!Number.isSafeInteger(recipe.timeout_ms) || Number(recipe.timeout_ms) <= 0))) errors.push(`잘못된 recipe: ${id}`);
151
154
  }
152
155
  for (const graph of Array.isArray(graphs) ? graphs : []) {
@@ -162,11 +165,14 @@ export async function validatePluginDir(dir: string): Promise<string[]> {
162
165
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(template.graphId)) errors.push(`잘못된 graph_id: ${template.graphId}`);
163
166
  if (template.terminalNodes.length !== 2 || !template.terminalNodes.includes('done') || !template.terminalNodes.includes('failed')) errors.push('terminal_nodes 는 done, failed 이어야 한다');
164
167
  const executionNodes = template.nodes.filter(node => !template.terminalNodes.includes(node.nodeId));
165
- if (executionNodes.length < 2 || executionNodes.length > 6) errors.push(`실행 노드는 2~6개여야 한다: ${executionNodes.length}개`);
168
+ if (executionNodes.length < 2) errors.push(`실행 노드는 2개 이상이어야 한다: ${executionNodes.length}개`);
166
169
  for (const node of template.nodes) {
167
170
  if (node.recipe.startsWith('cmd:')) {
168
171
  const id = node.recipe.slice(4);
169
- if (!isRecord(recipes) || !isRecord(recipes[id]) || typeof recipes[id].command !== 'string' || !recipes[id].command) errors.push(`recipe ${id} 없음`);
172
+ if (!isRecord(recipes) || !isRecord(recipes[id]) || recipes[id].approval !== undefined || typeof recipes[id].command !== 'string' || !recipes[id].command.trim()) errors.push(`recipe ${id} 없음`);
173
+ } else if (node.recipe.startsWith('approval:')) {
174
+ const id = node.recipe.slice(9);
175
+ if (!isRecord(recipes) || !isRecord(recipes[id]) || typeof recipes[id].approval !== 'string' || !recipes[id].approval.trim()) errors.push(`approval recipe ${id} 없음`);
170
176
  } else if (node.recipe !== 'none') errors.push(`지원하지 않는 recipe: ${node.recipe}`);
171
177
  }
172
178
  } catch (error) { errors.push(`${graph} 파싱 실패: ${String(error)}`); }