@geraldmaron/construct 1.2.2 → 1.3.0

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 (497) hide show
  1. package/README.md +7 -10
  2. package/bin/construct +286 -220
  3. package/bin/construct-postinstall.mjs +0 -21
  4. package/commands/work/optimize-prompts.md +1 -1
  5. package/examples/distribution/sources/adr.md +2 -2
  6. package/examples/seed-observations/decisions.md +1 -1
  7. package/lib/artifact-loop-core.mjs +412 -0
  8. package/lib/artifact-manifest.mjs +6 -0
  9. package/lib/artifact-release-gate.mjs +115 -49
  10. package/lib/audit-rules.mjs +2 -4
  11. package/lib/audit-skills.mjs +32 -20
  12. package/lib/audit-specialists.mjs +44 -26
  13. package/lib/auto-docs.mjs +6 -5
  14. package/lib/brand-prose.mjs +3 -3
  15. package/lib/brand-tokens.mjs +2 -2
  16. package/lib/certification/artifact-fixtures.mjs +4 -1
  17. package/lib/certification/demo-parity.mjs +6 -32
  18. package/lib/certification/real-llm-scenarios.mjs +58 -13
  19. package/lib/certification/role-cards.mjs +7 -6
  20. package/lib/certification/role-overlays.mjs +4 -4
  21. package/lib/certification/runner.mjs +9 -2
  22. package/lib/certification/skill-inventory.mjs +34 -22
  23. package/lib/certification/skill-scenarios.mjs +21 -8
  24. package/lib/certification/specialist-contracts.mjs +4 -3
  25. package/lib/certification/specialist-scenarios.mjs +7 -6
  26. package/lib/certification/status.mjs +5 -4
  27. package/lib/cli-commands.mjs +56 -57
  28. package/lib/comment-lint.mjs +1 -5
  29. package/lib/completions.mjs +20 -2
  30. package/lib/config/legacy-config-migration.mjs +125 -0
  31. package/lib/config/schema.mjs +5 -5
  32. package/lib/config/source-targets.mjs +80 -0
  33. package/lib/contracts/validate.mjs +119 -51
  34. package/lib/decisions/golden.mjs +3 -2
  35. package/lib/decisions/registry.mjs +11 -6
  36. package/lib/demo-project.mjs +188 -0
  37. package/lib/demo-recording.mjs +9 -34
  38. package/lib/demo-script.mjs +2 -2
  39. package/lib/demo-surface.mjs +13 -131
  40. package/lib/demo-tour-renderer.mjs +92 -0
  41. package/lib/demo.mjs +69 -54
  42. package/lib/diagram-export.mjs +63 -14
  43. package/lib/doctor/source-checkout.mjs +3 -4
  44. package/lib/doctor/watchers/consistency.mjs +90 -22
  45. package/lib/doctor/watchers/credential-parity.mjs +97 -0
  46. package/lib/doctor/watchers/mcp-protocol.mjs +1 -1
  47. package/lib/doctor/watchers/service-health.mjs +4 -32
  48. package/lib/document-export.mjs +73 -9
  49. package/lib/document-extract.mjs +10 -198
  50. package/lib/embed/artifact.mjs +2 -3
  51. package/lib/embed/cli.mjs +1 -1
  52. package/lib/embed/config.mjs +1 -2
  53. package/lib/embed/customer-profiles.mjs +1 -1
  54. package/lib/embed/daemon.mjs +5 -6
  55. package/lib/embed/demand-fetch.mjs +114 -2
  56. package/lib/embed/docs-lifecycle.mjs +9 -5
  57. package/lib/embed/intake-metrics.mjs +2 -2
  58. package/lib/embed/notifications.mjs +2 -3
  59. package/lib/embed/recommendation-store.mjs +25 -22
  60. package/lib/embed/role-framing.mjs +4 -9
  61. package/lib/embedded-contract/capability.mjs +4 -2
  62. package/lib/embedded-contract/contract-version.mjs +1 -1
  63. package/lib/env-config.mjs +2 -2
  64. package/lib/flavors/loader.mjs +21 -19
  65. package/lib/graph/build-from-registry.mjs +31 -7
  66. package/lib/graph/staleness.mjs +1 -1
  67. package/lib/headhunt.mjs +82 -35
  68. package/lib/hooks/agent-tracker.mjs +2 -2
  69. package/lib/hooks/config-protection.mjs +1 -1
  70. package/lib/hooks/registry-sync.mjs +3 -3
  71. package/lib/host-capabilities.mjs +5 -5
  72. package/lib/improvement/controller.mjs +2 -2
  73. package/lib/init-unified.mjs +19 -21
  74. package/lib/intake/classify.mjs +26 -0
  75. package/lib/intake/daemon.mjs +2 -3
  76. package/lib/intake/prepare.mjs +10 -3
  77. package/lib/intake/session-prelude.mjs +1 -1
  78. package/lib/intake/tables/creative.mjs +9 -9
  79. package/lib/intake/tables/operations.mjs +4 -4
  80. package/lib/intake/tables/research.mjs +2 -2
  81. package/lib/integrations/intake-integrations.mjs +9 -10
  82. package/lib/knowledge/research-store.mjs +2 -2
  83. package/lib/mcp/server.mjs +104 -61
  84. package/lib/mcp/tool-budget.mjs +1 -46
  85. package/lib/mcp/tool-recovery.mjs +44 -0
  86. package/lib/mcp/tools/artifact-author.mjs +36 -0
  87. package/lib/mcp/tools/find-tool.mjs +86 -0
  88. package/lib/mcp/tools/orchestration-run.mjs +107 -69
  89. package/lib/mcp/tools/project.mjs +4 -3
  90. package/lib/mcp/tools/{profile.mjs → scope.mjs} +54 -52
  91. package/lib/mcp/tools/skills.mjs +39 -22
  92. package/lib/mcp/tools/telemetry.mjs +2 -1
  93. package/lib/mcp/tools/workflow.mjs +1 -1
  94. package/lib/mcp-platform-config.mjs +7 -5
  95. package/lib/migrations/index.mjs +4 -2
  96. package/lib/migrations/v2-unified-registry.mjs +35 -0
  97. package/lib/model-router.mjs +203 -36
  98. package/lib/models/catalog.mjs +25 -9
  99. package/lib/models/execution-capability-profile.mjs +3 -3
  100. package/lib/models/execution-policy.mjs +7 -6
  101. package/lib/models/provider-poll.mjs +18 -4
  102. package/lib/opencode-config.mjs +56 -1
  103. package/lib/opencode-runtime-plugin.mjs +13 -10
  104. package/lib/oracle/artifact-gate.mjs +14 -4
  105. package/lib/oracle/cli.mjs +2 -0
  106. package/lib/oracle/dispatch.mjs +18 -1
  107. package/lib/oracle/execute.mjs +39 -7
  108. package/lib/oracle/index.mjs +1 -1
  109. package/lib/oracle/org-graph.mjs +10 -5
  110. package/lib/oracle/policy.mjs +6 -0
  111. package/lib/oracle/read-model.mjs +103 -6
  112. package/lib/oracle/reconcile.mjs +31 -4
  113. package/lib/oracle/remediation-dispatch.mjs +53 -0
  114. package/lib/oracle/routing.mjs +5 -0
  115. package/lib/oracle/synthesize.mjs +80 -3
  116. package/lib/orchestration/routing-tables.mjs +44 -10
  117. package/lib/orchestration/runtime.mjs +2 -0
  118. package/lib/orchestration/worker.mjs +1 -1
  119. package/lib/orchestration-policy.mjs +351 -62
  120. package/lib/overrides/resolver.mjs +1 -12
  121. package/lib/parity.mjs +37 -11
  122. package/lib/policy/engine.mjs +80 -15
  123. package/lib/prompt-composer.js +51 -15
  124. package/lib/prompt-metadata.mjs +3 -2
  125. package/lib/providers/connection-probe.mjs +58 -0
  126. package/lib/providers/copilot-auth.mjs +1 -1
  127. package/lib/providers/credential-bootstrap.mjs +1 -1
  128. package/lib/providers/op-run.mjs +3 -4
  129. package/lib/providers/secret-resolver.mjs +31 -0
  130. package/lib/publish-tooling.mjs +3 -11
  131. package/lib/publish.mjs +12 -29
  132. package/lib/reconcile/mcp-entry-reconcile.mjs +14 -9
  133. package/lib/reflect.mjs +2 -2
  134. package/lib/registry/agent-manifest.mjs +190 -0
  135. package/lib/registry/assemble.mjs +133 -0
  136. package/lib/registry/catalog.mjs +171 -0
  137. package/lib/registry/cli.mjs +590 -6
  138. package/lib/registry/consolidation.mjs +5 -4
  139. package/lib/registry/docs-sync.mjs +67 -0
  140. package/lib/registry/loader.mjs +147 -0
  141. package/lib/registry/org-io.mjs +76 -0
  142. package/lib/registry/retired-paths.mjs +71 -0
  143. package/lib/registry/surface-map.mjs +5 -7
  144. package/lib/registry/validator.mjs +438 -0
  145. package/lib/research-execution-policy.mjs +173 -0
  146. package/lib/roles/catalog.mjs +4 -9
  147. package/lib/roles/fence.mjs +107 -1
  148. package/lib/roles/flavor-bindings.mjs +68 -0
  149. package/lib/roles/gateway.mjs +140 -29
  150. package/lib/roles/manifest.mjs +22 -13
  151. package/lib/roles/router.mjs +1 -1
  152. package/lib/runtime-env.mjs +1 -1
  153. package/lib/scopes/enrich.mjs +67 -0
  154. package/lib/scopes/hooks.mjs +12 -0
  155. package/lib/{profiles → scopes}/lifecycle.mjs +70 -70
  156. package/lib/scopes/loader.mjs +104 -0
  157. package/lib/scopes/rebrand.mjs +32 -0
  158. package/lib/scopes/research-profile.mjs +16 -0
  159. package/lib/scopes/teams.mjs +98 -0
  160. package/lib/service-manager.mjs +1 -117
  161. package/lib/setup-prompts.mjs +1 -1
  162. package/lib/setup.mjs +17 -28
  163. package/lib/skills/composition-graph.mjs +98 -0
  164. package/lib/skills/router.mjs +80 -0
  165. package/lib/specialist-contracts.mjs +17 -18
  166. package/lib/specialists/postconditions.mjs +2 -2
  167. package/lib/specialists/prompt-schema.mjs +6 -5
  168. package/lib/specialists/roster.mjs +7 -12
  169. package/lib/specialists/schema.mjs +9 -6
  170. package/lib/status.mjs +25 -90
  171. package/lib/storage/file-lock.mjs +6 -3
  172. package/lib/telemetry/skill-calls.mjs +1 -1
  173. package/lib/templates/doc-presentation.mjs +63 -0
  174. package/lib/templates/visual-requirements.mjs +35 -2
  175. package/lib/term-format.mjs +107 -2
  176. package/lib/test-env-setup.mjs +21 -0
  177. package/lib/ui/components.mjs +42 -0
  178. package/lib/ui/glyphs.mjs +58 -0
  179. package/lib/ui/links.mjs +115 -0
  180. package/lib/ui/theme.mjs +108 -0
  181. package/lib/uninstall/uninstall.mjs +2 -1
  182. package/lib/validator.mjs +45 -8
  183. package/lib/validators/skill-effectiveness.mjs +174 -0
  184. package/lib/validators/skills.mjs +1 -1
  185. package/package.json +12 -7
  186. package/personas/construct.md +12 -3
  187. package/platforms/claude/CLAUDE.md +1 -1
  188. package/rules/common/beads-hygiene.md +52 -0
  189. package/rules/common/neurodivergent-output.md +4 -7
  190. package/rules/common/research.md +2 -0
  191. package/scripts/sync-specialists.mjs +260 -49
  192. package/skills/ai/prompt-optimizer.md +3 -3
  193. package/skills/brand/output-vibe.md +48 -0
  194. package/skills/docs/adr-workflow.md +3 -0
  195. package/skills/docs/backlog-proposal-workflow.md +3 -0
  196. package/skills/docs/codebase-research-workflow.md +5 -2
  197. package/skills/docs/customer-profile-workflow.md +3 -0
  198. package/skills/docs/document-ingest-workflow.md +3 -0
  199. package/skills/docs/evidence-ingest-workflow.md +4 -1
  200. package/skills/docs/init-project.md +1 -1
  201. package/skills/docs/prd-workflow.md +1 -1
  202. package/skills/docs/prfaq-workflow.md +3 -0
  203. package/skills/docs/product-intelligence-workflow.md +4 -1
  204. package/skills/docs/product-signal-workflow.md +3 -0
  205. package/skills/docs/research-workflow.md +13 -6
  206. package/skills/docs/runbook-workflow.md +3 -0
  207. package/skills/docs/strategy-workflow.md +3 -0
  208. package/skills/docs/user-research-workflow.md +6 -3
  209. package/skills/operating/orchestration-reference.md +1 -1
  210. package/skills/roles/{engineer.ai.md → ai-engineer.md} +2 -2
  211. package/skills/roles/architect.ai-systems.md +1 -1
  212. package/skills/roles/architect.data.md +1 -1
  213. package/skills/roles/architect.enterprise.md +1 -1
  214. package/skills/roles/architect.integration.md +1 -1
  215. package/skills/roles/architect.md +1 -1
  216. package/skills/roles/architect.platform.md +1 -1
  217. package/skills/roles/{product-manager.business-strategy.md → business-strategist.md} +3 -3
  218. package/skills/roles/data-analyst.experiment.md +1 -1
  219. package/skills/roles/data-analyst.md +1 -1
  220. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  221. package/skills/roles/data-analyst.product.md +1 -1
  222. package/skills/roles/data-analyst.telemetry.md +1 -1
  223. package/skills/roles/{engineer.data.md → data-engineer.md} +2 -2
  224. package/skills/roles/data-engineer.pipeline.md +2 -2
  225. package/skills/roles/data-engineer.vector-retrieval.md +2 -2
  226. package/skills/roles/data-engineer.warehouse.md +2 -2
  227. package/skills/roles/debugger.md +1 -1
  228. package/skills/roles/designer.accessibility.md +1 -1
  229. package/skills/roles/designer.md +1 -1
  230. package/skills/roles/{reviewer.devil-advocate.md → devil-advocate.md} +2 -2
  231. package/skills/roles/{operator.docs.md → docs-keeper.md} +3 -3
  232. package/skills/roles/engineer.md +3 -7
  233. package/skills/roles/{reviewer.evaluator.md → evaluator.md} +2 -2
  234. package/skills/roles/{researcher.explorer.md → explorer.md} +2 -2
  235. package/skills/roles/{operator.md → operations.md} +2 -5
  236. package/skills/roles/orchestrator.md +1 -1
  237. package/skills/roles/{engineer.platform.md → platform-engineer.md} +2 -2
  238. package/skills/roles/product-manager.ai-product.md +1 -1
  239. package/skills/roles/product-manager.enterprise.md +1 -1
  240. package/skills/roles/product-manager.growth.md +1 -2
  241. package/skills/roles/product-manager.md +1 -2
  242. package/skills/roles/product-manager.platform.md +1 -1
  243. package/skills/roles/product-manager.product.md +1 -1
  244. package/skills/roles/qa.ai-eval.md +1 -1
  245. package/skills/roles/qa.api-contract.md +1 -1
  246. package/skills/roles/qa.data-pipeline.md +1 -1
  247. package/skills/roles/qa.md +1 -1
  248. package/skills/roles/qa.web-ui.md +1 -1
  249. package/skills/roles/{operator.release.md → release-manager.md} +3 -3
  250. package/skills/roles/researcher.md +1 -1
  251. package/skills/roles/reviewer.md +1 -1
  252. package/skills/roles/security.ai.md +1 -1
  253. package/skills/roles/security.appsec.md +1 -1
  254. package/skills/roles/security.cloud.md +1 -1
  255. package/skills/roles/security.legal-compliance.md +1 -1
  256. package/skills/roles/security.md +1 -1
  257. package/skills/roles/security.privacy.md +1 -1
  258. package/skills/roles/security.supply-chain.md +1 -1
  259. package/skills/roles/{operator.sre.md → sre.md} +3 -3
  260. package/skills/roles/{qa.test-automation.md → test-automation.md} +2 -2
  261. package/skills/roles/{reviewer.trace.md → trace-reviewer.md} +2 -2
  262. package/skills/roles/{researcher.ux.md → ux-researcher.md} +2 -2
  263. package/skills/routing.json +18 -0
  264. package/skills/routing.md +1 -1
  265. package/specialists/artifact-manifest.json +2 -1
  266. package/specialists/audit-enrichments.json +14 -14
  267. package/specialists/org/contracts/accessibility-to-qa.json +37 -0
  268. package/specialists/org/contracts/any-to-business-strategist.json +57 -0
  269. package/specialists/org/contracts/any-to-debugger.json +38 -0
  270. package/specialists/org/contracts/any-to-designer.json +33 -0
  271. package/specialists/org/contracts/any-to-docs-keeper.json +34 -0
  272. package/specialists/org/contracts/any-to-explorer.json +39 -0
  273. package/specialists/org/contracts/any-to-sre-incident.json +33 -0
  274. package/specialists/org/contracts/any-to-trace-reviewer.json +32 -0
  275. package/specialists/org/contracts/architect-to-ai-engineer.json +32 -0
  276. package/specialists/org/contracts/architect-to-data-engineer.json +52 -0
  277. package/specialists/org/contracts/architect-to-devil-advocate.json +38 -0
  278. package/specialists/org/contracts/architect-to-engineer.json +34 -0
  279. package/specialists/org/contracts/architect-to-evaluator.json +59 -0
  280. package/specialists/org/contracts/architect-to-legal-compliance.json +55 -0
  281. package/specialists/org/contracts/architect-to-operations.json +45 -0
  282. package/specialists/org/contracts/architect-to-platform-engineer.json +42 -0
  283. package/specialists/org/contracts/business-strategist-to-product-manager.json +39 -0
  284. package/specialists/org/contracts/construct-to-orchestrator.json +36 -0
  285. package/specialists/org/contracts/construct-to-rd-lead.json +53 -0
  286. package/specialists/org/contracts/data-analyst-to-product-manager.json +43 -0
  287. package/specialists/org/contracts/data-engineer-to-platform-engineer.json +36 -0
  288. package/specialists/org/contracts/designer-to-accessibility.json +36 -0
  289. package/specialists/org/contracts/engineer-to-qa.json +34 -0
  290. package/specialists/org/contracts/engineer-to-reviewer.json +52 -0
  291. package/specialists/org/contracts/explorer-to-engineer.json +38 -0
  292. package/specialists/org/contracts/legal-compliance-to-release-manager.json +43 -0
  293. package/specialists/org/contracts/platform-engineer-to-engineer.json +31 -0
  294. package/specialists/org/contracts/product-manager-to-architect.json +71 -0
  295. package/specialists/org/contracts/product-manager-to-data-analyst.json +49 -0
  296. package/specialists/org/contracts/product-manager-to-ux-researcher.json +51 -0
  297. package/specialists/org/contracts/qa-to-release-manager.json +42 -0
  298. package/specialists/org/contracts/qa-to-test-automation.json +42 -0
  299. package/specialists/org/contracts/rd-lead-to-architect.json +38 -0
  300. package/specialists/org/contracts/researcher-to-architect.json +40 -0
  301. package/specialists/org/contracts/researcher-to-product-manager.json +57 -0
  302. package/specialists/org/contracts/reviewer-to-security.json +41 -0
  303. package/specialists/org/contracts/sre-to-release-manager.json +36 -0
  304. package/specialists/org/contracts/test-automation-to-engineer.json +34 -0
  305. package/specialists/org/contracts/trace-reviewer-to-sre.json +41 -0
  306. package/specialists/org/contracts/user-to-construct.json +30 -0
  307. package/specialists/org/groups/engineering-group.json +43 -0
  308. package/specialists/org/groups/governance-group.json +38 -0
  309. package/specialists/org/groups/operations-group.json +41 -0
  310. package/specialists/org/groups/product-group.json +46 -0
  311. package/specialists/org/groups/quality-group.json +42 -0
  312. package/specialists/org/groups/strategy-group.json +41 -0
  313. package/specialists/org/policies/action-approval.json +13 -0
  314. package/specialists/org/policies/agents-routing.json +13 -0
  315. package/specialists/org/policies/architecture.json +20 -0
  316. package/specialists/org/policies/bash-safety.json +13 -0
  317. package/specialists/org/policies/beads-hygiene.json +13 -0
  318. package/specialists/org/policies/bootstrap-state.json +13 -0
  319. package/specialists/org/policies/code-review.json +13 -0
  320. package/specialists/org/policies/coding-style.json +13 -0
  321. package/specialists/org/policies/comments.json +13 -0
  322. package/specialists/org/policies/commit-approval.json +13 -0
  323. package/specialists/org/policies/contract-preconditions.json +13 -0
  324. package/specialists/org/policies/deployment.json +20 -0
  325. package/specialists/org/policies/description.json +14 -0
  326. package/specialists/org/policies/design-approval.json +19 -0
  327. package/specialists/org/policies/doc-ownership.json +13 -0
  328. package/specialists/org/policies/file-path-fence.json +13 -0
  329. package/specialists/org/policies/framing.json +13 -0
  330. package/specialists/org/policies/git-workflow.json +13 -0
  331. package/specialists/org/policies/incident-response.json +15 -0
  332. package/specialists/org/policies/intake-triage.json +15 -0
  333. package/specialists/org/policies/neurodivergent-output.json +13 -0
  334. package/specialists/org/policies/no-fabrication.json +13 -0
  335. package/specialists/org/policies/patterns.json +13 -0
  336. package/specialists/org/policies/quality-gate-approval.json +17 -0
  337. package/specialists/org/policies/release-gates.json +13 -0
  338. package/specialists/org/policies/research-evidence.json +13 -0
  339. package/specialists/org/policies/review-before-change.json +13 -0
  340. package/specialists/org/policies/rollback.json +15 -0
  341. package/specialists/org/policies/scope-change.json +20 -0
  342. package/specialists/org/policies/secret-scan.json +13 -0
  343. package/specialists/org/policies/security-approval.json +17 -0
  344. package/specialists/org/policies/security.json +13 -0
  345. package/specialists/org/policies/session-efficiency.json +13 -0
  346. package/specialists/org/policies/skill-routing.json +13 -0
  347. package/specialists/org/policies/strategic-prioritization.json +17 -0
  348. package/specialists/org/policies/testing.json +13 -0
  349. package/specialists/org/policies/tool-invisibility.json +14 -0
  350. package/specialists/org/scopes/creative.json +54 -0
  351. package/specialists/org/scopes/operations.json +51 -0
  352. package/specialists/org/scopes/research.json +60 -0
  353. package/specialists/org/scopes/rnd.json +81 -0
  354. package/specialists/org/specialists/cx-accessibility.json +69 -0
  355. package/specialists/org/specialists/cx-ai-engineer.json +75 -0
  356. package/specialists/org/specialists/cx-architect.json +89 -0
  357. package/specialists/org/specialists/cx-business-strategist.json +72 -0
  358. package/specialists/org/specialists/cx-data-analyst.json +68 -0
  359. package/specialists/org/specialists/cx-data-engineer.json +71 -0
  360. package/specialists/org/specialists/cx-debugger.json +75 -0
  361. package/specialists/org/specialists/cx-designer.json +85 -0
  362. package/specialists/org/specialists/cx-devil-advocate.json +71 -0
  363. package/specialists/org/specialists/cx-docs-keeper.json +82 -0
  364. package/specialists/org/specialists/cx-engineer.json +106 -0
  365. package/specialists/org/specialists/cx-evaluator.json +69 -0
  366. package/specialists/org/specialists/cx-explorer.json +69 -0
  367. package/specialists/org/specialists/cx-legal-compliance.json +74 -0
  368. package/specialists/org/specialists/cx-operations.json +72 -0
  369. package/specialists/org/specialists/cx-oracle.json +46 -0
  370. package/specialists/org/specialists/cx-orchestrator.json +81 -0
  371. package/specialists/org/specialists/cx-platform-engineer.json +80 -0
  372. package/specialists/org/specialists/cx-product-manager.json +102 -0
  373. package/specialists/org/specialists/cx-qa.json +79 -0
  374. package/specialists/org/specialists/cx-rd-lead.json +73 -0
  375. package/specialists/org/specialists/cx-release-manager.json +77 -0
  376. package/specialists/org/specialists/cx-researcher.json +82 -0
  377. package/specialists/org/specialists/cx-reviewer.json +71 -0
  378. package/specialists/org/specialists/cx-security.json +91 -0
  379. package/specialists/org/specialists/cx-sre.json +94 -0
  380. package/specialists/org/specialists/cx-test-automation.json +69 -0
  381. package/specialists/org/specialists/cx-trace-reviewer.json +69 -0
  382. package/specialists/org/specialists/cx-ux-researcher.json +68 -0
  383. package/specialists/org/teams/accessibility-team.json +39 -0
  384. package/specialists/org/teams/design-team.json +40 -0
  385. package/specialists/org/teams/engineering-team.json +50 -0
  386. package/specialists/org/teams/governance-team.json +41 -0
  387. package/specialists/org/teams/operations-team.json +46 -0
  388. package/specialists/org/teams/product-management-team.json +45 -0
  389. package/specialists/org/teams/quality-team.json +49 -0
  390. package/specialists/org/teams/research-team.json +39 -0
  391. package/specialists/org/teams/strategy-team.json +48 -0
  392. package/specialists/org/teams/ux-research-team.json +39 -0
  393. package/specialists/prompts/_shared/validation-contract.md +1 -1
  394. package/specialists/prompts/_team-template.md +10 -0
  395. package/specialists/prompts/cx-accessibility.md +1 -0
  396. package/specialists/prompts/cx-ai-engineer.md +1 -1
  397. package/specialists/prompts/cx-business-strategist.md +1 -1
  398. package/specialists/prompts/cx-data-engineer.md +1 -1
  399. package/specialists/prompts/cx-designer.md +1 -0
  400. package/specialists/prompts/cx-devil-advocate.md +1 -1
  401. package/specialists/prompts/cx-docs-keeper.md +1 -1
  402. package/specialists/prompts/cx-evaluator.md +1 -1
  403. package/specialists/prompts/cx-explorer.md +1 -1
  404. package/specialists/prompts/cx-operations.md +1 -1
  405. package/specialists/prompts/cx-oracle.md +7 -3
  406. package/specialists/prompts/cx-orchestrator.md +17 -1
  407. package/specialists/prompts/cx-platform-engineer.md +1 -1
  408. package/specialists/prompts/cx-product-manager.md +2 -0
  409. package/specialists/prompts/cx-release-manager.md +1 -1
  410. package/specialists/prompts/cx-researcher.md +7 -4
  411. package/specialists/prompts/cx-sre.md +1 -1
  412. package/specialists/prompts/cx-test-automation.md +2 -2
  413. package/specialists/prompts/cx-trace-reviewer.md +3 -3
  414. package/specialists/prompts/cx-ux-researcher.md +2 -1
  415. package/templates/demos/scripts/architecture-review-adr.json +30 -0
  416. package/templates/demos/scripts/capability-contract.json +25 -0
  417. package/templates/demos/scripts/intake-triage.json +25 -0
  418. package/templates/demos/scripts/profile-doctor-health.json +25 -0
  419. package/templates/demos/tapes/agentic-platforms-prd.tape +2 -2
  420. package/templates/demos/tapes/architecture-review-adr.tape +44 -0
  421. package/templates/demos/tapes/capability-contract.tape +39 -0
  422. package/templates/demos/tapes/intake-triage.tape +39 -0
  423. package/templates/demos/tapes/profile-doctor-health.tape +39 -0
  424. package/templates/distribution/construct-brand.typ +23 -18
  425. package/templates/distribution/construct-decision.typ +1 -1
  426. package/templates/distribution/construct-pdf.typ +1 -1
  427. package/templates/distribution/construct-prd.typ +1 -1
  428. package/templates/distribution/construct-research.typ +1 -1
  429. package/templates/distribution/mermaid-puppeteer.json +8 -0
  430. package/templates/docs/persona-artifact.md +1 -1
  431. package/templates/docs/prd.md +6 -0
  432. package/lib/boundary.mjs +0 -127
  433. package/lib/certification/dashboard-api.mjs +0 -71
  434. package/lib/chat/cli.mjs +0 -333
  435. package/lib/chat/command-suggest.mjs +0 -161
  436. package/lib/chat/commands.mjs +0 -215
  437. package/lib/chat/config.mjs +0 -142
  438. package/lib/chat/context-compactor.mjs +0 -250
  439. package/lib/chat/context-continuation.mjs +0 -253
  440. package/lib/chat/continuation-source.mjs +0 -58
  441. package/lib/chat/demo-guide.mjs +0 -61
  442. package/lib/chat/design-tokens.mjs +0 -91
  443. package/lib/chat/desktop-binary.mjs +0 -81
  444. package/lib/chat/desktop-build.mjs +0 -130
  445. package/lib/chat/desktop-launcher.mjs +0 -133
  446. package/lib/chat/evidence.mjs +0 -145
  447. package/lib/chat/export.mjs +0 -74
  448. package/lib/chat/harness/driver.mjs +0 -91
  449. package/lib/chat/list-picker.mjs +0 -112
  450. package/lib/chat/model-picker.mjs +0 -356
  451. package/lib/chat/openrouter-fallback.mjs +0 -151
  452. package/lib/chat/permission-prompt.mjs +0 -33
  453. package/lib/chat/picker-catalog.mjs +0 -45
  454. package/lib/chat/policy-telemetry.mjs +0 -34
  455. package/lib/chat/present.mjs +0 -246
  456. package/lib/chat/session-context.mjs +0 -39
  457. package/lib/chat/session-persist.mjs +0 -73
  458. package/lib/chat/session-restore.mjs +0 -71
  459. package/lib/chat/session-settings.mjs +0 -53
  460. package/lib/chat/system-prompt.mjs +0 -52
  461. package/lib/chat/transparency.mjs +0 -93
  462. package/lib/chat/tui/color-scheme.mjs +0 -42
  463. package/lib/chat/tui/markdown.mjs +0 -123
  464. package/lib/chat/tui/presentation.mjs +0 -100
  465. package/lib/chat/tui/render.mjs +0 -500
  466. package/lib/chat/tui/turn-block.mjs +0 -284
  467. package/lib/chat/tui/turn-present.mjs +0 -18
  468. package/lib/chat/tui/turn-state.mjs +0 -88
  469. package/lib/chat/tui/usage.mjs +0 -122
  470. package/lib/chat/web-commands.mjs +0 -146
  471. package/lib/chat/web-launcher.mjs +0 -63
  472. package/lib/chat/web-picker-keys.mjs +0 -46
  473. package/lib/chat/web-session.mjs +0 -159
  474. package/lib/config/alias.mjs +0 -57
  475. package/lib/dashboard-demo.mjs +0 -71
  476. package/lib/dashboard-static.mjs +0 -175
  477. package/lib/install/desktop-binary-download.mjs +0 -88
  478. package/lib/profiles/loader.mjs +0 -123
  479. package/lib/profiles/rebrand.mjs +0 -46
  480. package/lib/server/auth.mjs +0 -169
  481. package/lib/server/chat-loop.mjs +0 -622
  482. package/lib/server/chat.mjs +0 -336
  483. package/lib/server/cors.mjs +0 -77
  484. package/lib/server/csrf.mjs +0 -103
  485. package/lib/server/demo-preview.mjs +0 -63
  486. package/lib/server/index.mjs +0 -2641
  487. package/lib/server/insights.mjs +0 -780
  488. package/lib/server/langfuse-login.mjs +0 -58
  489. package/lib/server/rate-limit.mjs +0 -93
  490. package/lib/server/telemetry-login.mjs +0 -100
  491. package/lib/server/webhook.mjs +0 -512
  492. package/specialists/contracts.json +0 -1032
  493. package/specialists/contracts.schema.json +0 -83
  494. package/specialists/policy-inventory.json +0 -188
  495. package/specialists/registry.json +0 -1412
  496. package/specialists/role-manifests.json +0 -217
  497. package/specialists/teams.json +0 -94
@@ -1,2641 +0,0 @@
1
- /**
2
- * lib/server/index.mjs — Construct dashboard HTTP server.
3
- *
4
- * Serves the single-page dashboard from lib/server/static/, provides a JSON
5
- * status API, SSE live-reload, REST endpoints for registry management,
6
- * artifact generation/listing, approval queue inspection, snapshot data,
7
- * token-based auth, legacy claude CLI chat, and owned-loop SSE web chat.
8
- * Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
9
- */
10
- import { createServer } from 'http';
11
- import { registerBoundary } from '../boundary.mjs';
12
- import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
13
- import { spawnSync } from 'child_process';
14
- import { join, extname, relative, normalize, dirname } from 'path';
15
- import { homedir } from 'os';
16
- import { fileURLToPath } from 'url';
17
- import { spawn } from 'child_process';
18
- import { buildStatus as buildSharedStatus } from '../status.mjs';
19
- import { generateArtifact, listArtifacts } from '../embed/artifact.mjs';
20
- import { ApprovalQueue } from '../embed/approval-queue.mjs';
21
- import { resolveEmbedStatus } from '../embed/cli.mjs';
22
- import { loadConstructEnv, prepareConstructEnv } from '../runtime-env.mjs';
23
- import { configDir, doctorRoot } from '../config/xdg.mjs';
24
- import {
25
- isAuthConfigured, isAuthenticated, rejectUnauthorized,
26
- validateToken, createSession, sessionCookieHeader, clearSessionCookieHeader,
27
- getDashboardToken, getAuthConfig,
28
- } from './auth.mjs';
29
- import { handleChatStream, handleChat, handleChatHistory } from './chat.mjs';
30
- import {
31
- handleChatLoopStream, handleChatLoopPermission, handleChatLoopPending, handleChatLoopConfig,
32
- handleChatLoopCommand, handleChatLoopHistory, handleChatLoopCancel, handleChatModels, handleChatModelSelect,
33
- } from './chat-loop.mjs';
34
- import { createWebhookHandler, createSlackCommandHandler } from './webhook.mjs';
35
- import { onEmbedNotification } from '../embed/notifications.mjs';
36
- import { artifactTypes, templateMetadata } from '../artifact-manifest.mjs';
37
- import { resolveDemoPreviewPath, demoPreviewMime } from './demo-preview.mjs';
38
-
39
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
40
- const ROOT_DIR = join(__dirname, '..', '..');
41
- const HOME = homedir();
42
-
43
- // The daemon is a long-lived, detached service not bound to one project, so its
44
- // orchestration runs persist under the user data root (HOME → ~/.cx/runtime/
45
- // orchestration via the run store's relative path) rather than the install
46
- // directory. A global install dir can be shared or read-only and would mix
47
- // every project's runs together; HOME is always writable and survives reinstalls.
48
-
49
- const ORCHESTRATION_RUN_ROOT = HOME;
50
- const PORT = parseInt(process.env.PORT ?? '4242', 10);
51
-
52
- // Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
53
- // non-loopback interfaces is opt-in via BIND_HOST and gated on configured
54
- // auth (see assertSafeBind below).
55
-
56
- const BIND_HOST = process.env.BIND_HOST ?? '127.0.0.1';
57
-
58
- function isLoopbackHost(host) {
59
- return host === '127.0.0.1' || host === 'localhost' || host === '::1';
60
- }
61
-
62
- function assertSafeBind() {
63
- if (isLoopbackHost(BIND_HOST)) return;
64
- if (!isAuthConfigured()) {
65
- process.stderr.write(
66
- `[dashboard] refuses to bind to non-loopback host ${BIND_HOST} without auth configured. ` +
67
- `Set CONSTRUCT_DASHBOARD_TOKEN (or run \`construct dashboard auth init\`) before exposing the dashboard.\n`,
68
- );
69
- process.exit(1);
70
- }
71
- }
72
-
73
- const STATIC_DIR = join(__dirname, 'static');
74
- const REGISTRY_FILE = join(ROOT_DIR, 'specialists', 'registry.json');
75
- const FEATURES_FILE = join(configDir(HOME), 'features.json');
76
- const WORKFLOW_FILE = join(ROOT_DIR, 'plan.md');
77
- const SKILLS_DIR = join(ROOT_DIR, 'skills');
78
- const COMMANDS_DIR = join(ROOT_DIR, 'commands');
79
- const SNAPSHOTS_FILE = join(doctorRoot(), 'snapshots.jsonl');
80
- const APPROVAL_QUEUE_FILE = join(doctorRoot(), 'approval-queue.jsonl');
81
- const CONFIG_ENV_FILE = join(configDir(HOME), 'config.env');
82
- const EMBED_YAML_FILE = join(configDir(HOME), 'embed.yaml');
83
- const CREDENTIAL_AUDIT_FILE = join(doctorRoot(), 'credential-audit.jsonl');
84
-
85
- // Provider → expected env vars. Source of truth for the credentials surface,
86
- // the per-provider health classification ("not configured" vs "unhealthy"),
87
- // and the write-side allowlist — POST refuses env vars not listed here.
88
-
89
- const BUILTIN_CREDENTIAL_MAP = [
90
- { provider: 'anthropic', label: 'Anthropic', kind: 'llm', envVars: ['ANTHROPIC_API_KEY'] },
91
- { provider: 'openai', label: 'OpenAI', kind: 'llm', envVars: ['OPENAI_API_KEY'] },
92
- { provider: 'openrouter', label: 'OpenRouter', kind: 'llm', envVars: ['OPENROUTER_API_KEY'] },
93
- { provider: 'gemini', label: 'Google Gemini', kind: 'llm', envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
94
- { provider: 'github', label: 'GitHub', kind: 'integration', envVars: ['GITHUB_TOKEN', 'GH_TOKEN'] },
95
- { provider: 'atlassian-jira', label: 'Jira', kind: 'integration', envVars: ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'] },
96
- { provider: 'atlassian-confluence',label: 'Confluence', kind: 'integration', envVars: ['CONFLUENCE_BASE_URL', 'CONFLUENCE_EMAIL', 'CONFLUENCE_API_TOKEN'] },
97
- { provider: 'slack', label: 'Slack', kind: 'integration', envVars: ['SLACK_BOT_TOKEN', 'SLACK_USER_TOKEN'] },
98
- { provider: 'salesforce', label: 'Salesforce', kind: 'integration', envVars: ['SALESFORCE_INSTANCE_URL', 'SALESFORCE_ACCESS_TOKEN'] },
99
- ];
100
-
101
- const CUSTOM_CREDENTIALS_FILE = join(configDir(HOME), 'custom-credentials.json');
102
-
103
- // Env-var name validator: must match POSIX shell variable syntax and avoid
104
- // stomping built-in entries. Refusing $PATH / $HOME / OS-special prefixes
105
- // keeps a custom provider from being a foot-gun for the shell.
106
- const ENV_VAR_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
107
- const ENV_VAR_BLOCKLIST = new Set(['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'PWD', 'TERM']);
108
-
109
- function readCustomCredentials() {
110
- if (!existsSync(CUSTOM_CREDENTIALS_FILE)) return [];
111
- try {
112
- const raw = JSON.parse(readFileSync(CUSTOM_CREDENTIALS_FILE, 'utf8'));
113
- const list = Array.isArray(raw?.providers) ? raw.providers : [];
114
- return list.filter((entry) => {
115
- if (!entry || typeof entry.provider !== 'string') return false;
116
- if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === entry.provider)) return false;
117
- if (!Array.isArray(entry.envVars)) return false;
118
- return entry.envVars.every((name) => ENV_VAR_PATTERN.test(name) && !ENV_VAR_BLOCKLIST.has(name));
119
- });
120
- } catch {
121
- return [];
122
- }
123
- }
124
-
125
- function writeCustomCredentials(list) {
126
- mkdirSync(dirname(CUSTOM_CREDENTIALS_FILE), { recursive: true });
127
- writeFileSync(CUSTOM_CREDENTIALS_FILE, JSON.stringify({ providers: list }, null, 2) + '\n');
128
- try { chmodSync(CUSTOM_CREDENTIALS_FILE, 0o600); } catch { /* perms best-effort */ }
129
- }
130
-
131
- function credentialMap() {
132
- return [...BUILTIN_CREDENTIAL_MAP, ...readCustomCredentials()];
133
- }
134
-
135
- function allowedCredentialEnvVars() {
136
- return new Set(credentialMap().flatMap((c) => c.envVars));
137
- }
138
-
139
- function maskValue(value) {
140
- if (!value) return null;
141
- if (value.length <= 10) return `${value.slice(0, 2)}…${value.slice(-2)}`;
142
- return `${value.slice(0, 6)}…${value.slice(-4)}`;
143
- }
144
-
145
- function buildCredentialsView(env = process.env) {
146
- return credentialMap().map(c => {
147
- const vars = c.envVars.map(name => {
148
- const val = resolveCredential(name, env);
149
- const set = Boolean(val);
150
- return { envVar: name, set, preview: set ? maskValue(val) : null };
151
- });
152
- const setCount = vars.filter(v => v.set).length;
153
- let configured;
154
- if (setCount === 0) configured = 'none';
155
- else if (setCount === c.envVars.length) configured = 'full';
156
- else configured = 'partial';
157
- return { provider: c.provider, label: c.label, kind: c.kind, vars, configured };
158
- });
159
- }
160
-
161
- // A provider with no required env vars set is "not_configured" regardless of
162
- // whether the probe succeeds — some probes (e.g. GitHub anonymous rate_limit)
163
- // return ok:true even with zero auth, which would otherwise read as a green
164
- // "Healthy" while the credentials column shows everything blank.
165
-
166
- /**
167
- * Resolve a credential from env, config.env, ~/.env, and shell rc files.
168
- * Mirrors the logic in model-router.mjs isProviderConfigured.
169
- */
170
- function resolveCredential(varName, env) {
171
- if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
172
- const homeDir = HOME || homedir();
173
-
174
- // Special case: Ollama on default port
175
- if (varName === 'OLLAMA_BASE_URL') {
176
- try {
177
- const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
178
- if (r.status === 0 && r.stdout?.trim() === '200') return 'http://localhost:11434';
179
- } catch { /* not available */ }
180
- try {
181
- const r = spawnSync('ollama', ['--version'], { encoding: 'utf8', timeout: 2000 });
182
- if (r.status === 0) return 'found-ollama-binary';
183
- } catch { /* not available */ }
184
- }
185
-
186
- const paths = [join(configDir(homeDir), 'config.env'), join(homeDir, '.env')];
187
- for (const p of paths) {
188
- try {
189
- if (existsSync(p)) {
190
- const content = readFileSync(p, 'utf8');
191
- const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
192
- if (m && m[1]) return m[1].trim();
193
- }
194
- } catch { /* skip */ }
195
- }
196
- // Check shell rc files for op:// refs
197
- const shellFiles = [join(homeDir, '.zshrc'), join(homeDir, '.bashrc'), join(homeDir, '.bash_profile'), join(homeDir, '.profile')];
198
- for (const rc of shellFiles) {
199
- if (!existsSync(rc)) continue;
200
- try {
201
- const content = readFileSync(rc, 'utf8');
202
- const directRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
203
- if (directRe.test(content)) return 'found-in-rc';
204
- const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
205
- const m = content.match(opRe);
206
- if (m) {
207
- const r = spawnSync('op', ['read', m[1]], { encoding: 'utf8', timeout: 5000 });
208
- if (r.status === 0 && r.stdout?.trim()) return r.stdout.trim();
209
- }
210
- } catch { /* skip */ }
211
- }
212
- return null;
213
- }
214
-
215
- function classifyProviderStatus(providerId, healthOk, env = process.env) {
216
- const entry = credentialMap().find(c => c.provider === providerId);
217
- const anySet = entry ? entry.envVars.some(name => Boolean(resolveCredential(name, env))) : false;
218
- if (!anySet) return 'not_configured';
219
- return healthOk ? 'healthy' : 'unhealthy';
220
- }
221
-
222
- function describeMissingCredentials(providerId, env = process.env) {
223
- const entry = credentialMap().find(c => c.provider === providerId);
224
- if (!entry) return null;
225
- const missing = entry.envVars.filter(name => !resolveCredential(name, env));
226
- if (missing.length === 0) return null;
227
- return `missing: ${missing.join(', ')}`;
228
- }
229
-
230
- const approvalQueue = new ApprovalQueue({ path: APPROVAL_QUEUE_FILE });
231
- const sseClients = new Set();
232
-
233
- // ── Terraform config ───────────────────────────────────────────────────────
234
- const TERRAFORM_DIR = join(ROOT_DIR, 'deploy', 'terraform');
235
- const TERRAFORM_ALLOWED_EXTS = new Set(['.tf', '.tfvars', '.json']);
236
-
237
- function terraformFiles(dir, base) {
238
- const results = [];
239
- if (!existsSync(dir)) return results;
240
- for (const entry of readdirSync(dir)) {
241
- const full = join(dir, entry);
242
- let stat;
243
- try { stat = statSync(full); } catch { continue; }
244
- if (stat.isDirectory()) {
245
- results.push(...terraformFiles(full, base));
246
- } else {
247
- const ext = extname(entry);
248
- if (TERRAFORM_ALLOWED_EXTS.has(ext)) {
249
- results.push(relative(base, full));
250
- }
251
- }
252
- }
253
- return results.sort();
254
- }
255
-
256
- function assertTerraformPath(relPath) {
257
- const abs = normalize(join(TERRAFORM_DIR, relPath));
258
- if (!abs.startsWith(TERRAFORM_DIR + '/') && abs !== TERRAFORM_DIR) {
259
- throw new Error('Path traversal not allowed');
260
- }
261
- if (!TERRAFORM_ALLOWED_EXTS.has(extname(abs))) {
262
- throw new Error('Only .tf, .tfvars, and .json files are editable');
263
- }
264
- return abs;
265
- }
266
-
267
- // Webhook handler — created after approvalQueue so it can share the instance
268
- let _webhookHandler = null;
269
- function getWebhookHandler() {
270
- if (!_webhookHandler) {
271
- _webhookHandler = createWebhookHandler({ approvalQueue, notifyClients });
272
- }
273
- return _webhookHandler;
274
- }
275
-
276
- let _slackCommandHandler = null;
277
- function getSlackCommandHandler() {
278
- if (!_slackCommandHandler) {
279
- _slackCommandHandler = createSlackCommandHandler();
280
- }
281
- return _slackCommandHandler;
282
- }
283
-
284
- const MIME = {
285
- '.html': 'text/html; charset=utf-8',
286
- '.js': 'text/javascript; charset=utf-8',
287
- '.css': 'text/css; charset=utf-8',
288
- '.json': 'application/json',
289
- '.svg': 'image/svg+xml',
290
- '.ico': 'image/x-icon',
291
- '.png': 'image/png',
292
- '.jpg': 'image/jpeg',
293
- '.jpeg': 'image/jpeg',
294
- '.gif': 'image/gif',
295
- '.webp': 'image/webp',
296
- '.woff': 'font/woff',
297
- '.woff2': 'font/woff2',
298
- '.ttf': 'font/ttf',
299
- '.xml': 'application/xml',
300
- '.txt': 'text/plain; charset=utf-8',
301
- '.manifest': 'text/cache-manifest',
302
- '.map': 'application/json',
303
- '.eot': 'application/vnd.ms-fontobject',
304
- '.otf': 'font/otf',
305
- '.mp4': 'video/mp4',
306
- '.mp3': 'audio/mpeg',
307
- '.pdf': 'application/pdf',
308
- };
309
-
310
- function listCommands() {
311
- if (!existsSync(COMMANDS_DIR)) return [];
312
- const result = [];
313
- for (const domain of readdirSync(COMMANDS_DIR).sort()) {
314
- const domainPath = join(COMMANDS_DIR, domain);
315
- try {
316
- if (!statSync(domainPath).isDirectory()) continue;
317
- const commands = [];
318
- for (const file of readdirSync(domainPath).sort()) {
319
- if (!file.endsWith('.md')) continue;
320
- const content = readFileSync(join(domainPath, file), 'utf8');
321
- const match = content.match(/^---\r?\n[\s\S]*?description:\s*(.+?)\r?\n[\s\S]*?---/);
322
- const description = match ? match[1].trim() : file.replace('.md', '');
323
- commands.push({ name: file.replace('.md', ''), description, slash: `/${domain}:${file.replace('.md', '')}` });
324
- }
325
- if (commands.length) result.push({ domain, commands });
326
- } catch { continue; }
327
- }
328
- return result;
329
- }
330
-
331
- function listSkills() {
332
- if (!existsSync(SKILLS_DIR)) return [];
333
- const result = [];
334
- for (const cat of readdirSync(SKILLS_DIR)) {
335
- const catPath = join(SKILLS_DIR, cat);
336
- try {
337
- const stat = statSync(catPath);
338
- if (!stat.isDirectory()) continue;
339
- const files = readdirSync(catPath)
340
- .filter(f => f.endsWith('.md') || f.endsWith('.mjs'))
341
- .filter(f => f !== 'SKILL.md');
342
- result.push({ category: cat, files });
343
- } catch { continue; }
344
- }
345
- return result;
346
- }
347
-
348
- async function buildStatus() {
349
- const status = await buildSharedStatus({
350
- rootDir: ROOT_DIR,
351
- cwd: process.cwd(),
352
- homeDir: HOME,
353
- // The dashboard is long-lived and `construct dev` rewrites managed values in
354
- // ~/.construct/config.env. Read fresh config on every request instead of
355
- // letting inherited process env shadow updated ports/credentials.
356
- env: {},
357
- dashboardPort: PORT,
358
- selfDashboard: true,
359
- });
360
-
361
- return {
362
- ...status,
363
- skills: listSkills(),
364
- commands: listCommands(),
365
- artifactManifest: Object.fromEntries(
366
- artifactTypes({ rootDir: ROOT_DIR }).map((type) => [type, templateMetadata(type, { rootDir: ROOT_DIR })]),
367
- ),
368
- };
369
- }
370
-
371
- async function handleSessionUsage(_req, res) {
372
- try {
373
- const status = await buildSharedStatus({
374
- rootDir: ROOT_DIR,
375
- cwd: process.cwd(),
376
- homeDir: HOME,
377
- env: {},
378
- });
379
-
380
- const payload = {
381
- source: status.sessionUsage?.status === 'available' ? 'local-session-log' : 'unavailable',
382
- status: status.sessionUsage?.status ?? 'unavailable',
383
- sessionUsage: status.sessionUsage,
384
- guidance: status.sessionUsage?.status === 'available'
385
- ? 'Construct can answer token usage questions from locally recorded session usage.'
386
- : 'No local token usage has been recorded for this session yet.',
387
- };
388
-
389
- res.writeHead(200, { 'Content-Type': 'application/json' });
390
- res.end(JSON.stringify(payload));
391
- } catch (err) {
392
- res.writeHead(500, { 'Content-Type': 'application/json' });
393
- res.end(JSON.stringify({ error: err.message }));
394
- }
395
- }
396
-
397
- // ── Artifacts handler ─────────────────────────────────────────────────────
398
-
399
- async function handleArtifacts(req, res) {
400
- const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
401
-
402
- if (req.method === 'GET') {
403
- const type = url.searchParams.get('type') || undefined;
404
- try {
405
- const artifacts = listArtifacts({ type, rootDir: ROOT_DIR });
406
- res.writeHead(200, { 'Content-Type': 'application/json' });
407
- res.end(JSON.stringify({ artifacts }));
408
- } catch (err) {
409
- res.writeHead(500, { 'Content-Type': 'application/json' });
410
- res.end(JSON.stringify({ error: err.message }));
411
- }
412
- return;
413
- }
414
-
415
- if (req.method === 'POST') {
416
- let body = '';
417
- req.on('data', chunk => { body += chunk; });
418
- req.on('end', () => {
419
- try {
420
- const data = JSON.parse(body || '{}');
421
- const result = generateArtifact({
422
- type: data.type,
423
- title: data.title,
424
- rootDir: ROOT_DIR,
425
- fields: data.fields || {},
426
- dryRun: data.dryRun === true,
427
- });
428
- res.writeHead(200, { 'Content-Type': 'application/json' });
429
- res.end(JSON.stringify({ success: true, ...result }));
430
- if (!data.dryRun) notifyClients();
431
- } catch (err) {
432
- res.writeHead(400, { 'Content-Type': 'application/json' });
433
- res.end(JSON.stringify({ error: err.message }));
434
- }
435
- });
436
- return;
437
- }
438
-
439
- res.writeHead(405);
440
- res.end('Method Not Allowed');
441
- }
442
-
443
- // ── Approval queue handler ────────────────────────────────────────────────
444
-
445
- async function handleApprovals(req, res) {
446
- const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
447
-
448
- if (req.method === 'GET') {
449
- const pending = approvalQueue.list('pending');
450
- res.writeHead(200, { 'Content-Type': 'application/json' });
451
- res.end(JSON.stringify({ items: pending }));
452
- return;
453
- }
454
-
455
- if (req.method === 'POST') {
456
- let body = '';
457
- req.on('data', chunk => { body += chunk; });
458
- req.on('end', () => {
459
- try {
460
- const data = JSON.parse(body || '{}');
461
- const { action, id, note } = data;
462
- if (!id) throw new Error('Missing id');
463
- if (action === 'approve') {
464
- approvalQueue.approve(id);
465
- } else if (action === 'reject') {
466
- approvalQueue.reject(id, { reason: note || 'Rejected via dashboard' });
467
- } else {
468
- throw new Error('action must be approve or reject');
469
- }
470
- res.writeHead(200, { 'Content-Type': 'application/json' });
471
- res.end(JSON.stringify({ success: true }));
472
- notifyClients();
473
- } catch (err) {
474
- res.writeHead(400, { 'Content-Type': 'application/json' });
475
- res.end(JSON.stringify({ error: err.message }));
476
- }
477
- });
478
- return;
479
- }
480
-
481
- res.writeHead(405);
482
- res.end('Method Not Allowed');
483
- }
484
-
485
- // Orchestration daemon surface: start a run (background execution), inspect runs,
486
- // and stream a single run's lifecycle over SSE. Construct's runtime owns the
487
- // orchestration; this lets any thin client (editor, TUI, CI) reach the outcome.
488
- // Responses are wrapped in the versioned, secret-free embedded-contract envelope.
489
-
490
- async function handleOrchestration(req, res) {
491
- const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
492
- const { startRun, getRun, getRuns } = await import('../orchestration/runtime.mjs');
493
- const { onRunEvent, requestCancel } = await import('../orchestration/events.mjs');
494
- const { wrapResponse } = await import('../embedded-contract/envelope.mjs');
495
-
496
- const send = (code, data) => {
497
- res.writeHead(code, { 'Content-Type': 'application/json' });
498
- res.end(JSON.stringify(wrapResponse({ data, surface: 'sdk' })));
499
- };
500
- const fail = (code, message) => {
501
- res.writeHead(code, { 'Content-Type': 'application/json' });
502
- res.end(JSON.stringify({ error: message }));
503
- };
504
-
505
- if (url.pathname === '/api/orchestration/runs' && req.method === 'POST') {
506
- let body = '';
507
- req.on('data', (c) => { body += c; });
508
- req.on('end', async () => {
509
- try {
510
- const d = JSON.parse(body || '{}');
511
- if (!d.request || typeof d.request !== 'string') throw new Error('Missing "request" string');
512
- const run = await startRun(
513
- { request: d.request, workflowType: d.workflowType, requestedStrategy: d.requestedStrategy, host: d.host, hostModel: d.hostModel, hostProvider: d.hostProvider, fileCount: d.fileCount, moduleCount: d.moduleCount },
514
- { cwd: ORCHESTRATION_RUN_ROOT, workerBackend: d.workerBackend || null },
515
- );
516
- send(202, run);
517
- } catch (err) {
518
- fail(400, err.message);
519
- }
520
- });
521
- return;
522
- }
523
-
524
- if (url.pathname === '/api/orchestration/runs' && req.method === 'GET') {
525
- const limit = Number(url.searchParams.get('limit') || 20);
526
- send(200, { runs: await getRuns(ORCHESTRATION_RUN_ROOT, { limit }) });
527
- return;
528
- }
529
-
530
- const match = url.pathname.match(/^\/api\/orchestration\/runs\/([^/]+)(\/events|\/cancel)?$/);
531
- if (match) {
532
- const runId = decodeURIComponent(match[1]);
533
- const sub = match[2];
534
-
535
- if (!sub && req.method === 'GET') {
536
- const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
537
- if (!run) { fail(404, `Run not found: ${runId}`); return; }
538
- send(200, run);
539
- return;
540
- }
541
-
542
- if (sub === '/cancel' && req.method === 'POST') {
543
- requestCancel(runId);
544
- send(202, { runId, cancelRequested: true });
545
- return;
546
- }
547
-
548
- if (sub === '/events' && req.method === 'GET') {
549
- const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
550
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
551
- res.write(': connected\n\n');
552
- if (run) {
553
- const snapshot = { type: 'snapshot', runId, status: run.status, chainOfThought: run.chainOfThought ?? null, tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })) };
554
- res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
555
- }
556
- const off = onRunEvent(runId, (event) => {
557
- try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { off(); }
558
- });
559
- req.on('close', off);
560
- return;
561
- }
562
- }
563
-
564
- fail(404, 'Not found');
565
- }
566
-
567
- // ── Config handler ────────────────────────────────────────────────────────
568
-
569
- function handleConfig(req, res) {
570
- if (req.method === 'GET') {
571
- const env = existsSync(CONFIG_ENV_FILE) ? readFileSync(CONFIG_ENV_FILE, 'utf8') : '';
572
- const embed = existsSync(EMBED_YAML_FILE) ? readFileSync(EMBED_YAML_FILE, 'utf8') : '';
573
- // Extract roles from embed YAML (simple regex — works for flat keys)
574
- let roles = { primary: null, secondary: null };
575
- const primaryMatch = embed.match(/^\s*primary:\s*(.+)$/m);
576
- const secondaryMatch = embed.match(/^\s*secondary:\s*(.+)$/m);
577
- if (primaryMatch) roles.primary = primaryMatch[1].trim() || null;
578
- if (secondaryMatch) roles.secondary = secondaryMatch[1].trim() || null;
579
- res.writeHead(200, { 'Content-Type': 'application/json' });
580
- res.end(JSON.stringify({ env, embed, roles }));
581
- return;
582
- }
583
-
584
- if (req.method === 'POST') {
585
- let body = '';
586
- req.on('data', chunk => { body += chunk; });
587
- req.on('end', () => {
588
- try {
589
- const { type, content } = JSON.parse(body || '{}');
590
- if (type !== 'env' && type !== 'embed') throw new Error('type must be env or embed');
591
- if (typeof content !== 'string') throw new Error('content must be a string');
592
- mkdirSync(configDir(HOME), { recursive: true });
593
- const target = type === 'env' ? CONFIG_ENV_FILE : EMBED_YAML_FILE;
594
- writeFileSync(target, content, 'utf8');
595
- res.writeHead(200, { 'Content-Type': 'application/json' });
596
- res.end(JSON.stringify({ success: true }));
597
- } catch (err) {
598
- res.writeHead(400, { 'Content-Type': 'application/json' });
599
- res.end(JSON.stringify({ error: err.message }));
600
- }
601
- });
602
- return;
603
- }
604
-
605
- res.writeHead(405);
606
- res.end('Method Not Allowed');
607
- }
608
-
609
- // ── Snapshots handler ─────────────────────────────────────────────────────
610
-
611
- function handleSnapshots(req, res) {
612
- if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return; }
613
-
614
- const snapshots = [];
615
- if (existsSync(SNAPSHOTS_FILE)) {
616
- const lines = readFileSync(SNAPSHOTS_FILE, 'utf8').split('\n').filter(Boolean);
617
- // Return last 20 snapshots most-recent-first
618
- for (const line of lines.slice(-20).reverse()) {
619
- try { snapshots.push(JSON.parse(line)); } catch { /* skip malformed */ }
620
- }
621
- }
622
-
623
- res.writeHead(200, { 'Content-Type': 'application/json' });
624
- res.end(JSON.stringify({ snapshots }));
625
- }
626
- const activeWatchers = [];
627
- let watchRefreshTimer = null;
628
-
629
- function notifyClients(event) {
630
- const payload = event ? `data: ${JSON.stringify(event)}\n\n` : 'data: refresh\n\n';
631
- for (const res of sseClients) {
632
- try { res.write(payload); }
633
- catch { sseClients.delete(res); }
634
- }
635
- }
636
-
637
- function closeWatchers() {
638
- while (activeWatchers.length) {
639
- try { activeWatchers.pop().close(); }
640
- catch { /* ignore close errors */ }
641
- }
642
- }
643
-
644
- function scheduleWatchRefresh() {
645
- clearTimeout(watchRefreshTimer);
646
- watchRefreshTimer = setTimeout(() => {
647
- watchFiles();
648
- notifyClients();
649
- }, 150);
650
- }
651
-
652
- function addWatcher(target) {
653
- if (!existsSync(target)) return;
654
- try {
655
- const watcher = watch(target, () => scheduleWatchRefresh());
656
- activeWatchers.push(watcher);
657
- } catch {
658
- /* ignore watch errors */
659
- }
660
- }
661
-
662
- function addDirectoryTreeWatch(root) {
663
- if (!existsSync(root)) return;
664
- addWatcher(root);
665
- try {
666
- for (const entry of readdirSync(root)) {
667
- const full = join(root, entry);
668
- try {
669
- if (statSync(full).isDirectory()) addWatcher(full);
670
- } catch {
671
- /* ignore stat errors */
672
- }
673
- }
674
- } catch {
675
- /* ignore read errors */
676
- }
677
- }
678
-
679
- function watchFiles() {
680
- closeWatchers();
681
- [REGISTRY_FILE, FEATURES_FILE, WORKFLOW_FILE].forEach(addWatcher);
682
- addDirectoryTreeWatch(SKILLS_DIR);
683
- addDirectoryTreeWatch(COMMANDS_DIR);
684
- }
685
-
686
- const server = createServer(async (req, res) => {
687
- const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
688
-
689
- // ── Security middleware ────────────────────────────────────────────────
690
- // Imported lazily so a missing module never breaks the server boot.
691
- let applyCors, ensureCsrfCookie, verifyCsrf, csrfDefaultSkip, checkRateLimit, logger;
692
- try {
693
- ({ applyCors } = await import('./cors.mjs'));
694
- ({ ensureCsrfCookie, verifyCsrf, defaultSkip: csrfDefaultSkip } = await import('./csrf.mjs'));
695
- ({ checkRateLimit } = await import('./rate-limit.mjs'));
696
- ({ logger } = await import('../logger.mjs'));
697
- } catch { /* run without the middleware if any module is unavailable */ }
698
-
699
- if (applyCors) {
700
- if (applyCors(req, res, { env: process.env })) return;
701
- }
702
- if (ensureCsrfCookie && req.method === 'GET') {
703
- ensureCsrfCookie(req, res);
704
- }
705
- const rateTier = url.pathname === '/api/chat/stream' || url.pathname === '/api/chat'
706
- || url.pathname === '/api/chat/loop/stream'
707
- ? 'chat'
708
- : url.pathname === '/api/chat/loop/command'
709
- ? 'command'
710
- : (req.method !== 'GET' && req.method !== 'HEAD' ? 'write' : 'read');
711
- if (checkRateLimit) {
712
- const limit = checkRateLimit(req, rateTier, { env: process.env });
713
- if (!limit.allowed) {
714
- res.writeHead(429, {
715
- 'Content-Type': 'application/json',
716
- 'Retry-After': Math.ceil(limit.retryAfterMs / 1000),
717
- });
718
- res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: limit.retryAfterMs }));
719
- logger?.().warn('http.rate_limited', { route: url.pathname, tier: rateTier });
720
- return;
721
- }
722
- }
723
- if (verifyCsrf && !verifyCsrf(req, { skip: csrfDefaultSkip })) {
724
- res.writeHead(403, { 'Content-Type': 'application/json' });
725
- res.end(JSON.stringify({ error: 'csrf_token_missing_or_invalid' }));
726
- logger?.().warn('http.csrf_blocked', { route: url.pathname });
727
- return;
728
- }
729
-
730
- // ── Auth endpoints (always public) ──────────────────────────────────────
731
- if (url.pathname === '/api/auth/status' && req.method === 'GET') {
732
- const auth = getAuthConfig();
733
- res.writeHead(200, { 'Content-Type': 'application/json' });
734
- res.end(JSON.stringify({
735
- configured: auth.tokenConfigured,
736
- authenticated: isAuthenticated(req),
737
- auth,
738
- }));
739
- return;
740
- }
741
-
742
- if (url.pathname === '/api/auth/login' && req.method === 'POST') {
743
- let body = '';
744
- req.on('data', c => { body += c; });
745
- req.on('end', () => {
746
- try {
747
- const { token } = JSON.parse(body || '{}');
748
- if (!validateToken(token)) {
749
- res.writeHead(401, { 'Content-Type': 'application/json' });
750
- res.end(JSON.stringify({ error: 'Invalid token' }));
751
- return;
752
- }
753
- const sessionToken = createSession();
754
- res.writeHead(200, {
755
- 'Content-Type': 'application/json',
756
- 'Set-Cookie': sessionCookieHeader(sessionToken),
757
- });
758
- res.end(JSON.stringify({ success: true }));
759
- } catch {
760
- res.writeHead(400, { 'Content-Type': 'application/json' });
761
- res.end(JSON.stringify({ error: 'Bad request' }));
762
- }
763
- });
764
- return;
765
- }
766
-
767
- if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
768
- res.writeHead(200, {
769
- 'Content-Type': 'application/json',
770
- 'Set-Cookie': clearSessionCookieHeader(),
771
- });
772
- res.end(JSON.stringify({ success: true }));
773
- return;
774
- }
775
-
776
- // ── Webhook ingestion (public — signature-verified per provider) ─────────
777
- if (url.pathname.startsWith('/api/webhooks/') && req.method === 'POST') {
778
- await getWebhookHandler()(req, res);
779
- return;
780
- }
781
-
782
- // ── Slack slash commands (public — HMAC-verified) ────────────────────────
783
- if (url.pathname === '/api/slack/commands' && req.method === 'POST') {
784
- await getSlackCommandHandler()(req, res);
785
- return;
786
- }
787
-
788
- // ── Auth gate for all /api/* routes ─────────────────────────────────────
789
- if (url.pathname.startsWith('/api/') && !isAuthenticated(req)) {
790
- rejectUnauthorized(res);
791
- return;
792
- }
793
-
794
- if (url.pathname === '/events') {
795
- res.writeHead(200, {
796
- 'Content-Type': 'text/event-stream',
797
- 'Cache-Control': 'no-cache',
798
- 'Connection': 'keep-alive',
799
- 'Access-Control-Allow-Origin': '*',
800
- });
801
- res.write(': connected\n\n');
802
- sseClients.add(res);
803
- req.on('close', () => sseClients.delete(res));
804
- return;
805
- }
806
-
807
- if (url.pathname.startsWith('/api/orchestration/')) {
808
- await handleOrchestration(req, res);
809
- return;
810
- }
811
-
812
- if (url.pathname === '/api/registry' && req.method === 'GET') {
813
- try {
814
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
815
- res.writeHead(200, { 'Content-Type': 'application/json' });
816
- res.end(JSON.stringify({ mcpServers: registry.mcpServers ?? {}, models: registry.models ?? {} }));
817
- } catch (err) {
818
- res.writeHead(500, { 'Content-Type': 'application/json' });
819
- res.end(JSON.stringify({ error: err.message }));
820
- }
821
- return;
822
- }
823
-
824
- // Provider-agnostic telemetry-UI sign-in. An external backend configured via
825
- // CONSTRUCT_TELEMETRY_URL uses the vendor-neutral bridge; with none set, fall
826
- // back to the zero-touch local Langfuse stack. `/langfuse/login` stays as a
827
- // back-compat alias of the canonical `/telemetry/login`.
828
-
829
- if ((url.pathname === '/api/services/telemetry/login' || url.pathname === '/api/services/langfuse/login') && req.method === 'GET') {
830
- if (process.env.CONSTRUCT_TELEMETRY_URL) {
831
- const { handleTelemetryLogin } = await import('./telemetry-login.mjs');
832
- await handleTelemetryLogin(req, res, {});
833
- } else {
834
- const { handleLangfuseLogin } = await import('./langfuse-login.mjs');
835
- await handleLangfuseLogin(req, res);
836
- }
837
- return;
838
- }
839
-
840
- if (url.pathname === '/api/status') {
841
- try {
842
- const status = await buildStatus();
843
- res.writeHead(200, { 'Content-Type': 'application/json' });
844
- res.end(JSON.stringify(status));
845
- } catch (err) {
846
- res.writeHead(500, { 'Content-Type': 'application/json' });
847
- res.end(JSON.stringify({ error: err.message }));
848
- }
849
- return;
850
- }
851
-
852
- if (url.pathname === '/api/certification/status' && req.method === 'GET') {
853
- try {
854
- const { buildCertificationDashboard } = await import('../certification/dashboard-api.mjs');
855
- const capabilityId = url.searchParams.get('capabilityId');
856
- const payload = capabilityId
857
- ? (await import('../certification/status.mjs')).buildCertificationStatus({
858
- rootDir: process.cwd(),
859
- capabilityId,
860
- })
861
- : buildCertificationDashboard({ rootDir: process.cwd() });
862
- res.writeHead(200, { 'Content-Type': 'application/json' });
863
- res.end(JSON.stringify(payload));
864
- } catch (err) {
865
- res.writeHead(500, { 'Content-Type': 'application/json' });
866
- res.end(JSON.stringify({ error: err.message }));
867
- }
868
- return;
869
- }
870
-
871
- if (url.pathname === '/api/oracle/pending' && req.method === 'GET') {
872
- try {
873
- const { listPending } = await import('../oracle/actions.mjs');
874
- const pending = listPending(process.cwd()).filter((p) => p.status !== 'approved');
875
- res.writeHead(200, { 'Content-Type': 'application/json' });
876
- res.end(JSON.stringify({ pending }));
877
- } catch (err) {
878
- res.writeHead(500, { 'Content-Type': 'application/json' });
879
- res.end(JSON.stringify({ error: err.message }));
880
- }
881
- return;
882
- }
883
-
884
- if (url.pathname === '/api/improvement/pending' && req.method === 'GET') {
885
- try {
886
- const { listPending } = await import('../improvement/surface.mjs');
887
- const pending = listPending(process.cwd());
888
- res.writeHead(200, { 'Content-Type': 'application/json' });
889
- res.end(JSON.stringify({ pending }));
890
- } catch (err) {
891
- res.writeHead(500, { 'Content-Type': 'application/json' });
892
- res.end(JSON.stringify({ error: err.message }));
893
- }
894
- return;
895
- }
896
-
897
- if (url.pathname.startsWith('/api/improvement/') && req.method === 'POST') {
898
- const action = url.pathname.slice('/api/improvement/'.length);
899
- let body = '';
900
- req.on('data', (chunk) => { body += chunk; });
901
- req.on('end', async () => {
902
- try {
903
- const data = JSON.parse(body || '{}');
904
- const projectDir = process.cwd();
905
- const { reviewRecord, approveRecord, applyRecord, rollbackRecord } = await import('../improvement/surface.mjs');
906
- let result;
907
- if (action === 'review') {
908
- if (!data.id) throw new Error('Missing id');
909
- result = reviewRecord(projectDir, data.id);
910
- } else if (action === 'approve') {
911
- if (!data.id) throw new Error('Missing id');
912
- result = approveRecord(projectDir, data.id, { identity: data.identity });
913
- } else if (action === 'apply') {
914
- if (!data.id) throw new Error('Missing id');
915
- result = applyRecord(projectDir, data.id, { monitor: data.monitor });
916
- } else if (action === 'rollback') {
917
- if (!data.id) throw new Error('Missing id');
918
- result = rollbackRecord(projectDir, data.id, { reason: data.reason });
919
- } else {
920
- throw new Error(`Unknown improvement action: ${action}`);
921
- }
922
- const status = result.ok === false && result.error ? 400 : 200;
923
- res.writeHead(status, { 'Content-Type': 'application/json' });
924
- res.end(JSON.stringify(result));
925
- notifyClients();
926
- } catch (err) {
927
- res.writeHead(400, { 'Content-Type': 'application/json' });
928
- res.end(JSON.stringify({ error: err.message }));
929
- }
930
- });
931
- return;
932
- }
933
-
934
- if (url.pathname.startsWith('/api/improvement/') && req.method === 'GET') {
935
- const id = decodeURIComponent(url.pathname.slice('/api/improvement/'.length));
936
- if (id && id !== 'pending') {
937
- try {
938
- const { loadRecord } = await import('../improvement/store.mjs');
939
- const record = loadRecord(process.cwd(), id);
940
- if (!record) {
941
- res.writeHead(404, { 'Content-Type': 'application/json' });
942
- res.end(JSON.stringify({ error: 'not-found' }));
943
- return;
944
- }
945
- res.writeHead(200, { 'Content-Type': 'application/json' });
946
- res.end(JSON.stringify(record));
947
- } catch (err) {
948
- res.writeHead(500, { 'Content-Type': 'application/json' });
949
- res.end(JSON.stringify({ error: err.message }));
950
- }
951
- return;
952
- }
953
- }
954
-
955
- if (url.pathname === '/api/oracle' && req.method === 'GET') {
956
- try {
957
- const { homedir: osHome } = await import('node:os');
958
- const { readHeartbeat } = await import('../daemons/contract.mjs');
959
- const { heartbeatPath, readLastTick } = await import('../oracle/index.mjs');
960
- const { collectReadModel, enrichReadModel } = await import('../oracle/read-model.mjs');
961
- const { synthesizeVerdict } = await import('../oracle/synthesize.mjs');
962
- const home = osHome();
963
- const projectDir = process.cwd();
964
- const readModel = collectReadModel({ rootDir: ROOT_DIR, projectDir, homeDir: home });
965
- const enriched = await enrichReadModel(readModel);
966
- const synthesis = synthesizeVerdict(enriched);
967
- const hb = readHeartbeat(heartbeatPath(home));
968
- res.writeHead(200, { 'Content-Type': 'application/json' });
969
- res.end(JSON.stringify({
970
- running: !!hb,
971
- heartbeat: hb,
972
- lastTick: readLastTick(home),
973
- verdict: synthesis.verdict,
974
- gaps: synthesis.gaps,
975
- recommendedActions: synthesis.recommendedActions,
976
- orgGraph: enriched.orgGraph,
977
- openBeads: enriched.beads?.openCount ?? null,
978
- lastPropagationCheck: enriched.orgGraph?.propagation ?? null,
979
- }));
980
- } catch (err) {
981
- res.writeHead(500, { 'Content-Type': 'application/json' });
982
- res.end(JSON.stringify({ error: err.message }));
983
- }
984
- return;
985
- }
986
-
987
- if (url.pathname === '/api/doctor') {
988
- try {
989
- const { readState } = await import('../doctor/index.mjs');
990
- const { recent } = await import('../doctor/audit.mjs');
991
- const { getTotalDailySpend, getDailySpend, totalBudget, personaBudget, dayKey } = await import('../cost-ledger.mjs');
992
- const { listOnboardedPersonas } = await import('../roles/manifest.mjs');
993
- const { listPending } = await import('../roles/gateway.mjs');
994
- const { existsSync: fsExists, readFileSync: fsRead } = await import('node:fs');
995
- const { join: pathJoin } = await import('node:path');
996
- const { homedir: osHome } = await import('node:os');
997
- const limit = parseInt(url.searchParams.get('limit') || '50', 10);
998
- const watcher = url.searchParams.get('watcher') || undefined;
999
- const personas = listOnboardedPersonas();
1000
- const costsByPersona = {};
1001
- for (const p of personas) {
1002
- const spend = getDailySpend({ personaId: p });
1003
- if (spend.invocations > 0 || spend.costUsd > 0) {
1004
- costsByPersona[p] = { spent: spend.costUsd, cap: personaBudget(p), invocations: spend.invocations };
1005
- }
1006
- }
1007
- const total = getTotalDailySpend();
1008
- const approvalPath = pathJoin(osHome(), '.cx', 'approval-pending.jsonl');
1009
- let approvals = [];
1010
- if (fsExists(approvalPath)) {
1011
- approvals = fsRead(approvalPath, 'utf8').split('\n').filter(Boolean)
1012
- .map((l) => { try { return JSON.parse(l); } catch { return null; } })
1013
- .filter(Boolean)
1014
- .slice(-25).reverse();
1015
- }
1016
- const pendingRoleInvocations = listPending({ unresolved: true }).slice(-25).reverse();
1017
- res.writeHead(200, { 'Content-Type': 'application/json' });
1018
- res.end(JSON.stringify({
1019
- daemon: readState(),
1020
- audit: recent({ watcher, limit }),
1021
- cost: { dayKey: dayKey(), total: { spent: total.costUsd, cap: totalBudget(), invocations: total.invocations }, byPersona: costsByPersona },
1022
- approvals,
1023
- pendingRoleInvocations,
1024
- onboardedPersonas: personas,
1025
- }));
1026
- } catch (err) {
1027
- res.writeHead(500, { 'Content-Type': 'application/json' });
1028
- res.end(JSON.stringify({ error: err.message }));
1029
- }
1030
- return;
1031
- }
1032
-
1033
- if (url.pathname.startsWith('/api/overrides/') && req.method === 'GET') {
1034
- try {
1035
- const { describeOverrides, resolveOverride, readResolved, listBackups, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
1036
- const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
1037
- const category = segs[0];
1038
- if (!category || !SUPPORTED_CATEGORIES.includes(category)) {
1039
- res.writeHead(400, { 'Content-Type': 'application/json' });
1040
- res.end(JSON.stringify({ error: `unknown category: ${category}. Use ${SUPPORTED_CATEGORIES.join(', ')}` }));
1041
- return;
1042
- }
1043
- if (segs.length === 1) {
1044
- const root = ROOT_DIR;
1045
- const overrides = describeOverrides(root)[category] || [];
1046
- const cwdOverrides = describeOverrides(process.cwd())[category] || [];
1047
- let originals = [];
1048
- try {
1049
- const dir =
1050
- category === 'contracts' || category === 'role-manifests'
1051
- ? join(root, 'specialists')
1052
- : category === 'agents'
1053
- ? join(root, 'specialists', 'prompts')
1054
- : join(root, category);
1055
- if (existsSync(dir)) {
1056
- const walk = (rel = '') => {
1057
- const cur = join(dir, rel);
1058
- for (const name of readdirSync(cur)) {
1059
- const full = join(cur, name);
1060
- const relPath = rel ? `${rel}/${name}` : name;
1061
- if (statSync(full).isDirectory()) { walk(relPath); continue; }
1062
- if (!name.endsWith('.md') && !name.endsWith('.json')) continue;
1063
- originals.push(relPath);
1064
- }
1065
- };
1066
- walk();
1067
- }
1068
- } catch { /* best effort */ }
1069
- const items = originals.map((name) => ({
1070
- name,
1071
- hasOverride: overrides.includes(name) || cwdOverrides.includes(name),
1072
- source: overrides.includes(name) || cwdOverrides.includes(name) ? 'override' : 'original',
1073
- }));
1074
- for (const ov of [...overrides, ...cwdOverrides]) {
1075
- if (!originals.includes(ov)) items.push({ name: ov, hasOverride: true, source: 'override', custom: true });
1076
- }
1077
- items.sort((a, b) => a.name.localeCompare(b.name));
1078
- res.writeHead(200, { 'Content-Type': 'application/json' });
1079
- res.end(JSON.stringify({ category, items }));
1080
- return;
1081
- }
1082
- const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
1083
- const action = url.searchParams.get('action');
1084
- if (action === 'backups') {
1085
- const backups = listBackups(process.cwd(), category, name);
1086
- res.writeHead(200, { 'Content-Type': 'application/json' });
1087
- res.end(JSON.stringify({ category, name, backups: backups.map((b) => ({ filename: b.filename, mtimeMs: b.mtimeMs, size: b.size })) }));
1088
- return;
1089
- }
1090
- const tryRoot = (root) => {
1091
- const r = resolveOverride(root, category, name);
1092
- if (r.path) return { ...r, content: readFileSync(r.path, 'utf8') };
1093
- return null;
1094
- };
1095
- const result = tryRoot(process.cwd()) || tryRoot(ROOT_DIR);
1096
- if (!result) {
1097
- res.writeHead(404, { 'Content-Type': 'application/json' });
1098
- res.end(JSON.stringify({ error: 'not found', category, name }));
1099
- return;
1100
- }
1101
- res.writeHead(200, { 'Content-Type': 'application/json' });
1102
- res.end(JSON.stringify({ category, name, source: result.source, content: result.content, path: result.path }));
1103
- } catch (err) {
1104
- res.writeHead(500, { 'Content-Type': 'application/json' });
1105
- res.end(JSON.stringify({ error: err.message }));
1106
- }
1107
- return;
1108
- }
1109
-
1110
- if (url.pathname.startsWith('/api/overrides/') && (req.method === 'PUT' || req.method === 'POST')) {
1111
- let body = '';
1112
- req.on('data', (chunk) => { body += chunk; });
1113
- req.on('end', async () => {
1114
- try {
1115
- const { applyEdit, restoreFromBackup, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
1116
- const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
1117
- const category = segs[0];
1118
- if (!SUPPORTED_CATEGORIES.includes(category)) {
1119
- res.writeHead(400, { 'Content-Type': 'application/json' });
1120
- res.end(JSON.stringify({ error: `unknown category: ${category}` }));
1121
- return;
1122
- }
1123
- const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
1124
- const action = url.searchParams.get('action');
1125
- const payload = JSON.parse(body || '{}');
1126
- if (action === 'restore') {
1127
- if (!payload.backupFilename) { res.writeHead(400); res.end(JSON.stringify({ error: 'backupFilename required' })); return; }
1128
- const result = restoreFromBackup(process.cwd(), category, name, payload.backupFilename);
1129
- res.writeHead(200, { 'Content-Type': 'application/json' });
1130
- res.end(JSON.stringify({ ok: true, ...result }));
1131
- return;
1132
- }
1133
- if (typeof payload.content !== 'string') {
1134
- res.writeHead(400, { 'Content-Type': 'application/json' });
1135
- res.end(JSON.stringify({ error: 'content (string) required' }));
1136
- return;
1137
- }
1138
- const result = applyEdit(process.cwd(), category, name, payload.content);
1139
- try {
1140
- const auditDir = join(process.cwd(), '.cx');
1141
- mkdirSync(auditDir, { recursive: true });
1142
- appendFileSync(join(auditDir, 'audit.jsonl'), JSON.stringify({
1143
- ts: new Date().toISOString(),
1144
- action: `override.${category}.${name}.write`,
1145
- backupPath: result.backupPath,
1146
- bytes: result.wrote,
1147
- }) + '\n');
1148
- } catch { /* audit best-effort */ }
1149
- res.writeHead(200, { 'Content-Type': 'application/json' });
1150
- res.end(JSON.stringify({ ok: true, ...result }));
1151
- } catch (err) {
1152
- res.writeHead(400, { 'Content-Type': 'application/json' });
1153
- res.end(JSON.stringify({ error: err.message }));
1154
- }
1155
- });
1156
- return;
1157
- }
1158
-
1159
- if (url.pathname === '/api/project-config' && req.method === 'GET') {
1160
- try {
1161
- const { loadProjectConfig } = await import('../config/project-config.mjs');
1162
- const result = loadProjectConfig(process.cwd(), process.env);
1163
- res.writeHead(200, { 'Content-Type': 'application/json' });
1164
- res.end(JSON.stringify(result));
1165
- } catch (err) {
1166
- res.writeHead(500, { 'Content-Type': 'application/json' });
1167
- res.end(JSON.stringify({ error: err.message }));
1168
- }
1169
- return;
1170
- }
1171
-
1172
- if (url.pathname === '/api/project-config' && (req.method === 'PUT' || req.method === 'PATCH')) {
1173
- let body = '';
1174
- req.on('data', (chunk) => { body += chunk; });
1175
- req.on('end', async () => {
1176
- try {
1177
- const payload = JSON.parse(body || '{}');
1178
- const { writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
1179
- const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
1180
- writeProjectConfig(cfgPath, payload);
1181
- try {
1182
- const auditDir = join(process.cwd(), '.cx');
1183
- mkdirSync(auditDir, { recursive: true });
1184
- const entry = JSON.stringify({
1185
- ts: new Date().toISOString(),
1186
- action: 'project-config.write',
1187
- path: cfgPath,
1188
- keys: Object.keys(payload || {}),
1189
- }) + '\n';
1190
- writeFileSync(join(auditDir, 'audit.jsonl'), entry, { flag: 'a' });
1191
- } catch { /* audit is best-effort */ }
1192
- res.writeHead(200, { 'Content-Type': 'application/json' });
1193
- res.end(JSON.stringify({ ok: true, path: cfgPath }));
1194
- } catch (err) {
1195
- res.writeHead(400, { 'Content-Type': 'application/json' });
1196
- res.end(JSON.stringify({ error: err.message }));
1197
- }
1198
- });
1199
- return;
1200
- }
1201
-
1202
- if (url.pathname === '/api/performance/reviews' && req.method === 'GET') {
1203
- try {
1204
- const dir = join(doctorRoot(), 'performance-reviews');
1205
- if (!existsSync(dir)) {
1206
- res.writeHead(200, { 'Content-Type': 'application/json' });
1207
- res.end(JSON.stringify({ reviews: [], total: 0, mockFiltered: 0, generatorImplemented: false }));
1208
- return;
1209
- }
1210
- const allFiles = readdirSync(dir).filter((f) => f.endsWith('.json'));
1211
- // Filter out test/mock fixtures — anything matching `test-*-mock.json` or
1212
- // `*-mock.json` is a seeded fixture, not a real session-end review.
1213
- // The real generator isn't wired yet; until it is, this endpoint should
1214
- // return an honest empty list rather than parade fake data.
1215
- const realFiles = allFiles.filter((f) => !/(^test-|-mock\.json$)/i.test(f));
1216
- const files = realFiles
1217
- .map((f) => {
1218
- try {
1219
- const full = join(dir, f);
1220
- const stat = statSync(full);
1221
- const body = JSON.parse(readFileSync(full, 'utf8'));
1222
- return { filename: f, mtimeMs: stat.mtimeMs, ...body };
1223
- } catch { return null; }
1224
- })
1225
- .filter(Boolean)
1226
- .sort((a, b) => b.mtimeMs - a.mtimeMs);
1227
- res.writeHead(200, { 'Content-Type': 'application/json' });
1228
- res.end(JSON.stringify({
1229
- reviews: files,
1230
- total: files.length,
1231
- mockFiltered: allFiles.length - realFiles.length,
1232
- generatorImplemented: true,
1233
- }));
1234
- } catch (err) {
1235
- res.writeHead(500, { 'Content-Type': 'application/json' });
1236
- res.end(JSON.stringify({ error: err.message }));
1237
- }
1238
- return;
1239
- }
1240
-
1241
- if (url.pathname === '/api/performance/feedback' && req.method === 'POST') {
1242
- let body = '';
1243
- req.on('data', (chunk) => { body += chunk; });
1244
- req.on('end', () => {
1245
- try {
1246
- const payload = JSON.parse(body || '{}');
1247
- if (!payload.agent || !payload.text) {
1248
- res.writeHead(400, { 'Content-Type': 'application/json' });
1249
- res.end(JSON.stringify({ error: 'agent + text required' }));
1250
- return;
1251
- }
1252
- const dir = join(process.cwd(), '.cx', 'feedback');
1253
- mkdirSync(dir, { recursive: true });
1254
- const ts = new Date().toISOString();
1255
- const filename = `${ts.replace(/[:.]/g, '-')}-${payload.agent}.json`;
1256
- writeFileSync(join(dir, filename), JSON.stringify({
1257
- ts,
1258
- agent: payload.agent,
1259
- text: payload.text,
1260
- proposedChanges: payload.proposedChanges || null,
1261
- }, null, 2));
1262
- appendFileSync(join(process.cwd(), '.cx', 'audit.jsonl'), JSON.stringify({
1263
- ts,
1264
- action: 'performance.feedback.submitted',
1265
- agent: payload.agent,
1266
- filename,
1267
- }) + '\n');
1268
- res.writeHead(200, { 'Content-Type': 'application/json' });
1269
- res.end(JSON.stringify({ ok: true, filename }));
1270
- } catch (err) {
1271
- res.writeHead(400, { 'Content-Type': 'application/json' });
1272
- res.end(JSON.stringify({ error: err.message }));
1273
- }
1274
- });
1275
- return;
1276
- }
1277
-
1278
- if (url.pathname === '/api/audit' && req.method === 'GET') {
1279
- try {
1280
- const auditPath = join(process.cwd(), '.cx', 'audit.jsonl');
1281
- if (!existsSync(auditPath)) {
1282
- res.writeHead(200, { 'Content-Type': 'application/json' });
1283
- res.end(JSON.stringify({ entries: [], total: 0 }));
1284
- return;
1285
- }
1286
- const limit = Math.min(500, Number(url.searchParams.get('limit')) || 200);
1287
- const raw = readFileSync(auditPath, 'utf8');
1288
- const lines = raw.split('\n').filter(Boolean);
1289
- const entries = [];
1290
- for (let i = Math.max(0, lines.length - limit); i < lines.length; i++) {
1291
- try { entries.push(JSON.parse(lines[i])); } catch { /* skip malformed */ }
1292
- }
1293
- res.writeHead(200, { 'Content-Type': 'application/json' });
1294
- res.end(JSON.stringify({ entries: entries.reverse(), total: lines.length }));
1295
- } catch (err) {
1296
- res.writeHead(500, { 'Content-Type': 'application/json' });
1297
- res.end(JSON.stringify({ error: err.message }));
1298
- }
1299
- return;
1300
- }
1301
-
1302
- if (url.pathname === '/api/insights' && req.method === 'GET') {
1303
- try {
1304
- const { buildInsights } = await import('./insights.mjs');
1305
- const data = await buildInsights({ env: process.env, cwd: process.cwd(), rootDir: ROOT_DIR });
1306
- res.writeHead(200, { 'Content-Type': 'application/json' });
1307
- res.end(JSON.stringify(data));
1308
- } catch (err) {
1309
- res.writeHead(500, { 'Content-Type': 'application/json' });
1310
- res.end(JSON.stringify({ error: err.message }));
1311
- }
1312
- return;
1313
- }
1314
-
1315
- if (url.pathname === '/api/alias' && req.method === 'GET') {
1316
- try {
1317
- const { resolveAlias } = await import('../config/alias.mjs');
1318
- const r = resolveAlias({ cwd: process.cwd(), env: process.env });
1319
- res.writeHead(200, { 'Content-Type': 'application/json' });
1320
- res.end(JSON.stringify(r));
1321
- } catch (err) {
1322
- res.writeHead(500, { 'Content-Type': 'application/json' });
1323
- res.end(JSON.stringify({ error: err.message, value: 'Construct', source: 'default' }));
1324
- }
1325
- return;
1326
- }
1327
-
1328
- if (url.pathname === '/api/mode' && req.method === 'GET') {
1329
- try {
1330
- const env = loadConstructEnv();
1331
- const embedYamlPath = join(configDir(HOME), 'embed.yaml');
1332
- const embedStatus = resolveEmbedStatus(env);
1333
-
1334
- // Determine mode based on embed status and configuration
1335
- let mode = 'init';
1336
- if (embedStatus.level === 'running') {
1337
- mode = 'embed';
1338
- } else if (existsSync(embedYamlPath)) {
1339
- mode = 'live';
1340
- }
1341
-
1342
- // Get instance ID if set
1343
- const instanceId = env.CONSTRUCT_INSTANCE_ID || null;
1344
-
1345
- res.writeHead(200, { 'Content-Type': 'application/json' });
1346
- res.end(JSON.stringify({
1347
- mode,
1348
- instanceId,
1349
- embedStatus: embedStatus.level,
1350
- embedYamlExists: existsSync(embedYamlPath),
1351
- embedYamlPath
1352
- }));
1353
- } catch (err) {
1354
- res.writeHead(500, { 'Content-Type': 'application/json' });
1355
- res.end(JSON.stringify({ error: err.message }));
1356
- }
1357
- return;
1358
- }
1359
-
1360
- if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
1361
- try {
1362
- const env = loadConstructEnv();
1363
- const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1364
- const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1365
- const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1366
- const isEmbedded = !!parentConstruct;
1367
- const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1368
- const embedStatus = resolveEmbedStatus(env);
1369
-
1370
- res.writeHead(200, { 'Content-Type': 'application/json' });
1371
- res.end(JSON.stringify({
1372
- boundaryStatus,
1373
- instanceId,
1374
- parentConstruct,
1375
- parentUrl,
1376
- isEmbedded,
1377
- embedStatus: embedStatus.level,
1378
- embedConfigExists: existsSync(join(configDir(HOME), 'embed.yaml')),
1379
- capabilities: {
1380
- modeDetection: true,
1381
- snapshotStatus: true,
1382
- approvalQueue: true,
1383
- configManagement: true
1384
- }
1385
- }));
1386
- } catch (err) {
1387
- res.writeHead(500, { 'Content-Type': 'application/json' });
1388
- res.end(JSON.stringify({ error: err.message }));
1389
- }
1390
- return;
1391
- }
1392
-
1393
- if (url.pathname === '/api/embed/boundary/register' && req.method === 'POST') {
1394
- try {
1395
- const chunks = [];
1396
- await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1397
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
1398
-
1399
- const { parentInstance, parentUrl, childInstanceId, nonce, signature } = body;
1400
-
1401
- if (!parentInstance || !parentUrl) {
1402
- res.writeHead(400, { 'Content-Type': 'application/json' });
1403
- res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
1404
- return;
1405
- }
1406
-
1407
- const result = await registerBoundary({
1408
- parentInstance,
1409
- parentUrl,
1410
- childInstanceId: childInstanceId || process.env.CONSTRUCT_INSTANCE_ID || 'default',
1411
- nonce,
1412
- signature,
1413
- sharedSecret: process.env.CONSTRUCT_BOUNDARY_SECRET || null,
1414
- });
1415
-
1416
- if (!result.ok) {
1417
- res.writeHead(result.status || 400, { 'Content-Type': 'application/json' });
1418
- res.end(JSON.stringify({ error: result.error }));
1419
- return;
1420
- }
1421
-
1422
- res.writeHead(200, { 'Content-Type': 'application/json' });
1423
- res.end(JSON.stringify({
1424
- success: true,
1425
- message: 'Boundary registration accepted',
1426
- boundaryConfig: result.config,
1427
- boundaryConfigPath: result.path,
1428
- }));
1429
- } catch (err) {
1430
- res.writeHead(500, { 'Content-Type': 'application/json' });
1431
- res.end(JSON.stringify({ error: err.message }));
1432
- }
1433
- return;
1434
- }
1435
-
1436
- if (url.pathname === '/api/embed/status' && req.method === 'GET') {
1437
- try {
1438
- const env = loadConstructEnv();
1439
- const status = resolveEmbedStatus(env);
1440
- res.writeHead(200, { 'Content-Type': 'application/json' });
1441
- res.end(JSON.stringify(status));
1442
- } catch (err) {
1443
- res.writeHead(500, { 'Content-Type': 'application/json' });
1444
- res.end(JSON.stringify({ error: err.message }));
1445
- }
1446
- return;
1447
- }
1448
-
1449
- if (url.pathname === '/api/models/providers' && req.method === 'GET') {
1450
- try {
1451
- const { getProviderModelCatalog } = await import('../model-router.mjs');
1452
- const catalog = getProviderModelCatalog({ env: process.env });
1453
- res.writeHead(200, { 'Content-Type': 'application/json' });
1454
- res.end(JSON.stringify(catalog));
1455
- } catch (err) {
1456
- res.writeHead(500, { 'Content-Type': 'application/json' });
1457
- res.end(JSON.stringify({ error: err.message }));
1458
- }
1459
- return;
1460
- }
1461
-
1462
- if (url.pathname === '/api/models/pricing' && req.method === 'GET') {
1463
- try {
1464
- const idsParam = url.searchParams.get('ids') || '';
1465
- const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean);
1466
- const { getPricingForModels } = await import('../model-pricing.mjs');
1467
- const pricing = await getPricingForModels(ids);
1468
- res.writeHead(200, { 'Content-Type': 'application/json' });
1469
- res.end(JSON.stringify({ pricing }));
1470
- } catch (err) {
1471
- res.writeHead(500, { 'Content-Type': 'application/json' });
1472
- res.end(JSON.stringify({ error: err.message }));
1473
- }
1474
- return;
1475
- }
1476
-
1477
- if (url.pathname === '/api/providers/credentials/custom' && req.method === 'GET') {
1478
- res.writeHead(200, { 'Content-Type': 'application/json' });
1479
- res.end(JSON.stringify({ providers: readCustomCredentials(), path: CUSTOM_CREDENTIALS_FILE }));
1480
- return;
1481
- }
1482
-
1483
- if (url.pathname === '/api/providers/billing' && req.method === 'GET') {
1484
- try {
1485
- const { loadProjectConfig } = await import('../config/project-config.mjs');
1486
- const cfg = loadProjectConfig(process.cwd(), process.env);
1487
- const costsCfg = cfg?.config?.costs ?? {};
1488
- res.writeHead(200, { 'Content-Type': 'application/json' });
1489
- res.end(JSON.stringify({
1490
- global: costsCfg.billingMode ?? 'metered',
1491
- providers: costsCfg.providers || {},
1492
- }));
1493
- } catch (err) {
1494
- res.writeHead(500, { 'Content-Type': 'application/json' });
1495
- res.end(JSON.stringify({ error: err.message }));
1496
- }
1497
- return;
1498
- }
1499
-
1500
- if (url.pathname === '/api/providers/credentials/op-status' && req.method === 'GET') {
1501
- // Tells the dashboard whether the 1Password CLI is installed and an account
1502
- // is signed in. No vault content is read here; this is just the availability
1503
- // probe. The pull endpoint below is the only thing that touches secret data.
1504
- try {
1505
- const which = spawnSync('which', ['op'], { encoding: 'utf8' });
1506
- if (which.status !== 0) {
1507
- res.writeHead(200, { 'Content-Type': 'application/json' });
1508
- res.end(JSON.stringify({ available: false, signedIn: false, version: null, accounts: [] }));
1509
- return;
1510
- }
1511
- const versionProc = spawnSync('op', ['--version'], { encoding: 'utf8', timeout: 2000 });
1512
- const version = versionProc.status === 0 ? versionProc.stdout.trim() : null;
1513
- const accountsProc = spawnSync('op', ['account', 'list', '--format=json'], { encoding: 'utf8', timeout: 3000 });
1514
- let accounts = [];
1515
- if (accountsProc.status === 0) {
1516
- try {
1517
- const parsed = JSON.parse(accountsProc.stdout || '[]');
1518
- accounts = (Array.isArray(parsed) ? parsed : []).map((a) => ({
1519
- url: a.url || null,
1520
- email: a.email || null,
1521
- user_uuid: a.user_uuid || null,
1522
- }));
1523
- } catch { accounts = []; }
1524
- }
1525
- res.writeHead(200, { 'Content-Type': 'application/json' });
1526
- res.end(JSON.stringify({ available: true, signedIn: accounts.length > 0, version, accounts }));
1527
- } catch (err) {
1528
- res.writeHead(500, { 'Content-Type': 'application/json' });
1529
- res.end(JSON.stringify({ error: err.message }));
1530
- }
1531
- return;
1532
- }
1533
-
1534
- if (url.pathname === '/api/fs/browse' && req.method === 'GET') {
1535
- try {
1536
- const { readdirSync, statSync } = await import('node:fs');
1537
- const { resolve: pathResolve, dirname: pathDirname, join: pathJoin, sep: pathSep } = await import('node:path');
1538
- const os = await import('node:os');
1539
- const HOME_DIR = os.homedir();
1540
- // Only roots the dashboard is allowed to expose. Loopback + same-user
1541
- // already gates file access at the OS level, but cap the reach to the
1542
- // user's HOME and the active project root anyway — surprise reads
1543
- // outside those are almost certainly a typo.
1544
- const allowedRoots = [HOME_DIR, ROOT_DIR];
1545
- const rawPath = url.searchParams.get('path') || HOME_DIR;
1546
- const target = pathResolve(rawPath.startsWith('~') ? pathJoin(HOME_DIR, rawPath.slice(1)) : rawPath);
1547
- const inAllowed = allowedRoots.some((root) => target === root || target.startsWith(root + pathSep));
1548
- if (!inAllowed) {
1549
- res.writeHead(403, { 'Content-Type': 'application/json' });
1550
- res.end(JSON.stringify({ error: `path outside allowed roots (${allowedRoots.join(', ')})` }));
1551
- return;
1552
- }
1553
- let entries = [];
1554
- try {
1555
- entries = readdirSync(target, { withFileTypes: true })
1556
- .filter((e) => !e.name.startsWith('.'))
1557
- .map((e) => ({
1558
- name: e.name,
1559
- type: e.isDirectory() ? 'dir' : e.isFile() ? 'file' : 'other',
1560
- }))
1561
- .sort((a, b) => {
1562
- if (a.type === b.type) return a.name.localeCompare(b.name);
1563
- return a.type === 'dir' ? -1 : 1;
1564
- });
1565
- } catch (err) {
1566
- res.writeHead(400, { 'Content-Type': 'application/json' });
1567
- res.end(JSON.stringify({ error: err.message }));
1568
- return;
1569
- }
1570
- const parent = pathDirname(target);
1571
- const parentAllowed = allowedRoots.some((root) => parent === root || parent.startsWith(root + pathSep));
1572
- res.writeHead(200, { 'Content-Type': 'application/json' });
1573
- res.end(JSON.stringify({
1574
- path: target,
1575
- parent: parentAllowed && parent !== target ? parent : null,
1576
- roots: allowedRoots,
1577
- entries,
1578
- }));
1579
- } catch (err) {
1580
- res.writeHead(500, { 'Content-Type': 'application/json' });
1581
- res.end(JSON.stringify({ error: err.message }));
1582
- }
1583
- return;
1584
- }
1585
-
1586
- if (url.pathname === '/api/sources' && req.method === 'GET') {
1587
- try {
1588
- const { loadProjectConfig } = await import('../config/project-config.mjs');
1589
- const {
1590
- resolveEffectiveSourceTargetsFromConfig,
1591
- legacyEnvSourceTargets,
1592
- normalizeConfigTarget,
1593
- } = await import('../config/source-targets.mjs');
1594
- const loaded = loadProjectConfig(ROOT_DIR, process.env);
1595
- const configTargets = (loaded.config.sources?.targets ?? []).map(normalizeConfigTarget);
1596
- const envTargets = legacyEnvSourceTargets(process.env);
1597
- const effective = resolveEffectiveSourceTargetsFromConfig(loaded.config, process.env);
1598
- res.writeHead(200, { 'Content-Type': 'application/json' });
1599
- res.end(JSON.stringify({
1600
- path: loaded.path,
1601
- configTargets,
1602
- envTargets,
1603
- effective,
1604
- }));
1605
- } catch (err) {
1606
- res.writeHead(500, { 'Content-Type': 'application/json' });
1607
- res.end(JSON.stringify({ error: err.message }));
1608
- }
1609
- return;
1610
- }
1611
-
1612
- if (url.pathname === '/api/intake/config' && req.method === 'GET') {
1613
- try {
1614
- const { loadIntakeConfig, INTAKE_DEPTH_GUIDANCE, INTAKE_HARD_MAX_DEPTH } = await import('../intake/intake-config.mjs');
1615
- const { getRebrand } = await import('../profiles/rebrand.mjs');
1616
- const config = loadIntakeConfig(ROOT_DIR, process.env);
1617
- const rebrand = getRebrand(ROOT_DIR);
1618
- res.writeHead(200, { 'Content-Type': 'application/json' });
1619
- res.end(JSON.stringify({
1620
- config,
1621
- rootDir: ROOT_DIR,
1622
- guidance: INTAKE_DEPTH_GUIDANCE,
1623
- hardMaxDepth: INTAKE_HARD_MAX_DEPTH,
1624
- defaults: { rootInbox: 'inbox/' },
1625
- label: rebrand.intakeQueueLabel,
1626
- itemNoun: rebrand.signalNoun,
1627
- }));
1628
- } catch (err) {
1629
- res.writeHead(500, { 'Content-Type': 'application/json' });
1630
- res.end(JSON.stringify({ error: err.message }));
1631
- }
1632
- return;
1633
- }
1634
-
1635
- if (url.pathname === '/api/intake/list' && req.method === 'GET') {
1636
- try {
1637
- const { createIntakeQueue } = await import('../intake/queue.mjs');
1638
- const { getRebrand } = await import('../profiles/rebrand.mjs');
1639
- const queue = createIntakeQueue(ROOT_DIR, process.env);
1640
- const items = queue.listPending();
1641
- const rebrand = getRebrand(ROOT_DIR);
1642
- res.writeHead(200, { 'Content-Type': 'application/json' });
1643
- res.end(JSON.stringify({
1644
- items,
1645
- total: items.length,
1646
- label: rebrand.intakeQueueLabel,
1647
- itemNoun: rebrand.signalNoun,
1648
- }));
1649
- } catch (err) {
1650
- res.writeHead(500, { 'Content-Type': 'application/json' });
1651
- res.end(JSON.stringify({ error: err.message }));
1652
- }
1653
- return;
1654
- }
1655
-
1656
- if (url.pathname === '/api/providers' && req.method === 'GET') {
1657
- try {
1658
- const probe = url.searchParams.get('probe') === '1';
1659
- const { resolveProviders } = await import('../providers/registry.mjs');
1660
- const { providers, sources, errors } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
1661
- const summary = await Promise.all(
1662
- Object.entries(providers).map(async ([id, p]) => {
1663
- const base = {
1664
- id,
1665
- displayName: p.meta.displayName,
1666
- description: p.meta.description || null,
1667
- capabilities: [...p.meta.capabilities],
1668
- source: sources[id],
1669
- configSchema: p.configSchema || null,
1670
- };
1671
- if (!probe) {
1672
- return { ...base, health: null, status: 'unknown' };
1673
- }
1674
- let health = { ok: false, detail: 'health probe failed' };
1675
- try { health = await p.health({}); } catch (err) { health = { ok: false, detail: err.message }; }
1676
- const status = classifyProviderStatus(id, health.ok, process.env);
1677
- // For not_configured, surface what's missing instead of the probe detail
1678
- // (which may incorrectly read as healthy from an anonymous code path).
1679
- const missing = status === 'not_configured' ? describeMissingCredentials(id, process.env) : null;
1680
- const finalHealth = missing ? { ...health, ok: false, detail: missing } : health;
1681
- return { ...base, health: finalHealth, status };
1682
- })
1683
- );
1684
- res.writeHead(200, { 'Content-Type': 'application/json' });
1685
- res.end(JSON.stringify({ summary, errors }));
1686
- } catch (err) {
1687
- res.writeHead(500, { 'Content-Type': 'application/json' });
1688
- res.end(JSON.stringify({ error: err.message }));
1689
- }
1690
- return;
1691
- }
1692
-
1693
- if (url.pathname === '/api/beads' && req.method === 'GET') {
1694
- try {
1695
- // Resolve to the main repo when running from a worktree — the bd
1696
- // database lives next to the canonical .git/, not the worktree's
1697
- // private .git/ stub. git rev-parse --git-common-dir returns the
1698
- // shared .git for both main and worktrees; its parent is the
1699
- // main repo root.
1700
- let bdCwd = ROOT_DIR;
1701
- try {
1702
- const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
1703
- cwd: ROOT_DIR,
1704
- encoding: 'utf8',
1705
- });
1706
- if (commonDir.status === 0) {
1707
- const trimmed = commonDir.stdout.trim();
1708
- const resolved = trimmed.startsWith('/') ? trimmed : join(ROOT_DIR, trimmed);
1709
- const candidate = dirname(resolved);
1710
- if (existsSync(join(candidate, '.beads'))) bdCwd = candidate;
1711
- }
1712
- } catch { /* fall back to ROOT_DIR */ }
1713
- const result = spawnSync('bd', ['list', '--status', 'all', '--json'], {
1714
- cwd: bdCwd,
1715
- encoding: 'utf8',
1716
- env: { ...process.env, NO_COLOR: '1' },
1717
- timeout: 5000,
1718
- });
1719
- // bd can return non-zero for legitimate reasons that don't warrant a
1720
- // server 500: bd not installed, dolt lock held by a concurrent bd, no
1721
- // .beads/ in cwd. Degrade gracefully — return an empty issue list with
1722
- // `degraded: true` so the dashboard UI shows "no beads" instead of
1723
- // breaking, and the dashboard-endpoints test (which sweeps every read
1724
- // endpoint for 5xx) stays green when bd's runtime is wobbly.
1725
-
1726
- if (result.status !== 0) {
1727
- res.writeHead(200, { 'Content-Type': 'application/json' });
1728
- res.end(JSON.stringify({
1729
- issues: [],
1730
- counts: { total: 0, byStatus: {}, byPriority: {} },
1731
- degraded: true,
1732
- reason: (result.stderr || result.stdout || 'bd list failed').slice(0, 300).trim(),
1733
- }));
1734
- return;
1735
- }
1736
- let raw = [];
1737
- try { raw = JSON.parse(result.stdout); } catch (parseErr) {
1738
- res.writeHead(200, { 'Content-Type': 'application/json' });
1739
- res.end(JSON.stringify({
1740
- issues: [],
1741
- counts: { total: 0, byStatus: {}, byPriority: {} },
1742
- degraded: true,
1743
- reason: `bd list JSON parse failed: ${parseErr.message}`,
1744
- }));
1745
- return;
1746
- }
1747
- const issues = raw.map(obj => ({
1748
- id: obj.id,
1749
- title: obj.title,
1750
- description: obj.description || null,
1751
- status: obj.status,
1752
- priority: obj.priority,
1753
- issue_type: obj.issue_type || null,
1754
- owner: obj.owner || null,
1755
- created_at: obj.created_at,
1756
- updated_at: obj.updated_at,
1757
- dependency_count: obj.dependency_count || 0,
1758
- dependent_count: obj.dependent_count || 0,
1759
- comment_count: obj.comment_count || 0,
1760
- labels: Array.isArray(obj.labels) ? obj.labels : [],
1761
- }));
1762
- const byStatus = {};
1763
- const byPriority = {};
1764
- for (const it of issues) {
1765
- byStatus[it.status] = (byStatus[it.status] || 0) + 1;
1766
- byPriority[`P${it.priority}`] = (byPriority[`P${it.priority}`] || 0) + 1;
1767
- }
1768
- res.writeHead(200, { 'Content-Type': 'application/json' });
1769
- res.end(JSON.stringify({ issues, counts: { total: issues.length, byStatus, byPriority } }));
1770
- } catch (err) {
1771
- res.writeHead(200, { 'Content-Type': 'application/json' });
1772
- res.end(JSON.stringify({
1773
- issues: [],
1774
- counts: { total: 0, byStatus: {}, byPriority: {} },
1775
- degraded: true,
1776
- reason: err.message,
1777
- }));
1778
- }
1779
- return;
1780
- }
1781
-
1782
- if (url.pathname === '/api/providers/subscriptions' && req.method === 'GET') {
1783
- try {
1784
- const file = join(configDir(HOME), 'provider-subscriptions.json');
1785
- let data = { subscriptions: [] };
1786
- if (existsSync(file)) {
1787
- try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { /* fall back to empty */ }
1788
- if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
1789
- }
1790
- res.writeHead(200, { 'Content-Type': 'application/json' });
1791
- res.end(JSON.stringify(data));
1792
- } catch (err) {
1793
- res.writeHead(500, { 'Content-Type': 'application/json' });
1794
- res.end(JSON.stringify({ error: err.message }));
1795
- }
1796
- return;
1797
- }
1798
-
1799
- if (url.pathname === '/api/providers/credentials' && req.method === 'GET') {
1800
- try {
1801
- res.writeHead(200, { 'Content-Type': 'application/json' });
1802
- res.end(JSON.stringify({ credentials: buildCredentialsView(process.env) }));
1803
- } catch (err) {
1804
- res.writeHead(500, { 'Content-Type': 'application/json' });
1805
- res.end(JSON.stringify({ error: err.message }));
1806
- }
1807
- return;
1808
- }
1809
-
1810
- if (url.pathname === '/api/providers/config-path' && req.method === 'GET') {
1811
- res.writeHead(200, { 'Content-Type': 'application/json' });
1812
- res.end(JSON.stringify({
1813
- envPath: CONFIG_ENV_FILE,
1814
- overridesPath: join(configDir(HOME), 'providers.json'),
1815
- }));
1816
- return;
1817
- }
1818
-
1819
- if (url.pathname === '/api/session-usage') {
1820
- await handleSessionUsage(req, res);
1821
- return;
1822
- }
1823
-
1824
- if (url.pathname === '/api/artifacts') {
1825
- await handleArtifacts(req, res);
1826
- return;
1827
- }
1828
-
1829
- if (url.pathname === '/api/approvals') {
1830
- await handleApprovals(req, res);
1831
- return;
1832
- }
1833
-
1834
- // ── Recommendations API ────────────────────────────────────────────────────
1835
- if (url.pathname === '/api/recommendations') {
1836
- if (req.method === 'GET') {
1837
- const { listActiveRecommendations, recommendationStats } = await import('../embed/recommendation-store.mjs');
1838
- const priority = url.searchParams.get('priority') || undefined;
1839
- const type = url.searchParams.get('type') || undefined;
1840
- const limit = Number(url.searchParams.get('limit') || 50);
1841
- const items = listActiveRecommendations({ priority, type, limit });
1842
- const stats = recommendationStats();
1843
- res.writeHead(200, { 'Content-Type': 'application/json' });
1844
- res.end(JSON.stringify({ items, stats }));
1845
- return;
1846
- }
1847
- if (req.method === 'PATCH' || req.method === 'POST') {
1848
- const chunks = [];
1849
- await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1850
- let body;
1851
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1852
- const action = body.action || url.searchParams.get('action');
1853
- const dedupKey = body.dedupKey || url.searchParams.get('dedupKey');
1854
- if (!action || !dedupKey) {
1855
- res.writeHead(400, { 'Content-Type': 'application/json' });
1856
- res.end(JSON.stringify({ error: 'action and dedupKey are required' }));
1857
- return;
1858
- }
1859
- const { dismissRecommendation, reviveRecommendation } = await import('../embed/recommendation-store.mjs');
1860
- try {
1861
- if (action === 'dismiss') {
1862
- const result = dismissRecommendation(dedupKey, { reason: body.reason, suppressDays: body.suppressDays });
1863
- res.writeHead(200, { 'Content-Type': 'application/json' });
1864
- res.end(JSON.stringify(result));
1865
- } else if (action === 'revive') {
1866
- const result = reviveRecommendation(dedupKey, { signalCount: body.signalCount, sourceSignalIds: body.sourceSignalIds });
1867
- res.writeHead(200, { 'Content-Type': 'application/json' });
1868
- res.end(JSON.stringify(result));
1869
- } else {
1870
- res.writeHead(400, { 'Content-Type': 'application/json' });
1871
- res.end(JSON.stringify({ error: `unknown action: ${action}` }));
1872
- }
1873
- } catch (err) {
1874
- res.writeHead(404, { 'Content-Type': 'application/json' });
1875
- res.end(JSON.stringify({ error: err?.message || 'not found' }));
1876
- }
1877
- return;
1878
- }
1879
- }
1880
-
1881
- // ── Strategy API ───────────────────────────────────────────────────────────
1882
- if (url.pathname === '/api/strategy') {
1883
- if (req.method === 'GET') {
1884
- const { readAllStrategies } = await import('../strategy-store.mjs');
1885
- const result = await readAllStrategies(process.env);
1886
- res.writeHead(200, { 'Content-Type': 'application/json' });
1887
- res.end(JSON.stringify({ scopes: Object.fromEntries(result) }));
1888
- return;
1889
- }
1890
- if (req.method === 'PUT' || req.method === 'POST') {
1891
- const chunks = [];
1892
- await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1893
- let body;
1894
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1895
- const content = (body.content || '').trim();
1896
- const scope = (body.scope || 'product').trim();
1897
- if (!content) {
1898
- res.writeHead(400, { 'Content-Type': 'application/json' });
1899
- res.end(JSON.stringify({ error: 'content is required' }));
1900
- return;
1901
- }
1902
- const { writeStrategy } = await import('../strategy-store.mjs');
1903
- await writeStrategy(content, scope, { updatedBy: 'dashboard', env: process.env });
1904
- res.writeHead(200, { 'Content-Type': 'application/json' });
1905
- res.end(JSON.stringify({ ok: true }));
1906
- return;
1907
- }
1908
- }
1909
-
1910
- if (url.pathname === '/api/snapshots') {
1911
- handleSnapshots(req, res);
1912
- return;
1913
- }
1914
-
1915
- // ── Knowledge API ──────────────────────────────────────────────────────
1916
- if (url.pathname === '/api/knowledge/trends') {
1917
- const { buildTrendReport } = await import('../knowledge/trends.mjs');
1918
- const report = buildTrendReport(ROOT_DIR);
1919
- res.writeHead(200, { 'Content-Type': 'application/json' });
1920
- res.end(JSON.stringify(report));
1921
- return;
1922
- }
1923
-
1924
- if (url.pathname === '/api/knowledge/index') {
1925
- const { buildCorpus } = await import('../knowledge/rag.mjs');
1926
- const corpus = buildCorpus(ROOT_DIR);
1927
- const sources = {};
1928
- for (const c of corpus) sources[c.source] = (sources[c.source] || 0) + 1;
1929
- res.writeHead(200, { 'Content-Type': 'application/json' });
1930
- res.end(JSON.stringify({ total: corpus.length, sources }));
1931
- return;
1932
- }
1933
-
1934
- if (url.pathname === '/api/knowledge/ask' && req.method === 'POST') {
1935
- const chunks = [];
1936
- await new Promise((resolve, reject) => {
1937
- req.on('data', (c) => chunks.push(c));
1938
- req.on('end', resolve);
1939
- req.on('error', reject);
1940
- });
1941
- let body;
1942
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1943
- const question = (body.question || '').trim();
1944
- if (!question) {
1945
- res.writeHead(400, { 'Content-Type': 'application/json' });
1946
- res.end(JSON.stringify({ error: 'question is required' }));
1947
- return;
1948
- }
1949
- const { ask } = await import('../knowledge/rag.mjs');
1950
- const result = await ask(question, { rootDir: ROOT_DIR });
1951
- res.writeHead(200, { 'Content-Type': 'application/json' });
1952
- res.end(JSON.stringify(result));
1953
- return;
1954
- }
1955
-
1956
- // ── Workflow API ──────────────────────────────────────────────────────────
1957
- if (url.pathname === '/api/workflow' && req.method === 'GET') {
1958
- try {
1959
- const planMd = existsSync(WORKFLOW_FILE) ? readFileSync(WORKFLOW_FILE, 'utf8') : '';
1960
- const { loadWorkflow } = await import('../workflow-state.mjs');
1961
- const wf = loadWorkflow(ROOT_DIR);
1962
- const tasks = wf?.tasks ?? [];
1963
- const phase = wf?.phase ?? null;
1964
- const phases = wf?.phases ?? {};
1965
- const status = wf?.status ?? null;
1966
- const summary = planMd
1967
- ? planMd.split('\n').slice(0, 30).filter(l => l.trim()).slice(0, 8).join('\n')
1968
- : '';
1969
-
1970
- res.writeHead(200, { 'Content-Type': 'application/json' });
1971
- res.end(JSON.stringify({
1972
- hasPlan: Boolean(planMd),
1973
- planSummary: summary,
1974
- planPath: WORKFLOW_FILE,
1975
- workflowState: wf ? { status, phase, phases, tasks, currentTaskKey: wf.currentTaskKey } : null,
1976
- taskCount: tasks.length,
1977
- taskStatusCounts: {
1978
- todo: tasks.filter(t => t.status === 'todo' || !t.status).length,
1979
- inProgress: tasks.filter(t => t.status === 'in-progress').length,
1980
- blocked: tasks.filter(t => t.status?.startsWith('blocked')).length,
1981
- done: tasks.filter(t => t.status === 'done').length,
1982
- skipped: tasks.filter(t => t.status === 'skipped').length,
1983
- },
1984
- }));
1985
- } catch (err) {
1986
- res.writeHead(500, { 'Content-Type': 'application/json' });
1987
- res.end(JSON.stringify({ error: err.message }));
1988
- }
1989
- return;
1990
- }
1991
-
1992
- if (url.pathname === '/api/config') {
1993
- handleConfig(req, res);
1994
- return;
1995
- }
1996
-
1997
- // ── Terraform API ─────────────────────────────────────────────────────────
1998
- if (url.pathname === '/api/terraform/files' && req.method === 'GET') {
1999
- try {
2000
- const files = terraformFiles(TERRAFORM_DIR, TERRAFORM_DIR);
2001
- res.writeHead(200, { 'Content-Type': 'application/json' });
2002
- res.end(JSON.stringify({ files, terraformDir: TERRAFORM_DIR }));
2003
- } catch (err) {
2004
- res.writeHead(500, { 'Content-Type': 'application/json' });
2005
- res.end(JSON.stringify({ error: err.message }));
2006
- }
2007
- return;
2008
- }
2009
-
2010
- if (url.pathname === '/api/terraform/file') {
2011
- if (req.method === 'GET') {
2012
- const relPath = url.searchParams.get('path');
2013
- if (!relPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return; }
2014
- try {
2015
- const abs = assertTerraformPath(relPath);
2016
- const content = readFileSync(abs, 'utf8');
2017
- res.writeHead(200, { 'Content-Type': 'application/json' });
2018
- res.end(JSON.stringify({ path: relPath, content }));
2019
- } catch (err) {
2020
- res.writeHead(400, { 'Content-Type': 'application/json' });
2021
- res.end(JSON.stringify({ error: err.message }));
2022
- }
2023
- return;
2024
- }
2025
-
2026
- if (req.method === 'POST') {
2027
- const chunks = [];
2028
- await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
2029
- let body;
2030
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
2031
- try {
2032
- const abs = assertTerraformPath(body.path || '');
2033
- writeFileSync(abs, body.content ?? '', 'utf8');
2034
- res.writeHead(200, { 'Content-Type': 'application/json' });
2035
- res.end(JSON.stringify({ success: true }));
2036
- } catch (err) {
2037
- res.writeHead(400, { 'Content-Type': 'application/json' });
2038
- res.end(JSON.stringify({ error: err.message }));
2039
- }
2040
- return;
2041
- }
2042
- }
2043
-
2044
- if (url.pathname === '/api/terraform/run' && req.method === 'POST') {
2045
- const chunks = [];
2046
- await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
2047
- let body;
2048
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
2049
-
2050
- const subcommand = body.subcommand; // 'plan' | 'apply'
2051
- const environment = body.environment || 'staging'; // 'staging' | 'production'
2052
-
2053
- if (!['plan', 'apply', 'validate', 'output'].includes(subcommand)) {
2054
- res.writeHead(400, { 'Content-Type': 'application/json' });
2055
- res.end(JSON.stringify({ error: "subcommand must be 'plan', 'apply', 'validate', or 'output'" }));
2056
- return;
2057
- }
2058
-
2059
- const envDir = join(TERRAFORM_DIR, 'environments', environment);
2060
- if (!existsSync(envDir)) {
2061
- res.writeHead(400, { 'Content-Type': 'application/json' });
2062
- res.end(JSON.stringify({ error: `Environment '${environment}' not found at ${envDir}` }));
2063
- return;
2064
- }
2065
-
2066
- // Stream output via SSE-style chunked response
2067
- res.writeHead(200, {
2068
- 'Content-Type': 'text/plain; charset=utf-8',
2069
- 'Transfer-Encoding': 'chunked',
2070
- 'Cache-Control': 'no-cache',
2071
- 'X-Accel-Buffering': 'no',
2072
- });
2073
-
2074
- const args = subcommand === 'plan'
2075
- ? ['plan', `-chdir=${envDir}`, '-no-color']
2076
- : subcommand === 'apply'
2077
- ? ['apply', `-chdir=${envDir}`, '-no-color', '-auto-approve']
2078
- : subcommand === 'validate'
2079
- ? ['validate', `-chdir=${envDir}`, '-no-color']
2080
- : ['output', `-chdir=${envDir}`, '-no-color'];
2081
-
2082
- res.write(`\u001b[90m$ terraform ${args.join(' ')}\u001b[0m\n\n`);
2083
-
2084
- const tf = spawn('terraform', args, {
2085
- cwd: envDir,
2086
- env: { ...process.env, TF_IN_AUTOMATION: '1' },
2087
- });
2088
-
2089
- tf.stdout.on('data', chunk => { try { res.write(chunk); } catch {} });
2090
- tf.stderr.on('data', chunk => { try { res.write(chunk); } catch {} });
2091
-
2092
- tf.on('close', (code) => {
2093
- try {
2094
- res.write(`\n\u001b[${code === 0 ? '32' : '31'}m\nExit code: ${code}\u001b[0m\n`);
2095
- res.end();
2096
- } catch {}
2097
- notifyClients();
2098
- });
2099
-
2100
- tf.on('error', (err) => {
2101
- try { res.write(`\n❌ Failed to run terraform: ${err.message}\n`); res.end(); } catch {}
2102
- });
2103
-
2104
- return;
2105
- }
2106
-
2107
- if (url.pathname === '/api/chat/stream' && req.method === 'GET') {
2108
- handleChatStream(req, res, { rootDir: ROOT_DIR });
2109
- return;
2110
- }
2111
-
2112
- if (url.pathname === '/api/chat/history' && req.method === 'GET') {
2113
- handleChatHistory(req, res);
2114
- return;
2115
- }
2116
-
2117
- if (url.pathname === '/api/chat' && req.method === 'POST') {
2118
- handleChat(req, res, { rootDir: ROOT_DIR });
2119
- return;
2120
- }
2121
-
2122
- if (url.pathname === '/api/chat/loop/stream' && req.method === 'GET') {
2123
- handleChatLoopStream(req, res, { rootDir: ROOT_DIR });
2124
- return;
2125
- }
2126
-
2127
- if (url.pathname === '/api/chat/loop/pending' && req.method === 'GET') {
2128
- handleChatLoopPending(req, res);
2129
- return;
2130
- }
2131
-
2132
- if (url.pathname === '/api/chat/loop/permission' && req.method === 'POST') {
2133
- handleChatLoopPermission(req, res);
2134
- return;
2135
- }
2136
-
2137
- if (url.pathname === '/api/chat/config' && (req.method === 'GET' || req.method === 'POST')) {
2138
- handleChatLoopConfig(req, res, { rootDir: ROOT_DIR });
2139
- return;
2140
- }
2141
-
2142
- if (url.pathname === '/api/chat/models' && req.method === 'GET') {
2143
- void handleChatModels(req, res, { rootDir: ROOT_DIR });
2144
- return;
2145
- }
2146
-
2147
- if (url.pathname === '/api/chat/models/select' && req.method === 'POST') {
2148
- handleChatModelSelect(req, res, { rootDir: ROOT_DIR });
2149
- return;
2150
- }
2151
-
2152
- if (url.pathname === '/api/chat/loop/command' && req.method === 'POST') {
2153
- handleChatLoopCommand(req, res, { rootDir: ROOT_DIR });
2154
- return;
2155
- }
2156
-
2157
- if (url.pathname === '/api/chat/loop/history' && req.method === 'GET') {
2158
- handleChatLoopHistory(req, res, { rootDir: ROOT_DIR });
2159
- return;
2160
- }
2161
-
2162
- if (url.pathname === '/api/chat/loop/cancel' && req.method === 'POST') {
2163
- handleChatLoopCancel(req, res);
2164
- return;
2165
- }
2166
-
2167
- if (req.method === 'POST') {
2168
- let body = '';
2169
- req.on('data', chunk => { body += chunk; });
2170
- req.on('end', async () => {
2171
- try {
2172
- const data = JSON.parse(body || '{}');
2173
- const { promoteHeadhunt, updatePromotionChallenge } = await import('../headhunt.mjs');
2174
-
2175
- if (url.pathname === '/api/headhunt/promote') {
2176
- if (!data.id) throw new Error('Missing overlay id');
2177
- const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
2178
- res.writeHead(200, { 'Content-Type': 'application/json' });
2179
- res.end(JSON.stringify({ success: true, request }));
2180
- notifyClients();
2181
- } else if (url.pathname === '/api/headhunt/challenge') {
2182
- if (!data.id) throw new Error('Missing overlay id');
2183
- if (!data.status) throw new Error('Missing challenge status');
2184
- const request = updatePromotionChallenge(data.id, {
2185
- cwd: process.cwd(),
2186
- status: data.status,
2187
- note: data.note || null,
2188
- });
2189
- res.writeHead(200, { 'Content-Type': 'application/json' });
2190
- res.end(JSON.stringify({ success: true, request }));
2191
- notifyClients();
2192
- } else if (url.pathname === '/api/registry/mcp') {
2193
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
2194
- if (!registry.mcpServers) registry.mcpServers = {};
2195
- const { action, id, server } = data;
2196
- if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
2197
- if (action === 'delete') {
2198
- delete registry.mcpServers[id];
2199
- } else if (action === 'save' && server && typeof server === 'object') {
2200
- const entry = {};
2201
- if (server.type === 'url') {
2202
- if (!server.url) throw new Error('URL required for type=url');
2203
- entry.type = 'url';
2204
- entry.url = String(server.url);
2205
- if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
2206
- } else {
2207
- if (!server.command) throw new Error('command required');
2208
- entry.command = String(server.command);
2209
- entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
2210
- }
2211
- if (server.description) entry.description = String(server.description);
2212
- registry.mcpServers[id] = entry;
2213
- } else {
2214
- throw new Error('action must be save or delete');
2215
- }
2216
- writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
2217
- res.writeHead(200, { 'Content-Type': 'application/json' });
2218
- res.end(JSON.stringify({ success: true }));
2219
- notifyClients();
2220
- } else if (url.pathname === '/api/registry/models') {
2221
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
2222
- const { tier, primary, fallback } = data;
2223
- const VALID_TIERS = ['reasoning', 'standard', 'fast'];
2224
- if (!VALID_TIERS.includes(tier)) throw new Error('tier must be reasoning, standard, or fast');
2225
- if (!primary || typeof primary !== 'string') throw new Error('primary model required');
2226
- if (!registry.models) registry.models = {};
2227
- registry.models[tier] = {
2228
- primary: String(primary),
2229
- fallback: Array.isArray(fallback) ? fallback.map(String).filter(Boolean) : [],
2230
- };
2231
- writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
2232
- res.writeHead(200, { 'Content-Type': 'application/json' });
2233
- res.end(JSON.stringify({ success: true }));
2234
- notifyClients();
2235
- } else if (url.pathname === '/api/providers/billing') {
2236
- // Persists per-provider billing mode into construct.config.json under
2237
- // costs.providers.<id>.billingMode. Validates ids against the known
2238
- // CREDENTIAL_MAP (built-in + custom) so we don't accept arbitrary
2239
- // strings into the schema.
2240
- const { provider, billingMode } = data;
2241
- if (typeof provider !== 'string' || !provider) throw new Error('provider id required');
2242
- const validProviders = new Set([
2243
- ...credentialMap().map((c) => c.provider),
2244
- 'ollama', 'local', 'mistral', 'groq',
2245
- ]);
2246
- if (!validProviders.has(provider)) {
2247
- throw new Error(`unknown provider '${provider}'. Add a custom provider first or use one of: ${[...validProviders].join(', ')}`);
2248
- }
2249
- if (!['metered', 'subscription', 'mixed'].includes(billingMode)) {
2250
- throw new Error("billingMode must be 'metered', 'subscription', or 'mixed'");
2251
- }
2252
- const { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
2253
- const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
2254
- const { config } = loadProjectConfig(process.cwd(), process.env);
2255
- const next = {
2256
- ...config,
2257
- costs: {
2258
- ...(config.costs || {}),
2259
- providers: {
2260
- ...(config.costs?.providers || {}),
2261
- [provider]: { billingMode },
2262
- },
2263
- },
2264
- };
2265
- writeProjectConfig(cfgPath, next);
2266
- res.writeHead(200, { 'Content-Type': 'application/json' });
2267
- res.end(JSON.stringify({ success: true, providers: next.costs.providers }));
2268
- notifyClients();
2269
- } else if (url.pathname === '/api/providers/credentials/op-pull') {
2270
- // Resolve a 1Password secret reference (op://vault/item/field) and
2271
- // store the result into ~/.construct/config.env via the same write
2272
- // path as the manual editor. The op:// URI never leaves the machine
2273
- // and the resolved value flows secret server-side → config.env
2274
- // (chmod 0600) → process.env, identical to a manual paste.
2275
- const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
2276
- if (isAuthConfigured()) {
2277
- if (!isAuthenticated(req)) {
2278
- res.writeHead(401, { 'Content-Type': 'application/json' });
2279
- res.end(JSON.stringify({ error: 'Unauthorized' }));
2280
- return;
2281
- }
2282
- } else if (!isLocal) {
2283
- throw new Error('op-pull on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
2284
- }
2285
- const { envVar, opRef } = data;
2286
- if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
2287
- if (!allowedCredentialEnvVars().has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
2288
- if (typeof opRef !== 'string' || !opRef.startsWith('op://')) {
2289
- throw new Error("opRef must be a 1Password secret reference starting with 'op://'");
2290
- }
2291
- if (!/^op:\/\/[\w\s\-._]+\/[\w\s\-._]+\/[\w\s\-._]+$/.test(opRef)) {
2292
- throw new Error("opRef must look like 'op://vault/item/field' (letters, digits, spaces, _-.)");
2293
- }
2294
- const opRead = spawnSync('op', ['read', '--no-newline', opRef], { encoding: 'utf8', timeout: 10000 });
2295
- if (opRead.status !== 0) {
2296
- throw new Error(`op read failed: ${(opRead.stderr || opRead.stdout || '').trim().slice(0, 240)}`);
2297
- }
2298
- const resolved = opRead.stdout;
2299
- if (!resolved) throw new Error('op read returned an empty value');
2300
-
2301
- const { writeEnvValues, loadConstructEnv } = await import('../env-config.mjs');
2302
- mkdirSync(configDir(HOME), { recursive: true });
2303
- writeEnvValues(CONFIG_ENV_FILE, { [envVar]: resolved });
2304
- process.env[envVar] = resolved;
2305
- try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms best-effort */ }
2306
-
2307
- const allowed = allowedCredentialEnvVars();
2308
- const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
2309
- for (const [k, v] of Object.entries(merged)) {
2310
- if (allowed.has(k) && k !== envVar) process.env[k] = v;
2311
- }
2312
-
2313
- try {
2314
- mkdirSync(doctorRoot(), { recursive: true });
2315
- appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action: 'set', envVar, source: '1password', opRef }) + '\n');
2316
- } catch { /* audit best-effort */ }
2317
-
2318
- res.writeHead(200, { 'Content-Type': 'application/json' });
2319
- res.end(JSON.stringify({ success: true, envVar }));
2320
- notifyClients();
2321
- } else if (url.pathname === '/api/providers/credentials/custom') {
2322
- const { action, provider, label, kind, envVars } = data;
2323
- const cleanedId = typeof provider === 'string' ? provider.trim().toLowerCase() : '';
2324
- if (!cleanedId || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(cleanedId)) {
2325
- throw new Error('provider id required: lowercase letters, digits, hyphens (max 63 chars)');
2326
- }
2327
- if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === cleanedId)) {
2328
- throw new Error(`provider id '${cleanedId}' is built-in; pick a different id`);
2329
- }
2330
- const existing = readCustomCredentials();
2331
- if (action === 'delete') {
2332
- const next = existing.filter((e) => e.provider !== cleanedId);
2333
- writeCustomCredentials(next);
2334
- res.writeHead(200, { 'Content-Type': 'application/json' });
2335
- res.end(JSON.stringify({ success: true, providers: next }));
2336
- notifyClients();
2337
- } else if (action === 'save') {
2338
- if (typeof label !== 'string' || !label.trim()) throw new Error('label required');
2339
- if (kind !== 'llm' && kind !== 'integration') throw new Error("kind must be 'llm' or 'integration'");
2340
- if (!Array.isArray(envVars) || envVars.length === 0) throw new Error('envVars must be a non-empty array');
2341
- const normalizedVars = envVars.map((v) => String(v || '').trim().toUpperCase());
2342
- for (const v of normalizedVars) {
2343
- if (!ENV_VAR_PATTERN.test(v)) throw new Error(`envVar '${v}' must match /^[A-Z][A-Z0-9_]{1,63}$/`);
2344
- if (ENV_VAR_BLOCKLIST.has(v)) throw new Error(`envVar '${v}' is reserved by the OS shell and not allowed`);
2345
- if (allowedCredentialEnvVars().has(v)) {
2346
- // Only block when the existing owner is a different provider.
2347
- const owner = credentialMap().find((c) => c.envVars.includes(v));
2348
- if (owner && owner.provider !== cleanedId) {
2349
- throw new Error(`envVar '${v}' already declared by provider '${owner.provider}'`);
2350
- }
2351
- }
2352
- }
2353
- const next = existing.filter((e) => e.provider !== cleanedId).concat([{
2354
- provider: cleanedId,
2355
- label: label.trim(),
2356
- kind,
2357
- envVars: normalizedVars,
2358
- }]);
2359
- writeCustomCredentials(next);
2360
- res.writeHead(200, { 'Content-Type': 'application/json' });
2361
- res.end(JSON.stringify({ success: true, providers: next }));
2362
- notifyClients();
2363
- } else {
2364
- throw new Error("action must be 'save' or 'delete'");
2365
- }
2366
- } else if (url.pathname === '/api/intake/config') {
2367
- const { saveIntakeConfig } = await import('../intake/intake-config.mjs');
2368
- const next = saveIntakeConfig(ROOT_DIR, {
2369
- parentDirs: Array.isArray(data.parentDirs) ? data.parentDirs : undefined,
2370
- maxDepth: data.maxDepth,
2371
- });
2372
- res.writeHead(200, { 'Content-Type': 'application/json' });
2373
- res.end(JSON.stringify({ success: true, config: next }));
2374
- notifyClients();
2375
- } else if (url.pathname === '/api/models/free/apply') {
2376
- const { pollFreeModels, selectForTier } = await import('../model-free-selector.mjs');
2377
- const { readOpenRouterApiKeyFromOpenCodeConfig } = await import('../model-router.mjs');
2378
- const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
2379
- if (!apiKey) {
2380
- res.writeHead(400, { 'Content-Type': 'application/json' });
2381
- res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured. Set it under Providers → Credentials.' }));
2382
- } else {
2383
- const freeModels = await pollFreeModels(apiKey);
2384
- if (!freeModels || freeModels.length === 0) {
2385
- res.writeHead(502, { 'Content-Type': 'application/json' });
2386
- res.end(JSON.stringify({ error: 'OpenRouter returned no free models. Check the key and try again.' }));
2387
- } else {
2388
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
2389
- if (!registry.models) registry.models = {};
2390
- const selections = {};
2391
- for (const tier of ['reasoning', 'standard', 'fast']) {
2392
- const fallback = registry.models?.[tier]?.fallback ?? [];
2393
- const id = selectForTier(freeModels, tier, fallback);
2394
- if (id) {
2395
- selections[tier] = id;
2396
- registry.models[tier] = { primary: id, fallback };
2397
- }
2398
- }
2399
- writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
2400
- res.writeHead(200, { 'Content-Type': 'application/json' });
2401
- res.end(JSON.stringify({ success: true, selections, polledCount: freeModels.length }));
2402
- notifyClients();
2403
- }
2404
- }
2405
- } else if (url.pathname === '/api/providers/registry') {
2406
- const { BUILT_INS } = await import('../providers/registry.mjs');
2407
- const overridesPath = join(configDir(HOME), 'providers.json');
2408
- const { action, id, package: pkg, options } = data;
2409
- if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid provider id');
2410
- if (BUILT_INS.includes(id)) throw new Error(`cannot override built-in provider '${id}'`);
2411
-
2412
- let current = { providers: [] };
2413
- if (existsSync(overridesPath)) {
2414
- try { current = JSON.parse(readFileSync(overridesPath, 'utf8')); } catch { current = { providers: [] }; }
2415
- if (!Array.isArray(current.providers)) current.providers = [];
2416
- }
2417
-
2418
- if (action === 'delete') {
2419
- current.providers = current.providers.filter(p => p?.id !== id);
2420
- } else if (action === 'save') {
2421
- if (!pkg || typeof pkg !== 'string') throw new Error('package required');
2422
- const { validateProviderEntry } = await import('../providers/registry.mjs');
2423
- const verdict = await Promise.race([
2424
- validateProviderEntry({ id, package: pkg, options: options || {} }, { rootDir: ROOT_DIR, env: process.env }),
2425
- new Promise((resolve) => setTimeout(() => resolve({ ok: false, error: 'provider load timeout (5s)' }), 5000)),
2426
- ]);
2427
- if (!verdict.ok) throw new Error(`provider '${id}' failed to load: ${verdict.error}`);
2428
- current.providers = current.providers.filter(p => p?.id !== id).concat([{ id, package: pkg, options: options || {} }]);
2429
- } else {
2430
- throw new Error('action must be save or delete');
2431
- }
2432
-
2433
- mkdirSync(configDir(HOME), { recursive: true });
2434
- const tmp = overridesPath + '.tmp';
2435
- writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
2436
- renameSync(tmp, overridesPath);
2437
- res.writeHead(200, { 'Content-Type': 'application/json' });
2438
- res.end(JSON.stringify({ success: true }));
2439
- notifyClients();
2440
- } else if (url.pathname === '/api/providers/credentials') {
2441
- // 127.0.0.1 already requires same-user file access to write config.env,
2442
- // so no extra security comes from forcing a token on localhost. On any
2443
- // non-loopback bind, require the dashboard token to gate writes.
2444
- const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
2445
- if (isAuthConfigured()) {
2446
- if (!isAuthenticated(req)) {
2447
- res.writeHead(401, { 'Content-Type': 'application/json' });
2448
- res.end(JSON.stringify({ error: 'Unauthorized' }));
2449
- return;
2450
- }
2451
- } else if (!isLocal) {
2452
- throw new Error('credential management on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
2453
- }
2454
- const { envVar, value } = data;
2455
- if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
2456
- const allowed = allowedCredentialEnvVars();
2457
- if (!allowed.has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
2458
- if (value !== '' && typeof value !== 'string') throw new Error('value must be a string');
2459
-
2460
- const { writeEnvValues, parseEnvFile, loadConstructEnv } = await import('../env-config.mjs');
2461
- mkdirSync(configDir(HOME), { recursive: true });
2462
- const action = value === '' ? 'unset' : 'set';
2463
-
2464
- if (action === 'unset') {
2465
- // writeEnvValues drops empty values, so passing '' deletes the key.
2466
- writeEnvValues(CONFIG_ENV_FILE, { ...parseEnvFile(CONFIG_ENV_FILE), [envVar]: '' });
2467
- delete process.env[envVar];
2468
- } else {
2469
- writeEnvValues(CONFIG_ENV_FILE, { [envVar]: value });
2470
- process.env[envVar] = value;
2471
- }
2472
- try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms set best-effort */ }
2473
-
2474
- // Re-overlay so any non-targeted keys edited out-of-band also refresh.
2475
- const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
2476
- for (const [k, v] of Object.entries(merged)) {
2477
- if (allowed.has(k) && k !== envVar) process.env[k] = v;
2478
- }
2479
-
2480
- try {
2481
- mkdirSync(doctorRoot(), { recursive: true });
2482
- appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action, envVar }) + '\n');
2483
- } catch { /* audit append best-effort */ }
2484
-
2485
- res.writeHead(200, { 'Content-Type': 'application/json' });
2486
- res.end(JSON.stringify({ success: true }));
2487
- notifyClients();
2488
- } else if (url.pathname === '/api/providers/subscriptions') {
2489
- const subsPath = join(configDir(HOME), 'provider-subscriptions.json');
2490
- const { action, id, provider, name, config } = data;
2491
- if (!id || typeof id !== 'string' || !/^[\w.-]+$/.test(id)) throw new Error('Invalid subscription id');
2492
-
2493
- let current = { subscriptions: [] };
2494
- if (existsSync(subsPath)) {
2495
- try { current = JSON.parse(readFileSync(subsPath, 'utf8')); } catch { current = { subscriptions: [] }; }
2496
- if (!Array.isArray(current.subscriptions)) current.subscriptions = [];
2497
- }
2498
-
2499
- if (action === 'delete') {
2500
- current.subscriptions = current.subscriptions.filter(s => s?.id !== id);
2501
- } else if (action === 'save') {
2502
- if (!provider || typeof provider !== 'string') throw new Error('provider required');
2503
- if (config && typeof config !== 'object') throw new Error('config must be an object');
2504
- const entry = { id, provider, name: name || id, config: config || {} };
2505
- current.subscriptions = current.subscriptions.filter(s => s?.id !== id).concat([entry]);
2506
- } else {
2507
- throw new Error('action must be save or delete');
2508
- }
2509
-
2510
- mkdirSync(configDir(HOME), { recursive: true });
2511
- const tmp = subsPath + '.tmp';
2512
- writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
2513
- renameSync(tmp, subsPath);
2514
- res.writeHead(200, { 'Content-Type': 'application/json' });
2515
- res.end(JSON.stringify({ success: true }));
2516
- notifyClients();
2517
- } else {
2518
- res.writeHead(404);
2519
- res.end('Not found');
2520
- }
2521
- } catch (err) {
2522
- res.writeHead(400, { 'Content-Type': 'application/json' });
2523
- res.end(JSON.stringify({ error: err.message }));
2524
- }
2525
- });
2526
- return;
2527
- }
2528
-
2529
- if (url.pathname.startsWith('/demo-preview/')) {
2530
- const preview = resolveDemoPreviewPath(url.pathname);
2531
- if (!preview.ok) {
2532
- res.writeHead(preview.status);
2533
- res.end(preview.message);
2534
- return;
2535
- }
2536
- res.writeHead(200, { 'Content-Type': demoPreviewMime(preview.ext) });
2537
- res.end(readFileSync(preview.filePath));
2538
- return;
2539
- }
2540
-
2541
- // Static files
2542
- let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
2543
- let fullPath = join(STATIC_DIR, filePath);
2544
-
2545
- if (!fullPath.startsWith(STATIC_DIR)) {
2546
- res.writeHead(403);
2547
- res.end('Forbidden');
2548
- return;
2549
- }
2550
-
2551
- if (existsSync(fullPath) && statSync(fullPath).isDirectory()) {
2552
- fullPath = join(fullPath, 'index.html');
2553
- } else if (!existsSync(fullPath)) {
2554
- const indexCandidate = join(STATIC_DIR, filePath, 'index.html');
2555
- if (existsSync(indexCandidate)) fullPath = indexCandidate;
2556
- }
2557
-
2558
- if (!existsSync(fullPath) || statSync(fullPath).isDirectory()) {
2559
- res.writeHead(404);
2560
- res.end('Not found');
2561
- return;
2562
- }
2563
-
2564
- const ext = extname(fullPath);
2565
- const mime = MIME[ext] ?? 'application/octet-stream';
2566
- res.writeHead(200, { 'Content-Type': mime });
2567
- res.end(readFileSync(fullPath));
2568
- });
2569
-
2570
- watchFiles();
2571
-
2572
- // ── Embed scheduler ──────────────────────────────────────────────────────────
2573
- // When CX_AUTO_EMBED=1, run `construct sync` at startup and on a fixed interval
2574
- // to keep the knowledge base current without manual intervention.
2575
- // Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
2576
- function runEmbedSync() {
2577
- const syncScript = join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
2578
- if (!existsSync(syncScript)) return;
2579
- try {
2580
- spawnSync(process.execPath, [syncScript], {
2581
- cwd: ROOT_DIR,
2582
- stdio: 'ignore',
2583
- timeout: 120_000,
2584
- env: { ...process.env },
2585
- });
2586
- } catch {
2587
- // non-fatal — next interval will retry
2588
- }
2589
- }
2590
-
2591
- // Load user env config into process.env so dashboard API endpoints that read
2592
- // from process.env (telemetry credentials, model provider keys, etc.) find them
2593
- // even when the server is started directly (not through bin/construct).
2594
- try {
2595
- const { getUserEnvPath } = await import('../env-config.mjs');
2596
- const { prepareConstructEnv } = await import('../runtime-env.mjs');
2597
- const homeDir = HOME || homedir();
2598
- const envPath = getUserEnvPath(homeDir);
2599
- if (existsSync(envPath)) {
2600
- const ENV = prepareConstructEnv({ rootDir: ROOT_DIR, homeDir, env: process.env });
2601
- for (const [key, value] of Object.entries(ENV)) {
2602
- if (!(key in process.env)) process.env[key] = value;
2603
- }
2604
- }
2605
- } catch { /* non-fatal — env loading is best-effort */ }
2606
-
2607
- if (process.env.CX_AUTO_EMBED === '1') {
2608
- const intervalMs = parseInt(process.env.CX_EMBED_INTERVAL_MS ?? '', 10) || 30 * 60 * 1000;
2609
- runEmbedSync();
2610
- setInterval(runEmbedSync, intervalMs).unref();
2611
- console.log(`Embed scheduler active — syncing every ${intervalMs / 60_000} min`);
2612
- }
2613
- assertSafeBind();
2614
- server.listen(PORT, BIND_HOST, () => {
2615
- console.log(`Construct dashboard running at http://${BIND_HOST}:${PORT}`);
2616
- });
2617
-
2618
- // SIGTERM/SIGINT close the server cleanly so the process exits 0 instead of
2619
- // 143 — keeps shell tooling and supervisors from flagging normal shutdowns
2620
- // as failures.
2621
-
2622
- let shuttingDown = false;
2623
- function shutdown(signal) {
2624
- if (shuttingDown) return;
2625
- shuttingDown = true;
2626
- console.log(`Received ${signal}, shutting down dashboard...`);
2627
- closeWatchers();
2628
- for (const client of sseClients) {
2629
- try { client.end(); } catch { /* ignore */ }
2630
- }
2631
- server.close(() => process.exit(0));
2632
- setTimeout(() => process.exit(0), 2000).unref();
2633
- }
2634
- process.on('SIGTERM', () => shutdown('SIGTERM'));
2635
- process.on('SIGINT', () => shutdown('SIGINT'));
2636
-
2637
- // ── Subscribe to embed notification bus → SSE toast events ──────────────────
2638
- onEmbedNotification((event) => {
2639
- notifyClients({ type: 'toast', ...event });
2640
- });
2641
-