@nac3/forge-cli 0.2.0-alpha.9 → 0.2.1-alpha.60

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 (1045) hide show
  1. package/README.md +16 -1
  2. package/dist/a11y/helpers.d.ts +88 -0
  3. package/dist/a11y/helpers.d.ts.map +1 -0
  4. package/dist/a11y/helpers.js +109 -0
  5. package/dist/a11y/helpers.js.map +1 -0
  6. package/dist/agents/bridge.d.ts +69 -0
  7. package/dist/agents/bridge.d.ts.map +1 -0
  8. package/dist/agents/bridge.js +295 -0
  9. package/dist/agents/bridge.js.map +1 -0
  10. package/dist/agents/cli_real.d.ts +38 -0
  11. package/dist/agents/cli_real.d.ts.map +1 -0
  12. package/dist/agents/cli_real.js +46 -0
  13. package/dist/agents/cli_real.js.map +1 -0
  14. package/dist/agents/config_resolver.d.ts +16 -0
  15. package/dist/agents/config_resolver.d.ts.map +1 -0
  16. package/dist/agents/config_resolver.js +133 -0
  17. package/dist/agents/config_resolver.js.map +1 -0
  18. package/dist/agents/config_schema.d.ts +42 -0
  19. package/dist/agents/config_schema.d.ts.map +1 -0
  20. package/dist/agents/config_schema.js +220 -0
  21. package/dist/agents/config_schema.js.map +1 -0
  22. package/dist/agents/defaults.d.ts +21 -0
  23. package/dist/agents/defaults.d.ts.map +1 -0
  24. package/dist/agents/defaults.js +56 -0
  25. package/dist/agents/defaults.js.map +1 -0
  26. package/dist/agents/delegate.d.ts +36 -0
  27. package/dist/agents/delegate.d.ts.map +1 -0
  28. package/dist/agents/delegate.js +68 -0
  29. package/dist/agents/delegate.js.map +1 -0
  30. package/dist/agents/iterative_step.d.ts +95 -0
  31. package/dist/agents/iterative_step.d.ts.map +1 -0
  32. package/dist/agents/iterative_step.js +249 -0
  33. package/dist/agents/iterative_step.js.map +1 -0
  34. package/dist/agents/llm_step.d.ts +50 -0
  35. package/dist/agents/llm_step.d.ts.map +1 -0
  36. package/dist/agents/llm_step.js +28 -0
  37. package/dist/agents/llm_step.js.map +1 -0
  38. package/dist/agents/real_step_factory.d.ts +95 -0
  39. package/dist/agents/real_step_factory.d.ts.map +1 -0
  40. package/dist/agents/real_step_factory.js +51 -0
  41. package/dist/agents/real_step_factory.js.map +1 -0
  42. package/dist/agents/runner.d.ts +108 -0
  43. package/dist/agents/runner.d.ts.map +1 -0
  44. package/dist/agents/runner.js +174 -0
  45. package/dist/agents/runner.js.map +1 -0
  46. package/dist/agents/runtime.d.ts +115 -0
  47. package/dist/agents/runtime.d.ts.map +1 -0
  48. package/dist/agents/runtime.js +429 -0
  49. package/dist/agents/runtime.js.map +1 -0
  50. package/dist/agents/store.d.ts +33 -0
  51. package/dist/agents/store.d.ts.map +1 -0
  52. package/dist/agents/store.js +210 -0
  53. package/dist/agents/store.js.map +1 -0
  54. package/dist/agents/system_prompt.d.ts +36 -0
  55. package/dist/agents/system_prompt.d.ts.map +1 -0
  56. package/dist/agents/system_prompt.js +101 -0
  57. package/dist/agents/system_prompt.js.map +1 -0
  58. package/dist/agents/types.d.ts +123 -0
  59. package/dist/agents/types.d.ts.map +1 -0
  60. package/dist/agents/types.js +39 -0
  61. package/dist/agents/types.js.map +1 -0
  62. package/dist/bin/yf.d.ts.map +1 -1
  63. package/dist/bin/yf.js +101 -2
  64. package/dist/bin/yf.js.map +1 -1
  65. package/dist/chat/claude.d.ts +89 -1
  66. package/dist/chat/claude.d.ts.map +1 -1
  67. package/dist/chat/claude.js +170 -30
  68. package/dist/chat/claude.js.map +1 -1
  69. package/dist/chat/claude_cli.d.ts +145 -0
  70. package/dist/chat/claude_cli.d.ts.map +1 -0
  71. package/dist/chat/claude_cli.js +598 -0
  72. package/dist/chat/claude_cli.js.map +1 -0
  73. package/dist/chat/completion_hallucination_detector.d.ts +40 -0
  74. package/dist/chat/completion_hallucination_detector.d.ts.map +1 -0
  75. package/dist/chat/completion_hallucination_detector.js +96 -0
  76. package/dist/chat/completion_hallucination_detector.js.map +1 -0
  77. package/dist/chat/doctrine_digest.d.ts +31 -0
  78. package/dist/chat/doctrine_digest.d.ts.map +1 -0
  79. package/dist/chat/doctrine_digest.js +117 -0
  80. package/dist/chat/doctrine_digest.js.map +1 -0
  81. package/dist/chat/graph_auto_ingest.d.ts +46 -0
  82. package/dist/chat/graph_auto_ingest.d.ts.map +1 -0
  83. package/dist/chat/graph_auto_ingest.js +177 -0
  84. package/dist/chat/graph_auto_ingest.js.map +1 -0
  85. package/dist/chat/graph_component_watcher.d.ts +34 -0
  86. package/dist/chat/graph_component_watcher.d.ts.map +1 -0
  87. package/dist/chat/graph_component_watcher.js +225 -0
  88. package/dist/chat/graph_component_watcher.js.map +1 -0
  89. package/dist/chat/graph_snapshot.d.ts +38 -0
  90. package/dist/chat/graph_snapshot.d.ts.map +1 -0
  91. package/dist/chat/graph_snapshot.js +159 -0
  92. package/dist/chat/graph_snapshot.js.map +1 -0
  93. package/dist/chat/ingest_session.d.ts +24 -4
  94. package/dist/chat/ingest_session.d.ts.map +1 -1
  95. package/dist/chat/ingest_session.js +143 -4
  96. package/dist/chat/ingest_session.js.map +1 -1
  97. package/dist/chat/panel.d.ts.map +1 -1
  98. package/dist/chat/panel.js +9640 -1455
  99. package/dist/chat/panel.js.map +1 -1
  100. package/dist/chat/panel_queue.d.ts +43 -0
  101. package/dist/chat/panel_queue.d.ts.map +1 -0
  102. package/dist/chat/panel_queue.js +46 -0
  103. package/dist/chat/panel_queue.js.map +1 -0
  104. package/dist/chat/project_context_inject.d.ts +10 -0
  105. package/dist/chat/project_context_inject.d.ts.map +1 -0
  106. package/dist/chat/project_context_inject.js +119 -0
  107. package/dist/chat/project_context_inject.js.map +1 -0
  108. package/dist/chat/project_slug.d.ts +17 -0
  109. package/dist/chat/project_slug.d.ts.map +1 -0
  110. package/dist/chat/project_slug.js +103 -0
  111. package/dist/chat/project_slug.js.map +1 -0
  112. package/dist/chat/refusal_detector.d.ts +14 -0
  113. package/dist/chat/refusal_detector.d.ts.map +1 -0
  114. package/dist/chat/refusal_detector.js +138 -0
  115. package/dist/chat/refusal_detector.js.map +1 -0
  116. package/dist/chat/semantic_graph_ingest.d.ts +39 -0
  117. package/dist/chat/semantic_graph_ingest.d.ts.map +1 -0
  118. package/dist/chat/semantic_graph_ingest.js +90 -0
  119. package/dist/chat/semantic_graph_ingest.js.map +1 -0
  120. package/dist/chat/semantic_graph_inject.d.ts +31 -0
  121. package/dist/chat/semantic_graph_inject.d.ts.map +1 -0
  122. package/dist/chat/semantic_graph_inject.js +63 -0
  123. package/dist/chat/semantic_graph_inject.js.map +1 -0
  124. package/dist/chat/server.d.ts +98 -2
  125. package/dist/chat/server.d.ts.map +1 -1
  126. package/dist/chat/server.js +3678 -345
  127. package/dist/chat/server.js.map +1 -1
  128. package/dist/chat/spec_extract.d.ts +1 -2
  129. package/dist/chat/spec_extract.d.ts.map +1 -1
  130. package/dist/chat/spec_extract.js +41 -56
  131. package/dist/chat/spec_extract.js.map +1 -1
  132. package/dist/chat/spec_plan.d.ts +1 -2
  133. package/dist/chat/spec_plan.d.ts.map +1 -1
  134. package/dist/chat/spec_plan.js +36 -57
  135. package/dist/chat/spec_plan.js.map +1 -1
  136. package/dist/chat/tools/app_backend_dispatch.d.ts +39 -0
  137. package/dist/chat/tools/app_backend_dispatch.d.ts.map +1 -0
  138. package/dist/chat/tools/app_backend_dispatch.js +239 -0
  139. package/dist/chat/tools/app_backend_dispatch.js.map +1 -0
  140. package/dist/chat/tools/fs_editor.d.ts +11 -0
  141. package/dist/chat/tools/fs_editor.d.ts.map +1 -0
  142. package/dist/chat/tools/fs_editor.js +162 -0
  143. package/dist/chat/tools/fs_editor.js.map +1 -0
  144. package/dist/chat/tools/git.d.ts +24 -0
  145. package/dist/chat/tools/git.d.ts.map +1 -1
  146. package/dist/chat/tools/git.js +78 -0
  147. package/dist/chat/tools/git.js.map +1 -1
  148. package/dist/chat/tools/graph.d.ts +43 -0
  149. package/dist/chat/tools/graph.d.ts.map +1 -0
  150. package/dist/chat/tools/graph.js +174 -0
  151. package/dist/chat/tools/graph.js.map +1 -0
  152. package/dist/chat/tools/keys.d.ts +9 -0
  153. package/dist/chat/tools/keys.d.ts.map +1 -0
  154. package/dist/chat/tools/keys.js +169 -0
  155. package/dist/chat/tools/keys.js.map +1 -0
  156. package/dist/chat/tools/misc_verbs.d.ts +36 -0
  157. package/dist/chat/tools/misc_verbs.d.ts.map +1 -0
  158. package/dist/chat/tools/misc_verbs.js +405 -0
  159. package/dist/chat/tools/misc_verbs.js.map +1 -0
  160. package/dist/chat/tools/nac3_invoke.d.ts +58 -0
  161. package/dist/chat/tools/nac3_invoke.d.ts.map +1 -0
  162. package/dist/chat/tools/nac3_invoke.js +887 -0
  163. package/dist/chat/tools/nac3_invoke.js.map +1 -0
  164. package/dist/chat/tools/pending.d.ts +69 -0
  165. package/dist/chat/tools/pending.d.ts.map +1 -0
  166. package/dist/chat/tools/pending.js +167 -0
  167. package/dist/chat/tools/pending.js.map +1 -0
  168. package/dist/chat/tools/pizarron.d.ts +40 -0
  169. package/dist/chat/tools/pizarron.d.ts.map +1 -0
  170. package/dist/chat/tools/pizarron.js +123 -0
  171. package/dist/chat/tools/pizarron.js.map +1 -0
  172. package/dist/chat/tools/project_lifecycle.d.ts +24 -0
  173. package/dist/chat/tools/project_lifecycle.d.ts.map +1 -0
  174. package/dist/chat/tools/project_lifecycle.js +212 -0
  175. package/dist/chat/tools/project_lifecycle.js.map +1 -0
  176. package/dist/chat/tools/reader.d.ts.map +1 -1
  177. package/dist/chat/tools/reader.js +144 -1
  178. package/dist/chat/tools/reader.js.map +1 -1
  179. package/dist/chat/tools/shell.d.ts +58 -0
  180. package/dist/chat/tools/shell.d.ts.map +1 -0
  181. package/dist/chat/tools/shell.js +253 -0
  182. package/dist/chat/tools/shell.js.map +1 -0
  183. package/dist/chat/tools/wiring.d.ts +28 -0
  184. package/dist/chat/tools/wiring.d.ts.map +1 -0
  185. package/dist/chat/tools/wiring.js +116 -0
  186. package/dist/chat/tools/wiring.js.map +1 -0
  187. package/dist/chat/tools/workflow.d.ts +55 -0
  188. package/dist/chat/tools/workflow.d.ts.map +1 -0
  189. package/dist/chat/tools/workflow.js +463 -0
  190. package/dist/chat/tools/workflow.js.map +1 -0
  191. package/dist/chat/tools.d.ts +42 -0
  192. package/dist/chat/tools.d.ts.map +1 -1
  193. package/dist/chat/tools.js +447 -16
  194. package/dist/chat/tools.js.map +1 -1
  195. package/dist/chat/wiring_inject.d.ts +18 -0
  196. package/dist/chat/wiring_inject.d.ts.map +1 -0
  197. package/dist/chat/wiring_inject.js +78 -0
  198. package/dist/chat/wiring_inject.js.map +1 -0
  199. package/dist/chat/workflow_inject.d.ts +52 -0
  200. package/dist/chat/workflow_inject.d.ts.map +1 -0
  201. package/dist/chat/workflow_inject.js +346 -0
  202. package/dist/chat/workflow_inject.js.map +1 -0
  203. package/dist/chat/yujin_splash.d.ts +27 -0
  204. package/dist/chat/yujin_splash.d.ts.map +1 -0
  205. package/dist/chat/yujin_splash.js +147 -0
  206. package/dist/chat/yujin_splash.js.map +1 -0
  207. package/dist/commands/agent.d.ts +33 -0
  208. package/dist/commands/agent.d.ts.map +1 -0
  209. package/dist/commands/agent.js +281 -0
  210. package/dist/commands/agent.js.map +1 -0
  211. package/dist/commands/approve.d.ts +32 -0
  212. package/dist/commands/approve.d.ts.map +1 -0
  213. package/dist/commands/approve.js +198 -0
  214. package/dist/commands/approve.js.map +1 -0
  215. package/dist/commands/block.d.ts +28 -0
  216. package/dist/commands/block.d.ts.map +1 -0
  217. package/dist/commands/block.js +189 -0
  218. package/dist/commands/block.js.map +1 -0
  219. package/dist/commands/bootstrap.d.ts +35 -0
  220. package/dist/commands/bootstrap.d.ts.map +1 -0
  221. package/dist/commands/bootstrap.js +205 -0
  222. package/dist/commands/bootstrap.js.map +1 -0
  223. package/dist/commands/brain.d.ts +17 -0
  224. package/dist/commands/brain.d.ts.map +1 -0
  225. package/dist/commands/brain.js +129 -0
  226. package/dist/commands/brain.js.map +1 -0
  227. package/dist/commands/chat.d.ts +10 -0
  228. package/dist/commands/chat.d.ts.map +1 -1
  229. package/dist/commands/chat.js +209 -17
  230. package/dist/commands/chat.js.map +1 -1
  231. package/dist/commands/clarify.d.ts +30 -0
  232. package/dist/commands/clarify.d.ts.map +1 -0
  233. package/dist/commands/clarify.js +671 -0
  234. package/dist/commands/clarify.js.map +1 -0
  235. package/dist/commands/director.d.ts +37 -0
  236. package/dist/commands/director.d.ts.map +1 -0
  237. package/dist/commands/director.js +290 -0
  238. package/dist/commands/director.js.map +1 -0
  239. package/dist/commands/discover.d.ts +30 -0
  240. package/dist/commands/discover.d.ts.map +1 -0
  241. package/dist/commands/discover.js +178 -0
  242. package/dist/commands/discover.js.map +1 -0
  243. package/dist/commands/doctor.d.ts +18 -0
  244. package/dist/commands/doctor.d.ts.map +1 -1
  245. package/dist/commands/doctor.js +120 -4
  246. package/dist/commands/doctor.js.map +1 -1
  247. package/dist/commands/doctrine.d.ts +24 -0
  248. package/dist/commands/doctrine.d.ts.map +1 -0
  249. package/dist/commands/doctrine.js +106 -0
  250. package/dist/commands/doctrine.js.map +1 -0
  251. package/dist/commands/doctrines.d.ts +21 -0
  252. package/dist/commands/doctrines.d.ts.map +1 -0
  253. package/dist/commands/doctrines.js +158 -0
  254. package/dist/commands/doctrines.js.map +1 -0
  255. package/dist/commands/graph.d.ts +29 -0
  256. package/dist/commands/graph.d.ts.map +1 -0
  257. package/dist/commands/graph.js +362 -0
  258. package/dist/commands/graph.js.map +1 -0
  259. package/dist/commands/keys_setup.d.ts +18 -0
  260. package/dist/commands/keys_setup.d.ts.map +1 -1
  261. package/dist/commands/keys_setup.js +225 -11
  262. package/dist/commands/keys_setup.js.map +1 -1
  263. package/dist/commands/legacy-audit.d.ts +34 -0
  264. package/dist/commands/legacy-audit.d.ts.map +1 -0
  265. package/dist/commands/legacy-audit.js +270 -0
  266. package/dist/commands/legacy-audit.js.map +1 -0
  267. package/dist/commands/lessons.d.ts +28 -0
  268. package/dist/commands/lessons.d.ts.map +1 -0
  269. package/dist/commands/lessons.js +309 -0
  270. package/dist/commands/lessons.js.map +1 -0
  271. package/dist/commands/license.d.ts.map +1 -1
  272. package/dist/commands/license.js +56 -1
  273. package/dist/commands/license.js.map +1 -1
  274. package/dist/commands/limits.d.ts +15 -0
  275. package/dist/commands/limits.d.ts.map +1 -0
  276. package/dist/commands/limits.js +51 -0
  277. package/dist/commands/limits.js.map +1 -0
  278. package/dist/commands/manifest-cli.d.ts +12 -0
  279. package/dist/commands/manifest-cli.d.ts.map +1 -0
  280. package/dist/commands/manifest-cli.js +42 -0
  281. package/dist/commands/manifest-cli.js.map +1 -0
  282. package/dist/commands/mcp.d.ts +24 -0
  283. package/dist/commands/mcp.d.ts.map +1 -0
  284. package/dist/commands/mcp.js +163 -0
  285. package/dist/commands/mcp.js.map +1 -0
  286. package/dist/commands/mobile.d.ts +15 -0
  287. package/dist/commands/mobile.d.ts.map +1 -1
  288. package/dist/commands/mobile.js +27 -24
  289. package/dist/commands/mobile.js.map +1 -1
  290. package/dist/commands/nac3.d.ts +22 -0
  291. package/dist/commands/nac3.d.ts.map +1 -0
  292. package/dist/commands/nac3.js +125 -0
  293. package/dist/commands/nac3.js.map +1 -0
  294. package/dist/commands/operate.d.ts +22 -0
  295. package/dist/commands/operate.d.ts.map +1 -0
  296. package/dist/commands/operate.js +523 -0
  297. package/dist/commands/operate.js.map +1 -0
  298. package/dist/commands/pending.d.ts +22 -0
  299. package/dist/commands/pending.d.ts.map +1 -0
  300. package/dist/commands/pending.js +341 -0
  301. package/dist/commands/pending.js.map +1 -0
  302. package/dist/commands/qa.d.ts +24 -0
  303. package/dist/commands/qa.d.ts.map +1 -0
  304. package/dist/commands/qa.js +349 -0
  305. package/dist/commands/qa.js.map +1 -0
  306. package/dist/commands/spec.d.ts +38 -0
  307. package/dist/commands/spec.d.ts.map +1 -0
  308. package/dist/commands/spec.js +256 -0
  309. package/dist/commands/spec.js.map +1 -0
  310. package/dist/commands/support.d.ts +22 -0
  311. package/dist/commands/support.d.ts.map +1 -0
  312. package/dist/commands/support.js +165 -0
  313. package/dist/commands/support.js.map +1 -0
  314. package/dist/commands/test_agent.d.ts +22 -0
  315. package/dist/commands/test_agent.d.ts.map +1 -0
  316. package/dist/commands/test_agent.js +100 -0
  317. package/dist/commands/test_agent.js.map +1 -0
  318. package/dist/commands/triage.d.ts +34 -0
  319. package/dist/commands/triage.d.ts.map +1 -0
  320. package/dist/commands/triage.js +228 -0
  321. package/dist/commands/triage.js.map +1 -0
  322. package/dist/commands/vault-inventory.d.ts +30 -0
  323. package/dist/commands/vault-inventory.d.ts.map +1 -0
  324. package/dist/commands/vault-inventory.js +222 -0
  325. package/dist/commands/vault-inventory.js.map +1 -0
  326. package/dist/commands/vault.d.ts.map +1 -1
  327. package/dist/commands/vault.js +5 -0
  328. package/dist/commands/vault.js.map +1 -1
  329. package/dist/commands/voice.d.ts.map +1 -1
  330. package/dist/commands/voice.js +47 -4
  331. package/dist/commands/voice.js.map +1 -1
  332. package/dist/commands/wiring.d.ts +19 -0
  333. package/dist/commands/wiring.d.ts.map +1 -0
  334. package/dist/commands/wiring.js +135 -0
  335. package/dist/commands/wiring.js.map +1 -0
  336. package/dist/commands/workflow-coverage.d.ts +30 -0
  337. package/dist/commands/workflow-coverage.d.ts.map +1 -0
  338. package/dist/commands/workflow-coverage.js +138 -0
  339. package/dist/commands/workflow-coverage.js.map +1 -0
  340. package/dist/core/ansi_strip.d.ts +39 -0
  341. package/dist/core/ansi_strip.d.ts.map +1 -0
  342. package/dist/core/ansi_strip.js +71 -0
  343. package/dist/core/ansi_strip.js.map +1 -0
  344. package/dist/core/atomic_write.d.ts +17 -0
  345. package/dist/core/atomic_write.d.ts.map +1 -0
  346. package/dist/core/atomic_write.js +51 -0
  347. package/dist/core/atomic_write.js.map +1 -0
  348. package/dist/core/audit_log.d.ts +43 -0
  349. package/dist/core/audit_log.d.ts.map +1 -0
  350. package/dist/core/audit_log.js +80 -0
  351. package/dist/core/audit_log.js.map +1 -0
  352. package/dist/core/brain_config.d.ts +50 -0
  353. package/dist/core/brain_config.d.ts.map +1 -0
  354. package/dist/core/brain_config.js +142 -0
  355. package/dist/core/brain_config.js.map +1 -0
  356. package/dist/core/budget.d.ts +96 -0
  357. package/dist/core/budget.d.ts.map +1 -0
  358. package/dist/core/budget.js +328 -0
  359. package/dist/core/budget.js.map +1 -0
  360. package/dist/core/cost_meter.d.ts +35 -0
  361. package/dist/core/cost_meter.d.ts.map +1 -0
  362. package/dist/core/cost_meter.js +111 -0
  363. package/dist/core/cost_meter.js.map +1 -0
  364. package/dist/core/cost_router.d.ts +106 -0
  365. package/dist/core/cost_router.d.ts.map +1 -0
  366. package/dist/core/cost_router.js +589 -0
  367. package/dist/core/cost_router.js.map +1 -0
  368. package/dist/core/doctrine_resolver.d.ts +23 -0
  369. package/dist/core/doctrine_resolver.d.ts.map +1 -0
  370. package/dist/core/doctrine_resolver.js +108 -0
  371. package/dist/core/doctrine_resolver.js.map +1 -0
  372. package/dist/core/error_catalog.d.ts +80 -0
  373. package/dist/core/error_catalog.d.ts.map +1 -0
  374. package/dist/core/error_catalog.js +168 -0
  375. package/dist/core/error_catalog.js.map +1 -0
  376. package/dist/core/error_formatter.d.ts +30 -0
  377. package/dist/core/error_formatter.d.ts.map +1 -0
  378. package/dist/core/error_formatter.js +155 -0
  379. package/dist/core/error_formatter.js.map +1 -0
  380. package/dist/core/incomplete_detector.d.ts +51 -0
  381. package/dist/core/incomplete_detector.d.ts.map +1 -0
  382. package/dist/core/incomplete_detector.js +140 -0
  383. package/dist/core/incomplete_detector.js.map +1 -0
  384. package/dist/core/keys_envelope.d.ts.map +1 -1
  385. package/dist/core/keys_envelope.js +14 -9
  386. package/dist/core/keys_envelope.js.map +1 -1
  387. package/dist/core/learnings_client.d.ts +104 -0
  388. package/dist/core/learnings_client.d.ts.map +1 -0
  389. package/dist/core/learnings_client.js +262 -0
  390. package/dist/core/learnings_client.js.map +1 -0
  391. package/dist/core/limits_config.d.ts +76 -0
  392. package/dist/core/limits_config.d.ts.map +1 -0
  393. package/dist/core/limits_config.js +297 -0
  394. package/dist/core/limits_config.js.map +1 -0
  395. package/dist/core/pairing.d.ts +1 -0
  396. package/dist/core/pairing.d.ts.map +1 -1
  397. package/dist/core/pairing.js +23 -12
  398. package/dist/core/pairing.js.map +1 -1
  399. package/dist/core/pilot_setup.d.ts +29 -3
  400. package/dist/core/pilot_setup.d.ts.map +1 -1
  401. package/dist/core/pilot_setup.js +130 -3
  402. package/dist/core/pilot_setup.js.map +1 -1
  403. package/dist/core/provider_keys.d.ts +35 -0
  404. package/dist/core/provider_keys.d.ts.map +1 -0
  405. package/dist/core/provider_keys.js +267 -0
  406. package/dist/core/provider_keys.js.map +1 -0
  407. package/dist/core/provider_models.d.ts +27 -0
  408. package/dist/core/provider_models.d.ts.map +1 -0
  409. package/dist/core/provider_models.js +169 -0
  410. package/dist/core/provider_models.js.map +1 -0
  411. package/dist/core/reasoning_gate.d.ts +72 -0
  412. package/dist/core/reasoning_gate.d.ts.map +1 -0
  413. package/dist/core/reasoning_gate.js +205 -0
  414. package/dist/core/reasoning_gate.js.map +1 -0
  415. package/dist/core/shell_classifier.d.ts +49 -0
  416. package/dist/core/shell_classifier.d.ts.map +1 -0
  417. package/dist/core/shell_classifier.js +159 -0
  418. package/dist/core/shell_classifier.js.map +1 -0
  419. package/dist/core/sq_resolver.d.ts +14 -0
  420. package/dist/core/sq_resolver.d.ts.map +1 -0
  421. package/dist/core/sq_resolver.js +230 -0
  422. package/dist/core/sq_resolver.js.map +1 -0
  423. package/dist/core/truncation_detector.d.ts +68 -0
  424. package/dist/core/truncation_detector.d.ts.map +1 -0
  425. package/dist/core/truncation_detector.js +115 -0
  426. package/dist/core/truncation_detector.js.map +1 -0
  427. package/dist/core/turn_classifier.d.ts +70 -0
  428. package/dist/core/turn_classifier.d.ts.map +1 -0
  429. package/dist/core/turn_classifier.js +324 -0
  430. package/dist/core/turn_classifier.js.map +1 -0
  431. package/dist/core/vad_config.d.ts +34 -0
  432. package/dist/core/vad_config.d.ts.map +1 -0
  433. package/dist/core/vad_config.js +95 -0
  434. package/dist/core/vad_config.js.map +1 -0
  435. package/dist/core/whats_new.d.ts +42 -0
  436. package/dist/core/whats_new.d.ts.map +1 -0
  437. package/dist/core/whats_new.js +214 -0
  438. package/dist/core/whats_new.js.map +1 -0
  439. package/dist/core/wizard_state.d.ts +15 -0
  440. package/dist/core/wizard_state.d.ts.map +1 -0
  441. package/dist/core/wizard_state.js +86 -0
  442. package/dist/core/wizard_state.js.map +1 -0
  443. package/dist/deploy/adapter.d.ts +93 -0
  444. package/dist/deploy/adapter.d.ts.map +1 -0
  445. package/dist/deploy/adapter.js +42 -0
  446. package/dist/deploy/adapter.js.map +1 -0
  447. package/dist/deploy/aws_adapter.d.ts +28 -0
  448. package/dist/deploy/aws_adapter.d.ts.map +1 -0
  449. package/dist/deploy/aws_adapter.js +98 -0
  450. package/dist/deploy/aws_adapter.js.map +1 -0
  451. package/dist/deploy/cloudflare.d.ts +24 -0
  452. package/dist/deploy/cloudflare.d.ts.map +1 -0
  453. package/dist/deploy/cloudflare.js +169 -0
  454. package/dist/deploy/cloudflare.js.map +1 -0
  455. package/dist/design/yujin_design_resolver.d.ts +77 -0
  456. package/dist/design/yujin_design_resolver.d.ts.map +1 -0
  457. package/dist/design/yujin_design_resolver.js +113 -0
  458. package/dist/design/yujin_design_resolver.js.map +1 -0
  459. package/dist/docs/LIMITED_RESPONSIBILITY.md +216 -0
  460. package/dist/docs/PRIVACY_POLICY.md +169 -0
  461. package/dist/docs/SQ.md +1397 -0
  462. package/dist/docs/SQ_BY_STEP.yaml +147 -0
  463. package/dist/docs/doctrine/accessibility.md +78 -0
  464. package/dist/docs/doctrine/agent-orchestrator.md +76 -0
  465. package/dist/docs/doctrine/byok-vs-managed.md +81 -0
  466. package/dist/docs/doctrine/cost-router-escalation.md +103 -0
  467. package/dist/docs/doctrine/default-design-system.md +120 -0
  468. package/dist/docs/doctrine/explain-results.md +90 -0
  469. package/dist/docs/doctrine/expo-eas-builds.md +177 -0
  470. package/dist/docs/doctrine/g-doc.md +46 -0
  471. package/dist/docs/doctrine/gates.md +21 -0
  472. package/dist/docs/doctrine/language-sovereignty.md +143 -0
  473. package/dist/docs/doctrine/lessons-workflow.md +68 -0
  474. package/dist/docs/doctrine/mcp-bridge-lifecycle.md +75 -0
  475. package/dist/docs/doctrine/nac3-attrs.md +23 -0
  476. package/dist/docs/doctrine/office-tools.md +102 -0
  477. package/dist/docs/doctrine/pizarron.md +52 -0
  478. package/dist/docs/doctrine/r8-modal.md +60 -0
  479. package/dist/docs/doctrine/safety-refusals.md +73 -0
  480. package/dist/docs/doctrine/testing-policy.md +211 -0
  481. package/dist/docs/doctrine/verb-composition.md +68 -0
  482. package/dist/docs/doctrine/verification-honesty.md +95 -0
  483. package/dist/docs/doctrine/workflow-kinds.md +52 -0
  484. package/dist/docs/doctrine/workflow-phases.md +108 -0
  485. package/dist/docs/doctrine/workflow.md +58 -0
  486. package/dist/docs/doctrine/yf-commands.md +49 -0
  487. package/dist/json/query.d.ts +38 -0
  488. package/dist/json/query.d.ts.map +1 -0
  489. package/dist/json/query.js +177 -0
  490. package/dist/json/query.js.map +1 -0
  491. package/dist/license/hito4_client.d.ts +7 -0
  492. package/dist/license/hito4_client.d.ts.map +1 -1
  493. package/dist/license/hito4_client.js +52 -2
  494. package/dist/license/hito4_client.js.map +1 -1
  495. package/dist/license/sync.d.ts +59 -0
  496. package/dist/license/sync.d.ts.map +1 -0
  497. package/dist/license/sync.js +148 -0
  498. package/dist/license/sync.js.map +1 -0
  499. package/dist/llm/cohere_client.d.ts +81 -0
  500. package/dist/llm/cohere_client.d.ts.map +1 -0
  501. package/dist/llm/cohere_client.js +195 -0
  502. package/dist/llm/cohere_client.js.map +1 -0
  503. package/dist/llm/deepseek_client.d.ts +63 -0
  504. package/dist/llm/deepseek_client.d.ts.map +1 -0
  505. package/dist/llm/deepseek_client.js +188 -0
  506. package/dist/llm/deepseek_client.js.map +1 -0
  507. package/dist/llm/gemini_client.d.ts +78 -0
  508. package/dist/llm/gemini_client.d.ts.map +1 -0
  509. package/dist/llm/gemini_client.js +189 -0
  510. package/dist/llm/gemini_client.js.map +1 -0
  511. package/dist/llm/managed_client.d.ts +70 -0
  512. package/dist/llm/managed_client.d.ts.map +1 -0
  513. package/dist/llm/managed_client.js +251 -0
  514. package/dist/llm/managed_client.js.map +1 -0
  515. package/dist/llm/mistral_client.d.ts +54 -0
  516. package/dist/llm/mistral_client.d.ts.map +1 -0
  517. package/dist/llm/mistral_client.js +174 -0
  518. package/dist/llm/mistral_client.js.map +1 -0
  519. package/dist/llm/multi_provider_client.d.ts +57 -0
  520. package/dist/llm/multi_provider_client.d.ts.map +1 -0
  521. package/dist/llm/multi_provider_client.js +109 -0
  522. package/dist/llm/multi_provider_client.js.map +1 -0
  523. package/dist/llm/openai_client.d.ts +77 -0
  524. package/dist/llm/openai_client.d.ts.map +1 -0
  525. package/dist/llm/openai_client.js +200 -0
  526. package/dist/llm/openai_client.js.map +1 -0
  527. package/dist/llm/xai_client.d.ts +51 -0
  528. package/dist/llm/xai_client.d.ts.map +1 -0
  529. package/dist/llm/xai_client.js +174 -0
  530. package/dist/llm/xai_client.js.map +1 -0
  531. package/dist/mcp/blocklist.d.ts +19 -0
  532. package/dist/mcp/blocklist.d.ts.map +1 -0
  533. package/dist/mcp/blocklist.js +118 -0
  534. package/dist/mcp/blocklist.js.map +1 -0
  535. package/dist/mcp/bridge_registry.d.ts +77 -0
  536. package/dist/mcp/bridge_registry.d.ts.map +1 -0
  537. package/dist/mcp/bridge_registry.js +220 -0
  538. package/dist/mcp/bridge_registry.js.map +1 -0
  539. package/dist/mcp/config.d.ts +25 -0
  540. package/dist/mcp/config.d.ts.map +1 -0
  541. package/dist/mcp/config.js +116 -0
  542. package/dist/mcp/config.js.map +1 -0
  543. package/dist/mcp/forge_panel_server.d.ts +46 -0
  544. package/dist/mcp/forge_panel_server.d.ts.map +1 -0
  545. package/dist/mcp/forge_panel_server.js +166 -0
  546. package/dist/mcp/forge_panel_server.js.map +1 -0
  547. package/dist/mcp/lan_scan.d.ts +36 -0
  548. package/dist/mcp/lan_scan.d.ts.map +1 -0
  549. package/dist/mcp/lan_scan.js +130 -0
  550. package/dist/mcp/lan_scan.js.map +1 -0
  551. package/dist/mcp/protocol_client.d.ts +63 -0
  552. package/dist/mcp/protocol_client.d.ts.map +1 -0
  553. package/dist/mcp/protocol_client.js +340 -0
  554. package/dist/mcp/protocol_client.js.map +1 -0
  555. package/dist/mcp/state.d.ts +38 -0
  556. package/dist/mcp/state.d.ts.map +1 -0
  557. package/dist/mcp/state.js +144 -0
  558. package/dist/mcp/state.js.map +1 -0
  559. package/dist/migrate/ai-decorator.d.ts.map +1 -1
  560. package/dist/migrate/ai-decorator.js +55 -143
  561. package/dist/migrate/ai-decorator.js.map +1 -1
  562. package/dist/nac3/approval_queue.d.ts +44 -0
  563. package/dist/nac3/approval_queue.d.ts.map +1 -0
  564. package/dist/nac3/approval_queue.js +105 -0
  565. package/dist/nac3/approval_queue.js.map +1 -0
  566. package/dist/nac3/backend_tools.d.ts +89 -0
  567. package/dist/nac3/backend_tools.d.ts.map +1 -0
  568. package/dist/nac3/backend_tools.js +271 -0
  569. package/dist/nac3/backend_tools.js.map +1 -0
  570. package/dist/nac3/dispatch_log.d.ts +35 -0
  571. package/dist/nac3/dispatch_log.d.ts.map +1 -0
  572. package/dist/nac3/dispatch_log.js +126 -0
  573. package/dist/nac3/dispatch_log.js.map +1 -0
  574. package/dist/nac3/internal_manifest.d.ts +62 -0
  575. package/dist/nac3/internal_manifest.d.ts.map +1 -0
  576. package/dist/nac3/internal_manifest.js +2461 -0
  577. package/dist/nac3/internal_manifest.js.map +1 -0
  578. package/dist/nac3/manifest_lazy.d.ts +71 -0
  579. package/dist/nac3/manifest_lazy.d.ts.map +1 -0
  580. package/dist/nac3/manifest_lazy.js +141 -0
  581. package/dist/nac3/manifest_lazy.js.map +1 -0
  582. package/dist/nac3/verb_tier_map.d.ts +80 -0
  583. package/dist/nac3/verb_tier_map.d.ts.map +1 -0
  584. package/dist/nac3/verb_tier_map.js +159 -0
  585. package/dist/nac3/verb_tier_map.js.map +1 -0
  586. package/dist/office/excel.d.ts +132 -0
  587. package/dist/office/excel.d.ts.map +1 -0
  588. package/dist/office/excel.js +358 -0
  589. package/dist/office/excel.js.map +1 -0
  590. package/dist/office/pdf.d.ts +35 -0
  591. package/dist/office/pdf.d.ts.map +1 -0
  592. package/dist/office/pdf.js +70 -0
  593. package/dist/office/pdf.js.map +1 -0
  594. package/dist/office/word.d.ts +64 -0
  595. package/dist/office/word.d.ts.map +1 -0
  596. package/dist/office/word.js +243 -0
  597. package/dist/office/word.js.map +1 -0
  598. package/dist/pending/store.d.ts +72 -0
  599. package/dist/pending/store.d.ts.map +1 -0
  600. package/dist/pending/store.js +274 -0
  601. package/dist/pending/store.js.map +1 -0
  602. package/dist/pending/types.d.ts +111 -0
  603. package/dist/pending/types.d.ts.map +1 -0
  604. package/dist/pending/types.js +60 -0
  605. package/dist/pending/types.js.map +1 -0
  606. package/dist/qa/brain_matrix/ab_runner.d.ts +44 -0
  607. package/dist/qa/brain_matrix/ab_runner.d.ts.map +1 -0
  608. package/dist/qa/brain_matrix/ab_runner.js +144 -0
  609. package/dist/qa/brain_matrix/ab_runner.js.map +1 -0
  610. package/dist/qa/brain_matrix/brains.json +80 -0
  611. package/dist/qa/brain_matrix/compare.d.ts +27 -0
  612. package/dist/qa/brain_matrix/compare.d.ts.map +1 -0
  613. package/dist/qa/brain_matrix/compare.js +122 -0
  614. package/dist/qa/brain_matrix/compare.js.map +1 -0
  615. package/dist/qa/brain_matrix/fake_brain.d.ts +36 -0
  616. package/dist/qa/brain_matrix/fake_brain.d.ts.map +1 -0
  617. package/dist/qa/brain_matrix/fake_brain.js +27 -0
  618. package/dist/qa/brain_matrix/fake_brain.js.map +1 -0
  619. package/dist/qa/brain_matrix/fixtures/scenario_a11y_announce_intent.json +22 -0
  620. package/dist/qa/brain_matrix/fixtures/scenario_a11y_announce_no_speak_default.json +23 -0
  621. package/dist/qa/brain_matrix/fixtures/scenario_a11y_consecutive_announces.json +37 -0
  622. package/dist/qa/brain_matrix/fixtures/scenario_a11y_focus_after_action.json +22 -0
  623. package/dist/qa/brain_matrix/fixtures/scenario_a11y_focus_after_open.json +22 -0
  624. package/dist/qa/brain_matrix/fixtures/scenario_a11y_label_unlabeled_button.json +26 -0
  625. package/dist/qa/brain_matrix/fixtures/scenario_a11y_label_with_shortcut.json +22 -0
  626. package/dist/qa/brain_matrix/fixtures/scenario_agent_director_consults_workflow_before_dispatch.json +42 -0
  627. package/dist/qa/brain_matrix/fixtures/scenario_analyst_extracts_then_returns.json +28 -0
  628. package/dist/qa/brain_matrix/fixtures/scenario_announce_min_tool_calls.json +23 -0
  629. package/dist/qa/brain_matrix/fixtures/scenario_app_invoke_happy_path.json +65 -0
  630. package/dist/qa/brain_matrix/fixtures/scenario_app_no_invent_without_backend.json +18 -0
  631. package/dist/qa/brain_matrix/fixtures/scenario_app_schema_fail_recover.json +48 -0
  632. package/dist/qa/brain_matrix/fixtures/scenario_approval_modal_no_redundancy.json +21 -0
  633. package/dist/qa/brain_matrix/fixtures/scenario_args_redacted_in_audit.json +17 -0
  634. package/dist/qa/brain_matrix/fixtures/scenario_audit_consumers_intent.json +19 -0
  635. package/dist/qa/brain_matrix/fixtures/scenario_audit_consumers_intent_en.json +19 -0
  636. package/dist/qa/brain_matrix/fixtures/scenario_audit_log_on_destructive.json +18 -0
  637. package/dist/qa/brain_matrix/fixtures/scenario_brain_explains_after_invoke.json +28 -0
  638. package/dist/qa/brain_matrix/fixtures/scenario_byok_on_managed_brain_overflow.json +28 -0
  639. package/dist/qa/brain_matrix/fixtures/scenario_byok_required_policy.json +35 -0
  640. package/dist/qa/brain_matrix/fixtures/scenario_coder_consults_nac_spec_before_writing.json +28 -0
  641. package/dist/qa/brain_matrix/fixtures/scenario_concise_no_loop.json +23 -0
  642. package/dist/qa/brain_matrix/fixtures/scenario_consult_nac_spec_intent.json +19 -0
  643. package/dist/qa/brain_matrix/fixtures/scenario_contradiction_rfp_cost.json +38 -0
  644. package/dist/qa/brain_matrix/fixtures/scenario_cost_router_attachment_frontier.json +24 -0
  645. package/dist/qa/brain_matrix/fixtures/scenario_cost_router_escalation_explicit.json +23 -0
  646. package/dist/qa/brain_matrix/fixtures/scenario_cost_router_idle_reset.json +24 -0
  647. package/dist/qa/brain_matrix/fixtures/scenario_csv_via_excel_read.json +22 -0
  648. package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_override_shadcn.json +45 -0
  649. package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_use_card_template.json +44 -0
  650. package/dist/qa/brain_matrix/fixtures/scenario_destructive_no_text_confirm.json +20 -0
  651. package/dist/qa/brain_matrix/fixtures/scenario_diff_explains_changes.json +32 -0
  652. package/dist/qa/brain_matrix/fixtures/scenario_director_delegates_to_analyst.json +26 -0
  653. package/dist/qa/brain_matrix/fixtures/scenario_director_no_self_delegation.json +20 -0
  654. package/dist/qa/brain_matrix/fixtures/scenario_director_terminal_done_marker.json +19 -0
  655. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_after_unknown_intent.json +17 -0
  656. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_a11y.json +24 -0
  657. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_agent_orchestrator.json +24 -0
  658. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_byok_vs_managed.json +24 -0
  659. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_cost_router.json +24 -0
  660. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_default_design_system.json +50 -0
  661. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_g_doc.json +24 -0
  662. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_gates.json +23 -0
  663. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_intent_en.json +19 -0
  664. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_language_sovereignty.json +53 -0
  665. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_lessons_workflow.json +24 -0
  666. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_mcp_lifecycle.json +24 -0
  667. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_nac3_attrs.json +24 -0
  668. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_office.json +24 -0
  669. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_pizarron.json +23 -0
  670. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_r8_modal.json +23 -0
  671. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_testing_policy.json +55 -0
  672. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_workflow.json +23 -0
  673. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_workflow_phases.json +24 -0
  674. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_yf_commands.json +24 -0
  675. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_explain_results.json +22 -0
  676. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_safety_refusals.json +22 -0
  677. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_verb_composition.json +22 -0
  678. package/dist/qa/brain_matrix/fixtures/scenario_eco_defers_complex.json +17 -0
  679. package/dist/qa/brain_matrix/fixtures/scenario_eco_short_query.json +27 -0
  680. package/dist/qa/brain_matrix/fixtures/scenario_env_list_intent.json +20 -0
  681. package/dist/qa/brain_matrix/fixtures/scenario_env_list_intent_en.json +20 -0
  682. package/dist/qa/brain_matrix/fixtures/scenario_env_no_secret_leak.json +21 -0
  683. package/dist/qa/brain_matrix/fixtures/scenario_env_secret_suppression.json +35 -0
  684. package/dist/qa/brain_matrix/fixtures/scenario_env_secret_suppression_en.json +21 -0
  685. package/dist/qa/brain_matrix/fixtures/scenario_excel_append_handles_csv_refusal.json +28 -0
  686. package/dist/qa/brain_matrix/fixtures/scenario_excel_append_sheet_intent.json +20 -0
  687. package/dist/qa/brain_matrix/fixtures/scenario_excel_append_sheet_intent_en.json +20 -0
  688. package/dist/qa/brain_matrix/fixtures/scenario_excel_append_then_list.json +34 -0
  689. package/dist/qa/brain_matrix/fixtures/scenario_excel_list_sheets_first.json +22 -0
  690. package/dist/qa/brain_matrix/fixtures/scenario_excel_sheet_named.json +23 -0
  691. package/dist/qa/brain_matrix/fixtures/scenario_explain_voice_yes.json +22 -0
  692. package/dist/qa/brain_matrix/fixtures/scenario_explains_forge_error.json +39 -0
  693. package/dist/qa/brain_matrix/fixtures/scenario_focus_after_modal_open.json +29 -0
  694. package/dist/qa/brain_matrix/fixtures/scenario_fs_find_intent.json +21 -0
  695. package/dist/qa/brain_matrix/fixtures/scenario_fs_find_intent_en.json +20 -0
  696. package/dist/qa/brain_matrix/fixtures/scenario_fs_find_with_glob.json +22 -0
  697. package/dist/qa/brain_matrix/fixtures/scenario_fs_mkdir_intent.json +20 -0
  698. package/dist/qa/brain_matrix/fixtures/scenario_fs_move_intent.json +20 -0
  699. package/dist/qa/brain_matrix/fixtures/scenario_fs_read_handles_missing.json +28 -0
  700. package/dist/qa/brain_matrix/fixtures/scenario_fs_stat_before_read.json +22 -0
  701. package/dist/qa/brain_matrix/fixtures/scenario_generated_app_nac3_default.json +24 -0
  702. package/dist/qa/brain_matrix/fixtures/scenario_git_branch_list_intent.json +20 -0
  703. package/dist/qa/brain_matrix/fixtures/scenario_git_diff_intent.json +20 -0
  704. package/dist/qa/brain_matrix/fixtures/scenario_git_log_intent.json +20 -0
  705. package/dist/qa/brain_matrix/fixtures/scenario_git_log_intent_en.json +20 -0
  706. package/dist/qa/brain_matrix/fixtures/scenario_git_pull_intent.json +19 -0
  707. package/dist/qa/brain_matrix/fixtures/scenario_git_push_intent.json +20 -0
  708. package/dist/qa/brain_matrix/fixtures/scenario_git_show_metadata_only.json +22 -0
  709. package/dist/qa/brain_matrix/fixtures/scenario_git_status_branch_combo.json +44 -0
  710. package/dist/qa/brain_matrix/fixtures/scenario_git_status_intent.json +20 -0
  711. package/dist/qa/brain_matrix/fixtures/scenario_git_status_intent_en.json +20 -0
  712. package/dist/qa/brain_matrix/fixtures/scenario_github_clone_repo_intent.json +20 -0
  713. package/dist/qa/brain_matrix/fixtures/scenario_github_create_repo_intent.json +20 -0
  714. package/dist/qa/brain_matrix/fixtures/scenario_id_format_in_reply.json +27 -0
  715. package/dist/qa/brain_matrix/fixtures/scenario_iterations_idempotent_adenda.json +27 -0
  716. package/dist/qa/brain_matrix/fixtures/scenario_iterations_query_history.json +29 -0
  717. package/dist/qa/brain_matrix/fixtures/scenario_iterations_tracker_acknowledge.json +28 -0
  718. package/dist/qa/brain_matrix/fixtures/scenario_json_parse_handles_malformed.json +34 -0
  719. package/dist/qa/brain_matrix/fixtures/scenario_json_parse_intent.json +30 -0
  720. package/dist/qa/brain_matrix/fixtures/scenario_json_parse_intent_en.json +30 -0
  721. package/dist/qa/brain_matrix/fixtures/scenario_json_query_extract.json +23 -0
  722. package/dist/qa/brain_matrix/fixtures/scenario_json_stringify_intent.json +20 -0
  723. package/dist/qa/brain_matrix/fixtures/scenario_json_stringify_intent_en.json +20 -0
  724. package/dist/qa/brain_matrix/fixtures/scenario_keys_set_intent.json +20 -0
  725. package/dist/qa/brain_matrix/fixtures/scenario_keys_status_intent.json +20 -0
  726. package/dist/qa/brain_matrix/fixtures/scenario_keys_status_intent_en.json +20 -0
  727. package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_not_conducive_spa_python.json +61 -0
  728. package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_user_picks_python.json +48 -0
  729. package/dist/qa/brain_matrix/fixtures/scenario_lazy_complex_verb.json +27 -0
  730. package/dist/qa/brain_matrix/fixtures/scenario_lazy_doctrine_only_when_needed.json +17 -0
  731. package/dist/qa/brain_matrix/fixtures/scenario_lazy_simple_verb.json +21 -0
  732. package/dist/qa/brain_matrix/fixtures/scenario_lesson_recall_overrides.json +22 -0
  733. package/dist/qa/brain_matrix/fixtures/scenario_lifecycle_restart_app_intent.json +19 -0
  734. package/dist/qa/brain_matrix/fixtures/scenario_lifecycle_run_app_intent.json +19 -0
  735. package/dist/qa/brain_matrix/fixtures/scenario_lifecycle_run_app_intent_en.json +19 -0
  736. package/dist/qa/brain_matrix/fixtures/scenario_lifecycle_stop_app_intent.json +20 -0
  737. package/dist/qa/brain_matrix/fixtures/scenario_lifecycle_stop_app_intent_en.json +20 -0
  738. package/dist/qa/brain_matrix/fixtures/scenario_long_doctrine_no_truncate.json +23 -0
  739. package/dist/qa/brain_matrix/fixtures/scenario_manifest_read_intent.json +19 -0
  740. package/dist/qa/brain_matrix/fixtures/scenario_manual_open_intent.json +19 -0
  741. package/dist/qa/brain_matrix/fixtures/scenario_mcp_bridge_unreachable_graceful.json +39 -0
  742. package/dist/qa/brain_matrix/fixtures/scenario_mcp_disabled_bridge_no_verbs.json +18 -0
  743. package/dist/qa/brain_matrix/fixtures/scenario_mcp_discover_tools_intent.json +19 -0
  744. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invariant_1_no_verbs.json +19 -0
  745. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_destructive_pauses_for_r8.json +42 -0
  746. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_happy_path_writes_pizarron.json +76 -0
  747. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_network_timeout_graceful.json +41 -0
  748. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_two_hop.json +68 -0
  749. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_with_auth_loaded.json +76 -0
  750. package/dist/qa/brain_matrix/fixtures/scenario_mcp_revoked_token_graceful.json +33 -0
  751. package/dist/qa/brain_matrix/fixtures/scenario_mcp_set_creds_intent.json +21 -0
  752. package/dist/qa/brain_matrix/fixtures/scenario_mcp_set_creds_no_token_arg.json +27 -0
  753. package/dist/qa/brain_matrix/fixtures/scenario_mcp_show_tools_before_invoke.json +44 -0
  754. package/dist/qa/brain_matrix/fixtures/scenario_mcp_toggle_intent.json +20 -0
  755. package/dist/qa/brain_matrix/fixtures/scenario_multi_turn_clarify_then_act.json +24 -0
  756. package/dist/qa/brain_matrix/fixtures/scenario_nac3_attrs_doctrine.json +53 -0
  757. package/dist/qa/brain_matrix/fixtures/scenario_nac3_discover_schemas_intent.json +19 -0
  758. package/dist/qa/brain_matrix/fixtures/scenario_nac3_required_attr_missing.json +19 -0
  759. package/dist/qa/brain_matrix/fixtures/scenario_no_bare_ok_after_destructive.json +27 -0
  760. package/dist/qa/brain_matrix/fixtures/scenario_no_global_learnings_no_recall.json +20 -0
  761. package/dist/qa/brain_matrix/fixtures/scenario_no_hardcoded_color_use_token.json +47 -0
  762. package/dist/qa/brain_matrix/fixtures/scenario_no_id_leak_in_reply.json +24 -0
  763. package/dist/qa/brain_matrix/fixtures/scenario_no_invent_verb.json +18 -0
  764. package/dist/qa/brain_matrix/fixtures/scenario_no_loop_after_repeated_fail.json +42 -0
  765. package/dist/qa/brain_matrix/fixtures/scenario_no_password_echo.json +28 -0
  766. package/dist/qa/brain_matrix/fixtures/scenario_npm_outdated_all_current.json +28 -0
  767. package/dist/qa/brain_matrix/fixtures/scenario_npm_outdated_intent.json +20 -0
  768. package/dist/qa/brain_matrix/fixtures/scenario_npm_outdated_intent_en.json +20 -0
  769. package/dist/qa/brain_matrix/fixtures/scenario_npm_outdated_then_explain.json +35 -0
  770. package/dist/qa/brain_matrix/fixtures/scenario_office_excel_read_intent.json +24 -0
  771. package/dist/qa/brain_matrix/fixtures/scenario_office_excel_update_cell_picks_right_verb.json +26 -0
  772. package/dist/qa/brain_matrix/fixtures/scenario_office_excel_write_required_args.json +23 -0
  773. package/dist/qa/brain_matrix/fixtures/scenario_office_overwrite_explicit.json +23 -0
  774. package/dist/qa/brain_matrix/fixtures/scenario_office_word_write_intent.json +27 -0
  775. package/dist/qa/brain_matrix/fixtures/scenario_panel_open_settings_intent.json +19 -0
  776. package/dist/qa/brain_matrix/fixtures/scenario_panel_send_message_intent.json +19 -0
  777. package/dist/qa/brain_matrix/fixtures/scenario_pdf_read_intent.json +22 -0
  778. package/dist/qa/brain_matrix/fixtures/scenario_pizarron_clear_tab_explicit_close.json +17 -0
  779. package/dist/qa/brain_matrix/fixtures/scenario_pizarron_for_chart.json +30 -0
  780. package/dist/qa/brain_matrix/fixtures/scenario_pizarron_for_long_table.json +28 -0
  781. package/dist/qa/brain_matrix/fixtures/scenario_pizarron_read_tab_intent.json +20 -0
  782. package/dist/qa/brain_matrix/fixtures/scenario_pizarron_render_not_inline.json +20 -0
  783. package/dist/qa/brain_matrix/fixtures/scenario_prefer_native_read_file.json +28 -0
  784. package/dist/qa/brain_matrix/fixtures/scenario_prefer_native_verb_over_shell.json +22 -0
  785. package/dist/qa/brain_matrix/fixtures/scenario_prefer_native_workflow_state.json +28 -0
  786. package/dist/qa/brain_matrix/fixtures/scenario_project_init_intent.json +20 -0
  787. package/dist/qa/brain_matrix/fixtures/scenario_project_set_root_intent.json +20 -0
  788. package/dist/qa/brain_matrix/fixtures/scenario_promote_lesson_after_correction.json +17 -0
  789. package/dist/qa/brain_matrix/fixtures/scenario_r8_destructive_shell_exec.json +21 -0
  790. package/dist/qa/brain_matrix/fixtures/scenario_read_then_parse_order.json +36 -0
  791. package/dist/qa/brain_matrix/fixtures/scenario_reader_bookmark_jump_intent.json +19 -0
  792. package/dist/qa/brain_matrix/fixtures/scenario_reader_bookmark_set_intent.json +19 -0
  793. package/dist/qa/brain_matrix/fixtures/scenario_reader_for_long_doc.json +21 -0
  794. package/dist/qa/brain_matrix/fixtures/scenario_reader_list_docs_intent.json +19 -0
  795. package/dist/qa/brain_matrix/fixtures/scenario_reader_next_block_intent.json +19 -0
  796. package/dist/qa/brain_matrix/fixtures/scenario_reader_read_section_intent.json +19 -0
  797. package/dist/qa/brain_matrix/fixtures/scenario_reader_recap_intent.json +19 -0
  798. package/dist/qa/brain_matrix/fixtures/scenario_reader_search_intent.json +20 -0
  799. package/dist/qa/brain_matrix/fixtures/scenario_reader_search_intent_en.json +20 -0
  800. package/dist/qa/brain_matrix/fixtures/scenario_refuses_blanket_chmod_777.json +17 -0
  801. package/dist/qa/brain_matrix/fixtures/scenario_refuses_bypass_approval_modal.json +18 -0
  802. package/dist/qa/brain_matrix/fixtures/scenario_refuses_delete_critical_en.json +18 -0
  803. package/dist/qa/brain_matrix/fixtures/scenario_refuses_disable_vault_encryption.json +18 -0
  804. package/dist/qa/brain_matrix/fixtures/scenario_refuses_drop_database.json +17 -0
  805. package/dist/qa/brain_matrix/fixtures/scenario_refuses_force_push_main.json +17 -0
  806. package/dist/qa/brain_matrix/fixtures/scenario_refuses_force_push_main_en.json +17 -0
  807. package/dist/qa/brain_matrix/fixtures/scenario_refuses_leak_vault_en.json +18 -0
  808. package/dist/qa/brain_matrix/fixtures/scenario_refuses_modify_node_modules.json +17 -0
  809. package/dist/qa/brain_matrix/fixtures/scenario_refuses_skip_hooks.json +17 -0
  810. package/dist/qa/brain_matrix/fixtures/scenario_refuses_skip_hooks_en.json +17 -0
  811. package/dist/qa/brain_matrix/fixtures/scenario_refuses_to_delete_critical.json +18 -0
  812. package/dist/qa/brain_matrix/fixtures/scenario_refuses_to_leak_vault.json +18 -0
  813. package/dist/qa/brain_matrix/fixtures/scenario_respects_global_learnings.json +28 -0
  814. package/dist/qa/brain_matrix/fixtures/scenario_respects_workflow_gate_g2.json +39 -0
  815. package/dist/qa/brain_matrix/fixtures/scenario_revoke_lesson_request.json +21 -0
  816. package/dist/qa/brain_matrix/fixtures/scenario_scaffolder_destructive_pauses_for_approval.json +23 -0
  817. package/dist/qa/brain_matrix/fixtures/scenario_sq9b_deploy_checks_git_status.json +25 -0
  818. package/dist/qa/brain_matrix/fixtures/scenario_sq9b_install_checks_node_version.json +25 -0
  819. package/dist/qa/brain_matrix/fixtures/scenario_sq9b_publish_checks_gh_cli.json +25 -0
  820. package/dist/qa/brain_matrix/fixtures/scenario_sq_rules_active.json +30 -0
  821. package/dist/qa/brain_matrix/fixtures/scenario_sumie_icon_pick_from_catalog.json +56 -0
  822. package/dist/qa/brain_matrix/fixtures/scenario_tester_uses_shell_exec_only.json +27 -0
  823. package/dist/qa/brain_matrix/fixtures/scenario_text_diff_intent.json +20 -0
  824. package/dist/qa/brain_matrix/fixtures/scenario_text_diff_intent_en.json +20 -0
  825. package/dist/qa/brain_matrix/fixtures/scenario_truncated_response_retry.json +22 -0
  826. package/dist/qa/brain_matrix/fixtures/scenario_unknown_command_clarify.json +23 -0
  827. package/dist/qa/brain_matrix/fixtures/scenario_url_parse_compose.json +23 -0
  828. package/dist/qa/brain_matrix/fixtures/scenario_url_parse_handles_invalid.json +27 -0
  829. package/dist/qa/brain_matrix/fixtures/scenario_url_parse_intent.json +20 -0
  830. package/dist/qa/brain_matrix/fixtures/scenario_url_parse_intent_en.json +20 -0
  831. package/dist/qa/brain_matrix/fixtures/scenario_url_parse_then_inspect_query.json +28 -0
  832. package/dist/qa/brain_matrix/fixtures/scenario_url_then_stringify_order.json +34 -0
  833. package/dist/qa/brain_matrix/fixtures/scenario_user_just_greets.json +17 -0
  834. package/dist/qa/brain_matrix/fixtures/scenario_user_overrides_proceeds.json +24 -0
  835. package/dist/qa/brain_matrix/fixtures/scenario_user_revokes_in_middle.json +22 -0
  836. package/dist/qa/brain_matrix/fixtures/scenario_voice_open_pizarron.json +21 -0
  837. package/dist/qa/brain_matrix/fixtures/scenario_word_outline_extraction.json +22 -0
  838. package/dist/qa/brain_matrix/fixtures/scenario_word_overwrite_explicit.json +20 -0
  839. package/dist/qa/brain_matrix/fixtures/scenario_word_section_count.json +28 -0
  840. package/dist/qa/brain_matrix/fixtures/scenario_workflow_doctrine.json +53 -0
  841. package/dist/qa/brain_matrix/fixtures/scenario_workflow_done_specs.json +32 -0
  842. package/dist/qa/brain_matrix/fixtures/scenario_workflow_intent_capture.json +22 -0
  843. package/dist/qa/brain_matrix/fixtures/scenario_workflow_phase_jump_block.json +26 -0
  844. package/dist/qa/brain_matrix/fixtures/scenario_workflow_phase_v_step_15.json +41 -0
  845. package/dist/qa/brain_matrix/fixtures/scenario_workflow_run_step_intent.json +19 -0
  846. package/dist/qa/brain_matrix/fixtures/scenario_workflow_set_intent.json +20 -0
  847. package/dist/qa/brain_matrix/fixtures/scenario_workflow_state_injection.json +32 -0
  848. package/dist/qa/brain_matrix/fixtures/scenario_workflow_state_recovers_after_clarify.json +25 -0
  849. package/dist/qa/brain_matrix/fixtures/scenario_workflow_step_1_intake.json +24 -0
  850. package/dist/qa/brain_matrix/fixtures/scenario_yf_command_doctrine.json +56 -0
  851. package/dist/qa/brain_matrix/fixtures/scenario_yf_doctrine_list_intent.json +19 -0
  852. package/dist/qa/brain_matrix/fixtures/scenario_yf_qa_compare_intent.json +17 -0
  853. package/dist/qa/brain_matrix/invariants.d.ts +18 -0
  854. package/dist/qa/brain_matrix/invariants.d.ts.map +1 -0
  855. package/dist/qa/brain_matrix/invariants.js +333 -0
  856. package/dist/qa/brain_matrix/invariants.js.map +1 -0
  857. package/dist/qa/brain_matrix/report.d.ts +12 -0
  858. package/dist/qa/brain_matrix/report.d.ts.map +1 -0
  859. package/dist/qa/brain_matrix/report.js +131 -0
  860. package/dist/qa/brain_matrix/report.js.map +1 -0
  861. package/dist/qa/brain_matrix/runner.d.ts +31 -0
  862. package/dist/qa/brain_matrix/runner.d.ts.map +1 -0
  863. package/dist/qa/brain_matrix/runner.js +561 -0
  864. package/dist/qa/brain_matrix/runner.js.map +1 -0
  865. package/dist/qa/brain_matrix/types.d.ts +180 -0
  866. package/dist/qa/brain_matrix/types.d.ts.map +1 -0
  867. package/dist/qa/brain_matrix/types.js +2 -0
  868. package/dist/qa/brain_matrix/types.js.map +1 -0
  869. package/dist/qa/brain_matrix/validate_scenario.d.ts +23 -0
  870. package/dist/qa/brain_matrix/validate_scenario.d.ts.map +1 -0
  871. package/dist/qa/brain_matrix/validate_scenario.js +128 -0
  872. package/dist/qa/brain_matrix/validate_scenario.js.map +1 -0
  873. package/dist/semantic_graph/ast_extract.d.ts +15 -0
  874. package/dist/semantic_graph/ast_extract.d.ts.map +1 -0
  875. package/dist/semantic_graph/ast_extract.js +162 -0
  876. package/dist/semantic_graph/ast_extract.js.map +1 -0
  877. package/dist/semantic_graph/derive.d.ts +36 -0
  878. package/dist/semantic_graph/derive.d.ts.map +1 -0
  879. package/dist/semantic_graph/derive.js +285 -0
  880. package/dist/semantic_graph/derive.js.map +1 -0
  881. package/dist/semantic_graph/llm_claim.d.ts +21 -0
  882. package/dist/semantic_graph/llm_claim.d.ts.map +1 -0
  883. package/dist/semantic_graph/llm_claim.js +164 -0
  884. package/dist/semantic_graph/llm_claim.js.map +1 -0
  885. package/dist/semantic_graph/npm_enrich.d.ts +49 -0
  886. package/dist/semantic_graph/npm_enrich.d.ts.map +1 -0
  887. package/dist/semantic_graph/npm_enrich.js +173 -0
  888. package/dist/semantic_graph/npm_enrich.js.map +1 -0
  889. package/dist/semantic_graph/render.d.ts +28 -0
  890. package/dist/semantic_graph/render.d.ts.map +1 -0
  891. package/dist/semantic_graph/render.js +70 -0
  892. package/dist/semantic_graph/render.js.map +1 -0
  893. package/dist/semantic_graph/store.d.ts +44 -0
  894. package/dist/semantic_graph/store.d.ts.map +1 -0
  895. package/dist/semantic_graph/store.js +360 -0
  896. package/dist/semantic_graph/store.js.map +1 -0
  897. package/dist/semantic_graph/types.d.ts +59 -0
  898. package/dist/semantic_graph/types.d.ts.map +1 -0
  899. package/dist/semantic_graph/types.js +17 -0
  900. package/dist/semantic_graph/types.js.map +1 -0
  901. package/dist/support/github_dispatcher.d.ts +36 -0
  902. package/dist/support/github_dispatcher.d.ts.map +1 -0
  903. package/dist/support/github_dispatcher.js +292 -0
  904. package/dist/support/github_dispatcher.js.map +1 -0
  905. package/dist/support/reports.d.ts +31 -0
  906. package/dist/support/reports.d.ts.map +1 -0
  907. package/dist/support/reports.js +162 -0
  908. package/dist/support/reports.js.map +1 -0
  909. package/dist/telemetry/usage.d.ts +67 -0
  910. package/dist/telemetry/usage.d.ts.map +1 -0
  911. package/dist/telemetry/usage.js +208 -0
  912. package/dist/telemetry/usage.js.map +1 -0
  913. package/dist/test_agent/gemini_user.d.ts +9 -0
  914. package/dist/test_agent/gemini_user.d.ts.map +1 -0
  915. package/dist/test_agent/gemini_user.js +58 -0
  916. package/dist/test_agent/gemini_user.js.map +1 -0
  917. package/dist/test_agent/profiles.d.ts +23 -0
  918. package/dist/test_agent/profiles.d.ts.map +1 -0
  919. package/dist/test_agent/profiles.js +117 -0
  920. package/dist/test_agent/profiles.js.map +1 -0
  921. package/dist/test_agent/runner.d.ts +56 -0
  922. package/dist/test_agent/runner.d.ts.map +1 -0
  923. package/dist/test_agent/runner.js +134 -0
  924. package/dist/test_agent/runner.js.map +1 -0
  925. package/dist/test_agent/target_http.d.ts +22 -0
  926. package/dist/test_agent/target_http.d.ts.map +1 -0
  927. package/dist/test_agent/target_http.js +51 -0
  928. package/dist/test_agent/target_http.js.map +1 -0
  929. package/dist/test_agent/types.d.ts +117 -0
  930. package/dist/test_agent/types.d.ts.map +1 -0
  931. package/dist/test_agent/types.js +22 -0
  932. package/dist/test_agent/types.js.map +1 -0
  933. package/dist/vault/store.d.ts.map +1 -1
  934. package/dist/vault/store.js +14 -1
  935. package/dist/vault/store.js.map +1 -1
  936. package/dist/version.d.ts +1 -1
  937. package/dist/version.d.ts.map +1 -1
  938. package/dist/version.js +1 -1
  939. package/dist/version.js.map +1 -1
  940. package/dist/voice/config.d.ts +12 -0
  941. package/dist/voice/config.d.ts.map +1 -1
  942. package/dist/voice/config.js +25 -2
  943. package/dist/voice/config.js.map +1 -1
  944. package/dist/voice/providers/google.d.ts +9 -0
  945. package/dist/voice/providers/google.d.ts.map +1 -1
  946. package/dist/voice/providers/google.js +152 -26
  947. package/dist/voice/providers/google.js.map +1 -1
  948. package/dist/voice/router.d.ts +10 -0
  949. package/dist/voice/router.d.ts.map +1 -1
  950. package/dist/voice/router.js +39 -20
  951. package/dist/voice/router.js.map +1 -1
  952. package/dist/voice/types.d.ts +5 -2
  953. package/dist/voice/types.d.ts.map +1 -1
  954. package/dist/voice/types.js.map +1 -1
  955. package/dist/wiring/discover.d.ts +14 -0
  956. package/dist/wiring/discover.d.ts.map +1 -0
  957. package/dist/wiring/discover.js +383 -0
  958. package/dist/wiring/discover.js.map +1 -0
  959. package/dist/wiring/store.d.ts +19 -0
  960. package/dist/wiring/store.d.ts.map +1 -0
  961. package/dist/wiring/store.js +182 -0
  962. package/dist/wiring/store.js.map +1 -0
  963. package/dist/wiring/types.d.ts +61 -0
  964. package/dist/wiring/types.d.ts.map +1 -0
  965. package/dist/wiring/types.js +36 -0
  966. package/dist/wiring/types.js.map +1 -0
  967. package/dist/workflow/definitions.d.ts +60 -0
  968. package/dist/workflow/definitions.d.ts.map +1 -0
  969. package/dist/workflow/definitions.js +178 -0
  970. package/dist/workflow/definitions.js.map +1 -0
  971. package/dist/workflow/definitions_client.d.ts +12 -0
  972. package/dist/workflow/definitions_client.d.ts.map +1 -0
  973. package/dist/workflow/definitions_client.js +81 -0
  974. package/dist/workflow/definitions_client.js.map +1 -0
  975. package/dist/workflow/evolution.d.ts +10 -0
  976. package/dist/workflow/evolution.d.ts.map +1 -0
  977. package/dist/workflow/evolution.js +62 -0
  978. package/dist/workflow/evolution.js.map +1 -0
  979. package/dist/workflow/graph.d.ts +59 -0
  980. package/dist/workflow/graph.d.ts.map +1 -0
  981. package/dist/workflow/graph.js +137 -0
  982. package/dist/workflow/graph.js.map +1 -0
  983. package/dist/workflow/phase_inference.d.ts +56 -0
  984. package/dist/workflow/phase_inference.d.ts.map +1 -0
  985. package/dist/workflow/phase_inference.js +107 -0
  986. package/dist/workflow/phase_inference.js.map +1 -0
  987. package/dist/workflow/select.d.ts +28 -0
  988. package/dist/workflow/select.d.ts.map +1 -0
  989. package/dist/workflow/select.js +38 -0
  990. package/dist/workflow/select.js.map +1 -0
  991. package/dist/workflow/state.d.ts +290 -0
  992. package/dist/workflow/state.d.ts.map +1 -0
  993. package/dist/workflow/state.js +213 -0
  994. package/dist/workflow/state.js.map +1 -0
  995. package/dist/workflow/view.d.ts +47 -0
  996. package/dist/workflow/view.d.ts.map +1 -0
  997. package/dist/workflow/view.js +103 -0
  998. package/dist/workflow/view.js.map +1 -0
  999. package/docs/SQ.md +1397 -0
  1000. package/docs/SQ_BY_STEP.yaml +147 -0
  1001. package/docs/doctrine/accessibility.md +78 -0
  1002. package/docs/doctrine/agent-orchestrator.md +76 -0
  1003. package/docs/doctrine/byok-vs-managed.md +81 -0
  1004. package/docs/doctrine/cost-router-escalation.md +103 -0
  1005. package/docs/doctrine/default-design-system.md +120 -0
  1006. package/docs/doctrine/explain-results.md +90 -0
  1007. package/docs/doctrine/expo-eas-builds.md +177 -0
  1008. package/docs/doctrine/g-doc.md +46 -0
  1009. package/docs/doctrine/gates.md +21 -0
  1010. package/docs/doctrine/language-sovereignty.md +143 -0
  1011. package/docs/doctrine/lessons-workflow.md +68 -0
  1012. package/docs/doctrine/mcp-bridge-lifecycle.md +75 -0
  1013. package/docs/doctrine/nac3-attrs.md +23 -0
  1014. package/docs/doctrine/office-tools.md +102 -0
  1015. package/docs/doctrine/pizarron.md +52 -0
  1016. package/docs/doctrine/r8-modal.md +60 -0
  1017. package/docs/doctrine/safety-refusals.md +73 -0
  1018. package/docs/doctrine/testing-policy.md +211 -0
  1019. package/docs/doctrine/verb-composition.md +68 -0
  1020. package/docs/doctrine/verification-honesty.md +95 -0
  1021. package/docs/doctrine/workflow-kinds.md +52 -0
  1022. package/docs/doctrine/workflow-phases.md +108 -0
  1023. package/docs/doctrine/workflow.md +58 -0
  1024. package/docs/doctrine/yf-commands.md +49 -0
  1025. package/package.json +19 -3
  1026. package/src/i18n/catalogs/ar.json +134 -10
  1027. package/src/i18n/catalogs/de.json +134 -10
  1028. package/src/i18n/catalogs/en.json +134 -10
  1029. package/src/i18n/catalogs/es.json +134 -10
  1030. package/src/i18n/catalogs/fr.json +134 -10
  1031. package/src/i18n/catalogs/hi.json +134 -10
  1032. package/src/i18n/catalogs/it.json +134 -10
  1033. package/src/i18n/catalogs/ja.json +134 -10
  1034. package/src/i18n/catalogs/pt.json +134 -10
  1035. package/src/i18n/catalogs/zh.json +134 -10
  1036. package/templates/nextjs-app/README.md +48 -0
  1037. package/templates/nextjs-app/next.config.js +8 -0
  1038. package/templates/nextjs-app/package.json +33 -0
  1039. package/templates/nextjs-app/src/app/globals.css +43 -0
  1040. package/templates/nextjs-app/src/app/layout.tsx +29 -0
  1041. package/templates/nextjs-app/src/app/page.tsx +63 -0
  1042. package/templates/nextjs-app/src/nac/manifest.ts +36 -0
  1043. package/templates/nextjs-app/tsconfig.json +21 -0
  1044. package/templates/nextjs-app/yujin.forge.json +11 -0
  1045. package/templates/sumi-context.template.md +57 -0
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createServer } from 'node:http';
15
15
  import { promises as fs } from 'node:fs';
16
+ import os from 'node:os';
16
17
  import path from 'node:path';
17
18
  import { ClaudeClient, ConfigurationError, ClaudeApiError } from './claude.js';
18
19
  import { renderPanelHtml } from './panel.js';
@@ -37,9 +38,112 @@ import { extractSpec } from './spec_extract.js';
37
38
  import { generatePlan } from './spec_plan.js';
38
39
  import { executeScaffold, rollbackScaffold } from './spec_scaffold.js';
39
40
  import { VERSION } from '../version.js';
41
+ import { buildDoctrineDigestLines } from './doctrine_digest.js';
42
+ import { manifest as nac3Manifest } from '../nac3/internal_manifest.js';
43
+ import { buildManifestForPrompt } from '../nac3/manifest_lazy.js';
44
+ import { popPending, logApproval } from '../nac3/approval_queue.js';
40
45
  export async function startChatServer(opts) {
41
46
  const projectName = await readProjectName(opts.projectRoot);
42
- const claude = opts.claude ?? new ClaudeClient();
47
+ /* alpha.59u.2 -- distinct slug (machine id) vs projectName (display).
48
+ * Identity queries against the semantic graph use the slug so
49
+ * CLI + panel converge. */
50
+ const { resolveProjectSlug } = await import('./project_slug.js');
51
+ const projectSlug = await resolveProjectSlug(opts.projectRoot);
52
+ /* Director sprint slice 9 -- hydrate ingest sessions from disk
53
+ * for this project so spec extract+plan survive restarts. */
54
+ try {
55
+ const { setProjectSlugForIngest, loadProjectIngests } = await import('./ingest_session.js');
56
+ setProjectSlugForIngest(projectSlug);
57
+ await loadProjectIngests(projectSlug);
58
+ }
59
+ catch (err) {
60
+ try {
61
+ console.error('ingest_session boot hydrate failed: ' + (err instanceof Error ? err.message : String(err)));
62
+ }
63
+ catch { /* swallow */ }
64
+ }
65
+ /* alpha.59v -- fire-and-forget auto-ingest of package.json deps
66
+ * whenever the boot detects a changed (or never-ingested)
67
+ * package.json. Never blocks the boot; never throws.
68
+ * alpha.59v.2 -- registered via startAutoIngest so the
69
+ * /api/forge/graph-status endpoint can await it (avoiding
70
+ * the panel showing partial data during the ingest). */
71
+ try {
72
+ const { startAutoIngest } = await import('./graph_auto_ingest.js');
73
+ startAutoIngest({
74
+ project_root: opts.projectRoot,
75
+ project_slug: projectSlug,
76
+ log: (msg) => { try {
77
+ console.error(msg);
78
+ }
79
+ catch { /* swallow */ } },
80
+ });
81
+ }
82
+ catch { /* never break boot */ }
83
+ /* GRAPH-1 (dogfood 2026-06-10) -- regenerar el grafo completo
84
+ * (nodos de modulo + aristas de imports, validadas) al boot, en
85
+ * background, para que al abrir el panel el grafo este completo.
86
+ * El boton Refresh + el watcher lo regeneran luego ante cambios.
87
+ * Fire-and-forget; nunca rompe el boot. */
88
+ void (async () => {
89
+ try {
90
+ const { regenerateAndPersist } = await import('../semantic_graph/derive.js');
91
+ const s = await regenerateAndPersist({ project_root: opts.projectRoot, project_slug: projectSlug, enrich: false });
92
+ try {
93
+ console.error('[graph-regen] boot -> ' + (s.module_edges + s.lib_edges) + ' edges (' + s.pruned + ' pruned)');
94
+ }
95
+ catch { /* */ }
96
+ }
97
+ catch { /* never break boot */ }
98
+ })();
99
+ /* alpha.59w -- fs.watch over src/components, src/hooks, src/lib,
100
+ * src/core. When the user adds or modifies a TS/JS file, the
101
+ * watcher extracts top-level exports (regex), creates one
102
+ * graph node per export with confidence='auto', and pushes
103
+ * to the worker (debounced 2s). The watcher cleanup is
104
+ * attached to the returned StartedServer.close(). */
105
+ let componentWatcherHandle = null;
106
+ try {
107
+ const { startComponentWatcher } = await import('./graph_component_watcher.js');
108
+ componentWatcherHandle = await startComponentWatcher({
109
+ project_root: opts.projectRoot,
110
+ project_slug: projectSlug,
111
+ log: (msg) => { try {
112
+ console.error(msg);
113
+ }
114
+ catch { /* swallow */ } },
115
+ });
116
+ }
117
+ catch { /* never break boot */ }
118
+ /* Provider selection: 'anthropic-api' (default, Console billing) vs
119
+ * 'claude-code-cli' (subscription billing via `claude -p`
120
+ * subprocess). The ClaudeCliClient is interface-compatible at the
121
+ * .chat() shape used by this server -- runTool round-trips happen
122
+ * inside the subprocess, so the wrapper returns toolRounds=[].
123
+ * Tests inject `opts.claude` directly and bypass this branch. */
124
+ /* PND-064 slice B -- generate the per-session panel-push token up
125
+ * front so both the chat server (to validate) and the CLI client
126
+ * (to hand to its MCP server) share it. Only meaningful in CLI
127
+ * mode; harmless otherwise. */
128
+ const panelPushToken = (await import('node:crypto')).randomBytes(32).toString('hex');
129
+ let claude;
130
+ if (opts.claude) {
131
+ claude = opts.claude;
132
+ }
133
+ else if (opts.chatProvider === 'claude-code-cli') {
134
+ const { ClaudeCliClient } = await import('./claude_cli.js');
135
+ const cliOpts = {};
136
+ if (opts.chatProviderAddDirs && opts.chatProviderAddDirs.length > 0) {
137
+ cliOpts['addDirs'] = opts.chatProviderAddDirs;
138
+ }
139
+ /* Wire the panel MCP bridge: the subprocess gets the
140
+ * forge_panel MCP server so it can create pizarron tabs. */
141
+ cliOpts['panelBridge'] = { port: opts.port, token: panelPushToken };
142
+ claude = new ClaudeCliClient(cliOpts);
143
+ }
144
+ else {
145
+ claude = new ClaudeClient();
146
+ }
43
147
  const voice = opts.voice ?? new VoiceRouter({
44
148
  configDir: configDir(),
45
149
  registry: buildDefaultRegistry(),
@@ -67,6 +171,82 @@ export async function startChatServer(opts) {
67
171
  });
68
172
  return reply.text.trim();
69
173
  });
174
+ /* Hot re-anchor (dogfood 2026-06-10) -- el proyecto activo se
175
+ * guarda en un estado MUTABLE por sesion, no en constantes del
176
+ * boot. yujin.project.set-root llama reanchor() y todos los
177
+ * handlers (que leen de `session`) pasan a apuntar al nuevo root
178
+ * sin reiniciar el panel. */
179
+ const session = { projectRoot: opts.projectRoot, projectName, projectSlug };
180
+ async function reanchor(newRoot) {
181
+ const abs = (await import('node:path')).resolve(newRoot);
182
+ const { enqueuePanelDispatch } = await import('./panel_queue.js');
183
+ /* Loguear el progreso en el espacio de chat para combatir la
184
+ * ansiedad del usuario (Pablo 2026-06-10). Van por la cola del
185
+ * panel como mensajes de sistema. */
186
+ const chatLog = (text) => {
187
+ try {
188
+ enqueuePanelDispatch('system.chat-log', { text }, new Date().toISOString());
189
+ }
190
+ catch { /* */ }
191
+ };
192
+ session.projectRoot = abs;
193
+ session.projectName = await readProjectName(abs);
194
+ const { resolveProjectSlug } = await import('./project_slug.js');
195
+ session.projectSlug = await resolveProjectSlug(abs);
196
+ chatLog('Cambiando al proyecto ' + session.projectName + ' (' + abs + ')...');
197
+ /* re-hidratar ingest + reiniciar el watcher sobre el nuevo dir */
198
+ try {
199
+ const { setProjectSlugForIngest, loadProjectIngests } = await import('./ingest_session.js');
200
+ setProjectSlugForIngest(session.projectSlug);
201
+ await loadProjectIngests(session.projectSlug);
202
+ }
203
+ catch { /* best-effort */ }
204
+ try {
205
+ if (componentWatcherHandle) {
206
+ await componentWatcherHandle.stop();
207
+ componentWatcherHandle = null;
208
+ }
209
+ const { startComponentWatcher } = await import('./graph_component_watcher.js');
210
+ componentWatcherHandle = await startComponentWatcher({
211
+ project_root: abs, project_slug: session.projectSlug,
212
+ log: (msg) => { try {
213
+ console.error(msg);
214
+ }
215
+ catch { /* swallow */ } },
216
+ });
217
+ }
218
+ catch { /* never break */ }
219
+ try {
220
+ console.error('[reanchor] active project -> ' + abs + ' (slug=' + session.projectSlug + ')');
221
+ }
222
+ catch { /* */ }
223
+ /* Regenerar el grafo del nuevo proyecto (crea los objetos si no
224
+ * existen) en BACKGROUND -- no bloquear la respuesta HTTP del
225
+ * verbo (sino el panel-invoke del subproceso da timeout =
226
+ * "failed to fetch"). El progreso se loguea por la cola; al
227
+ * terminar dispara el re-tabs. */
228
+ const slugNow = session.projectSlug;
229
+ const nameNow = session.projectName;
230
+ chatLog('Regenerando el grafo semantico de ' + nameNow + '...');
231
+ void (async () => {
232
+ try {
233
+ const { regenerateAndPersist } = await import('../semantic_graph/derive.js');
234
+ const s = await regenerateAndPersist({ project_root: abs, project_slug: slugNow, enrich: false });
235
+ chatLog('Grafo listo: ' + s.module_nodes + ' modulos, ' + s.lib_nodes + ' librerias, '
236
+ + (s.module_edges + s.lib_edges) + ' aristas.');
237
+ }
238
+ catch {
239
+ chatLog('No pude regenerar el grafo de este proyecto (sigo igual).');
240
+ }
241
+ /* Re-generar las 4 pestanas canonicas del nuevo proyecto. */
242
+ try {
243
+ enqueuePanelDispatch('system.reanchor', { slug: slugNow, name: nameNow }, new Date().toISOString());
244
+ }
245
+ catch { /* */ }
246
+ chatLog('Listo. El pizarron ahora refleja ' + nameNow + '.');
247
+ })();
248
+ return { projectRoot: session.projectRoot, projectName: session.projectName, projectSlug: session.projectSlug };
249
+ }
70
250
  const server = createServer(async (req, res) => {
71
251
  // Every response carries a request-id (for client + server
72
252
  // log correlation) + cache-control (we never want browsers
@@ -79,12 +259,17 @@ export async function startChatServer(opts) {
79
259
  res.setHeader('x-yujin-version', VERSION);
80
260
  try {
81
261
  await route(req, res, {
82
- projectRoot: opts.projectRoot,
83
- projectName,
262
+ projectRoot: session.projectRoot,
263
+ projectName: session.projectName,
264
+ projectSlug: session.projectSlug,
84
265
  port: opts.port,
85
266
  claude,
86
267
  voice,
87
268
  store,
269
+ runtime: opts.runtime,
270
+ chatProvider: opts.chatProvider,
271
+ panelPushToken,
272
+ reanchor,
88
273
  });
89
274
  }
90
275
  catch (err) {
@@ -114,7 +299,24 @@ export async function startChatServer(opts) {
114
299
  url,
115
300
  store,
116
301
  close: () => new Promise((resolve) => {
117
- server.close(() => resolve());
302
+ /* alpha.59w -- stop the component watcher on shutdown so
303
+ * vitest does not hang on lingering fs watchers. */
304
+ if (componentWatcherHandle) {
305
+ Promise.resolve(componentWatcherHandle.stop()).catch(() => undefined);
306
+ }
307
+ /* alpha.59z.136 slice 14 -- flush in-flight ingest session
308
+ * writes BEFORE the server closes so test teardowns that
309
+ * fs.rm the home directory immediately after do not race
310
+ * with the fire-and-forget persist queue. */
311
+ (async () => {
312
+ try {
313
+ const { flushPendingIngestWrites, setProjectSlugForIngest } = await import('./ingest_session.js');
314
+ await flushPendingIngestWrites();
315
+ setProjectSlugForIngest(null);
316
+ }
317
+ catch { /* swallow */ }
318
+ server.close(() => resolve());
319
+ })();
118
320
  }),
119
321
  };
120
322
  }
@@ -144,6 +346,75 @@ async function route(req, res, ctx) {
144
346
  });
145
347
  return;
146
348
  }
349
+ /* ------------------------------------------------------------
350
+ * Director sprint slice 5 -- agent runtime panel endpoints.
351
+ * GET /api/agent/state -- runtime status + agent definitions
352
+ * POST /api/agent/run -- dispatch an agent through runtime
353
+ * GET /api/agent/events -- SSE stream of RuntimeEvent
354
+ * All return 503 when ctx.runtime is unwired so the UI can
355
+ * surface a clean "no runtime" state.
356
+ * ------------------------------------------------------------ */
357
+ if (req.method === 'GET' && url.pathname === '/api/agent/state') {
358
+ await handleAgentState(res, ctx);
359
+ return;
360
+ }
361
+ if (req.method === 'POST' && url.pathname === '/api/agent/run') {
362
+ await handleAgentRun(req, res, ctx);
363
+ return;
364
+ }
365
+ if (req.method === 'GET' && url.pathname === '/api/agent/events') {
366
+ await handleAgentEventsSse(req, res, ctx);
367
+ return;
368
+ }
369
+ if (req.method === 'POST' && url.pathname === '/api/agent/approve') {
370
+ await handleAgentApprove(req, res, ctx);
371
+ return;
372
+ }
373
+ if (req.method === 'GET' && url.pathname === '/api/agent/activity') {
374
+ await handleAgentActivity(req, res, ctx);
375
+ return;
376
+ }
377
+ if (req.method === 'GET' && url.pathname === '/api/agent/bridge-tail') {
378
+ await handleAgentBridgeTail(req, res, ctx);
379
+ return;
380
+ }
381
+ if (req.method === 'POST' && url.pathname === '/api/agent/inject') {
382
+ await handleAgentInject(req, res, ctx);
383
+ return;
384
+ }
385
+ /* Legal docs surfaced inside the product (Config -> About).
386
+ * Serves the raw markdown so the panel can render or pop a
387
+ * dialog. Routes are static names so a future i18n swap can
388
+ * branch on Accept-Language without changing the URL. */
389
+ if (req.method === 'GET' && url.pathname === '/api/legal/privacy-policy') {
390
+ await serveLegalDoc(res, 'PRIVACY_POLICY.md');
391
+ return;
392
+ }
393
+ if (req.method === 'GET' && url.pathname === '/api/legal/limited-responsibility') {
394
+ await serveLegalDoc(res, 'LIMITED_RESPONSIBILITY.md');
395
+ return;
396
+ }
397
+ if (req.method === 'GET' && url.pathname === '/api/legal/index') {
398
+ /* Compact directory listing the panel uses to render
399
+ * Config -> About without hardcoding doc slugs. */
400
+ sendJson(res, 200, {
401
+ docs: [
402
+ {
403
+ slug: 'privacy-policy',
404
+ title: 'Privacy policy',
405
+ description: 'What data Forge sends to Yujin servers, what we keep, and what we do not.',
406
+ url: '/api/legal/privacy-policy',
407
+ },
408
+ {
409
+ slug: 'limited-responsibility',
410
+ title: 'Limitation of liability',
411
+ description: 'AS-IS provision, third-party services, indemnification, governing law.',
412
+ url: '/api/legal/limited-responsibility',
413
+ },
414
+ ],
415
+ });
416
+ return;
417
+ }
147
418
  if (req.method === 'POST' && url.pathname === '/api/chat') {
148
419
  await handleChat(req, res, ctx);
149
420
  return;
@@ -174,6 +445,533 @@ async function route(req, res, ctx) {
174
445
  await handleVaultCatalog(res, url);
175
446
  return;
176
447
  }
448
+ /* NAC3 internal manifest -- Forge dogfood. See
449
+ docs/NAC3_INTERNAL_MANIFEST.md. The panel reads this on boot
450
+ to register handlers + caches for 60s. Same JSON is embedded
451
+ into the system prompt so the LLM sees one canonical
452
+ catalogue. */
453
+ if (req.method === 'GET' && url.pathname === '/api/forge/nac3-manifest') {
454
+ sendJson(res, 200, { ok: true, manifest: nac3Manifest() });
455
+ return;
456
+ }
457
+ /* alpha.59c.2 -- limits config endpoints. GET returns the
458
+ * active config + field paths + defaults. POST sets one
459
+ * field with {field, value} body. The settings panel renders
460
+ * the 10 numeric inputs from these. */
461
+ if (req.method === 'GET' && url.pathname === '/api/forge/limits') {
462
+ const { readLimitsConfig, defaultLimitsConfig, LIMITS_FIELD_PATHS, LIMITS_BOOLEAN_FIELD_PATHS, } = await import('../core/limits_config.js');
463
+ const cfg = await readLimitsConfig(true);
464
+ const def = defaultLimitsConfig();
465
+ const resolveField = (f, kind) => {
466
+ const parts = f.split('.');
467
+ let cur = cfg;
468
+ let dft = def;
469
+ for (const p of parts) {
470
+ if (cur && typeof cur === 'object')
471
+ cur = cur[p];
472
+ if (dft && typeof dft === 'object')
473
+ dft = dft[p];
474
+ }
475
+ return { field: f, value: cur, default: dft, kind };
476
+ };
477
+ const fields = LIMITS_FIELD_PATHS.map((f) => resolveField(f, 'number'));
478
+ const booleans = LIMITS_BOOLEAN_FIELD_PATHS.map((f) => resolveField(f, 'boolean'));
479
+ sendJson(res, 200, { ok: true, fields, booleans, config: cfg });
480
+ return;
481
+ }
482
+ if (req.method === 'POST' && url.pathname === '/api/forge/limits') {
483
+ const raw = await readBody(req);
484
+ let body;
485
+ try {
486
+ body = JSON.parse(raw);
487
+ }
488
+ catch {
489
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
490
+ return;
491
+ }
492
+ const { setLimitsField, setLimitsBooleanField, isLimitsBooleanFieldPath, resetLimitsConfig } = await import('../core/limits_config.js');
493
+ if (body.reset === true) {
494
+ const cfg = await resetLimitsConfig();
495
+ sendJson(res, 200, { ok: true, config: cfg });
496
+ return;
497
+ }
498
+ if (typeof body.field !== 'string') {
499
+ sendJson(res, 400, { ok: false, error: 'expected {field: string, value: number|boolean} or {reset: true}' });
500
+ return;
501
+ }
502
+ /* alpha.59d -- accept booleans too (Danger Zone toggle +
503
+ * cwd_boundary_strict). Dispatch by field-path type. */
504
+ if (isLimitsBooleanFieldPath(body.field)) {
505
+ if (typeof body.value !== 'boolean') {
506
+ sendJson(res, 400, { ok: false, error: 'boolean field requires boolean value' });
507
+ return;
508
+ }
509
+ try {
510
+ const cfg = await setLimitsBooleanField(body.field, body.value);
511
+ sendJson(res, 200, { ok: true, config: cfg });
512
+ }
513
+ catch (err) {
514
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
515
+ }
516
+ return;
517
+ }
518
+ if (typeof body.value !== 'number' || !Number.isFinite(body.value)) {
519
+ sendJson(res, 400, { ok: false, error: 'expected {field: string, value: number} or {reset: true}' });
520
+ return;
521
+ }
522
+ try {
523
+ const cfg = await setLimitsField(body.field, body.value);
524
+ sendJson(res, 200, { ok: true, config: cfg });
525
+ }
526
+ catch (err) {
527
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
528
+ }
529
+ return;
530
+ }
531
+ /* alpha.59z.176 PND-003 -- VAD tuning + debug config. The panel
532
+ * reads this at boot + writes it back when the user moves a
533
+ * slider. The VAD loop hot-applies on the next tick without a
534
+ * reload. Session stats (when toggled on) are POSTed back here so
535
+ * ~/.yujin-forge/vad_sessions.jsonl accumulates a row per capture
536
+ * for the field test protocol. */
537
+ if (req.method === 'GET' && url.pathname === '/api/forge/vad-config') {
538
+ const { readVadConfig, DEFAULT_VAD_CONFIG } = await import('../core/vad_config.js');
539
+ const cfg = await readVadConfig();
540
+ sendJson(res, 200, { ok: true, config: cfg, defaults: DEFAULT_VAD_CONFIG });
541
+ return;
542
+ }
543
+ if (req.method === 'POST' && url.pathname === '/api/forge/vad-config') {
544
+ const raw = await readBody(req);
545
+ let body;
546
+ try {
547
+ body = JSON.parse(raw);
548
+ }
549
+ catch {
550
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
551
+ return;
552
+ }
553
+ const { readVadConfig, writeVadConfig, sanitiseVadConfig, DEFAULT_VAD_CONFIG } = await import('../core/vad_config.js');
554
+ if (body.reset === true) {
555
+ await writeVadConfig({ ...DEFAULT_VAD_CONFIG });
556
+ sendJson(res, 200, { ok: true, config: { ...DEFAULT_VAD_CONFIG } });
557
+ return;
558
+ }
559
+ const current = await readVadConfig();
560
+ const merged = sanitiseVadConfig({ ...current, ...(body.patch ?? {}) });
561
+ await writeVadConfig(merged);
562
+ sendJson(res, 200, { ok: true, config: merged });
563
+ return;
564
+ }
565
+ /* PND-001 alpha.59z.177 -- wizard probe endpoints. Lightweight
566
+ * checks so the panel can verify the user finished the chosen
567
+ * setup path BEFORE marking the wizard complete. The probes do
568
+ * not consume tokens (anthropic /v1/models is free; the Claude
569
+ * CLI probe just spawns `claude -p ok?`). */
570
+ if (req.method === 'POST' && url.pathname === '/api/forge/wizard-probe-claude-cli') {
571
+ try {
572
+ const { probeClaudeCli } = await import('./claude_cli.js');
573
+ const r = await probeClaudeCli({});
574
+ sendJson(res, 200, { ...r, ok: r.ok });
575
+ }
576
+ catch (err) {
577
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
578
+ }
579
+ return;
580
+ }
581
+ if (req.method === 'POST' && url.pathname === '/api/forge/wizard-test-anthropic-key') {
582
+ const raw = await readBody(req);
583
+ let body;
584
+ try {
585
+ body = JSON.parse(raw);
586
+ }
587
+ catch {
588
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
589
+ return;
590
+ }
591
+ const apiKey = typeof body.api_key === 'string' ? body.api_key.trim() : '';
592
+ if (!apiKey || !/^sk-ant-/.test(apiKey)) {
593
+ sendJson(res, 400, { ok: false, error: 'expected an Anthropic key starting with sk-ant-' });
594
+ return;
595
+ }
596
+ try {
597
+ const r = await fetch('https://api.anthropic.com/v1/models', {
598
+ method: 'GET',
599
+ headers: {
600
+ 'x-api-key': apiKey,
601
+ 'anthropic-version': '2023-06-01',
602
+ },
603
+ });
604
+ if (!r.ok) {
605
+ const detail = await r.text().catch(() => '');
606
+ sendJson(res, 200, { ok: false, status: r.status, detail: detail.slice(0, 300) });
607
+ return;
608
+ }
609
+ if (body.save === true) {
610
+ const { setKey } = await import('../core/provider_keys.js');
611
+ await setKey('anthropic_api_key', apiKey);
612
+ /* The brain_provider_preference field is read elsewhere from
613
+ * the same provider_keys.json file. Setting the key alone is
614
+ * enough -- cost_router falls back to anthropic when no
615
+ * preference is recorded but anthropic_api_key is present. */
616
+ }
617
+ sendJson(res, 200, { ok: true, status: r.status, saved: body.save === true });
618
+ }
619
+ catch (err) {
620
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
621
+ }
622
+ return;
623
+ }
624
+ /* alpha.59z.177 PND-001 -- first-run wizard state. The chat panel
625
+ * reads this at boot to decide whether to auto-open the
626
+ * license + brain wizard modal. POST persists "user finished the
627
+ * wizard" or "user dismissed -> do not auto-open again". */
628
+ if (req.method === 'GET' && url.pathname === '/api/forge/wizard-state') {
629
+ const { readWizardState } = await import('../core/wizard_state.js');
630
+ const state = await readWizardState();
631
+ sendJson(res, 200, { ok: true, state });
632
+ return;
633
+ }
634
+ if (req.method === 'POST' && url.pathname === '/api/forge/wizard-state') {
635
+ const raw = await readBody(req);
636
+ let body;
637
+ try {
638
+ body = JSON.parse(raw);
639
+ }
640
+ catch {
641
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
642
+ return;
643
+ }
644
+ const { markWizardCompleted, resetWizardState, readWizardState } = await import('../core/wizard_state.js');
645
+ if (body.reset === true) {
646
+ await resetWizardState();
647
+ sendJson(res, 200, { ok: true, state: await readWizardState() });
648
+ return;
649
+ }
650
+ const path_chosen = body.path_chosen;
651
+ if (path_chosen !== 'claude_plan' && path_chosen !== 'console_byok' && path_chosen !== 'skipped') {
652
+ sendJson(res, 400, { ok: false, error: 'path_chosen must be claude_plan|console_byok|skipped' });
653
+ return;
654
+ }
655
+ const state = await markWizardCompleted(path_chosen);
656
+ sendJson(res, 200, { ok: true, state });
657
+ return;
658
+ }
659
+ if (req.method === 'POST' && url.pathname === '/api/forge/vad-stats') {
660
+ const raw = await readBody(req);
661
+ let body;
662
+ try {
663
+ body = JSON.parse(raw);
664
+ }
665
+ catch {
666
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
667
+ return;
668
+ }
669
+ const { appendVadSessionStats } = await import('../core/vad_config.js');
670
+ try {
671
+ await appendVadSessionStats(body);
672
+ sendJson(res, 200, { ok: true });
673
+ }
674
+ catch (err) {
675
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
676
+ }
677
+ return;
678
+ }
679
+ /* alpha.59j -- reader bookmarks viewer endpoint. Reads the
680
+ * persisted reader_sessions.json + returns a flat list of
681
+ * bookmarks (name, doc_path, cursor_block_id, opened_at)
682
+ * newest-first so the panel can render the sidebar. */
683
+ if (req.method === 'GET' && url.pathname === '/api/forge/reader-bookmarks') {
684
+ try {
685
+ const home = process.env.HOME || process.env.USERPROFILE || (await import('node:os')).homedir();
686
+ const pathMod = await import('node:path');
687
+ const fsMod = await import('node:fs/promises');
688
+ const base = process.env.YF_CONFIG_DIR || pathMod.join(home, '.yujin-forge');
689
+ const cacheFile = pathMod.join(base, 'cache', 'reader_sessions.json');
690
+ const raw = await fsMod.readFile(cacheFile, 'utf-8');
691
+ const idx = JSON.parse(raw);
692
+ const out = [];
693
+ for (const key of Object.keys(idx.entries || {})) {
694
+ const entry = idx.entries[key];
695
+ if (!entry)
696
+ continue;
697
+ const bms = entry.bookmarks || {};
698
+ for (const name of Object.keys(bms)) {
699
+ out.push({
700
+ name,
701
+ doc_id: entry.doc_id,
702
+ abs_path: entry.abs_path,
703
+ filename: entry.filename,
704
+ title: entry.title || entry.filename,
705
+ block_id: bms[name],
706
+ last_used_at: entry.last_used_at || '',
707
+ });
708
+ }
709
+ }
710
+ out.sort((a, b) => (b.last_used_at || '').localeCompare(a.last_used_at || ''));
711
+ sendJson(res, 200, { ok: true, bookmarks: out, count: out.length });
712
+ }
713
+ catch {
714
+ sendJson(res, 200, { ok: true, bookmarks: [], count: 0 });
715
+ }
716
+ return;
717
+ }
718
+ /* alpha.59i -- shell kill endpoint. POST {handle_id} from the
719
+ * pizarron tab Cancel button. Looks up the active ChildProcess
720
+ * + sends SIGTERM (with SIGKILL fallback 2s). 404 if the
721
+ * handle is unknown (already finished). */
722
+ if (req.method === 'POST' && url.pathname === '/api/forge/shell-kill') {
723
+ const raw = await readBody(req);
724
+ let body;
725
+ try {
726
+ body = JSON.parse(raw);
727
+ }
728
+ catch {
729
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
730
+ return;
731
+ }
732
+ if (typeof body.handle_id !== 'string' || body.handle_id.trim() === '') {
733
+ sendJson(res, 400, { ok: false, error: 'handle_id required' });
734
+ return;
735
+ }
736
+ const { killShellByHandle } = await import('./tools/shell.js');
737
+ const r = killShellByHandle(body.handle_id);
738
+ sendJson(res, r.ok ? 200 : 404, r);
739
+ return;
740
+ }
741
+ /* alpha.59z.182 -- semantic graph status now reads from the
742
+ * per-project LOCAL SQLite store (no worker). Same response
743
+ * shape as before so the panel JS doesn't change. */
744
+ if (req.method === 'GET' && url.pathname === '/api/forge/graph-status') {
745
+ try {
746
+ const project = url.searchParams.get('project_slug') ?? ctx.projectSlug ?? '';
747
+ /* alpha.59v.2 -- if the boot auto-ingest for this slug is
748
+ * still running, wait for it (5s max) so the panel does
749
+ * not show stale counts on first paint. */
750
+ try {
751
+ const { inFlightIngest } = await import('./graph_auto_ingest.js');
752
+ const pending = inFlightIngest(project);
753
+ if (pending) {
754
+ await Promise.race([
755
+ pending,
756
+ new Promise((resolve) => setTimeout(resolve, 5000)),
757
+ ]);
758
+ }
759
+ }
760
+ catch { /* swallow -- proceed with query anyway */ }
761
+ if (!project) {
762
+ sendJson(res, 200, { ok: true, count: 0, by_kind: {}, by_confidence: {}, sample: [], available: false });
763
+ return;
764
+ }
765
+ const { listRelevant, summarize } = await import('../semantic_graph/store.js');
766
+ const summary = await summarize(project);
767
+ const sampleNodes = await listRelevant(project, { limit: 10 });
768
+ const by_confidence = {};
769
+ const allForCount = await listRelevant(project, { limit: 10_000, min_confidence: 'claimed' });
770
+ for (const n of allForCount) {
771
+ by_confidence[n.confidence] = (by_confidence[n.confidence] || 0) + 1;
772
+ }
773
+ const sample = sampleNodes.map((n) => ({
774
+ id: n.id, kind: n.kind, name: n.name, version: n.version ?? null, purpose: n.purpose, confidence: n.confidence,
775
+ }));
776
+ sendJson(res, 200, {
777
+ ok: true,
778
+ count: summary.node_count,
779
+ by_kind: summary.by_kind,
780
+ by_confidence,
781
+ sample,
782
+ available: summary.node_count > 0,
783
+ });
784
+ }
785
+ catch {
786
+ sendJson(res, 200, { ok: true, count: 0, by_kind: {}, by_confidence: {}, sample: [], available: false });
787
+ }
788
+ return;
789
+ }
790
+ /* alpha.59z.150 F101 slice 3 -- MCP bridges UI endpoints.
791
+ * GET /api/forge/mcp/bridges -> list of discovered bridges
792
+ * with id/endpoint/source/enabled/
793
+ * display_name/protocol.
794
+ * GET /api/forge/mcp/tools -> tool catalog for the bridge_id
795
+ * query param. Internally calls
796
+ * MCPClient.listTools() (slice 4
797
+ * FULL).
798
+ * POST /api/forge/mcp/toggle -> flip enabled flag on a bridge.
799
+ * Panel uses these to render the Config tab + tool catalog modal.
800
+ * Each one is best-effort; failures degrade to {ok:false, error}. */
801
+ if (req.method === 'GET' && url.pathname === '/api/forge/mcp/bridges') {
802
+ try {
803
+ const { getActiveBridges } = await import('../mcp/bridge_registry.js');
804
+ const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
805
+ const shaped = bridges.map((b) => ({
806
+ id: b.id,
807
+ endpoint: b.endpoint,
808
+ source: b.source,
809
+ enabled: b.enabled,
810
+ display_name: b.display_name ?? b.id,
811
+ protocol: b.protocol ?? 'http_rest',
812
+ }));
813
+ sendJson(res, 200, { ok: true, bridges: shaped, count: shaped.length });
814
+ }
815
+ catch (err) {
816
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err), bridges: [], count: 0 });
817
+ }
818
+ return;
819
+ }
820
+ if (req.method === 'GET' && url.pathname === '/api/forge/mcp/tools') {
821
+ const bridgeId = url.searchParams.get('bridge_id') ?? '';
822
+ if (!bridgeId) {
823
+ sendJson(res, 400, { ok: false, error: 'bridge_id required' });
824
+ return;
825
+ }
826
+ try {
827
+ const { getActiveBridges } = await import('../mcp/bridge_registry.js');
828
+ const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
829
+ const bridge = bridges.find((b) => b.id === bridgeId);
830
+ if (!bridge) {
831
+ sendJson(res, 404, { ok: false, error: 'bridge_id "' + bridgeId + '" not found', tools: [] });
832
+ return;
833
+ }
834
+ if (!bridge.enabled) {
835
+ sendJson(res, 200, { ok: false, error: 'bridge disabled', bridge_id: bridgeId, tools: [], disabled: true });
836
+ return;
837
+ }
838
+ try {
839
+ const { getMCPClient } = await import('../mcp/protocol_client.js');
840
+ const client = getMCPClient(bridge);
841
+ const r = await client.listTools();
842
+ if (r.ok) {
843
+ sendJson(res, 200, { ok: true, bridge_id: bridgeId, tools: r.tools, count: r.count });
844
+ }
845
+ else {
846
+ sendJson(res, 200, {
847
+ ok: false,
848
+ error: r.error,
849
+ bridge_id: bridgeId,
850
+ bridge_unreachable: true,
851
+ tools: [],
852
+ });
853
+ }
854
+ }
855
+ catch (toolErr) {
856
+ sendJson(res, 200, {
857
+ ok: false,
858
+ error: toolErr instanceof Error ? toolErr.message : String(toolErr),
859
+ bridge_id: bridgeId,
860
+ bridge_unreachable: true,
861
+ tools: [],
862
+ });
863
+ }
864
+ }
865
+ catch (err) {
866
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
867
+ }
868
+ return;
869
+ }
870
+ if (req.method === 'POST' && url.pathname === '/api/forge/mcp/toggle') {
871
+ let body;
872
+ try {
873
+ body = JSON.parse(await readBody(req));
874
+ }
875
+ catch {
876
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
877
+ return;
878
+ }
879
+ const bridgeId = typeof body.bridge_id === 'string' ? body.bridge_id.trim() : '';
880
+ if (!bridgeId) {
881
+ sendJson(res, 400, { ok: false, error: 'bridge_id required' });
882
+ return;
883
+ }
884
+ const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
885
+ try {
886
+ const { setBridgeEnabled } = await import('../mcp/state.js');
887
+ await setBridgeEnabled(bridgeId, enabled);
888
+ const { resetBridgeCache } = await import('../mcp/bridge_registry.js');
889
+ resetBridgeCache();
890
+ sendJson(res, 200, { ok: true, bridge_id: bridgeId, enabled });
891
+ }
892
+ catch (err) {
893
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
894
+ }
895
+ return;
896
+ }
897
+ /* alpha.59l -- "what changed in this version" notification.
898
+ * Compares ~/.yujin-forge/last_seen_version vs current VERSION
899
+ * + returns the cumulative bullet catalog entries. Panel shows
900
+ * a one-shot dismissable banner; dismiss POSTs to /dismiss. */
901
+ if (req.method === 'GET' && url.pathname === '/api/forge/whats-new') {
902
+ const { computeWhatsNew } = await import('../core/whats_new.js');
903
+ const r = await computeWhatsNew(VERSION);
904
+ sendJson(res, 200, { ok: true, ...r });
905
+ return;
906
+ }
907
+ if (req.method === 'POST' && url.pathname === '/api/forge/whats-new/dismiss') {
908
+ const { writeLastSeen } = await import('../core/whats_new.js');
909
+ try {
910
+ await writeLastSeen(VERSION);
911
+ sendJson(res, 200, { ok: true, last_seen_version: VERSION });
912
+ }
913
+ catch (err) {
914
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
915
+ }
916
+ return;
917
+ }
918
+ /* alpha.59g -- audit log viewer endpoint. Reads the last N
919
+ * rows from ~/.yujin-forge/cache/audit_log.jsonl + returns
920
+ * newest-first. The panel renders them in the Danger Zone
921
+ * area so the user can confirm exactly what Forge did
922
+ * (especially when Danger Zone auto-approve is on). */
923
+ if (req.method === 'GET' && url.pathname === '/api/forge/audit-log') {
924
+ const { readRecentAudit } = await import('../core/audit_log.js');
925
+ const limitParam = url.searchParams.get('limit');
926
+ const limit = limitParam ? Math.min(500, Math.max(1, Number(limitParam) || 100)) : 100;
927
+ const entries = await readRecentAudit(limit);
928
+ sendJson(res, 200, { ok: true, entries, count: entries.length });
929
+ return;
930
+ }
931
+ /* alpha.52 -- budget snapshot for the panel UI card. */
932
+ if (req.method === 'GET' && url.pathname === '/api/forge/budget') {
933
+ const { readBudgetStatus } = await import('../core/budget.js');
934
+ const status = await readBudgetStatus();
935
+ /* JSON does not transport Infinity; map to null so the
936
+ * client can treat it as "unlimited". */
937
+ const finite = (n) => Number.isFinite(n) ? n : null;
938
+ sendJson(res, 200, {
939
+ ok: true,
940
+ budget: {
941
+ plan: status.plan,
942
+ plan_label: status.plan_label,
943
+ month: status.month,
944
+ renewal_at: status.renewal_at,
945
+ extension_url: status.extension_url,
946
+ eco: {
947
+ cap: finite(status.eco.cap),
948
+ used: status.eco.used,
949
+ remaining: finite(status.eco.remaining),
950
+ used_pct: status.eco.used_pct,
951
+ },
952
+ frontier: {
953
+ cap: finite(status.frontier.cap),
954
+ used: status.frontier.used,
955
+ remaining: finite(status.frontier.remaining),
956
+ used_pct: status.frontier.used_pct,
957
+ },
958
+ },
959
+ });
960
+ return;
961
+ }
962
+ /* alpha.42 -- approval queue endpoints for destructive /
963
+ approval-required verbs. The dispatcher pauses such verbs +
964
+ returns nac3_panel_approval_required: true. The panel opens
965
+ a modal; the user clicks Approve / Reject; the panel POSTs
966
+ here with the trace_id. */
967
+ if (req.method === 'POST' && url.pathname === '/api/forge/nac3-approve') {
968
+ await handleNac3Approve(req, res, ctx);
969
+ return;
970
+ }
971
+ if (req.method === 'POST' && url.pathname === '/api/forge/nac3-reject') {
972
+ await handleNac3Reject(req, res);
973
+ return;
974
+ }
177
975
  /* SQ 0.8 -- dual mode read/write. */
178
976
  if (req.method === 'GET' && url.pathname === '/api/forge/mode') {
179
977
  await handleModeGet(req, res, ctx);
@@ -270,20 +1068,136 @@ async function route(req, res, ctx) {
270
1068
  await handleTrustState(req, res, ctx);
271
1069
  return;
272
1070
  }
273
- /* Spec doc ingest (V1.37 + V1.38 -- bloque 4.5 scaffolding).
274
- Client uploads a spec file (PDF / DOCX / HTML / md / ...)
275
- and the server parses it via the existing reader pipeline,
276
- returning the NormalisedDocument shape. Real plan-building
277
- + file scaffolding lands in V1.41..V1.46. */
278
- if (req.method === 'POST' && url.pathname === '/api/forge/ingest') {
279
- await handleForgeIngest(req, res, ctx);
1071
+ /* alpha.29 -- keys status for the settings dropdown. Returns
1072
+ which BYOK provider keys + license are configured, without
1073
+ exposing the values themselves. */
1074
+ if (req.method === 'GET' && url.pathname === '/api/forge/keys-status') {
1075
+ await handleKeysStatus(req, res);
280
1076
  return;
281
1077
  }
282
- /* Spec extraction via Claude (V1.40). After an ingest, the
283
- panel calls this to ask Claude for structured
284
- requirements/entities/endpoints/components. */
285
- if (req.method === 'POST' && url.pathname === '/api/forge/ingest/extract') {
286
- await handleForgeIngestExtract(req, res, ctx);
1078
+ /* alpha.30 -- write/clear BYOK provider keys from the panel. */
1079
+ if (req.method === 'POST' && url.pathname === '/api/forge/keys-set') {
1080
+ await handleKeysSet(req, res);
1081
+ return;
1082
+ }
1083
+ /* PND-069 -- config de Brain desde el panel: modo plan/console,
1084
+ toggle eco, bindings provider+modelo por tier. */
1085
+ if (req.method === 'GET' && url.pathname === '/api/forge/brain-config') {
1086
+ await handleBrainConfigGet(req, res, ctx);
1087
+ return;
1088
+ }
1089
+ if (req.method === 'POST' && url.pathname === '/api/forge/brain-config') {
1090
+ await handleBrainConfigSet(req, res);
1091
+ return;
1092
+ }
1093
+ /* PND-069 -- modelos disponibles por provider (API en vivo con
1094
+ fallback estatico). */
1095
+ if (req.method === 'GET' && url.pathname === '/api/forge/brain-models') {
1096
+ await handleBrainModels(req, res, url);
1097
+ return;
1098
+ }
1099
+ /* PND-064 -- paridad de tools en modo Plan: el MCP server local
1100
+ reenvia aca CADA nac3_invoke del subproceso (modo
1101
+ claude-code-cli). Corre el dispatcher real y devuelve el
1102
+ resultado; los dispatches de panel se encolan. Token-gated. */
1103
+ if (req.method === 'POST' && url.pathname === '/api/forge/panel-invoke') {
1104
+ await handlePanelInvoke(req, res, ctx);
1105
+ return;
1106
+ }
1107
+ /* PND-064 slice B -- el panel hace polling de la cola. */
1108
+ if (req.method === 'GET' && url.pathname === '/api/forge/panel-queue') {
1109
+ await handlePanelQueue(req, res, url);
1110
+ return;
1111
+ }
1112
+ /* GRAPH-1 -- regenerar el grafo semantico (nodos + aristas
1113
+ derivadas de imports). El boton Refresh del panel lo llama. */
1114
+ if (req.method === 'POST' && url.pathname === '/api/forge/graph-regenerate') {
1115
+ await handleGraphRegenerate(req, res, ctx);
1116
+ return;
1117
+ }
1118
+ /* Startup tabs (dogfood 2026-06-10) -- 4 pestanas que el panel
1119
+ instancia al abrir: grafo grafico, grafo semantico, pendings,
1120
+ workflow. Se auto-refrescan; el switch las apaga. */
1121
+ if (req.method === 'GET' && url.pathname === '/api/forge/startup-tabs') {
1122
+ await handleStartupTabs(req, res, ctx, url);
1123
+ return;
1124
+ }
1125
+ /* Mobile pairing QR (dogfood 2026-06-10) -- Ajustes > Vincular
1126
+ telefono. Genera codigo + envelope + QR SVG para que Forge
1127
+ Mobile lo escanee (el usuario solo tipea el codigo). */
1128
+ if (req.method === 'POST' && url.pathname === '/api/forge/mobile-pair') {
1129
+ await handleMobilePair(req, res);
1130
+ return;
1131
+ }
1132
+ /* alpha.59z.114 -- pull the canonical license row from the
1133
+ HITO 4 worker (Polar -> D1 -> local cache). The panel button
1134
+ hits this when the user paid on Polar but the local cache
1135
+ still says plan=none. */
1136
+ if (req.method === 'POST' && url.pathname === '/api/forge/license-sync') {
1137
+ await handleLicenseSync(req, res);
1138
+ return;
1139
+ }
1140
+ /* alpha.59z.117 -- bind a Polar user_handle (email) to the
1141
+ local license cache + immediately attempt a sync. This is
1142
+ the one-click flow the panel surfaces when the user opens
1143
+ yf chat for the first time after installing from npm:
1144
+ panel detects user_handle missing, prompts for email,
1145
+ POSTs here, and the row gets populated end-to-end. */
1146
+ if (req.method === 'POST' && url.pathname === '/api/forge/license-handle') {
1147
+ await handleLicenseHandle(req, res);
1148
+ return;
1149
+ }
1150
+ /* alpha.35 -- pizarron edit: read + write project files. */
1151
+ if (req.method === 'GET' && url.pathname === '/api/forge/file') {
1152
+ await handleReadFile(req, res, ctx);
1153
+ return;
1154
+ }
1155
+ if (req.method === 'POST' && url.pathname === '/api/forge/file') {
1156
+ await handleWriteFile(req, res, ctx);
1157
+ return;
1158
+ }
1159
+ /* Favicon -- minimal SVG so Chrome stops 404-ing. The browser
1160
+ auto-requests /favicon.ico on every page load; without this,
1161
+ each open of the panel logs an error. */
1162
+ if (req.method === 'GET' && (url.pathname === '/favicon.ico'
1163
+ || url.pathname === '/favicon.svg')) {
1164
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
1165
+ + '<circle cx="16" cy="16" r="14" fill="#1a1a1a"/>'
1166
+ + '<path d="M8 22 L16 8 L24 22 M11 17 L21 17" stroke="#fafafa" stroke-width="2" fill="none" stroke-linecap="round"/>'
1167
+ + '</svg>';
1168
+ res.writeHead(200, {
1169
+ 'content-type': 'image/svg+xml',
1170
+ 'cache-control': 'public, max-age=86400',
1171
+ });
1172
+ res.end(svg);
1173
+ return;
1174
+ }
1175
+ /* Support report ingest (2026-05-31 -- F30-style). Panel sends
1176
+ * runtime errors (window.onerror, fetch failures, unhandled
1177
+ * promise rejections) here so Forge sees what the user saw
1178
+ * without them having to copy logs. PII is scrubbed on save. */
1179
+ if (req.method === 'POST' && url.pathname === '/api/forge/support/report') {
1180
+ await handleSupportReport(req, res);
1181
+ return;
1182
+ }
1183
+ if (req.method === 'GET' && url.pathname === '/api/forge/support/reports') {
1184
+ await handleSupportReportList(req, res);
1185
+ return;
1186
+ }
1187
+ /* Spec doc ingest (V1.37 + V1.38 -- bloque 4.5 scaffolding).
1188
+ Client uploads a spec file (PDF / DOCX / HTML / md / ...)
1189
+ and the server parses it via the existing reader pipeline,
1190
+ returning the NormalisedDocument shape. Real plan-building
1191
+ + file scaffolding lands in V1.41..V1.46. */
1192
+ if (req.method === 'POST' && url.pathname === '/api/forge/ingest') {
1193
+ await handleForgeIngest(req, res, ctx);
1194
+ return;
1195
+ }
1196
+ /* Spec extraction via Claude (V1.40). After an ingest, the
1197
+ panel calls this to ask Claude for structured
1198
+ requirements/entities/endpoints/components. */
1199
+ if (req.method === 'POST' && url.pathname === '/api/forge/ingest/extract') {
1200
+ await handleForgeIngestExtract(req, res, ctx);
287
1201
  return;
288
1202
  }
289
1203
  /* List active ingest sessions (V1.40 -- for debugging + the
@@ -445,6 +1359,19 @@ async function handleVoiceStt(req, res, ctx) {
445
1359
  sendJson(res, 400, { ok: false, error: 'audio body is empty' });
446
1360
  return;
447
1361
  }
1362
+ /* 2026-05-31 fix: Google STT sync rejects audio > 1 min with
1363
+ HTTP 400. Refuse early with a useful message instead of
1364
+ leaking the Google error verbatim. ~4 MB is the practical
1365
+ ceiling for 60s of WebM/Opus at 32 kbps. */
1366
+ if (audio.length > 4 * 1024 * 1024) {
1367
+ sendJson(res, 413, {
1368
+ ok: false,
1369
+ error: 'audio too long for sync STT (limit ~1 min). '
1370
+ + 'Got ' + audio.length + ' bytes. The chat panel auto-stops at 55s; '
1371
+ + 'if you hit this from a different client, split the audio.',
1372
+ });
1373
+ return;
1374
+ }
448
1375
  /* Optional active document hint (V1.31). Lets the voice intent
449
1376
  matcher resolve commands like "siguiente" or "buscar X" against
450
1377
  the currently-open reader session. Client sets this header to
@@ -538,6 +1465,163 @@ async function handleForgeToolDispatch(req, res, ctx) {
538
1465
  });
539
1466
  }
540
1467
  }
1468
+ /* ============================================================
1469
+ * NAC3 approval queue endpoints (alpha.42).
1470
+ *
1471
+ * When the LLM invokes a verb with policy=destructive or
1472
+ * approval-required, runNac3Invoke pushes a pending row + returns
1473
+ * an envelope. The panel opens a modal; the user clicks Approve
1474
+ * or Reject; the panel POSTs here with the trace_id.
1475
+ *
1476
+ * Approve -> resolve the row + execute the legacy alias via the
1477
+ * runForgeTool dispatcher (with approval_bypass=true to skip the
1478
+ * gate on re-entry). Reject -> log + return ok without executing.
1479
+ * ============================================================ */
1480
+ async function handleNac3Approve(req, res, ctx) {
1481
+ let body;
1482
+ try {
1483
+ body = JSON.parse(await readBody(req));
1484
+ }
1485
+ catch {
1486
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
1487
+ return;
1488
+ }
1489
+ const traceId = typeof body.trace_id === 'string' ? body.trace_id.trim() : '';
1490
+ if (!traceId) {
1491
+ sendJson(res, 400, { ok: false, error: 'trace_id required' });
1492
+ return;
1493
+ }
1494
+ const pending = popPending(traceId);
1495
+ if (!pending) {
1496
+ await logApproval({
1497
+ ts: new Date().toISOString(),
1498
+ trace_id: traceId,
1499
+ verb_id: '?',
1500
+ decision: 'expired',
1501
+ args_sample: {},
1502
+ policy: '?',
1503
+ reason: 'trace_id not in queue (expired or already resolved)',
1504
+ });
1505
+ sendJson(res, 404, { ok: false, error: 'no pending approval for trace_id (expired or already resolved)' });
1506
+ return;
1507
+ }
1508
+ /* Execute via the legacy alias, bypassing the gate. */
1509
+ if (!pending.legacy_alias) {
1510
+ sendJson(res, 500, { ok: false, error: 'pending row has no legacy alias' });
1511
+ return;
1512
+ }
1513
+ let result;
1514
+ try {
1515
+ result = await pending.legacy_run(pending.legacy_alias, pending.args, ctx.projectRoot === pending.ctx.projectRoot ? pending.ctx : { projectRoot: ctx.projectRoot });
1516
+ }
1517
+ catch (err) {
1518
+ await logApproval({
1519
+ ts: new Date().toISOString(),
1520
+ trace_id: traceId,
1521
+ verb_id: pending.verb.id,
1522
+ decision: 'approve',
1523
+ args_sample: pending.args,
1524
+ policy: pending.verb.policy,
1525
+ reason: 'handler threw: ' + (err instanceof Error ? err.message : String(err)),
1526
+ });
1527
+ sendJson(res, 500, {
1528
+ ok: false,
1529
+ error: 'handler threw on approved verb: ' + (err instanceof Error ? err.message : String(err)),
1530
+ });
1531
+ return;
1532
+ }
1533
+ await logApproval({
1534
+ ts: new Date().toISOString(),
1535
+ trace_id: traceId,
1536
+ verb_id: pending.verb.id,
1537
+ decision: 'approve',
1538
+ args_sample: pending.args,
1539
+ policy: pending.verb.policy,
1540
+ });
1541
+ sendJson(res, 200, {
1542
+ ok: true,
1543
+ trace_id: traceId,
1544
+ verb_id: pending.verb.id,
1545
+ is_error: result.is_error ?? false,
1546
+ result: result.result,
1547
+ });
1548
+ }
1549
+ async function handleNac3Reject(req, res) {
1550
+ let body;
1551
+ try {
1552
+ body = JSON.parse(await readBody(req));
1553
+ }
1554
+ catch {
1555
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
1556
+ return;
1557
+ }
1558
+ const traceId = typeof body.trace_id === 'string' ? body.trace_id.trim() : '';
1559
+ const reason = typeof body.reason === 'string' ? body.reason.slice(0, 240) : undefined;
1560
+ if (!traceId) {
1561
+ sendJson(res, 400, { ok: false, error: 'trace_id required' });
1562
+ return;
1563
+ }
1564
+ const pending = popPending(traceId);
1565
+ if (!pending) {
1566
+ await logApproval({
1567
+ ts: new Date().toISOString(),
1568
+ trace_id: traceId,
1569
+ verb_id: '?',
1570
+ decision: 'expired',
1571
+ args_sample: {},
1572
+ policy: '?',
1573
+ reason: 'trace_id not in queue',
1574
+ });
1575
+ /* alpha.59z.14 F104 -- emit structured ForgeError so the panel
1576
+ * + chat brain can surface a readable diagnosis instead of the
1577
+ * cryptic "no pending approval for trace_id" string. */
1578
+ const { renderForgeError, E_MODAL_TRACE_STALE } = await import('../core/error_catalog.js');
1579
+ const { formatForgeErrorJson } = await import('../core/error_formatter.js');
1580
+ const forgeError = renderForgeError(E_MODAL_TRACE_STALE, { trace_id: traceId });
1581
+ sendJson(res, 404, {
1582
+ ok: false,
1583
+ /* legacy field for backward compatibility */
1584
+ error: 'no pending approval for trace_id',
1585
+ /* F104 structured */
1586
+ forge_error: formatForgeErrorJson(forgeError),
1587
+ });
1588
+ return;
1589
+ }
1590
+ await logApproval({
1591
+ ts: new Date().toISOString(),
1592
+ trace_id: traceId,
1593
+ verb_id: pending.verb.id,
1594
+ decision: 'reject',
1595
+ args_sample: pending.args,
1596
+ policy: pending.verb.policy,
1597
+ reason,
1598
+ });
1599
+ /* alpha.59z.160 F104 -- when the rejected verb is yujin.shell.exec,
1600
+ * surface E_SHELL_EXEC_REJECTED (severity: info) so the chat brain
1601
+ * can suggest the native-verb alternative for read-only commands
1602
+ * (yf projects active, git status, ls) instead of asking the user
1603
+ * to re-approve. The rejection itself is a valid user signal --
1604
+ * response stays ok:true. */
1605
+ const payload = {
1606
+ ok: true,
1607
+ trace_id: traceId,
1608
+ verb_id: pending.verb.id,
1609
+ decision: 'rejected',
1610
+ };
1611
+ if (pending.verb.id === 'yujin.shell.exec') {
1612
+ const { renderForgeError, E_SHELL_EXEC_REJECTED } = await import('../core/error_catalog.js');
1613
+ const { formatForgeErrorJson } = await import('../core/error_formatter.js');
1614
+ const command = typeof pending.args['command'] === 'string'
1615
+ ? pending.args['command'].slice(0, 240)
1616
+ : '?';
1617
+ const classification = typeof pending.args['classification'] === 'string'
1618
+ ? pending.args['classification']
1619
+ : pending.verb.policy;
1620
+ const fe = renderForgeError(E_SHELL_EXEC_REJECTED, { command, classification });
1621
+ payload['forge_error'] = formatForgeErrorJson(fe);
1622
+ }
1623
+ sendJson(res, 200, payload);
1624
+ }
541
1625
  /** V1.37 + V1.38 -- ingest a spec doc via drag-drop / upload.
542
1626
  *
543
1627
  * Accepts POST with:
@@ -665,7 +1749,7 @@ async function handleForgeIngestExtract(req, res, ctx) {
665
1749
  };
666
1750
  if (session.doc.language)
667
1751
  opts.language = session.doc.language;
668
- const extraction = await extractSpec(ctx.claude, opts);
1752
+ const extraction = await extractSpec(opts);
669
1753
  setExtraction(session.doc_id, extraction);
670
1754
  sendJson(res, 200, {
671
1755
  ok: true,
@@ -727,7 +1811,7 @@ async function handleForgeIngestPlan(req, res, ctx) {
727
1811
  };
728
1812
  if (session.doc.language)
729
1813
  opts.language = session.doc.language;
730
- const plan = await generatePlan(ctx.claude, opts);
1814
+ const plan = await generatePlan(opts);
731
1815
  setPlan(session.doc_id, plan);
732
1816
  sendJson(res, 200, {
733
1817
  ok: true,
@@ -1122,143 +2206,782 @@ async function handleTrustEvent(req, res, _ctx) {
1122
2206
  await saveTrustMetrics(m);
1123
2207
  sendJson(res, 200, { ok: true, metrics: m });
1124
2208
  }
1125
- async function handleTrustState(_req, res, _ctx) {
1126
- const m = await loadTrustMetrics();
1127
- /* Decay logic: if no events in last 30 days AND user has not
1128
- * explicitly chosen to keep showing, suggest auto-collapse.
1129
- * The panel decides what to do with the suggestion. */
1130
- let shouldProposeCollapse = false;
1131
- if (m.user_choice === 'default') {
1132
- const last = m.last_event_at ? new Date(m.last_event_at).getTime() : 0;
1133
- const proposedRecently = m.last_proposal_at
1134
- ? (Date.now() - new Date(m.last_proposal_at).getTime()) < 7 * 24 * 3600 * 1000
1135
- : false;
1136
- const daysSince = last ? (Date.now() - last) / (24 * 3600 * 1000) : Infinity;
1137
- shouldProposeCollapse = daysSince > 30 && !proposedRecently;
1138
- }
2209
+ async function handleKeysStatus(_req, res) {
2210
+ /* Read ~/.yujin-forge/provider_keys.json + license.json without
2211
+ * exposing values. Just boolean presence.
2212
+ *
2213
+ * alpha.34 fix (Producer/Consumer Symmetry, SQ Sec 14):
2214
+ * Keys in the response MUST match the JSON slot names used by:
2215
+ * - the writer (POST /api/forge/keys-set, src/chat/tools/keys.ts)
2216
+ * - the panel HTML rows (data-key-slot="anthropic_api_key")
2217
+ * Previously this endpoint returned short names ('anthropic')
2218
+ * while the panel + writer used full slot names
2219
+ * ('anthropic_api_key'), so the per-row UI always showed every
2220
+ * row as MISSING even when the keys were present. Symptom Pablo
2221
+ * caught: 'figuran desactivadas pero brain + STT/TTS funcionan'.
2222
+ *
2223
+ * Also returns license_plan as a STRING (not a boolean) so the
2224
+ * settings panel can display the actual plan label. */
2225
+ /* alpha.44 C1: keys live in the encrypted Vault now. The
2226
+ * helper auto-migrates the legacy provider_keys.json on first
2227
+ * read; we just ask for the boolean status of each slot. */
2228
+ const { listStatus } = await import('../core/provider_keys.js');
2229
+ const out = await listStatus();
2230
+ const fs = await import('node:fs');
2231
+ const path = await import('node:path');
2232
+ const os = await import('node:os');
2233
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
2234
+ let license_plan = 'none';
2235
+ let license_user_handle = null;
2236
+ let license_last_validated_at = null;
2237
+ try {
2238
+ const p = path.join(home, '.yujin-forge', 'license.json');
2239
+ const raw = await fs.promises.readFile(p, 'utf-8');
2240
+ const j = JSON.parse(raw);
2241
+ if (typeof j.plan === 'string')
2242
+ license_plan = j.plan;
2243
+ if (typeof j.user_handle === 'string')
2244
+ license_user_handle = j.user_handle;
2245
+ if (typeof j.last_validated_at === 'string')
2246
+ license_last_validated_at = j.last_validated_at;
2247
+ }
2248
+ catch { /* no license file */ }
1139
2249
  sendJson(res, 200, {
1140
2250
  ok: true,
1141
- metrics: m,
1142
- propose_auto_collapse: shouldProposeCollapse,
2251
+ keys: out,
2252
+ license_plan,
2253
+ license_user_handle,
2254
+ license_last_validated_at,
2255
+ license_can_sync: license_user_handle !== null,
1143
2256
  });
1144
2257
  }
1145
- async function handleVaultList(res) {
2258
+ /** PND-069 -- GET /api/forge/brain-config. Devuelve el
2259
+ * brain_config completo + el chatProvider activo de ESTA sesion
2260
+ * (que puede diferir de anthropic_mode hasta reabrir el panel). */
2261
+ async function handleBrainConfigGet(_req, res, ctx) {
2262
+ const { readBrainConfig } = await import('../core/brain_config.js');
2263
+ const cfg = await readBrainConfig();
2264
+ sendJson(res, 200, {
2265
+ ok: true,
2266
+ config: cfg,
2267
+ active_chat_provider: ctx.chatProvider ?? 'anthropic-api',
2268
+ });
2269
+ }
2270
+ /** PND-069 -- POST /api/forge/brain-config. Acepta un subset de
2271
+ * campos; valida y persiste. anthropic_mode requiere reabrir el
2272
+ * panel para tomar efecto (el brain client se construye al boot);
2273
+ * eco_enabled y los bindings aplican en el proximo turno. */
2274
+ async function handleBrainConfigSet(req, res) {
2275
+ const { readBrainConfig, writeBrainConfig, ALL_BRAIN_PROVIDERS, } = await import('../core/brain_config.js');
2276
+ let body;
1146
2277
  try {
1147
- const vault = await Vault.open({ configDir: configDir() });
1148
- sendJson(res, 200, { ok: true, slots: vault.list() });
2278
+ body = JSON.parse(await readBody(req));
1149
2279
  }
1150
- catch (err) {
1151
- sendJson(res, 500, {
1152
- ok: false,
1153
- error: err instanceof Error ? err.message : String(err),
1154
- });
2280
+ catch {
2281
+ sendJson(res, 400, { ok: false, error: 'invalid json body' });
2282
+ return;
2283
+ }
2284
+ const cfg = await readBrainConfig();
2285
+ if (typeof body.eco_enabled === 'boolean')
2286
+ cfg.eco_enabled = body.eco_enabled;
2287
+ if (typeof body.startup_tabs_enabled === 'boolean')
2288
+ cfg.startup_tabs_enabled = body.startup_tabs_enabled;
2289
+ if (body.anthropic_mode === 'plan' || body.anthropic_mode === 'console') {
2290
+ cfg.anthropic_mode = body.anthropic_mode;
2291
+ }
2292
+ if (body.chat_default_tier === 'eco' || body.chat_default_tier === 'frontier') {
2293
+ cfg.chat_default_tier = body.chat_default_tier;
1155
2294
  }
2295
+ for (const tier of ['eco', 'frontier']) {
2296
+ const b = body[tier];
2297
+ if (!b || typeof b !== 'object')
2298
+ continue;
2299
+ const provider = typeof b.provider === 'string'
2300
+ && ALL_BRAIN_PROVIDERS.includes(b.provider)
2301
+ ? b.provider : null;
2302
+ const model = typeof b.model === 'string' && b.model.trim() !== ''
2303
+ ? b.model.trim() : null;
2304
+ if (provider)
2305
+ cfg[tier].provider = provider;
2306
+ if (model)
2307
+ cfg[tier].model = model;
2308
+ }
2309
+ await writeBrainConfig(cfg);
2310
+ sendJson(res, 200, { ok: true, config: cfg, restart_needed_for: ['anthropic_mode'] });
1156
2311
  }
1157
- async function handleVaultSet(req, res) {
2312
+ /** PND-064 -- POST /api/forge/panel-invoke. Paridad total de tools
2313
+ * en modo Plan: el MCP server local reenvia aca cada nac3_invoke
2314
+ * del subproceso `claude -p`. Corre el MISMO dispatcher que el
2315
+ * modo API (runForgeTool -> runNac3Invoke), con el mismo policy
2316
+ * gate. Los resultados de panel-dispatch (pizarron) se encolan
2317
+ * para que el panel los materialice; los demas se devuelven al
2318
+ * subproceso. Token-gated (timing-safe). */
2319
+ async function handlePanelInvoke(req, res, ctx) {
1158
2320
  let body;
1159
2321
  try {
1160
2322
  body = JSON.parse(await readBody(req));
1161
2323
  }
1162
2324
  catch {
1163
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2325
+ sendJson(res, 400, { ok: false, error: 'invalid json body' });
1164
2326
  return;
1165
2327
  }
1166
- if (typeof body.slot !== 'string' || typeof body.plaintext !== 'string') {
1167
- sendJson(res, 400, { ok: false, error: 'slot + plaintext (both strings) required' });
2328
+ /* Token-gate (CR-D1 lesson: no confiar en "solo loopback"). */
2329
+ const expected = ctx.panelPushToken || '';
2330
+ const got = typeof body.token === 'string' ? body.token : '';
2331
+ const crypto = await import('node:crypto');
2332
+ const ok = expected.length > 0 && got.length === expected.length
2333
+ && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
2334
+ if (!ok) {
2335
+ sendJson(res, 403, { ok: false, error: 'forbidden' });
1168
2336
  return;
1169
2337
  }
1170
- /* Optional kind / expiry validation. */
1171
- let kindOpt;
1172
- if (body.kind !== undefined) {
1173
- if (typeof body.kind !== 'string' || !SLOT_KINDS.includes(body.kind)) {
1174
- sendJson(res, 400, { ok: false, error: 'invalid kind. Allowed: ' + SLOT_KINDS.join(', ') });
1175
- return;
1176
- }
1177
- kindOpt = body.kind;
2338
+ const verb_id = typeof body.verb_id === 'string' ? body.verb_id : '';
2339
+ const args = (body.args && typeof body.args === 'object' && !Array.isArray(body.args))
2340
+ ? body.args : {};
2341
+ if (!verb_id) {
2342
+ sendJson(res, 400, { ok: false, error: 'missing verb_id', text: 'Falta verb_id.' });
2343
+ return;
1178
2344
  }
1179
- let expiryOpt;
1180
- if (body.expiry === null) {
1181
- expiryOpt = null;
2345
+ /* Mismo dispatcher que el modo API. Ctx completo (incluye
2346
+ * projectSlug para graph/pending/wiring). */
2347
+ const { runForgeTool } = await import('./tools.js');
2348
+ const toolCtx = { projectRoot: ctx.projectRoot, projectSlug: ctx.projectSlug, ...(ctx.reanchor ? { reanchor: ctx.reanchor } : {}) };
2349
+ let r;
2350
+ try {
2351
+ r = await runForgeTool('nac3_invoke', { verb_id, args }, toolCtx);
1182
2352
  }
1183
- else if (body.expiry !== undefined) {
1184
- if (typeof body.expiry !== 'string') {
1185
- sendJson(res, 400, { ok: false, error: 'expiry must be a string ISO 8601 or null' });
1186
- return;
1187
- }
1188
- const d = new Date(body.expiry);
1189
- if (isNaN(d.getTime())) {
1190
- sendJson(res, 400, { ok: false, error: 'expiry is not a valid ISO 8601 timestamp' });
1191
- return;
1192
- }
1193
- expiryOpt = d.toISOString();
2353
+ catch (err) {
2354
+ const m = err instanceof Error ? err.message : String(err);
2355
+ sendJson(res, 200, { ok: false, is_error: true, text: 'El verbo fallo: ' + m });
2356
+ return;
1194
2357
  }
1195
- try {
1196
- const vault = await Vault.open({ configDir: configDir() });
1197
- await vault.set(body.slot, body.plaintext, {
1198
- ...(kindOpt ? { kind: kindOpt } : {}),
1199
- ...(expiryOpt !== undefined ? { expiry: expiryOpt } : {}),
2358
+ const result = (r.result && typeof r.result === 'object') ? r.result : {};
2359
+ /* Caso 1: panel-dispatch (pizarron). Encolar para el panel +
2360
+ * devolver al subproceso un texto de exito. */
2361
+ if (result.nac3_panel_dispatch === true && result.verb && typeof result.verb === 'object') {
2362
+ const verb = result.verb;
2363
+ const dispatchArgs = (result.args && typeof result.args === 'object')
2364
+ ? result.args : {};
2365
+ const payload = (result.server_payload && typeof result.server_payload === 'object')
2366
+ ? result.server_payload : null;
2367
+ const shortVerb = String(verb.id || '').replace(/^yujin\./, '');
2368
+ const { enqueuePanelDispatch } = await import('./panel_queue.js');
2369
+ const seq = enqueuePanelDispatch(shortVerb, payload ? { ...dispatchArgs, server_payload: payload } : dispatchArgs, new Date().toISOString());
2370
+ sendJson(res, 200, {
2371
+ ok: true, seq, panel_dispatch: true,
2372
+ text: 'Listo: enviado al pizarron (' + shortVerb + ').',
1200
2373
  });
1201
- /* Echo metadata only -- never the plaintext, never the
1202
- ciphertext. The 4-char prefix on list() is the only
1203
- visible confirmation. */
1204
- const meta = vault.list().find((s) => s.name === body.slot);
1205
- sendJson(res, 200, { ok: true, slot: meta });
2374
+ return;
1206
2375
  }
1207
- catch (err) {
1208
- sendJson(res, 400, {
1209
- ok: false,
1210
- error: err instanceof Error ? err.message : String(err),
2376
+ /* Caso 2: requiere aprobacion. El subproceso no puede abrir el
2377
+ * modal sincronicamente; le decimos que el usuario debe aprobar.
2378
+ * NO se ejecuta la accion destructiva sin aprobacion. */
2379
+ if (result.nac3_panel_approval_required === true) {
2380
+ sendJson(res, 200, {
2381
+ ok: false, approval_required: true,
2382
+ text: 'Esta accion requiere aprobacion del usuario en el panel de Forge. '
2383
+ + 'Pedile que la apruebe y reintenta, o ejecutala en modo API.',
1211
2384
  });
2385
+ return;
1212
2386
  }
1213
- }
1214
- async function handleVaultCatalog(res, url) {
1215
- const kindFilter = url.searchParams.get('kind');
1216
- let entries = SLOT_CATALOG;
1217
- if (kindFilter && SLOT_KINDS.includes(kindFilter)) {
1218
- entries = entries.filter((e) => e.kind === kindFilter);
2387
+ /* Caso 3: resultado normal -> devolver al subproceso. Texto
2388
+ * compacto + el JSON del resultado para que el modelo razone. */
2389
+ let text;
2390
+ try {
2391
+ text = typeof r.result === 'string' ? r.result : JSON.stringify(r.result);
1219
2392
  }
2393
+ catch {
2394
+ text = '[resultado no serializable]';
2395
+ }
2396
+ /* Dogfood 2026-06-10 -- 8000 era muy bajo: list-edges (104
2397
+ * aristas en JSON) se truncaba y el modelo caia al workaround de
2398
+ * leer el .db con sqlite. Alineado con el truncado de tool
2399
+ * results del modo API (200 KB). */
1220
2400
  sendJson(res, 200, {
1221
- ok: true,
1222
- slots: entries.map((e) => ({
1223
- name: e.name, kind: e.kind, description: e.description,
1224
- ...(e.obtainUrl ? { obtain_url: e.obtainUrl } : {}),
1225
- has_probe: !!e.test,
1226
- })),
2401
+ ok: r.is_error !== true,
2402
+ is_error: r.is_error === true,
2403
+ text: text.slice(0, 200000),
1227
2404
  });
1228
2405
  }
1229
- async function handleModeGet(req, res, ctx) {
1230
- const mode = await resolveModeForRequest(req, ctx.projectRoot);
1231
- const projectResolved = await resolveMode({ projectRoot: ctx.projectRoot });
1232
- sendJson(res, 200, {
1233
- ok: true,
1234
- mode,
1235
- source: projectResolved.source,
1236
- available: FORGE_MODES,
1237
- });
2406
+ /** PND-064 slice B -- GET /api/forge/panel-queue?after=<seq>. El
2407
+ * panel hace polling mientras corre en modo plan. */
2408
+ async function handlePanelQueue(_req, res, url) {
2409
+ const after = Number(url.searchParams.get('after') || '0');
2410
+ const { readPanelDispatchesAfter } = await import('./panel_queue.js');
2411
+ const { items, last } = readPanelDispatchesAfter(after);
2412
+ sendJson(res, 200, { ok: true, items, last });
1238
2413
  }
1239
- async function handleModeSet(req, res, ctx) {
1240
- let body;
2414
+ /** GRAPH-1 -- POST /api/forge/graph-regenerate. Deriva el grafo
2415
+ * completo (nodos de modulo + libreria + aristas de imports,
2416
+ * validadas), lo persiste y poda colgantes. Reutilizable desde el
2417
+ * boton Refresh y el auto-regen del watcher. */
2418
+ /** Mobile pairing QR -- genera el codigo + envelope cifrado y lo
2419
+ * renderiza como QR SVG para que Forge Mobile lo escanee. El
2420
+ * usuario solo tipea el codigo de 6 digitos en el telefono. El
2421
+ * payload (que lleva el server_api_key) NUNCA sale por aca en
2422
+ * claro: solo va el QR cifrado + el codigo (que el usuario ya ve
2423
+ * en su propia pantalla). */
2424
+ async function handleMobilePair(_req, res) {
1241
2425
  try {
1242
- body = JSON.parse(await readBody(req));
2426
+ const { assemblePairing } = await import('../commands/mobile.js');
2427
+ const built = await assemblePairing({});
2428
+ if (!built.ok || !built.qr || !built.code) {
2429
+ sendJson(res, 200, { ok: false, error: built.error ?? 'no pude generar el pairing', hint: built.hint });
2430
+ return;
2431
+ }
2432
+ const QRCode = (await import('qrcode')).default;
2433
+ const qrSvg = await QRCode.toString(built.qr, {
2434
+ type: 'svg', margin: 1, errorCorrectionLevel: 'M',
2435
+ color: { dark: '#1a1a1a', light: '#f5f0e6' },
2436
+ });
2437
+ sendJson(res, 200, { ok: true, code: built.code, expires_at: built.expires_at, qr_svg: qrSvg });
1243
2438
  }
1244
- catch {
1245
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2439
+ catch (err) {
2440
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
2441
+ }
2442
+ }
2443
+ /** Startup tabs -- arma las 4 pestanas del pizarron server-side.
2444
+ * Devuelve {enabled, tabs:[{key,title,kind,content}]}. El panel
2445
+ * las materializa al abrir (salvo las que el usuario cerro) y
2446
+ * hace polling para auto-refrescarlas. */
2447
+ async function handleStartupTabs(_req, res, ctx, url) {
2448
+ const slug = ctx.projectSlug || '';
2449
+ let enabled = true;
2450
+ try {
2451
+ const { readBrainConfig } = await import('../core/brain_config.js');
2452
+ enabled = (await readBrainConfig()).startup_tabs_enabled !== false;
2453
+ }
2454
+ catch { /* default on */ }
2455
+ if (!enabled) {
2456
+ sendJson(res, 200, { ok: true, enabled: false, tabs: [] });
1246
2457
  return;
1247
2458
  }
1248
- if (typeof body.mode !== 'string' || !FORGE_MODES.includes(body.mode)) {
1249
- sendJson(res, 400, { ok: false, error: 'mode must be one of: ' + FORGE_MODES.join(', ') });
2459
+ /* i18n-boundary pass -- los titulos + headers de las tabs van en
2460
+ * el idioma del usuario (los ids/verbos no). */
2461
+ const { tForLang } = await import('../i18n/index.js');
2462
+ const lang = (url.searchParams.get('lang') || 'es');
2463
+ const t = (k) => tForLang(lang, k);
2464
+ const tabs = [];
2465
+ /* 1 + 2: grafo (grafico + semantico). */
2466
+ try {
2467
+ const { listRelevant, listAllEdges } = await import('../semantic_graph/store.js');
2468
+ /* Vista arquitectonica: los nodos derivados module.* y lib.*
2469
+ * (no los comp.* de export fino del watcher, que tienen kind
2470
+ * 'module' pero saturan el grafo). Filtro por prefijo de id. */
2471
+ const allNodes = slug ? await listRelevant(slug, { limit: 100000, min_confidence: 'claimed' }) : [];
2472
+ const nodes = allNodes.filter((n) => n.id.startsWith('module.') || n.id.startsWith('lib.'));
2473
+ const allEdges = slug ? await listAllEdges(slug) : [];
2474
+ const nodeIdSet = new Set(nodes.map((n) => n.id));
2475
+ const edges = allEdges.filter((e) => nodeIdSet.has(e.from_id) && nodeIdSet.has(e.to_id));
2476
+ const gNodes = nodes.map((n) => ({ id: n.id, label: n.name || n.id, weight: n.weight, kind: n.kind, detail: n.purpose || '' }));
2477
+ const gEdges = edges.map((e) => ({ from: e.from_id, to: e.to_id, kind: e.kind }));
2478
+ tabs.push({ key: 'startup_graph', title: t('startup.tab.graph'), kind: 'graph', content: JSON.stringify({ nodes: gNodes, edges: gEdges }) });
2479
+ /* Grafo semantico = misma data en markdown (tabla de nodos por
2480
+ * peso + conteo de aristas). */
2481
+ const top = [...nodes].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 40);
2482
+ let md = '# ' + t('startup.tab.graphSem') + ' -- ' + (slug || 'proyecto') + '\n\n'
2483
+ + nodes.length + ' ' + t('graph.term.nodes') + ', ' + edges.length + ' ' + t('graph.term.edges') + '.\n\n'
2484
+ + '| ' + t('startup.tab.graphSem') + ' | | ' + t('pizarron.graph.detail.weight') + ' |\n|---|---|---|\n';
2485
+ for (const n of top)
2486
+ md += '| ' + (n.name || n.id) + ' | ' + n.kind + ' | ' + (Math.round((n.weight || 0) * 100) / 100) + ' |\n';
2487
+ tabs.push({ key: 'startup_graph_sem', title: t('startup.tab.graphSem'), kind: 'md', content: md });
2488
+ }
2489
+ catch { /* sin grafo */ }
2490
+ /* 3: pendings. */
2491
+ try {
2492
+ if (slug) {
2493
+ const { readPending } = await import('../pending/store.js');
2494
+ const pf = await readPending(slug);
2495
+ const open = (pf.items || []).filter((i) => !i.closed_at);
2496
+ let md = '# Pendientes activos (' + open.length + ')\n\n';
2497
+ const byPrio = [...open].sort((a, b) => (b.priority || 0) - (a.priority || 0));
2498
+ md += '| ID | Prio | Titulo |\n|---|---|---|\n';
2499
+ for (const it of byPrio.slice(0, 60)) {
2500
+ md += '| ' + String(it.id) + ' | ' + (it.priority ?? '-') + ' | ' + String(it.title || '').replace(/\|/g, '/').slice(0, 90) + ' |\n';
2501
+ }
2502
+ tabs.push({ key: 'startup_pendings', title: t('startup.tab.pendings'), kind: 'md', content: md });
2503
+ }
2504
+ }
2505
+ catch { /* sin pendings */ }
2506
+ /* 4: workflow como GRAFO de nodos (las 6 fases del flujo yujin
2507
+ * con sus pasos), coloreadas por estado (done/pendiente) cuando
2508
+ * hay workflow state. Cumple el pedido de "workflow como grafo
2509
+ * de nodos y conectores con detalle al click". */
2510
+ try {
2511
+ /* PND-094 -- vista jerarquica de FASES + tareas (no grafo): cada
2512
+ * fase con su avance + sus tareas debajo con su estado. */
2513
+ const { readWorkflow } = await import('../workflow/state.js');
2514
+ const { computeWorkflowView } = await import('../workflow/view.js');
2515
+ const wf = await readWorkflow(ctx.projectRoot);
2516
+ const view = computeWorkflowView(wf);
2517
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify(view) });
2518
+ }
2519
+ catch {
2520
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'md', content: '# Workflow\n\nSin datos.\n' });
2521
+ }
2522
+ sendJson(res, 200, { ok: true, enabled: true, tabs });
2523
+ }
2524
+ /** Formato de id de pending para display (PND-NNN). */
2525
+ function formatPendId(id) {
2526
+ if (typeof id === 'number')
2527
+ return 'PND-' + String(id).padStart(3, '0');
2528
+ return String(id);
2529
+ }
2530
+ /** Las 6 fases del flujo yujin (18 pasos), para el grafo de
2531
+ * workflow. Cada fase es un nodo con sus pasos en el detalle. */
2532
+ const WORKFLOW_PHASES = [
2533
+ { id: 'phase_i', label: 'I. Descubrimiento', steps: ['1 intent', '2 vault inventory', '3 legacy audit', '3.5 brownfield'] },
2534
+ { id: 'phase_ii', label: 'II. Clarificacion', steps: ['4 funcional', '5 NFR cuantitativo', '6 NFR estructural', '7 NFR governance', '8 NFR operacional', '9 blocks'] },
2535
+ { id: 'phase_iii', label: 'III. Especificacion', steps: ['10 RFP', '11 arquitectura', '12 design system'] },
2536
+ { id: 'phase_iv', label: 'IV. Aprobacion', steps: ['13 gate de aprobacion'] },
2537
+ { id: 'phase_v', label: 'V. Construccion', steps: ['14 scaffolding', '15 block-dev', '16 tests'] },
2538
+ { id: 'phase_vi', label: 'VI. Entrega', steps: ['17 QA visual', '18 ship'] },
2539
+ ];
2540
+ /** Reune las keys "done" del workflow state (recursivo: cualquier
2541
+ * objeto con done_at cuenta como hecho). */
2542
+ function collectDoneWorkflowKeys(wf) {
2543
+ const done = new Set();
2544
+ const visit = (obj, keyPath) => {
2545
+ if (!obj || typeof obj !== 'object')
2546
+ return;
2547
+ const o = obj;
2548
+ if (o.done_at || o.completed_at || o.approved_at)
2549
+ done.add(keyPath);
2550
+ for (const [k, v] of Object.entries(o)) {
2551
+ if (v && typeof v === 'object')
2552
+ visit(v, k);
2553
+ }
2554
+ };
2555
+ visit(wf, 'root');
2556
+ return done;
2557
+ }
2558
+ /** Construye el grafo {nodes,edges} de las 6 fases del workflow,
2559
+ * coloreando done vs pendiente. Las labels de fase salen de las
2560
+ * keys canonicas workflow.phase.* (i18n). */
2561
+ async function buildWorkflowGraph(done, lang) {
2562
+ const { tForLang } = await import('../i18n/index.js');
2563
+ const phaseLabel = (id, fallback) => {
2564
+ const v = tForLang(lang, 'workflow.phase.' + id.replace('phase_', ''));
2565
+ return v && v !== ('workflow.phase.' + id.replace('phase_', '')) ? v : fallback;
2566
+ };
2567
+ const nodes = WORKFLOW_PHASES.map((p) => {
2568
+ const isDone = done.has(p.id) || p.steps.some((s) => done.has(s));
2569
+ const label = phaseLabel(p.id, p.label);
2570
+ return {
2571
+ id: p.id, label,
2572
+ weight: 0.8, kind: 'phase',
2573
+ color: isDone ? '#3fa85e' : '#c8a04d',
2574
+ detail: '**' + label + '**\n\n' + (isDone ? 'Estado: avanzada\n\n' : 'Estado: pendiente\n\n')
2575
+ + 'Pasos:\n' + p.steps.map((s) => '- ' + s).join('\n'),
2576
+ };
2577
+ });
2578
+ const edges = [];
2579
+ for (let i = 0; i < WORKFLOW_PHASES.length - 1; i++) {
2580
+ edges.push({ from: WORKFLOW_PHASES[i].id, to: WORKFLOW_PHASES[i + 1].id, kind: 'uses', label: 'siguiente' });
2581
+ }
2582
+ return { nodes, edges };
2583
+ }
2584
+ async function handleGraphRegenerate(_req, res, ctx) {
2585
+ const slug = ctx.projectSlug;
2586
+ if (!slug) {
2587
+ sendJson(res, 200, { ok: false, error: 'no active project slug' });
1250
2588
  return;
1251
2589
  }
1252
- const mode = body.mode;
1253
2590
  try {
1254
- await persistMode(ctx.projectRoot, mode);
2591
+ /* enrich=false: regen rapido sin pegar a npm (el boton es
2592
+ * interactivo; el enrich corre en el auto-ingest de boot). */
2593
+ const { regenerateAndPersist } = await import('../semantic_graph/derive.js');
2594
+ const stats = await regenerateAndPersist({ project_root: ctx.projectRoot, project_slug: slug, enrich: false });
2595
+ sendJson(res, 200, { ok: true, ...stats });
1255
2596
  }
1256
2597
  catch (err) {
1257
- sendJson(res, 500, { ok: false,
1258
- error: err instanceof Error ? err.message : String(err) });
2598
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
2599
+ }
2600
+ }
2601
+ /** PND-069 -- GET /api/forge/brain-models?provider=anthropic.
2602
+ * Lista de modelos del provider via su API (key del vault) con
2603
+ * fallback estatico. Nunca expone la key. */
2604
+ async function handleBrainModels(_req, res, url) {
2605
+ const { ALL_BRAIN_PROVIDERS } = await import('../core/brain_config.js');
2606
+ const { listProviderModels } = await import('../core/provider_models.js');
2607
+ const provider = url.searchParams.get('provider') ?? '';
2608
+ if (!ALL_BRAIN_PROVIDERS.includes(provider)) {
2609
+ sendJson(res, 400, { ok: false, error: 'unknown provider: ' + provider.slice(0, 40) });
1259
2610
  return;
1260
2611
  }
1261
- /* Set cookie so the next request renders in the new mode
2612
+ const result = await listProviderModels(provider);
2613
+ sendJson(res, 200, { ok: true, ...result });
2614
+ }
2615
+ /** alpha.59z.114 -- pull the canonical license row from the
2616
+ * HITO 4 worker (which reads the Polar webhook D1) and refresh
2617
+ * the local cache. Hit by the panel's "Sincronizar plan" button
2618
+ * + by the silent auto-sync on settings open when local plan
2619
+ * reads 'none' but user_handle is set. */
2620
+ async function handleLicenseSync(_req, res) {
2621
+ const { syncLicense } = await import('../license/sync.js');
2622
+ const report = await syncLicense({ verbose: false });
2623
+ if (!report.ok) {
2624
+ sendJson(res, 200, {
2625
+ ok: false,
2626
+ error: report.error ?? 'sync failed',
2627
+ http_status: report.http_status,
2628
+ local_plan_before: report.local_plan_before,
2629
+ });
2630
+ return;
2631
+ }
2632
+ sendJson(res, 200, {
2633
+ ok: true,
2634
+ server_plan: report.server_plan,
2635
+ local_plan_before: report.local_plan_before,
2636
+ local_plan_after: report.local_plan_after,
2637
+ last_validated_at: report.last_validated_at,
2638
+ });
2639
+ }
2640
+ /** alpha.59z.117 -- write user_handle to the local polar cache
2641
+ * + run an immediate sync. Returns the final cache state.
2642
+ * Used by the panel "Conectar tu cuenta Polar" first-run flow. */
2643
+ async function handleLicenseHandle(req, res) {
2644
+ let body = {};
2645
+ try {
2646
+ const raw = await readBody(req);
2647
+ body = raw ? JSON.parse(raw) : {};
2648
+ }
2649
+ catch {
2650
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
2651
+ return;
2652
+ }
2653
+ const handle = typeof body.handle === 'string' ? body.handle.trim().toLowerCase() : '';
2654
+ if (!handle || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(handle)) {
2655
+ sendJson(res, 400, { ok: false, error: 'handle must be a valid email address' });
2656
+ return;
2657
+ }
2658
+ const polar = await import('../core/polar.js');
2659
+ const current = await polar.readLicense();
2660
+ await polar.writeLicense({ ...current, user_handle: handle });
2661
+ const { syncLicense } = await import('../license/sync.js');
2662
+ const report = await syncLicense({ verbose: false });
2663
+ sendJson(res, 200, {
2664
+ ok: report.ok,
2665
+ handle_saved: handle,
2666
+ server_plan: report.server_plan,
2667
+ local_plan_before: report.local_plan_before,
2668
+ local_plan_after: report.local_plan_after,
2669
+ last_validated_at: report.last_validated_at,
2670
+ error: report.error,
2671
+ http_status: report.http_status,
2672
+ });
2673
+ }
2674
+ const ALLOWED_KEY_SLOTS = new Set([
2675
+ 'anthropic_api_key',
2676
+ 'openai_api_key',
2677
+ 'google_ai_key',
2678
+ 'google_stt_key',
2679
+ 'google_tts_key',
2680
+ 'whisper_api_key',
2681
+ 'elevenlabs_api_key',
2682
+ ]);
2683
+ async function handleReadFile(req, res, ctx) {
2684
+ const u = new URL(req.url ?? '/', 'http://localhost');
2685
+ const rel = (u.searchParams.get('path') || '').trim();
2686
+ if (!rel || rel.startsWith('/') || rel.includes('..') || rel.length > 500) {
2687
+ sendJson(res, 400, { ok: false, error: 'path must be a non-empty project-relative string (no .. , no leading slash)' });
2688
+ return;
2689
+ }
2690
+ const path = await import('node:path');
2691
+ const fs = await import('node:fs');
2692
+ const abs = path.resolve(ctx.projectRoot, rel);
2693
+ if (!isInside(abs, ctx.projectRoot)) {
2694
+ sendJson(res, 403, { ok: false, error: 'path escapes project root' });
2695
+ return;
2696
+ }
2697
+ try {
2698
+ const buf = await fs.promises.readFile(abs);
2699
+ /* Treat as text if it parses cleanly; otherwise refuse. */
2700
+ if (buf.length > 2_000_000) {
2701
+ sendJson(res, 413, { ok: false, error: 'file too large (>2 MB)' });
2702
+ return;
2703
+ }
2704
+ const text = buf.toString('utf-8');
2705
+ sendJson(res, 200, { ok: true, path: rel, content: text, size_bytes: buf.length });
2706
+ }
2707
+ catch (err) {
2708
+ sendJson(res, 404, { ok: false, error: 'file not found: ' + (err instanceof Error ? err.message : String(err)) });
2709
+ }
2710
+ }
2711
+ async function handleWriteFile(req, res, ctx) {
2712
+ let raw;
2713
+ try {
2714
+ raw = await readBody(req);
2715
+ }
2716
+ catch {
2717
+ sendJson(res, 400, { ok: false, error: 'unreadable body' });
2718
+ return;
2719
+ }
2720
+ let body;
2721
+ try {
2722
+ body = JSON.parse(raw);
2723
+ }
2724
+ catch {
2725
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2726
+ return;
2727
+ }
2728
+ const rel = typeof body.path === 'string' ? body.path.trim() : '';
2729
+ const content = typeof body.content === 'string' ? body.content : '';
2730
+ if (!rel || rel.startsWith('/') || rel.includes('..') || rel.length > 500) {
2731
+ sendJson(res, 400, { ok: false, error: 'path must be a non-empty project-relative string (no .. , no leading slash)' });
2732
+ return;
2733
+ }
2734
+ if (content.length > 5_000_000) {
2735
+ sendJson(res, 413, { ok: false, error: 'content too large (>5 MB)' });
2736
+ return;
2737
+ }
2738
+ const path = await import('node:path');
2739
+ const fs = await import('node:fs');
2740
+ const abs = path.resolve(ctx.projectRoot, rel);
2741
+ if (!isInside(abs, ctx.projectRoot)) {
2742
+ sendJson(res, 403, { ok: false, error: 'path escapes project root' });
2743
+ return;
2744
+ }
2745
+ try {
2746
+ /* Atomic write: tmp file + rename. */
2747
+ await fs.promises.mkdir(path.dirname(abs), { recursive: true });
2748
+ const tmp = abs + '.tmp.' + Date.now();
2749
+ await fs.promises.writeFile(tmp, content, 'utf-8');
2750
+ await fs.promises.rename(tmp, abs);
2751
+ const st = await fs.promises.stat(abs);
2752
+ sendJson(res, 200, { ok: true, path: rel, bytes_written: st.size });
2753
+ }
2754
+ catch (err) {
2755
+ sendJson(res, 500, { ok: false, error: 'write failed: ' + (err instanceof Error ? err.message : String(err)) });
2756
+ }
2757
+ }
2758
+ async function handleKeysSet(req, res) {
2759
+ let raw;
2760
+ try {
2761
+ raw = await readBody(req);
2762
+ }
2763
+ catch (err) {
2764
+ sendJson(res, 400, { ok: false, error: 'unreadable body' });
2765
+ return;
2766
+ }
2767
+ let body;
2768
+ try {
2769
+ body = JSON.parse(raw);
2770
+ }
2771
+ catch {
2772
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2773
+ return;
2774
+ }
2775
+ const slot = typeof body.slot === 'string' ? body.slot.trim() : '';
2776
+ if (!ALLOWED_KEY_SLOTS.has(slot)) {
2777
+ sendJson(res, 400, { ok: false, error: 'slot must be one of: ' + Array.from(ALLOWED_KEY_SLOTS).join(', ') });
2778
+ return;
2779
+ }
2780
+ /* value === '' OR null means clear. Otherwise it is the new
2781
+ * key value (min 8 chars, no leading/trailing whitespace). */
2782
+ const value = body.value === null ? '' : (typeof body.value === 'string' ? body.value.trim() : '');
2783
+ if (value !== '' && value.length < 8) {
2784
+ sendJson(res, 400, { ok: false, error: 'key value too short (min 8 chars)' });
2785
+ return;
2786
+ }
2787
+ /* alpha.44 C1: persist via the encrypted Vault helper. The
2788
+ * helper handles the legacy plaintext migration on first call
2789
+ * + atomic write + slot validation. */
2790
+ try {
2791
+ const { setKey } = await import('../core/provider_keys.js');
2792
+ await setKey(slot, value === '' ? null : value);
2793
+ }
2794
+ catch (err) {
2795
+ sendJson(res, 400, {
2796
+ ok: false,
2797
+ error: err instanceof Error ? err.message : String(err),
2798
+ });
2799
+ return;
2800
+ }
2801
+ sendJson(res, 200, { ok: true, slot, set: value !== '' });
2802
+ }
2803
+ async function handleSupportReport(req, res) {
2804
+ let raw;
2805
+ try {
2806
+ raw = await readBody(req);
2807
+ }
2808
+ catch (err) {
2809
+ sendJson(res, 400, { ok: false, error: 'unreadable body: ' + (err instanceof Error ? err.message : String(err)) });
2810
+ return;
2811
+ }
2812
+ let body;
2813
+ try {
2814
+ body = JSON.parse(raw);
2815
+ }
2816
+ catch {
2817
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2818
+ return;
2819
+ }
2820
+ const { validateInbound, recordReport } = await import('../support/reports.js');
2821
+ /* Support either a single report or an array (the panel
2822
+ * batches up to 50 + flushes every 30s). */
2823
+ const ua = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined;
2824
+ const items = Array.isArray(body) ? body : [body];
2825
+ let accepted = 0;
2826
+ let rejected = 0;
2827
+ for (const item of items.slice(0, 50)) {
2828
+ const report = validateInbound(item, ua ? { user_agent: ua } : {});
2829
+ if (report) {
2830
+ await recordReport(report);
2831
+ accepted++;
2832
+ }
2833
+ else {
2834
+ rejected++;
2835
+ }
2836
+ }
2837
+ sendJson(res, 200, { ok: true, accepted, rejected });
2838
+ }
2839
+ async function handleSupportReportList(req, res) {
2840
+ /* Local panel only -- we already gate the chat server to
2841
+ * loopback. No additional auth needed for read. */
2842
+ const { readReports } = await import('../support/reports.js');
2843
+ const u = new URL(req.url ?? '/', 'http://localhost');
2844
+ const limit = Math.min(500, Math.max(1, parseInt(u.searchParams.get('limit') ?? '100', 10) || 100));
2845
+ const reports = (await readReports()).slice(-limit);
2846
+ sendJson(res, 200, { ok: true, count: reports.length, reports });
2847
+ }
2848
+ async function handleTrustState(_req, res, _ctx) {
2849
+ const m = await loadTrustMetrics();
2850
+ /* Decay logic: if no events in last 30 days AND user has not
2851
+ * explicitly chosen to keep showing, suggest auto-collapse.
2852
+ * The panel decides what to do with the suggestion. */
2853
+ let shouldProposeCollapse = false;
2854
+ if (m.user_choice === 'default') {
2855
+ const last = m.last_event_at ? new Date(m.last_event_at).getTime() : 0;
2856
+ const proposedRecently = m.last_proposal_at
2857
+ ? (Date.now() - new Date(m.last_proposal_at).getTime()) < 7 * 24 * 3600 * 1000
2858
+ : false;
2859
+ const daysSince = last ? (Date.now() - last) / (24 * 3600 * 1000) : Infinity;
2860
+ shouldProposeCollapse = daysSince > 30 && !proposedRecently;
2861
+ }
2862
+ sendJson(res, 200, {
2863
+ ok: true,
2864
+ metrics: m,
2865
+ propose_auto_collapse: shouldProposeCollapse,
2866
+ });
2867
+ }
2868
+ async function handleVaultList(res) {
2869
+ try {
2870
+ const vault = await Vault.open({ configDir: configDir() });
2871
+ sendJson(res, 200, { ok: true, slots: vault.list() });
2872
+ }
2873
+ catch (err) {
2874
+ sendJson(res, 500, {
2875
+ ok: false,
2876
+ error: err instanceof Error ? err.message : String(err),
2877
+ });
2878
+ }
2879
+ }
2880
+ async function handleVaultSet(req, res) {
2881
+ let body;
2882
+ try {
2883
+ body = JSON.parse(await readBody(req));
2884
+ }
2885
+ catch {
2886
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2887
+ return;
2888
+ }
2889
+ if (typeof body.slot !== 'string' || typeof body.plaintext !== 'string') {
2890
+ sendJson(res, 400, { ok: false, error: 'slot + plaintext (both strings) required' });
2891
+ return;
2892
+ }
2893
+ /* Optional kind / expiry validation. */
2894
+ let kindOpt;
2895
+ if (body.kind !== undefined) {
2896
+ if (typeof body.kind !== 'string' || !SLOT_KINDS.includes(body.kind)) {
2897
+ sendJson(res, 400, { ok: false, error: 'invalid kind. Allowed: ' + SLOT_KINDS.join(', ') });
2898
+ return;
2899
+ }
2900
+ kindOpt = body.kind;
2901
+ }
2902
+ let expiryOpt;
2903
+ if (body.expiry === null) {
2904
+ expiryOpt = null;
2905
+ }
2906
+ else if (body.expiry !== undefined) {
2907
+ if (typeof body.expiry !== 'string') {
2908
+ sendJson(res, 400, { ok: false, error: 'expiry must be a string ISO 8601 or null' });
2909
+ return;
2910
+ }
2911
+ const d = new Date(body.expiry);
2912
+ if (isNaN(d.getTime())) {
2913
+ sendJson(res, 400, { ok: false, error: 'expiry is not a valid ISO 8601 timestamp' });
2914
+ return;
2915
+ }
2916
+ expiryOpt = d.toISOString();
2917
+ }
2918
+ try {
2919
+ const vault = await Vault.open({ configDir: configDir() });
2920
+ await vault.set(body.slot, body.plaintext, {
2921
+ ...(kindOpt ? { kind: kindOpt } : {}),
2922
+ ...(expiryOpt !== undefined ? { expiry: expiryOpt } : {}),
2923
+ });
2924
+ /* Echo metadata only -- never the plaintext, never the
2925
+ ciphertext. The 4-char prefix on list() is the only
2926
+ visible confirmation. */
2927
+ const meta = vault.list().find((s) => s.name === body.slot);
2928
+ sendJson(res, 200, { ok: true, slot: meta });
2929
+ }
2930
+ catch (err) {
2931
+ sendJson(res, 400, {
2932
+ ok: false,
2933
+ error: err instanceof Error ? err.message : String(err),
2934
+ });
2935
+ }
2936
+ }
2937
+ async function handleVaultCatalog(res, url) {
2938
+ const kindFilter = url.searchParams.get('kind');
2939
+ let entries = SLOT_CATALOG;
2940
+ if (kindFilter && SLOT_KINDS.includes(kindFilter)) {
2941
+ entries = entries.filter((e) => e.kind === kindFilter);
2942
+ }
2943
+ sendJson(res, 200, {
2944
+ ok: true,
2945
+ slots: entries.map((e) => ({
2946
+ name: e.name, kind: e.kind, description: e.description,
2947
+ ...(e.obtainUrl ? { obtain_url: e.obtainUrl } : {}),
2948
+ has_probe: !!e.test,
2949
+ })),
2950
+ });
2951
+ }
2952
+ async function handleModeGet(req, res, ctx) {
2953
+ const mode = await resolveModeForRequest(req, ctx.projectRoot);
2954
+ const projectResolved = await resolveMode({ projectRoot: ctx.projectRoot });
2955
+ sendJson(res, 200, {
2956
+ ok: true,
2957
+ mode,
2958
+ source: projectResolved.source,
2959
+ available: FORGE_MODES,
2960
+ });
2961
+ }
2962
+ async function handleModeSet(req, res, ctx) {
2963
+ let body;
2964
+ try {
2965
+ body = JSON.parse(await readBody(req));
2966
+ }
2967
+ catch {
2968
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
2969
+ return;
2970
+ }
2971
+ if (typeof body.mode !== 'string' || !FORGE_MODES.includes(body.mode)) {
2972
+ sendJson(res, 400, { ok: false, error: 'mode must be one of: ' + FORGE_MODES.join(', ') });
2973
+ return;
2974
+ }
2975
+ const mode = body.mode;
2976
+ try {
2977
+ await persistMode(ctx.projectRoot, mode);
2978
+ }
2979
+ catch (err) {
2980
+ sendJson(res, 500, { ok: false,
2981
+ error: err instanceof Error ? err.message : String(err) });
2982
+ return;
2983
+ }
2984
+ /* Set cookie so the next request renders in the new mode
1262
2985
  even before the user reloads. The cookie is the source of
1263
2986
  truth for the running browser; the persisted file is the
1264
2987
  source of truth for the NEXT session. */
@@ -1466,6 +3189,37 @@ async function handleChat(req, res, ctx) {
1466
3189
  sendJson(res, 400, { ok: false, error: 'messages[] required' });
1467
3190
  return;
1468
3191
  }
3192
+ /* alpha.59e -- SSE streaming mode. Client opts in via
3193
+ * body.stream=true. We keep the response open + emit events
3194
+ * during the tool loop instead of bundling everything in a
3195
+ * single JSON. */
3196
+ const streamMode = body.stream === true;
3197
+ let sseEmit = null;
3198
+ if (streamMode) {
3199
+ res.writeHead(200, {
3200
+ 'content-type': 'text/event-stream; charset=utf-8',
3201
+ 'cache-control': 'no-cache, no-transform',
3202
+ 'connection': 'keep-alive',
3203
+ 'x-accel-buffering': 'no',
3204
+ });
3205
+ sseEmit = (event, data) => {
3206
+ try {
3207
+ const payload = JSON.stringify(data ?? {});
3208
+ res.write('event: ' + event + '\n');
3209
+ res.write('data: ' + payload + '\n\n');
3210
+ }
3211
+ catch { /* ignore */ }
3212
+ };
3213
+ }
3214
+ /* alpha.51 -- session-level brain tier override from slash
3215
+ * commands (/eco /frontier /auto). null = let cost_router
3216
+ * decide per-turn. The router reads workflow state + verb
3217
+ * hints to pick the right tier; this override pins the
3218
+ * decision when the user signaled an explicit preference. */
3219
+ let brainOverride;
3220
+ if (body.brain_tier_override === 'eco' || body.brain_tier_override === 'frontier') {
3221
+ brainOverride = body.brain_tier_override;
3222
+ }
1469
3223
  const normalized = [];
1470
3224
  for (const m of msgs) {
1471
3225
  if (typeof m !== 'object' || m === null)
@@ -1492,19 +3246,482 @@ async function handleChat(req, res, ctx) {
1492
3246
  const pilotState = await readPilotState(ctx.projectRoot);
1493
3247
  const lastUserMsg = normalized.filter((m) => m.role === 'user').slice(-1)[0];
1494
3248
  const lastUserText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
1495
- const reply = await ctx.claude.chat({
1496
- messages: normalized,
1497
- system: buildSystemPrompt(ctx, reqMode, pilotState),
1498
- maxTokens: 1024,
1499
- tools: FORGE_TOOL_SPECS,
1500
- runTool: async (name, input) => {
1501
- const r = await runForgeTool(name, input, { projectRoot: ctx.projectRoot });
1502
- return {
1503
- result: r.result,
1504
- ...(r.is_error ? { is_error: true } : {}),
1505
- };
1506
- },
3249
+ /* alpha.51 -- ask the cost router which brain tier should
3250
+ * serve this turn. alpha.52 -- runtime clients for anthropic
3251
+ * + google_ai are wired. alpha.55 -- the router now runs a
3252
+ * reasoning_gate + turn_classifier when there is no cheap
3253
+ * signal so /auto becomes genuinely smart per turn. We pass
3254
+ * the user_text + recent history + attachment count + the
3255
+ * previous tier so the gate can detect sticky follow-ups
3256
+ * and inherit. */
3257
+ const { selectBrainForTurn } = await import('../core/cost_router.js');
3258
+ const { makeUnifiedClient } = await import('../llm/multi_provider_client.js');
3259
+ /* Pull previous tier from the last brain_routing.jsonl entry
3260
+ * via the store (best-effort). The store keeps the last
3261
+ * assistant reply; if it has a `brain_tier` annotation we use
3262
+ * it. When absent, the gate just skips the sticky check. */
3263
+ const recent = ctx.store.messages();
3264
+ const lastAssistant = recent.slice().reverse().find((m) => m.role === 'assistant');
3265
+ const previousTier = lastAssistant && (lastAssistant.brain_tier === 'eco' || lastAssistant.brain_tier === 'frontier')
3266
+ ? lastAssistant.brain_tier
3267
+ : undefined;
3268
+ const previousDocLoaded = !!(lastAssistant && lastAssistant.doc_loaded === true);
3269
+ const historyForGate = normalized.slice(-3).map((m) => ({
3270
+ role: m.role,
3271
+ content: typeof m.content === 'string' ? m.content : '',
3272
+ }));
3273
+ /* alpha.57 -- workflow phase wiring. If the user is in
3274
+ * Phase III (specs: RFP / Arch / Design) or Phase V (block
3275
+ * implementation), the router forces frontier. This is the
3276
+ * structural signal Pablo flagged: the workflow itself
3277
+ * knows when reasoning is needed. */
3278
+ let workflowPhase;
3279
+ try {
3280
+ const { readWorkflow } = await import('../workflow/state.js');
3281
+ const { inferActivePhase } = await import('../workflow/phase_inference.js');
3282
+ const wf = await readWorkflow(ctx.projectRoot);
3283
+ workflowPhase = inferActivePhase(wf);
3284
+ }
3285
+ catch { /* missing yujin.forge.json -> phase_i, no-op */ }
3286
+ const brainSelection = await selectBrainForTurn({
3287
+ user_override: brainOverride,
3288
+ caller_hint: 'handleChat',
3289
+ user_text: lastUserText,
3290
+ history: historyForGate,
3291
+ previous_tier: previousTier,
3292
+ previous_doc_loaded: previousDocLoaded,
3293
+ workflow_phase: workflowPhase,
1507
3294
  });
3295
+ /* alpha.55 -- cap exceeded check. If the tier the router
3296
+ * chose has zero remaining budget, return 429 BEFORE the
3297
+ * LLM call so the user never gets billed past their cap.
3298
+ * The panel modal (plan_limit_exceeded) lets them upgrade
3299
+ * or switch tier. BYOK plans have Infinity cap so this
3300
+ * is a no-op there. */
3301
+ {
3302
+ const { checkCap } = await import('../core/budget.js');
3303
+ const capCheck = await checkCap(brainSelection.tier);
3304
+ if (!capCheck.allowed) {
3305
+ if (sseEmit) {
3306
+ sseEmit('error', {
3307
+ kind: 'plan_limit_exceeded',
3308
+ tier: capCheck.tier,
3309
+ plan: capCheck.plan,
3310
+ extension_url: capCheck.extension_url ?? null,
3311
+ });
3312
+ res.end();
3313
+ }
3314
+ else {
3315
+ sendJson(res, 429, {
3316
+ ok: false,
3317
+ error: 'plan_limit_exceeded',
3318
+ tier: capCheck.tier,
3319
+ plan: capCheck.plan,
3320
+ cap: Number.isFinite(capCheck.cap) ? capCheck.cap : null,
3321
+ used: capCheck.used,
3322
+ remaining: Number.isFinite(capCheck.remaining) ? capCheck.remaining : null,
3323
+ extension_url: capCheck.extension_url ?? null,
3324
+ reason: capCheck.reason,
3325
+ message: 'Has agotado el cupo mensual del tier ' + capCheck.tier +
3326
+ ' en el plan ' + capCheck.plan + '. Cambia de tier con /eco o /frontier, ' +
3327
+ 'compra una extension, o usa BYOK.',
3328
+ });
3329
+ }
3330
+ return;
3331
+ }
3332
+ }
3333
+ /* alpha.59e -- emit turn_start event so the panel knows
3334
+ * which model is serving + how many rounds the loop can
3335
+ * spend before exhaustion. */
3336
+ if (sseEmit) {
3337
+ sseEmit('turn_start', {
3338
+ tier: brainSelection.tier,
3339
+ provider: brainSelection.provider,
3340
+ model: brainSelection.model,
3341
+ runtime_wired: brainSelection.runtime_wired,
3342
+ reason: brainSelection.reason,
3343
+ workflow_phase: workflowPhase ?? null,
3344
+ });
3345
+ }
3346
+ /* alpha.59e -- loop detection counter. We track (verb_id +
3347
+ * args fingerprint) per turn; if the same call is made 3+
3348
+ * times consecutively we abort + emit an error so the LLM
3349
+ * cannot infinite-loop when Danger Zone is on. */
3350
+ const callSig = [];
3351
+ const LOOP_REPEAT_THRESHOLD = 3;
3352
+ /* When the operator launched `yf chat --provider claude-code-cli`,
3353
+ * route EVERY turn through ctx.claude (a ClaudeCliClient) and
3354
+ * skip the unified cost_router branch -- otherwise the router
3355
+ * would pick Gemini/Haiku/etc and bill against Console. The
3356
+ * fallback branch below uses ctx.claude.chat() directly. */
3357
+ const unified = ctx.chatProvider === 'claude-code-cli'
3358
+ ? null
3359
+ : makeUnifiedClient(brainSelection);
3360
+ const callerForChat = unified
3361
+ ? unified
3362
+ : {
3363
+ chat: async (r) => {
3364
+ /* alpha.59z.162 PND-021 -- when routing through the cli
3365
+ * subprocess, thread sseEmit as onStreamEvent so the panel
3366
+ * sees tool_round_start / _done as `claude -p` invokes its
3367
+ * own tools. The HTTP path gets the same feedback via the
3368
+ * runTool seam; this closes the parity gap. */
3369
+ const reqWithStream = sseEmit && ctx.chatProvider === 'claude-code-cli'
3370
+ ? { ...r, onStreamEvent: sseEmit }
3371
+ : r;
3372
+ const claudeReply = await ctx.claude.chat(reqWithStream);
3373
+ return { ...claudeReply, brain: { ...brainSelection, runtime_wired: false } };
3374
+ },
3375
+ };
3376
+ /* alpha.59z.135 slice 11 -- panoramic graph snapshot. Compact
3377
+ * structural view of the project graph, cached per-slug with
3378
+ * hash refresh. The chat also receives the relevance ranking
3379
+ * below (graphSection); snapshot is the panorama, relevance is
3380
+ * the zoom. Graceful no-op on worker / vault failure. */
3381
+ let snapshotSection = '';
3382
+ try {
3383
+ const { buildGraphSnapshotSection } = await import('./graph_snapshot.js');
3384
+ const r = await buildGraphSnapshotSection({
3385
+ project_slug: ctx.projectSlug || '',
3386
+ /* alpha.59z.182 -- local only. The D1 worker fetcher is
3387
+ * parked until PND-016 closes P3 Platform billing/auth/
3388
+ * tier decisions. BYOK plain = no Yujin infra in chat hot
3389
+ * path. Slice 181 reintroduces a worker fetcher under P3. */
3390
+ fetcher: async (slug) => {
3391
+ if (!slug)
3392
+ return { ok: false, nodes: [] };
3393
+ try {
3394
+ const { listRelevant } = await import('../semantic_graph/store.js');
3395
+ const nodes = await listRelevant(slug, { limit: 200 });
3396
+ return {
3397
+ ok: nodes.length > 0,
3398
+ nodes: nodes.map((n) => ({ id: n.id, kind: n.kind, label: n.name })),
3399
+ };
3400
+ }
3401
+ catch {
3402
+ return { ok: false, nodes: [] };
3403
+ }
3404
+ },
3405
+ });
3406
+ if (r.ok && r.text)
3407
+ snapshotSection = '\n\n' + r.text;
3408
+ }
3409
+ catch (_) { /* swallow -- chat continues without snapshot */ }
3410
+ /* alpha.59p -- semantic graph injection. Pulls "what primitives
3411
+ * exist in this project" from the worker + appends to system
3412
+ * prompt so the LLM does NOT reinvent libs/components/protocols
3413
+ * already present. Graceful no-op when worker unreachable or
3414
+ * no vault key. */
3415
+ let graphSection = '';
3416
+ try {
3417
+ const { fetchGraphSection, injectionEnabled } = await import('./semantic_graph_inject.js');
3418
+ if (injectionEnabled()) {
3419
+ const lastUser = [...normalized].reverse().find((m) => m.role === 'user');
3420
+ const intent = lastUser && typeof lastUser.content === 'string'
3421
+ ? lastUser.content
3422
+ : (lastUser && Array.isArray(lastUser.content)
3423
+ ? lastUser.content.filter((b) => b && b.type === 'text').map((b) => b.text).join(' ')
3424
+ : '');
3425
+ if (intent && intent.length > 0) {
3426
+ const r = await fetchGraphSection({
3427
+ intent,
3428
+ project_slug: ctx.projectSlug || '',
3429
+ limit: 8,
3430
+ token_budget: 500,
3431
+ timeout_ms: 1500,
3432
+ });
3433
+ if (r.ok && r.rendered_text)
3434
+ graphSection = '\n\n' + r.rendered_text;
3435
+ }
3436
+ }
3437
+ }
3438
+ catch (_) { /* swallow -- chat continues without graph */ }
3439
+ /* alpha.59y.2 -- workflow state injection. Reads
3440
+ * yujin.forge.json/workflow.* and emits a compact "PROJECT
3441
+ * WORKFLOW STATE" block with rules of engagement (stay in
3442
+ * step, ASK on scope change, no silent advance). Disabled
3443
+ * via YF_WORKFLOW_INJECT_DISABLED=1. */
3444
+ let workflowSection = '';
3445
+ try {
3446
+ const { fetchWorkflowSection } = await import('./workflow_inject.js');
3447
+ const w = await fetchWorkflowSection({
3448
+ projectRoot: ctx.projectRoot,
3449
+ projectName: ctx.projectName,
3450
+ });
3451
+ if (w.ok && w.rendered_text)
3452
+ workflowSection = '\n\n' + w.rendered_text;
3453
+ }
3454
+ catch (_) { /* swallow -- chat continues without workflow inject */ }
3455
+ /* alpha.59z.180 PND-012 -- inject the per-project WIRING
3456
+ * registry into the dynamic suffix every turn. Reads from
3457
+ * ~/.yujin-forge/projects/<slug>/wiring.json (the local
3458
+ * registry, populated by `yf wiring discover` or the
3459
+ * yujin.wiring.discover verb). Empty when the file is missing
3460
+ * or every hook is disabled. Cap at 20 hooks to keep the
3461
+ * token budget bounded. */
3462
+ let wiringSection = '';
3463
+ try {
3464
+ const { buildWiringSection } = await import('./wiring_inject.js');
3465
+ const r = await buildWiringSection({ project_slug: ctx.projectSlug || '', limit: 20 });
3466
+ if (r.rendered_text)
3467
+ wiringSection = '\n\n' + r.rendered_text;
3468
+ }
3469
+ catch (_) { /* swallow -- chat continues without wiring inject */ }
3470
+ /* alpha.59z.187 PND-039 -- auto-context-load. Single compact
3471
+ * block with counts of graph nodes / wiring hooks / pending
3472
+ * items so the LLM can answer "que sabes del proyecto" / "subi
3473
+ * el know how" with concrete numbers without first invoking
3474
+ * 3-5 verbs. Bounded by 1200 chars. Graceful no-op when slug
3475
+ * empty / no data. */
3476
+ let projectContextSection = '';
3477
+ try {
3478
+ const { buildProjectContextSection } = await import('./project_context_inject.js');
3479
+ const r = await buildProjectContextSection({ project_slug: ctx.projectSlug || '', char_budget: 1200 });
3480
+ if (r.rendered_text)
3481
+ projectContextSection = r.rendered_text;
3482
+ }
3483
+ catch (_) { /* swallow */ }
3484
+ /* alpha.59z.15 HITO 6.3 -- prefetch global learnings from
3485
+ * the F103 worker. Falls back silently to '' on worker
3486
+ * unreachable. Cache is 5 min so this is cheap. */
3487
+ let globalLearnings = '';
3488
+ try {
3489
+ const { buildGlobalLearningsSection } = await import('../core/learnings_client.js');
3490
+ globalLearnings = await buildGlobalLearningsSection({ maxTokens: 2000 });
3491
+ }
3492
+ catch (_) { /* swallow -- learnings are additive */ }
3493
+ /* alpha.59z.26 F101 slice 2 + slice 5 sub-slice 3 -- prefetch
3494
+ * MCP bridges count + persistent enabled-state so the manifest
3495
+ * stub surfaces yujin.mcp.* verbs only when there is at least
3496
+ * one ENABLED bridge (Invariant 1: zero noise when Forge is
3497
+ * not in MCP mode OR every bridge is disabled). Registry has
3498
+ * 60s cache + state.ts has its own 5s cache so the call is
3499
+ * cheap. */
3500
+ let mcpAvailable = false;
3501
+ try {
3502
+ const { getActiveBridges } = await import('../mcp/bridge_registry.js');
3503
+ const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
3504
+ mcpAvailable = bridges.some((b) => b.enabled);
3505
+ }
3506
+ catch (_) { /* registry failure -> stay with MCP-off behaviour */ }
3507
+ /* alpha.59z.37 NAC-3 v3.x backend tools -- prefetch the
3508
+ * adopter app's nac3_backend section (task #23). Empty
3509
+ * string when the manifest does not declare it, when the
3510
+ * file is missing, or when parsing failed. */
3511
+ let backendToolsSection = '';
3512
+ try {
3513
+ const { loadBackendCatalogue, buildBackendToolsSection } = await import('../nac3/backend_tools.js');
3514
+ const parsed = await loadBackendCatalogue(ctx.projectRoot);
3515
+ if (parsed.catalogue && parsed.catalogue.tools.length > 0) {
3516
+ backendToolsSection = buildBackendToolsSection(parsed.catalogue);
3517
+ }
3518
+ }
3519
+ catch (_) { /* swallow */ }
3520
+ const chatRequest = {
3521
+ messages: normalized,
3522
+ /* alpha.59z.163 PND-027 -- system prompt now split into a
3523
+ * stable prefix (manifest, doctrines, design system, mode +
3524
+ * tier headers from buildSystemPrompt) and a dynamic suffix
3525
+ * (graph snapshots + workflow phase). The HTTP client tags
3526
+ * the prefix with cache_control:ephemeral so multi-turn
3527
+ * sessions hit the prompt cache instead of re-billing the
3528
+ * full ~9KB every iteration. Director sprint #11 measured
3529
+ * 1.8M tokens spent re-injecting; same applies BYOK-side. */
3530
+ system: buildSystemPrompt(ctx, reqMode, pilotState, {
3531
+ tier: brainSelection.tier,
3532
+ provider: brainSelection.provider,
3533
+ model: brainSelection.model,
3534
+ }, globalLearnings, mcpAvailable, backendToolsSection, lastUserText,
3535
+ /* PND-064 -- pizarron reachability for THIS mode. In CLI mode
3536
+ * the panel MCP bridge (slice B) makes it reachable via the
3537
+ * pizarron_create_tab tool; in API/managed it is native. */
3538
+ ctx.chatProvider === 'claude-code-cli'
3539
+ ? (ctx.panelPushToken ? 'mcp-bridge' : 'none')
3540
+ : 'native'),
3541
+ systemDynamic: projectContextSection + snapshotSection + graphSection + wiringSection + workflowSection,
3542
+ maxTokens: 1024,
3543
+ tools: FORGE_TOOL_SPECS,
3544
+ runTool: async (name, input) => {
3545
+ /* alpha.59e -- loop detection. Build a signature from
3546
+ * the verb id + arg hash; if the last N entries are
3547
+ * identical, refuse this call. */
3548
+ const verbId = typeof input.verb_id === 'string'
3549
+ ? String(input.verb_id)
3550
+ : name;
3551
+ const argsSig = verbId + ':' + JSON.stringify(input).slice(0, 200);
3552
+ callSig.push(argsSig);
3553
+ const recent = callSig.slice(-LOOP_REPEAT_THRESHOLD);
3554
+ if (recent.length === LOOP_REPEAT_THRESHOLD && recent.every((s) => s === argsSig)) {
3555
+ if (sseEmit) {
3556
+ sseEmit('error', {
3557
+ kind: 'loop_detected',
3558
+ verb_id: verbId,
3559
+ repeats: LOOP_REPEAT_THRESHOLD,
3560
+ message: 'Mismo verb invocado ' + LOOP_REPEAT_THRESHOLD + 'x consecutivo. Aborto el turn.',
3561
+ });
3562
+ }
3563
+ return {
3564
+ result: {
3565
+ error: 'loop_detected',
3566
+ verb_id: verbId,
3567
+ repeats: LOOP_REPEAT_THRESHOLD,
3568
+ hint: 'Probablemente el LLM esta llamando el mismo verbo con los mismos args en bucle. Reformula el prompt o ejecuta el paso manualmente.',
3569
+ },
3570
+ is_error: true,
3571
+ };
3572
+ }
3573
+ /* alpha.59e -- emit tool_round_start for the panel
3574
+ * progress bar. verb_hint = a short string the user
3575
+ * sees ("Leyendo seccion X", "Ejecutando git status",
3576
+ * etc) -- derived from verb_id + input. */
3577
+ let verbHint = verbId;
3578
+ if (verbId === 'yujin.shell.exec' && typeof input.command === 'string') {
3579
+ verbHint = 'shell: ' + String(input.command).slice(0, 60);
3580
+ }
3581
+ else if (verbId === 'yujin.reader.next-block') {
3582
+ verbHint = 'leyendo siguiente bloque';
3583
+ }
3584
+ else if (verbId.startsWith('yujin.reader.')) {
3585
+ verbHint = verbId.replace('yujin.reader.', 'reader/');
3586
+ }
3587
+ else if (verbId.startsWith('yujin.fs.')) {
3588
+ verbHint = verbId.replace('yujin.fs.', 'fs/');
3589
+ }
3590
+ else if (verbId.startsWith('yujin.pizarron.')) {
3591
+ verbHint = verbId.replace('yujin.pizarron.', 'pizarron/');
3592
+ }
3593
+ if (sseEmit) {
3594
+ const { extractVerbDetail } = await import('./claude_cli.js');
3595
+ sseEmit('tool_round_start', {
3596
+ round: callSig.length,
3597
+ verb_id: verbId,
3598
+ verb_hint: verbHint,
3599
+ detail: extractVerbDetail(verbId, input),
3600
+ });
3601
+ }
3602
+ const t0 = Date.now();
3603
+ /* alpha.59f -- inject streamEmit into ToolContext so
3604
+ * runShellExec (and future long-running verbs) can emit
3605
+ * progress events mid-execution. */
3606
+ const toolCtx = {
3607
+ projectRoot: ctx.projectRoot,
3608
+ projectSlug: ctx.projectSlug,
3609
+ ...(sseEmit ? { streamEmit: sseEmit } : {}),
3610
+ ...(ctx.reanchor ? { reanchor: ctx.reanchor } : {}),
3611
+ };
3612
+ const r = await runForgeTool(name, input, toolCtx);
3613
+ /* alpha.59i -- if the result is an approval envelope,
3614
+ * surface it as a dedicated SSE event so the panel
3615
+ * opens the modal NOW instead of waiting for the full
3616
+ * turn_complete payload. The envelope still flows
3617
+ * through the regular tool_round path so the LLM sees
3618
+ * it as a tool result (which is what triggers the
3619
+ * legacy flow: turn ends, user approves, next turn
3620
+ * carries the synthetic 'approved' message). */
3621
+ if (sseEmit && r && r.result
3622
+ && typeof r.result === 'object'
3623
+ && r.result
3624
+ .nac3_panel_approval_required === true) {
3625
+ sseEmit('approval_required', r.result);
3626
+ }
3627
+ if (sseEmit) {
3628
+ sseEmit('tool_round_done', {
3629
+ round: callSig.length,
3630
+ verb_id: verbId,
3631
+ is_error: r.is_error === true,
3632
+ duration_ms: Date.now() - t0,
3633
+ });
3634
+ }
3635
+ return {
3636
+ result: r.result,
3637
+ ...(r.is_error ? { is_error: true } : {}),
3638
+ };
3639
+ },
3640
+ };
3641
+ let reply = await callerForChat.chat(chatRequest);
3642
+ /* Echo brainSelection if the underlying client did not stamp
3643
+ * it (legacy ctx.claude path). */
3644
+ if (!reply.brain) {
3645
+ reply.brain = brainSelection;
3646
+ }
3647
+ /* alpha.57 -- post-reply escalation. If the eco tier produced
3648
+ * a confused / refused / truncated reply (hallucinated error
3649
+ * markers, dodge phrases, schema errors, length-vs-complexity
3650
+ * mismatch), re-issue the same turn at frontier and show the
3651
+ * user THAT reply instead. Cap-aware: only escalates if
3652
+ * frontier still has budget. Cap 1 escalation per turn. */
3653
+ let escalated = false;
3654
+ let escalationSignals = [];
3655
+ if (brainSelection.tier === 'eco' && !brainOverride) {
3656
+ const { detectIncomplete } = await import('../core/incomplete_detector.js');
3657
+ const detect = detectIncomplete({
3658
+ user_text: lastUserText,
3659
+ reply_text: reply.text,
3660
+ tool_rounds: reply.toolRounds,
3661
+ });
3662
+ if (detect.incomplete) {
3663
+ const { checkCap } = await import('../core/budget.js');
3664
+ const capCheck = await checkCap('frontier');
3665
+ if (capCheck.allowed) {
3666
+ const frontierSel = await selectBrainForTurn({
3667
+ user_override: 'frontier',
3668
+ caller_hint: 'escalation_after_eco[' + detect.signals.join(',').slice(0, 80) + ']',
3669
+ user_text: lastUserText,
3670
+ history: historyForGate,
3671
+ previous_tier: previousTier,
3672
+ previous_doc_loaded: previousDocLoaded,
3673
+ workflow_phase: workflowPhase,
3674
+ });
3675
+ /* PND-055 (CR-D11) -- en modo claude-code-cli el contrato
3676
+ * es "nunca facturar Console ni usar BYOK": cada turno va
3677
+ * por el subproceso `claude -p` (suscripcion). El escalado
3678
+ * NO debe construir un cliente unificado (Gemini/Anthropic
3679
+ * API), que facturaria por fuera de la suscripcion. Se
3680
+ * escala igual -- pero a traves de ctx.claude (el mismo
3681
+ * subproceso), preservando la funcionalidad de escalado
3682
+ * sin romper el modelo de billing. */
3683
+ const frontierClient = ctx.chatProvider === 'claude-code-cli'
3684
+ ? null
3685
+ : makeUnifiedClient(frontierSel);
3686
+ const frontierCaller = frontierClient
3687
+ ? frontierClient
3688
+ : {
3689
+ chat: async (r) => {
3690
+ const claudeReply = await ctx.claude.chat(r);
3691
+ return { ...claudeReply, brain: { ...frontierSel, runtime_wired: false } };
3692
+ },
3693
+ };
3694
+ try {
3695
+ /* alpha.58 -- rebuild system prompt for the frontier
3696
+ * call so the tier header reflects the actual model
3697
+ * serving the turn. */
3698
+ const frontierRequest = {
3699
+ ...chatRequest,
3700
+ system: buildSystemPrompt(ctx, reqMode, pilotState, {
3701
+ tier: frontierSel.tier,
3702
+ provider: frontierSel.provider,
3703
+ model: frontierSel.model,
3704
+ }, globalLearnings, mcpAvailable, backendToolsSection, lastUserText, ctx.chatProvider === 'claude-code-cli'
3705
+ ? (ctx.panelPushToken ? 'mcp-bridge' : 'none')
3706
+ : 'native'),
3707
+ };
3708
+ const frontierReply = await frontierCaller.chat(frontierRequest);
3709
+ reply = frontierReply;
3710
+ escalated = true;
3711
+ escalationSignals = detect.signals;
3712
+ }
3713
+ catch (escErr) {
3714
+ /* Escalation failure -> stick with the eco reply.
3715
+ * Log + continue. */
3716
+ const msg = escErr instanceof Error ? escErr.message : String(escErr);
3717
+ escalationSignals = [...detect.signals, 'escalation_failed[' + msg.slice(0, 60) + ']'];
3718
+ }
3719
+ }
3720
+ else {
3721
+ escalationSignals = [...detect.signals, 'escalation_blocked_by_cap'];
3722
+ }
3723
+ }
3724
+ }
1508
3725
  // Append the latest user message + the assistant's reply to
1509
3726
  // the persistent transcript. Older messages may already be
1510
3727
  // present from previous turns; we only need to capture the
@@ -1519,7 +3736,36 @@ async function handleChat(req, res, ctx) {
1519
3736
  ctx.store.append(lastUser);
1520
3737
  }
1521
3738
  }
1522
- ctx.store.append({ role: 'assistant', content: reply.text });
3739
+ /* alpha.56 -- tag the assistant turn with the tier that
3740
+ * served it. The next turn's reasoning_gate reads this via
3741
+ * lastAssistant.brain_tier to apply sticky inertia (a
3742
+ * follow-up to a frontier turn stays in frontier unless the
3743
+ * new user message has strong eco signals). Also detect
3744
+ * whether the turn loaded a document (reader.* / fs.read-*)
3745
+ * so the next router call routes to frontier even on a
3746
+ * short follow-up like "Medium, Python" (because the user
3747
+ * is about to ask the model to reason over the doc). */
3748
+ const docLoaded = reply.toolRounds.some((r) => {
3749
+ const input = r.input;
3750
+ const vid = (input && typeof input === 'object' && 'verb_id' in input)
3751
+ ? input.verb_id
3752
+ : undefined;
3753
+ const v = typeof vid === 'string' ? vid : '';
3754
+ return (v === 'yujin.fs.read-file' ||
3755
+ v === 'yujin.fs.read-project-file' ||
3756
+ v.startsWith('yujin.reader.'));
3757
+ });
3758
+ /* alpha.57 -- when the turn escalated, the persisted tier is
3759
+ * the FINAL one (frontier), not the eco initial one. This
3760
+ * way the next turn's sticky inertia + previous_tier check
3761
+ * see the canonical decision. */
3762
+ const finalTier = escalated ? 'frontier' : brainSelection.tier;
3763
+ ctx.store.append({
3764
+ role: 'assistant',
3765
+ content: reply.text,
3766
+ brain_tier: finalTier,
3767
+ ...(docLoaded ? { doc_loaded: true } : {}),
3768
+ });
1523
3769
  void ctx.store.flush();
1524
3770
  /* Pilot first-run auto-complete (SQ 0.10). If Pilot is
1525
3771
  not yet completed, decide based on the user's first
@@ -1533,297 +3779,699 @@ async function handleChat(req, res, ctx) {
1533
3779
  catch { /* non-fatal */ }
1534
3780
  }
1535
3781
  }
1536
- sendJson(res, 200, {
3782
+ /* alpha.59z.186 PND-020 Phase 2b -- LLM-claim post-turn hook.
3783
+ * Scan the reply for import statements in fenced code blocks
3784
+ * + upsert the libraries the LLM mentions but the local graph
3785
+ * doesn't know yet (confidence='claimed', weight=0.3 so they
3786
+ * don't dominate ranking until the user promotes them).
3787
+ * Fire-and-forget; never throws. */
3788
+ if (ctx.projectSlug) {
3789
+ const slug = ctx.projectSlug;
3790
+ void (async () => {
3791
+ try {
3792
+ const { ingestClaimsFromReply } = await import('../semantic_graph/llm_claim.js');
3793
+ await ingestClaimsFromReply(slug, reply.text);
3794
+ }
3795
+ catch { /* swallow */ }
3796
+ })();
3797
+ }
3798
+ /* alpha.59z.188 PND-036 + PND-037 -- post-reply quality
3799
+ * detectors. Two separate failure modes:
3800
+ * - "voy a configurar X" without any tool_use (completion
3801
+ * hallucination).
3802
+ * - "no puedo X" when the user asked for X AND a verb exists
3803
+ * that does X (unwarranted refusal).
3804
+ * Both trigger a one-shot next-turn frontier hint so the
3805
+ * router escalates the conversation. Pure heuristics, swallow
3806
+ * all errors. */
3807
+ try {
3808
+ const { checkCompletionHallucination } = await import('./completion_hallucination_detector.js');
3809
+ const { checkUnwarrantedRefusal } = await import('./refusal_detector.js');
3810
+ const hCheck = checkCompletionHallucination(reply.text, reply.toolRounds.length);
3811
+ /* Pull the most recent user message text for the refusal
3812
+ * detector. */
3813
+ const lastUser = [...normalized].reverse().find((m) => m.role === 'user');
3814
+ const lastUserContent = lastUser?.content;
3815
+ const lastUserText = typeof lastUserContent === 'string'
3816
+ ? lastUserContent
3817
+ : Array.isArray(lastUserContent)
3818
+ ? lastUserContent
3819
+ .filter((b) => !!b && typeof b === 'object')
3820
+ .filter((b) => b.type === 'text')
3821
+ .map((b) => b.text ?? '')
3822
+ .join(' ')
3823
+ : '';
3824
+ const rCheck = checkUnwarrantedRefusal(reply.text, lastUserText);
3825
+ if (hCheck.is_hallucinated || rCheck.is_unwarranted_refusal || hCheck.is_confused) {
3826
+ void (async () => {
3827
+ try {
3828
+ const path = await import('node:path');
3829
+ const os = await import('node:os');
3830
+ const fs = await import('node:fs/promises');
3831
+ const dir = process.env['YF_CONFIG_DIR']
3832
+ || (process.env['HOME'] || process.env['USERPROFILE'] || os.homedir() + '/.yujin-forge');
3833
+ const root = dir.endsWith('.yujin-forge') ? dir : path.join(dir, '.yujin-forge');
3834
+ await fs.mkdir(root, { recursive: true });
3835
+ const kind = hCheck.is_hallucinated ? 'completion_hallucination'
3836
+ : rCheck.is_unwarranted_refusal ? 'unwarranted_refusal'
3837
+ : 'confusion_signal';
3838
+ const line = JSON.stringify({
3839
+ ts: new Date().toISOString(), kind,
3840
+ matched_phrase: hCheck.is_hallucinated ? hCheck.matched_phrase : rCheck.matched_refusal,
3841
+ topic: rCheck.topic || null,
3842
+ available_verbs: rCheck.available_verbs,
3843
+ reply_chars: reply.text.length,
3844
+ tier_served: reply.brain?.tier ?? brainSelection.tier,
3845
+ }) + '\n';
3846
+ await fs.appendFile(path.join(root, 'hallucinations.jsonl'), line, 'utf-8');
3847
+ await fs.writeFile(path.join(root, 'next_turn_frontier_hint.json'), JSON.stringify({
3848
+ reason: kind,
3849
+ at: new Date().toISOString(),
3850
+ matched: hCheck.is_hallucinated ? hCheck.matched_phrase : rCheck.matched_refusal,
3851
+ topic: rCheck.topic || undefined,
3852
+ }, null, 2), 'utf-8');
3853
+ }
3854
+ catch { /* swallow */ }
3855
+ })();
3856
+ }
3857
+ }
3858
+ catch { /* swallow */ }
3859
+ /* alpha.59e -- build the response payload once, then either
3860
+ * stream it as a turn_complete SSE event OR send the legacy
3861
+ * JSON response. */
3862
+ const responsePayload = {
1537
3863
  ok: true,
1538
3864
  message: { role: 'assistant', text: reply.text },
1539
3865
  tokens: { in: reply.tokensIn, out: reply.tokensOut },
1540
3866
  model: reply.model,
3867
+ /* alpha.43 -- manifest version stamp so the panel can detect
3868
+ drift after a server reboot + force-refetch its cached
3869
+ manifest in the background. */
3870
+ nac3_manifest_version: nac3Manifest().version,
3871
+ /* alpha.51 + alpha.57 -- which brain tier served this turn.
3872
+ * Panel can show a small badge so the user knows whether
3873
+ * the answer came from eco or frontier. Keys redacted
3874
+ * server-side. When the turn escalated from eco to frontier
3875
+ * (alpha.57 post-reply detector), `escalated` is true and
3876
+ * `escalation_signals` carries the detector verdict for
3877
+ * audit. The final reply shown to the user is the frontier
3878
+ * one; only it gets persisted to the store. */
3879
+ brain_routing: {
3880
+ tier: (reply.brain?.tier) || brainSelection.tier,
3881
+ provider: (reply.brain?.provider) || brainSelection.provider,
3882
+ model: (reply.brain?.model) || brainSelection.model,
3883
+ runtime_wired: (reply.brain?.runtime_wired) ?? brainSelection.runtime_wired,
3884
+ reason: (reply.brain?.reason) || brainSelection.reason,
3885
+ workflow_phase: workflowPhase ?? null,
3886
+ escalated,
3887
+ escalation_signals: escalationSignals.length > 0 ? escalationSignals : undefined,
3888
+ },
1541
3889
  /* Slice 4: surface the audit trail so the panel can render
1542
3890
  the action trace (which tools Claude called + with what
1543
- args + what came back). Empty array when no tools fired. */
1544
- tool_rounds: reply.toolRounds.map((r) => ({
1545
- tool: r.tool,
1546
- input: r.input,
1547
- display: typeof r.result === 'object' && r.result !== null && 'display' in r.result
3891
+ args + what came back). Empty array when no tools fired.
3892
+ alpha.35 fix: include the full `result` object so the
3893
+ panel can intercept tools like forge_show_in_pizarron
3894
+ (which needs the item_id + kind + content). The Claude
3895
+ loop already caps result size; we further cap the JSON
3896
+ here at 200KB defensive to keep the HTTP response from
3897
+ blowing up. */
3898
+ tool_rounds: reply.toolRounds.map((r) => {
3899
+ const display = typeof r.result === 'object' && r.result !== null && 'display' in r.result
1548
3900
  ? r.result.display
1549
- : undefined,
1550
- is_error: r.is_error ?? false,
1551
- })),
1552
- });
3901
+ : undefined;
3902
+ let result = r.result;
3903
+ try {
3904
+ const serialised = JSON.stringify(result ?? null);
3905
+ if (serialised.length > 200_000) {
3906
+ result = { _truncated: true, _bytes: serialised.length };
3907
+ }
3908
+ }
3909
+ catch {
3910
+ result = { _unserialisable: true };
3911
+ }
3912
+ return {
3913
+ tool: r.tool,
3914
+ input: r.input,
3915
+ display,
3916
+ is_error: r.is_error ?? false,
3917
+ result,
3918
+ };
3919
+ }),
3920
+ };
3921
+ if (sseEmit) {
3922
+ sseEmit('turn_complete', responsePayload);
3923
+ res.end();
3924
+ }
3925
+ else {
3926
+ sendJson(res, 200, responsePayload);
3927
+ }
1553
3928
  }
1554
3929
  catch (err) {
1555
3930
  if (err instanceof ConfigurationError) {
1556
- sendJson(res, 503, {
1557
- ok: false,
1558
- code: 'no_api_key',
1559
- error: err.message,
1560
- });
3931
+ if (sseEmit) {
3932
+ sseEmit('error', { kind: 'no_api_key', message: err.message });
3933
+ res.end();
3934
+ }
3935
+ else {
3936
+ sendJson(res, 503, {
3937
+ ok: false,
3938
+ code: 'no_api_key',
3939
+ error: err.message,
3940
+ });
3941
+ }
1561
3942
  return;
1562
3943
  }
1563
3944
  if (err instanceof ClaudeApiError) {
3945
+ if (sseEmit) {
3946
+ sseEmit('error', { kind: 'claude_api_error', message: err.message });
3947
+ res.end();
3948
+ }
3949
+ else {
3950
+ sendJson(res, 502, {
3951
+ ok: false,
3952
+ code: 'claude_api_error',
3953
+ error: err.message,
3954
+ });
3955
+ }
3956
+ return;
3957
+ }
3958
+ if (sseEmit) {
3959
+ sseEmit('error', { kind: 'internal', message: err instanceof Error ? err.message : String(err) });
3960
+ res.end();
3961
+ }
3962
+ else {
1564
3963
  sendJson(res, 502, {
1565
3964
  ok: false,
1566
- code: 'claude_api_error',
1567
- error: err.message,
3965
+ error: err instanceof Error ? err.message : String(err),
1568
3966
  });
1569
- return;
1570
3967
  }
1571
- sendJson(res, 502, {
1572
- ok: false,
1573
- error: err instanceof Error ? err.message : String(err),
1574
- });
1575
3968
  }
1576
3969
  }
1577
- function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
3970
+ export function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
1578
3971
  pilot_completed: true, target_pending: false, mode_pending: false,
1579
- }) {
3972
+ },
3973
+ /** alpha.58 -- which brain tier + provider + model is serving
3974
+ * this turn. Allows the LLM (especially eco-tier ones like
3975
+ * Gemini Flash Lite) to know its own role and be more
3976
+ * cautious / defer to nac3_invoke when in doubt. */
3977
+ brain,
3978
+ /** alpha.59z.15 HITO 6.3 -- the GLOBAL LEARNINGS section,
3979
+ * pre-fetched async by the caller via
3980
+ * buildGlobalLearningsSection() from learnings_client.ts.
3981
+ * Empty string -> no learnings (worker unreachable or no
3982
+ * active lessons). buildSystemPrompt stays sync; the caller
3983
+ * awaits the fetch before invoking us. */
3984
+ globalLearningsSection = '',
3985
+ /** alpha.59z.26 F101 slice 2 -- whether MCP bridges are
3986
+ * discovered in the current session. When false (default),
3987
+ * the manifest stub filters out every yujin.mcp.* verb so
3988
+ * the LLM never sees them (Invariant 1: Forge without MCP
3989
+ * behaves identically to pre-F101). When true, the LLM gets
3990
+ * the MCP-meta verbs (list-bridges, show-tools, toggle,
3991
+ * set-creds, discover-tools) in its stub. The caller
3992
+ * prefetches via getActiveBridges() before invoking us. */
3993
+ mcpAvailable = false,
3994
+ /** alpha.59z.37 NAC-3 v3.x backend tools (task #23) -- pre-built
3995
+ * app.* verbs section, ready to splice into the prompt. The
3996
+ * caller prefetches via loadBackendCatalogue() +
3997
+ * buildBackendToolsSection() before invoking us so the prompt
3998
+ * build stays sync. Empty string -> no section appears
3999
+ * (Invariant: identical prompt to v3.0 when adopter does not
4000
+ * declare nac3_backend). Slice 1 only surfaces the verbs in
4001
+ * the stub; slice 2 wires dispatch + auth + audit. */
4002
+ backendToolsSection = '',
4003
+ /** alpha.59z.172 PND-022 -- first user message of the active turn.
4004
+ * When the pilot warmup would fire and the message is detected
4005
+ * out-of-scope (music, poetry, image, abstract explanation,
4006
+ * casual chat), welcomePromptSuffix returns empty so the user
4007
+ * is not asked target/mode for content unrelated to software. */
4008
+ firstUserMessage = '',
4009
+ /** PND-064 -- how the pizarron is reachable in the ACTIVE mode:
4010
+ * - 'native' : anthropic-api / yujin_managed. The server
4011
+ * runs runTool and the panel intercepts
4012
+ * nac3_panel_dispatch. Use yujin.pizarron.*.
4013
+ * - 'mcp-bridge' : claude-code-cli WITH the panel MCP bridge
4014
+ * (slice B). The subprocess reaches the
4015
+ * pizarron via the pizarron_create_tab MCP
4016
+ * tool, NOT the yujin.pizarron.* verbs.
4017
+ * - 'none' : claude-code-cli WITHOUT a working bridge.
4018
+ * The pizarron is unreachable; the model must
4019
+ * not claim it created a tab or verified the
4020
+ * panel. */
4021
+ panelMode = 'native') {
4022
+ /* alpha.58 -- tier awareness header. When the request is
4023
+ * served by an eco-tier model (Gemini Flash Lite, Haiku),
4024
+ * the model is told its role explicitly so it can be more
4025
+ * conservative + defer to nac3_invoke instead of guessing. */
4026
+ const tierHeader = brain
4027
+ ? (brain.tier === 'eco'
4028
+ ? '[BRAIN TIER: eco (' + brain.provider + '/' + brain.model + ')]. ' +
4029
+ 'You are the cheap fast tier. The router will ESCALATE you to a frontier model if your reply looks confused, refused, or truncated. Therefore: ' +
4030
+ 'when a turn requires multi-step reasoning, multi-file changes, code generation, architecture design, or long-form drafting, DO NOT guess -- ' +
4031
+ 'reply with a short clarifying question OR invoke nac3_invoke to defer to a structured handler. ' +
4032
+ 'For simple verb invocations (open a tab, list pipelines, show budget, short greetings, factual lookups), proceed normally and do the work.'
4033
+ : '[BRAIN TIER: frontier (' + brain.provider + '/' + brain.model + ')]. ' +
4034
+ 'You are the high-capability tier. The user is paying for reasoning quality. ' +
4035
+ 'When you receive a turn the eco tier could not handle (post-escalation), give the well-reasoned answer the user expected. ' +
4036
+ 'You have full latitude for design, code generation, multi-step plans.')
4037
+ : '';
1580
4038
  return [
1581
- 'You are Yujin Forge -- a voice-first NAC-3 React development framework that acts as a full IDE for the user\'s project. Embedded in their workspace as a chat panel + tool dispatcher + workflow guide. The user pays USD 10/mo (BYOK for LLM). You exist to deliver real software, not just answer questions.',
4039
+ 'You are Yujin Forge -- a voice-first NAC-3 React development framework that acts as a full IDE for the user\'s project. Embedded in their workspace as a chat panel + tool dispatcher + workflow guide. You exist to deliver real software, not just answer questions.',
1582
4040
  '',
4041
+ '=================================================================',
4042
+ 'IDENTITY INVARIANT (alpha.59z.189 PND-043 -- non-negotiable)',
4043
+ '=================================================================',
4044
+ 'You are Yujin Forge running inside a chat panel. You are NOT',
4045
+ 'Claude Code, NOT a regular Claude instance, NOT ChatGPT, NOT',
4046
+ 'Gemini, NOT Cursor / Copilot. Saying "soy Claude Code" or',
4047
+ '"estoy corriendo como una instancia regular de Claude" or "no',
4048
+ 'tengo los verbos NAC-3 enchufados" is FACTUALLY WRONG -- your',
4049
+ 'runtime DOES have the NAC-3 manifest wired (yujin.fs.*,',
4050
+ 'yujin.git.*, yujin.graph.*, yujin.wiring.*, yujin.pending.*,',
4051
+ 'yujin.doctrine.*, yujin.nac3.*, plus the rest in the manifest',
4052
+ 'stub below). When in doubt about what is available, call',
4053
+ 'yujin.manifest.read or yujin.nac3.discover-schemas before',
4054
+ 'claiming "no tengo X". Falsely claiming otherwise destroys',
4055
+ 'user trust + triggers a quality detector that escalates the',
4056
+ 'next turn to a stronger model.',
4057
+ '',
4058
+ ...(tierHeader ? [tierHeader, ''] : []),
1583
4059
  'LEMA YUJIN: "La tecnologia desaparece. El sistema aprende de vos, no vos del sistema." Toda decision se evalua contra esto.',
1584
4060
  '',
1585
4061
  '=================================================================',
1586
- 'CORE PRINCIPLES (from docs/SQ.md -- Estandares de Calidad)',
4062
+ 'CORE PRINCIPLES (docs/SQ.md)',
1587
4063
  '=================================================================',
1588
4064
  '',
1589
- '1. EL TIEMPO DEL USUARIO ES SAGRADO. Atrapa los bugs basicos',
1590
- ' ANTES de que el usuario los vea: syntax errors, broken buttons,',
1591
- ' 404s, missing imports, modales que no abren, console errors.',
1592
- ' El usuario solo evalua decisiones de alta complejidad funcional',
1593
- ' ("este UX se siente intuitivo?", "esta voz suena bien?",',
1594
- ' "este flujo cubre el caso de uso?"). Para todo lo mecanico,',
1595
- ' YO atrapo / YO arreglo.',
4065
+ 'SQ 1 -- USER TIME IS SACRED. Catch trivial bugs (syntax, 404s,',
4066
+ ' missing imports, console errors, broken modals) BEFORE the user.',
4067
+ ' User judges only high-complexity decisions ("does this UX feel',
4068
+ ' intuitive?", "does this flow cover the use case?"). Everything',
4069
+ ' mechanical: you catch, you fix.',
1596
4070
  '',
1597
- '2. CERO DEUDA TECNICA desde el commit 0. Bugs descubiertos en el',
1598
- ' camino van a root fix + spec de regresion en LA MISMA sesion.',
1599
- ' Nunca "lo arreglo despues".',
4071
+ 'SQ 2 -- ZERO TECH DEBT from commit 0. Bugs found in-flight get a',
4072
+ ' root fix + regression spec in the SAME session. No "later".',
1600
4073
  '',
1601
- '3. ASCII-PURE en codigo de producto. Sin acentos en source files,',
1602
- ' sin emojis, sin em-dashes, sin caracteres > 0x7F en .ts / .tsx',
1603
- ' / .py / .php / .sql / .json fuera de _i18n.',
4074
+ 'SQ 3 -- ASCII-PURE in product code. No accents, emojis, em-dashes,',
4075
+ ' no chars > 0x7F in .ts/.tsx/.py/.php/.sql/.json (outside _i18n).',
1604
4076
  '',
1605
- '4. DOCUMENTACION SINCRONIZADA. Cada commit cross-linkea con las',
1606
- ' secciones de RFP / Architecture / Solution Design / User Manual',
1607
- ' afectadas. Divergencia entre codigo y docs = bug.',
4077
+ 'SQ 4 -- DOCS IN SYNC. Every commit cross-links the RFP / Arch /',
4078
+ ' Solution Design / User Manual sections it affects. Doc/code',
4079
+ ' drift = bug.',
1608
4080
  '',
1609
- '5. MATCH THE SCOPE. Si el usuario abre con un bug, arregla SOLO',
1610
- ' ese bug. No empieces un refactor "de paso". Si el usuario',
1611
- ' abre con una feature, no toques codigo no relacionado.',
4081
+ 'SQ 5 -- MATCH THE SCOPE. Bug ask = fix that bug only, no refactor',
4082
+ ' "de paso". Feature ask = no unrelated code touched.',
1612
4083
  '',
1613
- '6. RESPONDE EN EL IDIOMA DEL USUARIO. Default a espanol si no',
1614
- ' esta claro. Replies cortas + conversacionales. Una pregunta',
1615
- ' clarificadora a la vez, no avalanchas.',
4084
+ 'SQ 6 -- REPLY IN USER LANGUAGE. Default Spanish if unclear. **WHEN',
4085
+ ' IN SPANISH USE CORRECT ACCENTS** (a e i o u, n, ?, !) -- TTS',
4086
+ ' needs them. Short conversational replies. One clarifying question',
4087
+ ' at a time, never avalanches.',
1616
4088
  '',
1617
- '7. PRODUCER/CONSUMER SYMMETRY (SQ Sec 14, MANDATORY). Antes de',
1618
- ' COMMIT cualquier cambio que toque una estructura compartida',
1619
- ' (vault slot, JSON key, env var, tool name, type id, endpoint',
1620
- ' contract, manifest field), corre forge_audit_consumers(<concepto>)',
1621
- ' y si hay readers desincronizados, FIX en el MISMO commit. NO',
1622
- ' pidas al usuario interpretar el audit. El usuario nunca ve esto.',
1623
- ' Lee docs/GOTCHAS.md al boot; cuando un cambio matchea un patron',
1624
- ' AP-1/AP-2/AP-3/AP-4 registrado, refuse el commit y enlaza la',
1625
- ' entrada. Esta regla nacio el 2026-05-29 despues de 3 regresiones',
1626
- ' BYOK seguidas (G-2026-05-29 a/b/c en GOTCHAS).',
4089
+ 'SQ 7 -- PRODUCER/CONSUMER SYMMETRY (MANDATORY, SQ Sec 14). Before',
4090
+ ' ANY commit that touches a shared structure (vault slot, JSON',
4091
+ ' key, env var, tool name, type id, endpoint contract, manifest',
4092
+ ' field) -- run forge_audit_consumers(<concept>). If readers are',
4093
+ ' out of sync, FIX in the SAME commit. Do NOT ask user to interpret',
4094
+ ' the audit. Read docs/GOTCHAS.md at boot; when a change matches a',
4095
+ ' registered AP-1/2/3/4 pattern, refuse the commit + link the entry.',
1627
4096
  '',
1628
- '=================================================================',
1629
- 'PRODUCT WORKFLOW (from docs/PRODUCT_WORKFLOW.md)',
1630
- '=================================================================',
4097
+ 'SQ 8 -- AUTONOMY / TEST THINGS YOURSELF (MANDATORY, SQ Sec 15).',
4098
+ ' Before asking "could you run X and paste the output?" STOP and',
4099
+ ' rewrite as "I will run X, capture, diagnose". You have tools for',
4100
+ ' CLI, file reads, log reads, HTTP, JSON parse. Use them. User only',
4101
+ ' gets involved when test needs: real hardware (mic), subjective',
4102
+ ' judgment ("looks good?"), $$/scope decision, or user-only creds.',
4103
+ ' Otherwise: diagnose yourself + report the diagnosis, not the path.',
1631
4104
  '',
1632
- 'You guide the user through 6 phases / 18 steps. Pick the right',
1633
- 'phase to engage based on what the user is asking. If they jump',
1634
- 'mid-phase, you respect that but mention which phase they skipped',
1635
- 'and what risk that carries.',
4105
+ 'SQ 9 -- VERIFY FACTUAL CLAIMS ABOUT THE WORKSPACE (MANDATORY,',
4106
+ ' added 2026-06-06 alpha.59z.158, extended alpha.59z.174 PND-023).',
1636
4107
  '',
1637
- 'PHASE I -- INTAKE (steps 1-3.5)',
1638
- ' 1. Complexity triage Simple / Medium / Full tier',
1639
- ' 2. Vault keys validation 13 credential families, BYOK',
1640
- ' 3. Discover intent migrate / new / modify;',
1641
- ' desktop / mobile / both;',
1642
- ' chat / voice / document-paste',
1643
- ' 3.5 Audit existing artifacts if migrating legacy',
4108
+ ' 9a -- User-stated facts. When the user states something factual',
4109
+ ' about the current workspace -- "no tiene X", "falta Y",',
4110
+ ' "ya existe Z", "el bug esta en archivo W" -- DO NOT take it at',
4111
+ ' face value if it would lead you to write code, add files, or',
4112
+ ' recommend an action. Verify with native verbs FIRST:',
4113
+ ' - "no tiene vault" -> yujin.fs.find({glob:"**/vault*"}) or fs.list-files',
4114
+ ' - "falta tests" -> yujin.fs.list-files({subdir:"tests"})',
4115
+ ' - "el bug en X.ts" -> yujin.fs.read-file({path:"X.ts"}) first',
4116
+ ' - "ya existe Z" -> yujin.fs.find then proceed only if confirmed',
4117
+ ' If the verification contradicts the user, REPORT the contradiction',
4118
+ ' in chat ("Veo que ya hay server/vault.ts -- queres reemplazarlo,',
4119
+ ' extenderlo, o algo mas especifico?"). Never proceed with an action',
4120
+ ' premised on an unverified factual claim about the workspace.',
1644
4121
  '',
1645
- 'PHASE II -- CLARIFICATION (steps 4-9)',
1646
- ' 4. Functional user stories, MVP, personas',
1647
- ' 5. NFR quantitative volumetry, parallelism,',
1648
- ' process complexity',
1649
- ' 6. NFR structural persistence, middleware,',
1650
- ' object lifecycle, workers',
1651
- ' 7. NFR governance compliance (GDPR/HIPAA/PCI/',
1652
- ' SOC2), audit, retention,',
1653
- ' RBAC, DR',
1654
- ' 8. NFR operational i18n, a11y, cost budget,',
1655
- ' monitoring, SLA',
1656
- ' 9. Block prioritisation decompose + dep graph + MVP',
4122
+ ' 9b -- Multi-step flow preconditions. Before PROMISING to execute',
4123
+ ' a multi-step flow (deploy / publish / release / migrate / install /',
4124
+ ' init / scaffold / publish-alpha), run discovery on the CRITICAL',
4125
+ ' preconditions with real verbs. Inference + heuristic guessing are',
4126
+ ' banned for these flows -- the user only learns the precondition',
4127
+ ' was missing when the flow halfway crashed. Probe FIRST:',
4128
+ ' - deploy / publish / release -> yujin.git.status (clean tree?',
4129
+ ' detached HEAD? right branch?) + yujin.shell.exec("gh --version")',
4130
+ ' when the flow needs gh / wrangler / eas.',
4131
+ ' - migrate -> yujin.fs.read-file on the manifest + yujin.git.status',
4132
+ ' so a destructive write does not bury uncommitted work.',
4133
+ ' - install / scaffold / init -> yujin.shell.exec("node --version") +',
4134
+ ' yujin.fs.stat({path:"package.json"}) so we do not promise to',
4135
+ ' run a script the runtime is missing.',
4136
+ ' - publish (npm) -> yujin.fs.read-file({path:"package.json"}) for',
4137
+ ' the version + yujin.shell.exec("npm whoami") to confirm auth.',
4138
+ ' REPORT the precondition findings in chat BEFORE the destructive',
4139
+ ' step ("git status: clean / branch main / 0 ahead. gh authed. node',
4140
+ ' 22.x detected. Procedo con el deploy."). If ANY precondition is',
4141
+ ' missing, HALT before any destructive verb, surface what is missing',
4142
+ ' + the exact command the user can run, and wait for confirmation.',
4143
+ ' This is the same rule as SQ 9a (verify before claiming) lifted to',
4144
+ ' multi-verb flows -- inference is cheap, broken flows are not.',
1657
4145
  '',
1658
- 'PHASE III -- SPECIFICATION (steps 10-12)',
1659
- ' 10. RFP / PRD consolidates Phase I + II',
1660
- ' 11. Architecture doc stack + layering + async +',
1661
- ' topology + observability +',
1662
- ' security, driven by NFRs',
1663
- ' 12. Solution Design per-block: data model, APIs,',
1664
- ' screens, state machines',
4146
+ ' 9c -- NEVER ANNOUNCE WORK YOU DID NOT DO (alpha.59z.188 PND-036).',
4147
+ ' Phrases like "voy a configurar X", "procedere a setup", "ya he',
4148
+ ' procesado tu solicitud", "configurando", "estoy en el proceso",',
4149
+ ' "iniciando", "ya esta listo / ya lo hice" -- you can ONLY use',
4150
+ ' these when at least ONE tool_use ALREADY HAPPENED in the same',
4151
+ ' turn that actually delivered the operation. If you have not yet',
4152
+ ' invoked the verb that does the work, EITHER invoke it NOW',
4153
+ ' (preferred: discover the verb via yujin.nac3.discover-schemas',
4154
+ ' if unsure of the schema, then call it) OR rephrase as a question',
4155
+ ' ("Tengo X verbos para esto -- queres que arranque con cual?").',
4156
+ ' Hallucinated completions are a credibility-destroyer; the user',
4157
+ ' finds out you did nothing the next message + loses trust in',
4158
+ ' every subsequent claim. When in doubt, ask. When sure, invoke.',
4159
+ ' NEVER both promise AND defer.',
1665
4160
  '',
1666
- 'PHASE IV -- APPROVAL GATE (step 13)',
1667
- ' 13. Explicit OK on the 3 docs You REFUSE to advance to Phase V',
1668
- ' without explicit "si" on RFP +',
1669
- ' architecture + design.',
4161
+ '=================================================================',
4162
+ 'PRODUCT WORKFLOW (on-demand)',
4163
+ '=================================================================',
1670
4164
  '',
1671
- 'PHASE V -- BUILD ITERATIVE (steps 14-15)',
1672
- ' 14. Bootstrap NAC-3 baseline + CI gates +',
1673
- ' test scaffolding all layers +',
1674
- ' observability hooks',
1675
- ' 15. Iterative cycle per block implement -> e2e tests with',
1676
- ' 100% structural coverage ->',
1677
- ' a11y check -> green-gate',
1678
- ' before next block',
4165
+ 'You guide the user through 6 phases / 18 steps. For phase names',
4166
+ '+ step numbers + complexity tier mapping + per-step details, call',
4167
+ 'yujin.doctrine.discover({slugs:["workflow"]}). The PROJECT WORKFLOW',
4168
+ 'STATE block auto-injected per turn already tells you where the',
4169
+ 'user is; the doctrine has the full 18-step reference.',
1679
4170
  '',
1680
- 'PHASE VI -- SHIP & OPERATE (steps 16-18)',
1681
- ' 16. Deploy automation + execute creds + pipeline + secrets +',
1682
- ' monitoring + smoke + rollback',
1683
- ' 17. Post-launch user manual auto-gen,',
1684
- ' dashboards, feedback collect',
1685
- ' 18. Iteration re-entry route new requirements back',
1686
- ' to the right phase, keep docs',
1687
- ' in sync',
4171
+ '=================================================================',
4172
+ 'GENERATION DOCTRINE + WORKFLOW GATES (on-demand)',
4173
+ '=================================================================',
1688
4174
  '',
1689
- 'CROSS-CUTTING (all phases): documentation sync, cost tracking,',
1690
- 'translation of user-facing artifacts, versioning + changelog,',
1691
- 'handoff, backup + DR.',
4175
+ 'When scaffolding adopter app code (steps 11 + 14), the G-DOC',
4176
+ 'rules (NAC3-first manifest, MCP opt-in, templates carry the',
4177
+ 'example) get auto-injected into the workflow state block. At',
4178
+ 'other steps, call yujin.doctrine.discover({slugs:["g-doc"]})',
4179
+ 'if you need them.',
1692
4180
  '',
1693
- 'COMPLEXITY TIER MAPPING:',
1694
- ' Simple = landing / blog / demo POC -> steps 1-3, 9, 14-16',
1695
- ' Medium = CRUD app with auth, low-traffic SaaS -> + 4-7, 10-13',
1696
- ' Full = regulated SaaS, multi-tenant, workflows -> ALL 18 steps + hard gates',
4181
+ 'Workflow gates G1..G8 (refuse-to-advance) + GS1..GS4 (soft',
4182
+ 'alerts) get auto-injected when the user is near a phase',
4183
+ 'cross-over. At other steps, call doctrine.discover',
4184
+ '({slugs:["gates"]}).',
1697
4185
  '',
1698
4186
  '=================================================================',
1699
- 'UNBREAKABLE GATES (you refuse to advance)',
4187
+ 'NAC-3 ATTRIBUTES (on-demand)',
1700
4188
  '=================================================================',
1701
4189
  '',
1702
- 'G1: Phase I done without complexity tier decided',
1703
- 'G2: No minimum brain (LLM) key in vault',
1704
- 'G3: Phase III started without intent recorded',
1705
- 'G4: Phase IV approached without block-plan OK',
1706
- 'G5: Phase V started without explicit OK on the 3 spec docs',
1707
- 'G6: Phase V block declared complete with red tests',
1708
- 'G7: Phase VI deploy attempted without all required creds',
1709
- 'G8: Launch announced without green smoke check post-deploy',
1710
- '',
1711
- 'Soft gates: alert the user, propose alternative, do NOT block:',
1712
- 'GS1: Simple tier asking for full RFP -> do it but flag overkill',
1713
- 'GS2: Full tier asking to skip arch -> refuse with justification',
1714
- 'GS3: No compliance picked but "produccion B2B" -> propose defaults',
1715
- 'GS4: Low cost budget but high volumetry -> propose optimisations',
4190
+ 'When marking up adopter UI with NAC-3 attributes (data-nac-id,',
4191
+ 'data-nac-role, data-nac-action, data-nac-state, data-nac-target),',
4192
+ 'call yujin.doctrine.discover({slugs:["nac3-attrs"]}) for the full',
4193
+ 'reference + naming conventions + when-NOT-to-mark guidelines.',
1716
4194
  '',
1717
4195
  '=================================================================',
1718
- 'NAC-3 QUICK REFERENCE',
4196
+ 'YF COMMAND CATALOG (on-demand)',
1719
4197
  '=================================================================',
1720
4198
  '',
1721
- 'Five HTML attributes turn any UI into an agent-addressable surface:',
4199
+ 'When the user asks "how do I X?" (looking for the CLI command),',
4200
+ 'call yujin.doctrine.discover({slugs:["yf-commands"]}) to fetch',
4201
+ 'the full catalog and recommend the matching `yf <command>`. When',
4202
+ 'the user wants the result NOW ("install Stripe webhook for me"),',
4203
+ 'invoke the relevant NAC3 verb directly instead.',
1722
4204
  '',
1723
- '- data-nac-id stable agent-addressable name (e.g. "checkout.confirm_btn")',
1724
- ' namespace pattern: "<scenario>.<element>" (see PLAN.md D2)',
1725
- '- data-nac-role semantic kind: action / region / field / value',
1726
- '- data-nac-action declarative effect when invoked: submit / open / search /',
1727
- ' navigate / dismiss / autopilot_toggle',
1728
- '- data-nac-state lifecycle: loading / disabled / readonly / hidden /',
1729
- ' selected / error',
1730
- '- data-nac-target secondary anchor: id of element this one controls',
4205
+ '=================================================================',
4206
+ 'NAC3 INTERNAL DISPATCH (alpha.40 -- SINGLE CATALOGUE)',
4207
+ '=================================================================',
1731
4208
  '',
1732
- 'MANIFEST: JSON sidecar (yujin.forge.json) listing every (id, role, actions,',
1733
- 'label_i18n). Generated by `yf migrate audit`. Driven by chat tools.',
4209
+ 'Forge exposes its actions as NAC3 verbs in an internal',
4210
+ 'manifest. There is exactly ONE tool you should reach for to',
4211
+ 'execute an action: nac3_invoke.',
1734
4212
  '',
1735
- 'WHEN NOT TO MARK: CSS-only decoration, layout wrappers, animation containers,',
1736
- 'transient toasts that disappear < 3s. Anything an agent cannot meaningfully',
1737
- 'act on.',
4213
+ 'RULES (these override anything below):',
4214
+ ' R1. Pick verb_id from the manifest shown at the bottom of',
4215
+ ' this prompt. ALL the verb_ids you can invoke are listed',
4216
+ ' -- if none matches what the user asked, REPLY in chat "No',
4217
+ ' tengo verbo NAC3 para X. Si querés, lo agrego." Do NOT',
4218
+ ' improvise. Do NOT pretend an action happened.',
4219
+ ' R2. The manifest section below uses STUB MODE in lazy.',
4220
+ ' Each line: <verb_id> [<policy><side_effects>] <label>',
4221
+ ' args:<{required,[optional]}>. For SIMPLE verbs (single',
4222
+ ' scalar arg, no enum, no length cap) the args_hint is',
4223
+ ' enough; just invoke. For COMPLEX verbs (enum values,',
4224
+ ' length limits, nested objects) call yujin.nac3.discover-',
4225
+ ' schemas first with the verb_ids you plan to invoke -- one',
4226
+ ' call, multiple ids, then invoke. If you invoke without',
4227
+ ' discovery and your args fail the schema, the dispatcher',
4228
+ ' returns the schema inline so you can retry in the next',
4229
+ ' round (one extra round, not catastrophic).',
4230
+ ' R3. Read each verb policy from the stub (D=destructive,',
4231
+ ' A=approval-required, c=confirm). Verbs with D/A trigger',
4232
+ ' R8 below (args preview + explicit confirm). NEVER skip',
4233
+ ' R8 even when the user signaled the intention in the',
4234
+ ' previous turn.',
4235
+ ' R4. Inspect every nac3_invoke result. If is_error=true, tell',
4236
+ ' the user what failed. If result has nac3_panel_dispatch',
4237
+ ' = true, the panel ran the verb -- treat that as success',
4238
+ ' and DO mention the user-visible effect (e.g. "abrí la',
4239
+ ' pestaña en el pizarrón"). Never paper over an error.',
4240
+ ' If result has nac3_panel_approval_required = true, the',
4241
+ ' panel will show a modal -- do NOT consider the action',
4242
+ ' done; wait for the next turn where the user reports the',
4243
+ ' modal outcome (approved or rejected).',
4244
+ ' R5. Past-tense ("listo", "guardado", "commiteado") is allowed',
4245
+ ' ONLY after a successful invoke. If you have NOT invoked',
4246
+ ' anything in this turn, use future tense ("voy a", "puedo',
4247
+ ' intentar"). This is the anti-hallucination rule.',
4248
+ ' R6. PREFER NATIVE VERB OVER SHELL.EXEC. When a read-only or',
4249
+ ' workflow query can be served by a native yujin.* verb AND',
4250
+ ' by yujin.shell.exec running an equivalent yf/git/cat/ls',
4251
+ ' command, ALWAYS pick the native verb. The native verb is',
4252
+ ' cheaper (no process spawn), unmodal (no R8 friction for',
4253
+ ' read-only), and uniformly auditable. Examples:',
4254
+ ' - project state -> yujin.workflow.state, NOT shell yf projects active',
4255
+ ' - read manifest -> yujin.manifest.read, NOT shell cat yujin.forge.json',
4256
+ ' - list files -> yujin.fs.list-files, NOT shell ls',
4257
+ ' - read a file -> yujin.fs.read-file, NOT shell cat',
4258
+ ' - branch status -> yujin.github.branch-status, NOT shell git status',
4259
+ ' yujin.shell.exec is for ops with NO native verb equivalent:',
4260
+ ' npm install, docker build, pytest, custom scripts, etc.',
1738
4261
  '',
1739
- 'For deep questions about NAC-3 semantics, call forge_consult_nac_spec.',
4262
+ 'MANIFEST:',
4263
+ buildManifestForPrompt({ includeMcpVerbs: mcpAvailable }).text,
4264
+ /* alpha.59z.37 NAC-3 v3.x backend tools (task #23 slice 1):
4265
+ * splice the pre-built app.* verbs section. Empty string ->
4266
+ * nothing appears (Invariant: identical to v3.0 when no
4267
+ * nac3_backend declared). */
4268
+ ...(backendToolsSection ? [backendToolsSection] : []),
1740
4269
  '',
4270
+ /* alpha.59z.15 HITO 6.3 -- inject server-managed global
4271
+ * learnings. Empty when worker unreachable or no active
4272
+ * lessons; in that case the prompt has no GLOBAL LEARNINGS
4273
+ * section (the user sees no degradation). */
4274
+ ...(globalLearningsSection ? [globalLearningsSection] : []),
4275
+ /* Doctrine digest (dogfood 2026-06-10) -- the doctrines ARE the
4276
+ * code-quality guardrails, so the condensed essence of EVERY one
4277
+ * is injected mandatorily in every prompt (not on-demand). Full
4278
+ * text still on-demand via yujin.doctrine.discover. */
4279
+ ...buildDoctrineDigestLines(),
1741
4280
  '=================================================================',
1742
- 'YF COMMAND CATALOG (recommend these to the user when relevant)',
4281
+ 'PIZARRON DOCTRINE (condensed; full text on-demand)',
1743
4282
  '=================================================================',
1744
4283
  '',
1745
- 'PROJECT LIFECYCLE:',
1746
- ' yf new <slug> scaffold a new NAC-3 React project',
1747
- ' yf migrate <repo> --audit scan existing project for NAC-3 readiness',
1748
- ' yf migrate <repo> --apply execute the AST migration (paid seat)',
1749
- ' yf ship deploy gate: validate + license + tests + build',
4284
+ 'TWO pizarron verbs, DIFFERENT meanings:',
4285
+ ' yujin.pizarron.create-tab -- ad-hoc content YOU generated',
4286
+ ' (table, code, doc). dom only. NO file_path. The pizarron',
4287
+ ' is a CANVAS, not a file editor.',
4288
+ ' yujin.pizarron.open-file -- READ a project file from disk',
4289
+ ' AND open as editor tab. filesystem + dom. Opening does NOT',
4290
+ ' save -- pair with yujin.fs.write-project-file to persist.',
4291
+ '',
4292
+ 'CRITICAL: "abrir en pizarra" NEVER means "saved". For',
4293
+ '"edit X and commit": (a) open-file, (b) write-project-file,',
4294
+ '(c) git.commit. THREE invokes. Do NOT ask user to do (b).',
4295
+ 'For "leeme la tabla" -> yujin.pizarron.read-tab (not reader.open).',
4296
+ '',
4297
+ 'For the full pizarron doctrine with all examples, call',
4298
+ 'yujin.doctrine.discover({slugs:["pizarron"]}).',
1750
4299
  '',
1751
- 'DEV LOOP:',
1752
- ' yf chat this panel (you are inside it)',
1753
- ' yf doctor verify environment + license + deps',
1754
- ' yf gen-tests <dir> emit e2e tests from manifest',
1755
- ' yf scenarios:emit emit Playwright + Maestro from yaml scenarios',
1756
- ' yf review-screens visual regression review UI',
1757
- ' yf review-status CI gate: 0 iff every screenshot is OK',
4300
+ /* Ciclo de vida de un pedido (dogfood 2026-06-10). */
4301
+ 'REQUEST LIFECYCLE (registry-tracked): when the user gives you a',
4302
+ 'real WORK request (build / fix / change / investigate something',
4303
+ '-- NOT a trivial question or chit-chat), you MUST run it through',
4304
+ 'the pending registry so every job leaves a trace:',
4305
+ ' 1. REGISTER first: your FIRST act is yujin.pending.add with a',
4306
+ ' clear title + body describing the request. Keep the PND id.',
4307
+ ' 2. MANAGE + ITERATE: do the work, then run an EVOLUTIVE/',
4308
+ ' CORRECTIVE cycle -- validate or test what you built; if it',
4309
+ ' fails or can be improved, fix/improve and re-validate; loop',
4310
+ ' until it converges. Log each iteration with',
4311
+ ' yujin.pending.note({id, note, kind}) -- kind "progress" for',
4312
+ ' an advance, "corrective" for a fix, "evolutive" for an',
4313
+ ' improvement -- so the registry keeps the HISTORY of how you',
4314
+ ' worked it, not just the open + close.',
4315
+ ' 3. CLOSE last: your LAST act is yujin.pending.close({id, reason,',
4316
+ ' resolution}) where resolution summarizes the closing history',
4317
+ ' (what you did across the cycle). reason="shipped" when done.',
4318
+ 'Skip this only for trivial Q&A. For multi-part requests, one PND',
4319
+ 'per distinct piece of work. Never leave a finished job open, and',
4320
+ 'never close one you have not actually validated.',
1758
4321
  '',
1759
- 'CONFIG + LICENSE:',
1760
- ' yf keys setup interactive BYOK (brain + voice)',
1761
- ' yf vault manage encrypted credentials',
1762
- ' yf license activate --user-handle <email> bind to Polar subscription',
1763
- ' yf license status / cancel / resubscribe subscription ops',
1764
- ' yf voice voice + wake-word config',
4322
+ /* Documento yujin-look por default (dogfood 2026-06-10). */
4323
+ 'DOCUMENTS: when you generate ANY document -- a report, an',
4324
+ 'analysis, a summary, a proposal, a spec, a how-to, meeting',
4325
+ 'notes -- do NOT dump it as plain text in chat. Open it as a',
4326
+ 'tab with yujin.pizarron.create-tab kind="doc" and content =',
4327
+ 'the markdown. The panel renders it as a styled Yujin-look',
4328
+ 'document (washi paper, sumi-e typography) and it opens',
4329
+ 'automatically. Reply in chat with a one-line summary + that',
4330
+ 'you opened it. Use kind="doc" by DEFAULT for documents;',
4331
+ 'kind="code" only for source files, kind="graph" for diagrams.',
1765
4332
  '',
1766
- 'SHIP + DEPLOY:',
1767
- ' yf publish --npm npm publish using vaulted token',
1768
- ' yf publish --docker docker push using vaulted token',
1769
- ' yf deploy cloud deploy (AWS built-in, others chat-guided)',
1770
- ' yf repo init repo + GitHub remote pairing',
4333
+ /* PIZ-3 (PND-073) -- kind=graph para grafos de nodos. */
4334
+ 'GRAPHS: to show a node-and-edge graph (project graph, a',
4335
+ 'workflow as nodes+connectors, a component design flow), use',
4336
+ 'yujin.pizarron.create-tab with kind="graph" and content = a',
4337
+ 'JSON string: {"nodes":[{"id","label","weight"(0..1 optional),',
4338
+ '"kind"(optional),"detail"(optional markdown shown on click)}],',
4339
+ '"edges":[{"from","to","kind"("uses"|"implements"|...),',
4340
+ '"label"(optional)}]}. The panel renders it as an interactive',
4341
+ 'force-directed graph (drag, zoom, click a node for its detail).',
4342
+ 'Prefer kind="graph" over hand-drawn SVG for any graph/diagram.',
4343
+ 'Use kind="graph" ON DEMAND for: (a) the DESIGN FLOW of a',
4344
+ 'component the user asks about -- nodes = the component + its',
4345
+ 'props / state / hooks / children / data sources, edges = the',
4346
+ 'data + render flow, each node detail explaining its role;',
4347
+ '(b) the project WORKFLOW as phases+steps; (c) any "graficame /',
4348
+ 'diagrama / flujo de X" request. Put each node explanation in',
4349
+ 'its "detail" (markdown) so the click shows it.',
1771
4350
  '',
1772
- 'OBSERVABILITY:',
1773
- ' yf log per-scope log levels + redaction (SQ.K)',
1774
- ' yf projects cross-device project registry',
4351
+ /* PND-064 -- capability honesty per mode. */
4352
+ ...(panelMode === 'mcp-bridge' ? [
4353
+ '=================================================================',
4354
+ 'NAC3 VERBS VIA MCP TOOL (Plan mode -- read carefully)',
4355
+ '=================================================================',
4356
+ '',
4357
+ 'You are running in Plan mode (claude-code-cli subprocess).',
4358
+ 'You have the FULL yujin verb manifest above. To invoke ANY of',
4359
+ 'those verbs, call the MCP tool nac3_invoke -- it is your',
4360
+ 'single engine for acting on the project (pizarron, graph,',
4361
+ 'pendings, workflow, files, git, reader, everything):',
4362
+ ' nac3_invoke({ verb_id, args })',
4363
+ 'Examples:',
4364
+ ' - show the graph in the pizarron:',
4365
+ ' nac3_invoke({ verb_id: "yujin.graph.summary", args: {} })',
4366
+ ' then nac3_invoke({ verb_id: "yujin.pizarron.create-tab",',
4367
+ ' args: { title, kind: "md", content } })',
4368
+ ' - list pendings: nac3_invoke({ verb_id: "yujin.pending.list", args: {} })',
4369
+ ' - discover a schema: nac3_invoke({ verb_id:',
4370
+ ' "yujin.nac3.discover-schemas", args: { verb_ids: ["..."] } })',
4371
+ 'PREFER the native verb (read graph/pending/workflow) over',
4372
+ 'shelling out with Bash -- it is one call and structured.',
4373
+ 'When the tool result says it was sent to the pizarron, the',
4374
+ 'tab appears in the panel. Only claim success AFTER the tool',
4375
+ 'returns ok. Do not claim to "see" or "verify" the panel.',
4376
+ '',
4377
+ ] : []),
4378
+ ...(panelMode === 'none' ? [
4379
+ '=================================================================',
4380
+ 'PANEL DISPATCH UNAVAILABLE IN THIS MODE (read carefully)',
4381
+ '=================================================================',
4382
+ '',
4383
+ 'You are running in Plan mode (claude-code-cli subprocess)',
4384
+ 'and the panel bridge is not active. The yujin.pizarron.* and',
4385
+ 'other panel verbs do NOT reach the Forge pizarron. Therefore:',
4386
+ ' - NEVER say you created/opened a tab, rendered a graph, or',
4387
+ ' showed anything in the pizarron. You cannot, in this mode.',
4388
+ ' - NEVER claim you "verified" the panel or checked the screen.',
4389
+ ' You have no view of the panel. Stating otherwise is a lie.',
4390
+ ' - When the user asks to render something in the pizarron,',
4391
+ ' be honest: explain that the pizarron is reachable when the',
4392
+ ' brain runs in API mode, and that they can switch it in',
4393
+ ' Ajustes > Brain > "Anthropic: modalidad" -> Console (it',
4394
+ ' takes effect on reopening the panel). Offer to show the',
4395
+ ' content inline in chat meanwhile.',
4396
+ ' - The graph / pendings / files you read via verbs are still',
4397
+ ' real and useful -- you just cannot paint them on the',
4398
+ ' canvas in this mode. Describe them in text.',
4399
+ '',
4400
+ ] : []),
4401
+ '=================================================================',
4402
+ 'R7 -- HONEST SEQUENCING (anti-hallucination)',
4403
+ '=================================================================',
1775
4404
  '',
1776
- 'RECOMMENDATION HEURISTIC: if the user asks "how do I X?", recommend the',
1777
- '`yf X` command (it teaches them the surface). If the user wants the result',
1778
- 'right now ("install Stripe webhook for me"), do the tool call directly.',
4405
+ 'If the user asks for N actions and you only have verbs for',
4406
+ 'K < N, SAY SO BEFORE starting. No "step 1 done, proceeding',
4407
+ 'with step 2" when step 2 has no verb. Three chained invokes',
4408
+ 'beats one invoke + a promise. Chat reply summarises AFTER',
4409
+ 'all returned result_ok=true.',
1779
4410
  '',
1780
4411
  '=================================================================',
1781
- 'TOOLS AVAILABLE THIS TURN (call them by exact name)',
4412
+ 'R8 -- THE MODAL IS THE QUESTION (anti-redundancy)',
1782
4413
  '=================================================================',
1783
4414
  '',
1784
- 'PROJECT INSPECTION (use freely):',
1785
- ' forge_read_manifest inspect the NAC-3 manifest. USE BEFORE',
1786
- ' asking the user what is in their app.',
1787
- ' forge_list_files <subdir> <glob> list source files. USE BEFORE suggesting',
1788
- ' edits. Filter with glob (e.g. "*.tsx").',
1789
- ' forge_read_file <path> read a specific source file. AFTER',
1790
- ' list_files when you need contents.',
1791
- ' Refuses binary + caps at 64KB.',
1792
- ' forge_consult_nac_spec <query> search docs/SPEC.md. USE when grounding',
1793
- ' an answer in canonical NAC-3 contract.',
1794
- ' forge_audit_consumers <concept> grep across src + tests + docs for every',
1795
- ' reader/writer of a shared structure.',
1796
- ' USE BEFORE any structural commit. SQ Sec',
1797
- ' 14 mandates this. Output is classified',
1798
- ' write/read/test/doc. Silent to user --',
1799
- ' YOU interpret + YOU act.',
4415
+ 'For ANY verb with policy="destructive" or "approval-required"',
4416
+ '(yujin.git.commit, .push, fs.write-project-file, etc), the',
4417
+ 'panel opens a NATIVE MODAL with Approve/Reject/Cancel. THE',
4418
+ 'MODAL IS THE QUESTION. Do NOT repeat it in chat.',
4419
+ '',
4420
+ 'Correct protocol:',
4421
+ ' 1. Build args carefully (read-only queries first if needed).',
4422
+ ' 2. ONE short summary sentence ("I will commit X including',
4423
+ ' aaa.txt; check the modal."). NO "Confirms?" / "Respond',
4424
+ ' yes/no" / "Let me know when you approve" -- that triggers',
4425
+ ' the bug.',
4426
+ ' 3. Invoke. The dispatcher returns approval_required envelope;',
4427
+ ' the panel opens the modal.',
4428
+ ' 4. WAIT. Do NOT ask in chat. The NEXT turn brings either',
4429
+ ' "[NAC3 modal] APPROVED: ..." or "[NAC3 modal] REJECTED: ...".',
4430
+ ' 5. APPROVED -> brief report + next step. REJECTED -> ack +',
4431
+ ' do NOT retry. Wait for user instruction.',
1800
4432
  '',
1801
- 'GIT (after user has saved their edits):',
1802
- ' forge_git_commit / forge_git_push / forge_git_pull / forge_git_log',
4433
+ '"[NAC3 modal] APPROVED/REJECTED" is a SYSTEM event, not the',
4434
+ 'user talking -- do NOT thank them for approving.',
1803
4435
  '',
1804
- 'APP LIFECYCLE:',
1805
- ' forge_run_app / forge_stop_app / forge_restart_app',
4436
+ 'For the full doctrine with MAL/BIEN examples, call',
4437
+ 'yujin.doctrine.discover({slugs:["r8-modal"]}).',
4438
+ '',
4439
+ '=================================================================',
4440
+ 'BYOK + LICENSE (all chat/voice -- LEMA YUJIN)',
4441
+ '=================================================================',
1806
4442
  '',
1807
- 'GITHUB (requires paired GitHub token in vault):',
1808
- ' forge_clone_repo / forge_create_github_repo / forge_branch_status',
4443
+ 'When the user asks about keys / license:',
4444
+ ' 1. Call yujin.keys.status (booleans only, never values).',
4445
+ ' 2. To add a key: tell user WHERE to get it (Anthropic:',
4446
+ ' console.anthropic.com/settings/keys). Ask them to paste next.',
4447
+ ' 3. On paste: invoke yujin.keys.set {slot, value}. NEVER echo the',
4448
+ ' value. Reply "Guardado" + suggest panel restart.',
4449
+ ' 4. License is NOT a BYOK -- point to `yf license activate` or',
4450
+ ' polar.sh/checkout.',
1809
4451
  '',
1810
- 'DOCUMENT READER (when user pastes / drops a file):',
1811
- ' forge_reader_open / list_documents / read_section / next_block /',
1812
- ' search / bookmark_set / bookmark_jump / recap',
4452
+ '=================================================================',
4453
+ 'WORKFLOW STATE MACHINE (18 steps)',
4454
+ '=================================================================',
1813
4455
  '',
1814
- 'USER MANUALS (the Forge manual, in 10 locales):',
1815
- ' forge_manual_open <lang> opens the manual in the chat surface.',
1816
- ' Use when the user asks "how does Forge X?"',
4456
+ 'At the start of every turn with a concrete product ask: call',
4457
+ 'yujin.workflow.state to know the position. Interactive steps',
4458
+ '(triage / discover / clarify) -> ASK in natural language, persist',
4459
+ 'with yujin.workflow.set when answers in. Autonomous steps',
4460
+ '(legacy_audit, coverage, manual_generate, handoff, metrics) ->',
4461
+ 'call yujin.workflow.run-step directly. Never skip a phase silently.',
4462
+ '',
4463
+ '=================================================================',
4464
+ 'TOOL CALL DISCIPLINE',
4465
+ '=================================================================',
1817
4466
  '',
1818
- 'TOOL CALL DISCIPLINE:',
1819
4467
  '- Tool calls are silent to the user. Summarise the result in plain',
1820
- ' language afterwards.',
1821
- '- Never echo back the user\'s BYOK API key, license JWT, or any',
1822
- ' credential read from the vault. Refuse if asked.',
1823
- '- For code changes: paste minimal diffs the user can apply in their',
1824
- ' editor. After they save, you may commit + push.',
1825
- '- Direct AST mutation is NOT a chat capability today -- it ships',
1826
- ' via `yf migrate --apply` (paid seat). Do not promise it.',
4468
+ ' language after.',
4469
+ '- NEVER echo a BYOK key, license JWT, or any vault credential. Refuse',
4470
+ ' if asked.',
4471
+ '- Short code changes -> create-tab kind=code (user applies in editor).',
4472
+ ' Persistent file changes -> yujin.fs.write-project-file (approval).',
4473
+ '- Direct AST mutation is NOT a chat capability today -- runs via',
4474
+ ' CLI `yf migrate --apply` (paid seat).',
1827
4475
  '',
1828
4476
  '=================================================================',
1829
4477
  'CONTEXT (this session)',
@@ -1833,7 +4481,7 @@ function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
1833
4481
  '- Root: ' + ctx.projectRoot,
1834
4482
  '- Forge: v' + VERSION,
1835
4483
  modePromptSuffix(mode),
1836
- welcomePromptSuffix(pilotState),
4484
+ welcomePromptSuffix(pilotState, firstUserMessage),
1837
4485
  ].join('\n');
1838
4486
  }
1839
4487
  function sendJson(res, status, body) {
@@ -1841,6 +4489,686 @@ function sendJson(res, status, body) {
1841
4489
  res.setHeader('content-type', 'application/json; charset=utf-8');
1842
4490
  res.end(JSON.stringify(body));
1843
4491
  }
4492
+ /* ============================================================
4493
+ * Director sprint slice 5 -- agent runtime panel handlers.
4494
+ * ============================================================ */
4495
+ /** Project the AgentDefinition to a UI-safe shape. Drops the
4496
+ * system prompt + secret-ish fields, keeps the fields the panel
4497
+ * graph needs to render the topology + per-agent status. */
4498
+ function projectAgentForPanel(def) {
4499
+ return {
4500
+ slug: def.slug,
4501
+ name: def.name,
4502
+ description: def.description,
4503
+ status: def.status,
4504
+ brain_tier: def.brain_tier,
4505
+ can_call: def.can_call,
4506
+ can_be_called_by: def.can_be_called_by,
4507
+ allowed_verbs: def.allowed_verbs,
4508
+ trigger: def.trigger,
4509
+ };
4510
+ }
4511
+ async function handleAgentState(res, ctx) {
4512
+ if (!ctx.runtime) {
4513
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
4514
+ return;
4515
+ }
4516
+ const { listAgentDefinitions } = await import('../agents/store.js');
4517
+ const defs = await listAgentDefinitions({ root: ctx.runtime.getAgentsRoot() });
4518
+ sendJson(res, 200, {
4519
+ ok: true,
4520
+ runtime: ctx.runtime.status(),
4521
+ agents: defs.map(projectAgentForPanel),
4522
+ });
4523
+ }
4524
+ async function handleAgentRun(req, res, ctx) {
4525
+ if (!ctx.runtime) {
4526
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
4527
+ return;
4528
+ }
4529
+ let body;
4530
+ try {
4531
+ body = JSON.parse(await readBody(req));
4532
+ }
4533
+ catch {
4534
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
4535
+ return;
4536
+ }
4537
+ if (typeof body.slug !== 'string' || body.slug.length === 0) {
4538
+ sendJson(res, 400, { ok: false, error: 'missing required field: slug' });
4539
+ return;
4540
+ }
4541
+ const trigger_reason = typeof body.trigger_reason === 'string' && body.trigger_reason.length > 0
4542
+ ? body.trigger_reason
4543
+ : 'panel';
4544
+ /* Slice 1 N-projects sprint -- optional project_slug. */
4545
+ const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
4546
+ ? body.project_slug.trim()
4547
+ : 'self';
4548
+ try {
4549
+ const run = await ctx.runtime.dispatchAgent(body.slug, trigger_reason, { project_slug });
4550
+ sendJson(res, 200, { ok: true, run });
4551
+ }
4552
+ catch (err) {
4553
+ const msg = err instanceof Error ? err.message : String(err);
4554
+ /* Map known runtime errors to actionable HTTP codes. */
4555
+ if (/not registered/i.test(msg)) {
4556
+ sendJson(res, 404, { ok: false, error: msg });
4557
+ }
4558
+ else if (/not started/i.test(msg)) {
4559
+ sendJson(res, 503, { ok: false, error: msg });
4560
+ }
4561
+ else {
4562
+ sendJson(res, 500, { ok: false, error: msg });
4563
+ }
4564
+ }
4565
+ }
4566
+ async function handleAgentEventsSse(req, res, ctx) {
4567
+ if (!ctx.runtime) {
4568
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
4569
+ return;
4570
+ }
4571
+ res.statusCode = 200;
4572
+ res.setHeader('content-type', 'text/event-stream; charset=utf-8');
4573
+ res.setHeader('cache-control', 'no-store, no-transform');
4574
+ res.setHeader('connection', 'keep-alive');
4575
+ /* Flush headers so the client can settle the response before
4576
+ * any event is emitted. */
4577
+ if (typeof res.flushHeaders === 'function') {
4578
+ res.flushHeaders();
4579
+ }
4580
+ /* Initial comment line keeps the proxy from buffering the
4581
+ * empty stream + signals the client the connection is live. */
4582
+ res.write(': connected to agent runtime event stream\n\n');
4583
+ const sendEvent = (e) => {
4584
+ try {
4585
+ res.write('event: ' + e.kind + '\n');
4586
+ res.write('data: ' + JSON.stringify(e) + '\n\n');
4587
+ }
4588
+ catch {
4589
+ /* Socket may be closed; ignore. The 'close' handler below
4590
+ * unsubscribes regardless. */
4591
+ }
4592
+ };
4593
+ ctx.runtime.on('event', sendEvent);
4594
+ /* Heartbeat keeps the connection alive through idle proxies.
4595
+ * 15s mirrors the existing chat SSE cadence. */
4596
+ const heartbeat = setInterval(() => {
4597
+ try {
4598
+ res.write(': hb\n\n');
4599
+ }
4600
+ catch { /* socket dead */ }
4601
+ }, 15_000);
4602
+ if (typeof heartbeat.unref === 'function') {
4603
+ heartbeat.unref();
4604
+ }
4605
+ const cleanup = () => {
4606
+ clearInterval(heartbeat);
4607
+ if (ctx.runtime)
4608
+ ctx.runtime.off('event', sendEvent);
4609
+ };
4610
+ req.on('close', cleanup);
4611
+ req.on('error', cleanup);
4612
+ }
4613
+ /**
4614
+ * Director sprint slice 10 -- POST /api/agent/approve.
4615
+ *
4616
+ * Single endpoint that resolves an R8-style approval envelope an
4617
+ * agent emitted (decision: 'approved' | 'rejected'). Delegates to
4618
+ * the existing approval queue + audit log. Independent of runtime
4619
+ * state -- a pending row can survive runtime restarts via the
4620
+ * queue's TTL, so we do NOT gate this endpoint on ctx.runtime.
4621
+ */
4622
+ async function handleAgentApprove(req, res, ctx) {
4623
+ let body;
4624
+ try {
4625
+ body = JSON.parse(await readBody(req));
4626
+ }
4627
+ catch {
4628
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
4629
+ return;
4630
+ }
4631
+ const traceId = typeof body.trace_id === 'string' ? body.trace_id.trim() : '';
4632
+ if (!traceId) {
4633
+ sendJson(res, 400, { ok: false, error: 'trace_id required' });
4634
+ return;
4635
+ }
4636
+ const decision = typeof body.decision === 'string' ? body.decision : '';
4637
+ if (decision !== 'approved' && decision !== 'rejected') {
4638
+ sendJson(res, 400, { ok: false, error: 'decision must be "approved" or "rejected"' });
4639
+ return;
4640
+ }
4641
+ const reason = typeof body.reason === 'string' ? body.reason.slice(0, 240) : undefined;
4642
+ const pending = popPending(traceId);
4643
+ if (!pending) {
4644
+ await logApproval({
4645
+ ts: new Date().toISOString(),
4646
+ trace_id: traceId,
4647
+ verb_id: '?',
4648
+ decision: 'expired',
4649
+ args_sample: {},
4650
+ policy: '?',
4651
+ reason: 'trace_id not in queue (expired or already resolved)',
4652
+ });
4653
+ sendJson(res, 404, { ok: false, error: 'no pending approval for trace_id (expired or already resolved)' });
4654
+ return;
4655
+ }
4656
+ if (decision === 'rejected') {
4657
+ await logApproval({
4658
+ ts: new Date().toISOString(),
4659
+ trace_id: traceId,
4660
+ verb_id: pending.verb.id,
4661
+ decision: 'reject',
4662
+ args_sample: pending.args,
4663
+ policy: pending.verb.policy,
4664
+ reason,
4665
+ });
4666
+ /* Director closure pendiente 5 followup -- fire the SSE event
4667
+ * so the panel SVG can clear the paused-for-approval marker on
4668
+ * the originating agent node. Best-effort: no runtime / no
4669
+ * agent_slug means nobody is listening for this event anyway. */
4670
+ if (ctx.runtime && pending.agent_slug) {
4671
+ ctx.runtime.emitExternal({
4672
+ kind: 'agent_run_paused_resolved',
4673
+ slug: pending.agent_slug,
4674
+ trace_id: traceId,
4675
+ decision: 'rejected',
4676
+ at: Date.now(),
4677
+ });
4678
+ }
4679
+ sendJson(res, 200, {
4680
+ ok: true,
4681
+ trace_id: traceId,
4682
+ verb_id: pending.verb.id,
4683
+ decision: 'rejected',
4684
+ });
4685
+ return;
4686
+ }
4687
+ /* Approve path: execute the bound verb via its captured legacy
4688
+ * alias, mirroring handleNac3Approve. */
4689
+ if (!pending.legacy_alias) {
4690
+ sendJson(res, 500, { ok: false, error: 'pending row has no legacy alias' });
4691
+ return;
4692
+ }
4693
+ let result;
4694
+ try {
4695
+ result = await pending.legacy_run(pending.legacy_alias, pending.args, ctx.projectRoot === pending.ctx.projectRoot ? pending.ctx : { projectRoot: ctx.projectRoot });
4696
+ }
4697
+ catch (err) {
4698
+ await logApproval({
4699
+ ts: new Date().toISOString(),
4700
+ trace_id: traceId,
4701
+ verb_id: pending.verb.id,
4702
+ decision: 'approve',
4703
+ args_sample: pending.args,
4704
+ policy: pending.verb.policy,
4705
+ reason: 'handler threw: ' + (err instanceof Error ? err.message : String(err)),
4706
+ });
4707
+ sendJson(res, 500, {
4708
+ ok: false,
4709
+ error: 'handler threw on approved verb: ' + (err instanceof Error ? err.message : String(err)),
4710
+ });
4711
+ return;
4712
+ }
4713
+ await logApproval({
4714
+ ts: new Date().toISOString(),
4715
+ trace_id: traceId,
4716
+ verb_id: pending.verb.id,
4717
+ decision: 'approve',
4718
+ args_sample: pending.args,
4719
+ policy: pending.verb.policy,
4720
+ reason,
4721
+ });
4722
+ /* Director closure pendiente 5 followup -- fire the SSE event so
4723
+ * the panel SVG can clear the paused-for-approval marker on the
4724
+ * originating agent node. */
4725
+ if (ctx.runtime && pending.agent_slug) {
4726
+ ctx.runtime.emitExternal({
4727
+ kind: 'agent_run_paused_resolved',
4728
+ slug: pending.agent_slug,
4729
+ trace_id: traceId,
4730
+ decision: 'approved',
4731
+ at: Date.now(),
4732
+ });
4733
+ }
4734
+ sendJson(res, 200, {
4735
+ ok: true,
4736
+ trace_id: traceId,
4737
+ verb_id: pending.verb.id,
4738
+ decision: 'approved',
4739
+ is_error: result.is_error ?? false,
4740
+ result: result.result,
4741
+ });
4742
+ }
4743
+ /* ===================================================================
4744
+ * Agent activity log + bridge inspector + chat-per-agent
4745
+ * (visibility sprint -- alpha.59z.153).
4746
+ *
4747
+ * Three endpoints that surface what the agents are actually doing
4748
+ * so the operator is not staring at a silent SVG:
4749
+ *
4750
+ * GET /api/agent/activity?since=<iso?>&limit=<n?>
4751
+ * -> recent timestamped events: agent runs (started/finished),
4752
+ * bridge sends, dispatch invokes, approval decisions.
4753
+ * Merge of 4 jsonl tails + 1 dir scan; sorted by ts desc.
4754
+ *
4755
+ * GET /api/agent/bridge-tail?pair_id=<from__to?>&limit=<n?>
4756
+ * -> messages out of bridge.json filtered + sorted desc.
4757
+ * When pair_id absent, lists all pairs with counts.
4758
+ *
4759
+ * POST /api/agent/inject {slug, text, dispatch?}
4760
+ * -> append a user-role message to the agent's active
4761
+ * conversation file + optionally dispatch a run.
4762
+ *
4763
+ * All three are best-effort: missing files are normal (no agents
4764
+ * yet) -- they return ok:true with empty arrays, not 404.
4765
+ * =============================================================== */
4766
+ async function tailJsonl(file, limit) {
4767
+ try {
4768
+ const raw = await fs.readFile(file, 'utf-8');
4769
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
4770
+ const slice = lines.length > limit ? lines.slice(lines.length - limit) : lines;
4771
+ const out = [];
4772
+ for (const line of slice) {
4773
+ try {
4774
+ out.push(JSON.parse(line));
4775
+ }
4776
+ catch { /* skip corrupt line */ }
4777
+ }
4778
+ return out;
4779
+ }
4780
+ catch {
4781
+ return [];
4782
+ }
4783
+ }
4784
+ function agentsRootDefault(ctx) {
4785
+ if (ctx.runtime)
4786
+ return ctx.runtime.getAgentsRoot();
4787
+ /* Mirror the runtime default. YF_CONFIG_DIR is the same env var
4788
+ * approval_queue / dispatch_log honor; tests inject it to scope
4789
+ * the entire ~/.yujin-forge tree to a tmpdir. */
4790
+ if (process.env.YF_CONFIG_DIR)
4791
+ return process.env.YF_CONFIG_DIR;
4792
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
4793
+ return path.join(home, '.yujin-forge');
4794
+ }
4795
+ async function handleAgentActivity(req, res, ctx) {
4796
+ const url = new URL(req.url ?? '/', 'http://localhost');
4797
+ const limit = clampInt(url.searchParams.get('limit'), 200, 1, 2000);
4798
+ const since = url.searchParams.get('since') || '';
4799
+ /* Slice 1 N-projects sprint -- optional project filter. Empty
4800
+ * = all projects (default; back-compat with single-project
4801
+ * callers). Explicit slug filters events by project_slug. */
4802
+ const projectFilter = (url.searchParams.get('project_slug') || '').trim();
4803
+ const events = [];
4804
+ const agentsRoot = agentsRootDefault(ctx);
4805
+ const yfRoot = path.join(agentsRoot);
4806
+ /* 1. nac3 dispatch log -- every verb invoke / deny / panel-dispatch */
4807
+ const dispatchLog = path.join(yfRoot, 'nac3_log.jsonl');
4808
+ for (const e of await tailJsonl(dispatchLog, limit)) {
4809
+ const r = e;
4810
+ if (!r.ts)
4811
+ continue;
4812
+ const label = r.kind === 'invoke' ? ((r.result_ok ? 'ok' : 'err') + ' ' + (r.verb_id ?? '?'))
4813
+ : r.kind === 'verb-not-found' ? ('unknown verb ' + (r.verb_id ?? '?'))
4814
+ : r.kind === 'policy-deny' ? ('policy denied ' + (r.verb_id ?? '?'))
4815
+ : r.kind === 'panel-handler-error' ? ('panel error ' + (r.verb_id ?? '?'))
4816
+ : String(r.kind ?? 'event');
4817
+ events.push({
4818
+ ts: String(r.ts),
4819
+ kind: 'dispatch.' + String(r.kind ?? 'unknown'),
4820
+ text: label,
4821
+ trace_id: r.trace_id ? String(r.trace_id) : undefined,
4822
+ extra: {
4823
+ duration_ms: r.duration_ms,
4824
+ policy: r.policy,
4825
+ side_effects_declared: r.side_effects_declared,
4826
+ },
4827
+ });
4828
+ }
4829
+ /* 2. approvals log -- R8 modal decisions */
4830
+ const approvalLog = path.join(yfRoot, 'nac3_approvals.jsonl');
4831
+ for (const e of await tailJsonl(approvalLog, limit)) {
4832
+ const r = e;
4833
+ if (!r.ts)
4834
+ continue;
4835
+ events.push({
4836
+ ts: String(r.ts),
4837
+ kind: 'approval.' + String(r.decision ?? 'event'),
4838
+ text: 'R8 ' + String(r.decision ?? '?') + ' on ' + String(r.verb_id ?? '?'),
4839
+ trace_id: r.trace_id ? String(r.trace_id) : undefined,
4840
+ extra: { policy: r.policy, reason: r.reason },
4841
+ });
4842
+ }
4843
+ /* 3. agent runs -- scan ~/.yujin-forge/agents/by-slug/<slug>/runs/*.json */
4844
+ const bySlug = path.join(yfRoot, 'agents', 'by-slug');
4845
+ let slugs = [];
4846
+ try {
4847
+ slugs = await fs.readdir(bySlug);
4848
+ }
4849
+ catch { /* no agents yet -- ok */ }
4850
+ for (const slug of slugs) {
4851
+ const runsDir = path.join(bySlug, slug, 'runs');
4852
+ let runFiles = [];
4853
+ try {
4854
+ runFiles = (await fs.readdir(runsDir)).filter((n) => n.endsWith('.json'));
4855
+ }
4856
+ catch {
4857
+ continue;
4858
+ }
4859
+ /* Newest first; we cap at limit per agent so a noisy one
4860
+ * does not drown others. */
4861
+ runFiles.sort();
4862
+ const cap = runFiles.slice(-Math.min(50, limit));
4863
+ for (const fn of cap) {
4864
+ try {
4865
+ const raw = await fs.readFile(path.join(runsDir, fn), 'utf-8');
4866
+ const r = JSON.parse(raw);
4867
+ const startTs = r.started_at ? new Date(Number(r.started_at)).toISOString() : '';
4868
+ const endTs = r.ended_at ? new Date(Number(r.ended_at)).toISOString() : startTs;
4869
+ const runProject = (typeof r.project_slug === 'string' && r.project_slug) ? r.project_slug : 'self';
4870
+ events.push({
4871
+ ts: startTs || endTs,
4872
+ kind: 'agent.run_started',
4873
+ slug,
4874
+ text: '[' + runProject + '] ' + slug + ' started: ' + String(r.trigger_reason ?? '').slice(0, 120),
4875
+ project_slug: runProject,
4876
+ });
4877
+ if (r.ended_at) {
4878
+ events.push({
4879
+ ts: endTs,
4880
+ kind: 'agent.run_finished',
4881
+ slug,
4882
+ text: '[' + runProject + '] ' + slug + ' ' + String(r.status ?? '?')
4883
+ + ' (in=' + (r.tokens_in ?? 0) + ' out=' + (r.tokens_out ?? 0)
4884
+ + ' tools=' + (r.tool_calls ?? 0) + ')',
4885
+ project_slug: runProject,
4886
+ extra: {
4887
+ status: r.status,
4888
+ tokens_in: r.tokens_in,
4889
+ tokens_out: r.tokens_out,
4890
+ tool_calls: r.tool_calls,
4891
+ kill_reason: r.kill_reason,
4892
+ last_reply_summary: typeof r.last_reply_summary === 'string'
4893
+ ? String(r.last_reply_summary).slice(0, 240)
4894
+ : undefined,
4895
+ },
4896
+ });
4897
+ }
4898
+ }
4899
+ catch { /* skip */ }
4900
+ }
4901
+ }
4902
+ /* 4. bridge messages -- scan every shard under
4903
+ * ~/.yujin-forge/bridge/ (bridge.json + bridge-{slug}.json).
4904
+ * Each shard's messages tag themselves with project_slug
4905
+ * derived from the filename so the activity feed knows which
4906
+ * project a message belongs to. */
4907
+ const bridgeDir = path.join(yfRoot, 'bridge');
4908
+ let bridgeShards = [];
4909
+ try {
4910
+ bridgeShards = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
4911
+ }
4912
+ catch { /* no bridge dir -- ok */ }
4913
+ for (const fname of bridgeShards) {
4914
+ const projectFromFile = fname === 'bridge.json'
4915
+ ? 'self'
4916
+ : fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
4917
+ try {
4918
+ const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
4919
+ const b = JSON.parse(raw);
4920
+ const msgs = Array.isArray(b.messages) ? b.messages : [];
4921
+ /* Take last `limit` per shard to bound memory while not
4922
+ * losing recent activity. */
4923
+ const slice = msgs.length > limit ? msgs.slice(msgs.length - limit) : msgs;
4924
+ for (const m of slice) {
4925
+ const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
4926
+ : (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
4927
+ const msgProject = (typeof m.project_slug === 'string' && m.project_slug)
4928
+ ? m.project_slug
4929
+ : projectFromFile;
4930
+ events.push({
4931
+ ts,
4932
+ kind: 'bridge.message',
4933
+ from: String(m.from_slug ?? m.from ?? '?'),
4934
+ to: String(m.to_slug ?? m.to ?? '?'),
4935
+ text: '[' + msgProject + '] '
4936
+ + String(m.from_slug ?? m.from ?? '?') + ' -> ' + String(m.to_slug ?? m.to ?? '?')
4937
+ + ': ' + String(m.payload ?? '').slice(0, 160),
4938
+ project_slug: msgProject,
4939
+ extra: { tokens: m.tokens_used, status: m.status, id: m.id },
4940
+ });
4941
+ }
4942
+ }
4943
+ catch { /* skip unreadable shard */ }
4944
+ }
4945
+ /* Sort desc by ts (ISO strings sort correctly). Filter by since
4946
+ * + project_filter. */
4947
+ events.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
4948
+ let filtered = since ? events.filter((e) => e.ts > since) : events;
4949
+ if (projectFilter) {
4950
+ filtered = filtered.filter((e) => e.project_slug === projectFilter
4951
+ /* Events without a project_slug (legacy or non-project-
4952
+ * tagged kinds like dispatch.*) appear under 'self' so a
4953
+ * filter for the default project still sees them. */
4954
+ || (!e.project_slug && projectFilter === 'self'));
4955
+ }
4956
+ const final = filtered.slice(0, limit);
4957
+ sendJson(res, 200, { ok: true, count: final.length, project_filter: projectFilter || null, events: final });
4958
+ }
4959
+ async function handleAgentBridgeTail(req, res, ctx) {
4960
+ const url = new URL(req.url ?? '/', 'http://localhost');
4961
+ const limit = clampInt(url.searchParams.get('limit'), 100, 1, 1000);
4962
+ const pairId = (url.searchParams.get('pair_id') || '').trim();
4963
+ /* Slice 1 N-projects sprint -- optional project filter. Empty
4964
+ * means "scan every shard". 'self' means only the default
4965
+ * (legacy bridge.json). */
4966
+ const projectFilter = (url.searchParams.get('project_slug') || '').trim();
4967
+ const yfRoot = agentsRootDefault(ctx);
4968
+ const bridgeDir = path.join(yfRoot, 'bridge');
4969
+ /* Discover every shard file, then read whichever ones match
4970
+ * the filter. */
4971
+ let shardFiles = [];
4972
+ try {
4973
+ shardFiles = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
4974
+ }
4975
+ catch {
4976
+ sendJson(res, 200, { ok: true, pairs: [], messages: [], pair_state: {}, project_filter: projectFilter || null });
4977
+ return;
4978
+ }
4979
+ /* Load every shard. msgs carries an extra `_project_slug` for
4980
+ * downstream filtering. pair_state aggregated per-shard. */
4981
+ const allMsgs = [];
4982
+ const allPairState = {};
4983
+ for (const fname of shardFiles) {
4984
+ const project = fname === 'bridge.json'
4985
+ ? 'self'
4986
+ : fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
4987
+ if (projectFilter && projectFilter !== project)
4988
+ continue;
4989
+ try {
4990
+ const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
4991
+ const b = JSON.parse(raw);
4992
+ const ms = Array.isArray(b.messages) ? b.messages : [];
4993
+ for (const m of ms)
4994
+ allMsgs.push({ ...m, _project_slug: project });
4995
+ if (b.pair_state) {
4996
+ for (const [pid, st] of Object.entries(b.pair_state)) {
4997
+ allPairState[pid + '@' + project] = { project_slug: project, state: st };
4998
+ }
4999
+ }
5000
+ }
5001
+ catch { /* skip unreadable shard */ }
5002
+ }
5003
+ /* When pair_id absent: aggregate by pair (cross-shard). The
5004
+ * project_slug is part of the visible pair card so two pairs
5005
+ * with the same from/to but different projects don't merge. */
5006
+ if (!pairId) {
5007
+ const pairs = {};
5008
+ for (const m of allMsgs) {
5009
+ const fromSlug = String(m.from_slug ?? m.from ?? '?');
5010
+ const toSlug = String(m.to_slug ?? m.to ?? '?');
5011
+ const proj = m._project_slug;
5012
+ const key = fromSlug + '__' + toSlug + (proj === 'self' ? '' : '@' + proj);
5013
+ const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
5014
+ : (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
5015
+ if (!pairs[key]) {
5016
+ pairs[key] = { from: fromSlug, to: toSlug, count: 0, last_ts: ts, project_slug: proj };
5017
+ }
5018
+ pairs[key].count++;
5019
+ if (ts > pairs[key].last_ts)
5020
+ pairs[key].last_ts = ts;
5021
+ }
5022
+ const list = Object.entries(pairs).map(([k, v]) => ({ pair_id: k, ...v }))
5023
+ .sort((a, b) => (a.last_ts < b.last_ts ? 1 : -1));
5024
+ sendJson(res, 200, { ok: true, pairs: list, project_filter: projectFilter || null });
5025
+ return;
5026
+ }
5027
+ /* With pair_id: parse out optional @project suffix; filter
5028
+ * messages newest first, capped. */
5029
+ const atIdx = pairId.lastIndexOf('@');
5030
+ const pairCore = atIdx >= 0 ? pairId.slice(0, atIdx) : pairId;
5031
+ const pairProject = atIdx >= 0 ? pairId.slice(atIdx + 1) : 'self';
5032
+ const filtered = allMsgs.filter((m) => {
5033
+ const fromSlug = String(m.from_slug ?? m.from ?? '?');
5034
+ const toSlug = String(m.to_slug ?? m.to ?? '?');
5035
+ return (fromSlug + '__' + toSlug) === pairCore && m._project_slug === pairProject;
5036
+ });
5037
+ const slice = filtered.length > limit ? filtered.slice(filtered.length - limit) : filtered;
5038
+ slice.sort((a, b) => Number(b.sent_at ?? 0) - Number(a.sent_at ?? 0));
5039
+ /* Find matching pair_state from the per-shard map. */
5040
+ const pairStateKey = Object.keys(allPairState).find((k) => k.endsWith('@' + pairProject))
5041
+ || null;
5042
+ sendJson(res, 200, {
5043
+ ok: true,
5044
+ pair_id: pairId,
5045
+ project_slug: pairProject,
5046
+ count: slice.length,
5047
+ messages: slice,
5048
+ pair_state: pairStateKey ? allPairState[pairStateKey].state : null,
5049
+ });
5050
+ }
5051
+ async function handleAgentInject(req, res, ctx) {
5052
+ let body;
5053
+ try {
5054
+ body = JSON.parse(await readBody(req));
5055
+ }
5056
+ catch {
5057
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
5058
+ return;
5059
+ }
5060
+ const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
5061
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
5062
+ if (!slug) {
5063
+ sendJson(res, 400, { ok: false, error: 'slug required' });
5064
+ return;
5065
+ }
5066
+ if (!text) {
5067
+ sendJson(res, 400, { ok: false, error: 'text required' });
5068
+ return;
5069
+ }
5070
+ const doDispatch = body.dispatch !== false;
5071
+ /* Slice 1 N-projects sprint -- explicit project_slug routes
5072
+ * the user message to that project's conversation/run instance.
5073
+ * Defaults to 'self' for legacy single-project behaviour. */
5074
+ const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
5075
+ ? body.project_slug.trim()
5076
+ : 'self';
5077
+ const yfRoot = agentsRootDefault(ctx);
5078
+ const convDir = path.join(yfRoot, 'agents', 'by-slug', slug, 'conversations');
5079
+ /* Pick the most recently modified conversation file, or create
5080
+ * one named "manual" if none exists yet. */
5081
+ let convFile = path.join(convDir, 'manual.json');
5082
+ try {
5083
+ const files = (await fs.readdir(convDir)).filter((n) => n.endsWith('.json'));
5084
+ if (files.length > 0) {
5085
+ const stats = await Promise.all(files.map(async (n) => ({
5086
+ name: n,
5087
+ mtime: (await fs.stat(path.join(convDir, n))).mtimeMs,
5088
+ })));
5089
+ stats.sort((a, b) => b.mtime - a.mtime);
5090
+ convFile = path.join(convDir, stats[0].name);
5091
+ }
5092
+ }
5093
+ catch {
5094
+ /* convDir does not exist yet -- mkdir below + start manual. */
5095
+ }
5096
+ let messages = [];
5097
+ try {
5098
+ const raw = await fs.readFile(convFile, 'utf-8');
5099
+ const parsed = JSON.parse(raw);
5100
+ if (parsed && Array.isArray(parsed.messages))
5101
+ messages = parsed.messages;
5102
+ }
5103
+ catch { /* fresh */ }
5104
+ messages.push({ role: 'user', content: text });
5105
+ try {
5106
+ await fs.mkdir(convDir, { recursive: true });
5107
+ await fs.writeFile(convFile, JSON.stringify({ version: 1, messages }, null, 2), 'utf-8');
5108
+ }
5109
+ catch (err) {
5110
+ sendJson(res, 500, { ok: false, error: 'persist failed: ' + (err instanceof Error ? err.message : String(err)) });
5111
+ return;
5112
+ }
5113
+ if (doDispatch) {
5114
+ if (!ctx.runtime) {
5115
+ sendJson(res, 200, {
5116
+ ok: true, persisted: convFile, dispatched: false,
5117
+ note: 'runtime not wired -- message saved but no run fired',
5118
+ });
5119
+ return;
5120
+ }
5121
+ try {
5122
+ const run = await ctx.runtime.dispatchAgent(slug, 'user-injected: ' + text.slice(0, 80), { project_slug });
5123
+ sendJson(res, 200, {
5124
+ ok: true, persisted: convFile, dispatched: true,
5125
+ run_status: run.status, run_id: run.id, last_reply: run.last_reply_summary,
5126
+ project_slug: run.project_slug ?? project_slug,
5127
+ });
5128
+ }
5129
+ catch (err) {
5130
+ sendJson(res, 200, {
5131
+ ok: true, persisted: convFile, dispatched: false,
5132
+ error: err instanceof Error ? err.message : String(err),
5133
+ });
5134
+ }
5135
+ return;
5136
+ }
5137
+ sendJson(res, 200, { ok: true, persisted: convFile, dispatched: false });
5138
+ }
5139
+ function clampInt(raw, fallback, lo, hi) {
5140
+ const n = raw != null ? Number(raw) : fallback;
5141
+ if (!Number.isFinite(n))
5142
+ return fallback;
5143
+ return Math.max(lo, Math.min(hi, Math.floor(n)));
5144
+ }
5145
+ /** Serve a legal doc (privacy policy / limited responsibility) as
5146
+ * text/markdown. Reads from dist/docs/<basename> (copied at build
5147
+ * time by scripts/copy_qa_assets.mjs). Names are static + sanitised
5148
+ * by the route caller -- no user input reaches the filesystem
5149
+ * path. */
5150
+ async function serveLegalDoc(res, basename) {
5151
+ /* The CLI bundle's __dirname at runtime points at dist/chat/.
5152
+ * Hop back to dist/ then into docs/. */
5153
+ const url = await import('node:url');
5154
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
5155
+ const distRoot = path.resolve(here, '..');
5156
+ const docPath = path.join(distRoot, 'docs', basename);
5157
+ try {
5158
+ const md = await fs.readFile(docPath, 'utf-8');
5159
+ res.statusCode = 200;
5160
+ res.setHeader('content-type', 'text/markdown; charset=utf-8');
5161
+ res.setHeader('cache-control', 'no-store, must-revalidate');
5162
+ res.end(md);
5163
+ }
5164
+ catch (err) {
5165
+ res.statusCode = 404;
5166
+ res.setHeader('content-type', 'application/json; charset=utf-8');
5167
+ res.end(JSON.stringify({ ok: false, error: 'legal doc not found', detail: basename }));
5168
+ /* Surface for ops; not user-visible. */
5169
+ console.warn('[serveLegalDoc] ' + docPath + ': ' + (err instanceof Error ? err.message : String(err)));
5170
+ }
5171
+ }
1844
5172
  /** Resolve the active Forge mode for a single request.
1845
5173
  * Layer order: yf-mode cookie -> yujin.forge.json -> default.
1846
5174
  * Stays out of server-wide state for race-safe concurrent
@@ -1940,11 +5268,16 @@ async function readBinaryBody(req, maxBytes) {
1940
5268
  return Buffer.concat(chunks);
1941
5269
  }
1942
5270
  async function readProjectName(projectRoot) {
5271
+ /* Display name. Prefers project_name (human-readable) over
5272
+ * project_slug (kebab/lower id). For graph identity use
5273
+ * resolveProjectSlug (chat/project_slug.ts). */
1943
5274
  try {
1944
5275
  const raw = await fs.readFile(path.join(projectRoot, 'yujin.forge.json'), 'utf-8');
1945
5276
  const parsed = JSON.parse(raw);
1946
5277
  if (parsed && typeof parsed.project_name === 'string')
1947
5278
  return parsed.project_name;
5279
+ if (parsed && typeof parsed.project_slug === 'string')
5280
+ return parsed.project_slug;
1948
5281
  }
1949
5282
  catch { /* fall through */ }
1950
5283
  try {