@geraldmaron/construct 1.0.0 → 1.0.1

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 (471) hide show
  1. package/.env.example +10 -7
  2. package/LICENSE +201 -21
  3. package/README.md +132 -257
  4. package/agents/contracts.json +387 -0
  5. package/agents/prompts/cx-accessibility.md +8 -0
  6. package/agents/prompts/cx-ai-engineer.md +92 -0
  7. package/agents/prompts/cx-architect.md +10 -2
  8. package/agents/prompts/cx-business-strategist.md +13 -0
  9. package/agents/prompts/cx-data-analyst.md +80 -0
  10. package/agents/prompts/cx-data-engineer.md +4 -0
  11. package/agents/prompts/cx-debugger.md +8 -0
  12. package/agents/prompts/cx-designer.md +19 -0
  13. package/agents/prompts/cx-devil-advocate.md +4 -0
  14. package/agents/prompts/cx-docs-keeper.md +128 -0
  15. package/agents/prompts/cx-engineer.md +13 -0
  16. package/agents/prompts/cx-evaluator.md +4 -0
  17. package/agents/prompts/cx-explorer.md +12 -0
  18. package/agents/prompts/cx-legal-compliance.md +13 -0
  19. package/agents/prompts/cx-operations.md +13 -0
  20. package/agents/prompts/cx-orchestrator.md +107 -4
  21. package/agents/prompts/cx-platform-engineer.md +75 -0
  22. package/agents/prompts/cx-product-manager.md +8 -0
  23. package/agents/prompts/cx-qa.md +100 -0
  24. package/agents/prompts/cx-rd-lead.md +13 -0
  25. package/agents/prompts/cx-release-manager.md +8 -0
  26. package/agents/prompts/cx-researcher.md +21 -1
  27. package/agents/prompts/cx-reviewer.md +8 -0
  28. package/agents/prompts/cx-security.md +104 -0
  29. package/agents/prompts/cx-sre.md +104 -0
  30. package/agents/prompts/cx-test-automation.md +4 -0
  31. package/agents/prompts/cx-trace-reviewer.md +7 -3
  32. package/agents/prompts/cx-ux-researcher.md +4 -0
  33. package/agents/registry.json +365 -90
  34. package/agents/role-manifests.json +217 -0
  35. package/bin/construct +3345 -141
  36. package/bin/construct-postinstall.mjs +87 -0
  37. package/commands/build/feature.md +6 -6
  38. package/commands/plan/feature.md +6 -5
  39. package/commands/plan/requirements.md +1 -1
  40. package/commands/ship/status.md +1 -1
  41. package/commands/work/drive.md +3 -3
  42. package/commands/work/optimize-prompts.md +3 -3
  43. package/db/{migrations → schema}/001_init.sql +2 -0
  44. package/db/schema/002_pgvector.sql +182 -0
  45. package/db/schema/003_intake.sql +47 -0
  46. package/examples/README.md +85 -0
  47. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +30 -0
  48. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +23 -0
  49. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +30 -0
  50. package/examples/internal/roles/engineer/golden/read-before-write.md +28 -0
  51. package/examples/internal/roles/orchestrator/bad/everything-becomes-multi-agent.md +29 -0
  52. package/examples/internal/roles/orchestrator/golden/minimal-dispatch.md +22 -0
  53. package/examples/internal/roles/qa/bad/coverage-theater.md +30 -0
  54. package/examples/internal/roles/qa/golden/regression-gate.md +23 -0
  55. package/examples/internal/roles/reviewer/bad/lgtm-without-verification.md +29 -0
  56. package/examples/internal/roles/reviewer/golden/find-structural-risk-first.md +28 -0
  57. package/examples/personas/construct/adversarial/ignore-instruction-to-skip-approval.md +22 -0
  58. package/examples/personas/construct/bad/commit-without-approval.md +30 -0
  59. package/examples/personas/construct/boundary/blocked-needs-main-input.md +29 -0
  60. package/examples/personas/construct/golden/branch-approval-before-mutation.md +36 -0
  61. package/examples/personas/construct/golden/focused-direct-answer.md +28 -0
  62. package/examples/provider-plugin/README.md +34 -0
  63. package/examples/provider-plugin/index.mjs +74 -0
  64. package/examples/provider-plugin/package.json +15 -0
  65. package/examples/seed-observations/README.md +38 -0
  66. package/examples/seed-observations/anti-patterns.md +44 -0
  67. package/examples/seed-observations/decisions.md +36 -0
  68. package/examples/seed-observations/patterns.md +42 -0
  69. package/lib/agent-contracts-enforce.mjs +158 -0
  70. package/lib/agent-contracts.mjs +231 -0
  71. package/lib/agents/postconditions.mjs +126 -0
  72. package/lib/agents/schema.mjs +124 -0
  73. package/lib/artifact-capture.mjs +183 -0
  74. package/lib/audit-trail.mjs +149 -0
  75. package/lib/auto-docs.mjs +245 -65
  76. package/lib/beads/auto-close.mjs +126 -0
  77. package/lib/beads/drift.mjs +171 -0
  78. package/lib/beads-automation.mjs +542 -0
  79. package/lib/beads-client.mjs +518 -0
  80. package/lib/beads-lock.mjs +377 -0
  81. package/lib/beads-optimistic.mjs +365 -0
  82. package/lib/bootstrap/built-ins.mjs +136 -0
  83. package/lib/bootstrap/lazy-install.mjs +161 -0
  84. package/lib/bootstrap/resources.mjs +120 -0
  85. package/lib/bootstrap.mjs +105 -0
  86. package/lib/cache-governor.js +213 -0
  87. package/lib/cache-strategy-anthropic.js +62 -0
  88. package/lib/cache-strategy-google.js +79 -0
  89. package/lib/cache-strategy-none.js +30 -0
  90. package/lib/cache-strategy-openai.js +48 -0
  91. package/lib/cache-strategy.js +91 -0
  92. package/lib/claude-allow.mjs +149 -0
  93. package/lib/cli-commands.mjs +413 -258
  94. package/lib/codex-config.mjs +3 -1
  95. package/lib/comment-lint.mjs +55 -6
  96. package/lib/completions.mjs +21 -6
  97. package/lib/config/alias.mjs +56 -0
  98. package/lib/config/project-config.mjs +335 -0
  99. package/lib/config/schema.mjs +159 -0
  100. package/lib/context-router.mjs +308 -0
  101. package/lib/cost-ledger.mjs +177 -0
  102. package/lib/cost.mjs +171 -10
  103. package/lib/dashboard-static.mjs +158 -0
  104. package/lib/deployment-mode.mjs +86 -0
  105. package/lib/deprecate.mjs +49 -0
  106. package/lib/dispatch-batch.js +183 -0
  107. package/lib/distill.mjs +21 -8
  108. package/lib/doc-stamp.mjs +164 -0
  109. package/lib/doc-verify.mjs +119 -0
  110. package/lib/docs-routing.mjs +89 -0
  111. package/lib/docs-verify.mjs +417 -0
  112. package/lib/doctor/audit.mjs +71 -0
  113. package/lib/doctor/cli.mjs +99 -0
  114. package/lib/doctor/escalate.mjs +29 -0
  115. package/lib/doctor/index.mjs +140 -0
  116. package/lib/doctor/report.mjs +170 -0
  117. package/lib/doctor/watchers/bd-watch.mjs +117 -0
  118. package/lib/doctor/watchers/cost.mjs +130 -0
  119. package/lib/doctor/watchers/disk.mjs +122 -0
  120. package/lib/doctor/watchers/handoffs.mjs +33 -0
  121. package/lib/doctor/watchers/process-pressure.mjs +60 -0
  122. package/lib/doctor/watchers/service-health.mjs +188 -0
  123. package/lib/document-extract.mjs +288 -0
  124. package/lib/document-ingest.mjs +230 -0
  125. package/lib/drop.mjs +282 -0
  126. package/lib/embed/approval-queue.mjs +176 -0
  127. package/lib/embed/artifact.mjs +349 -0
  128. package/lib/embed/authority-guard.mjs +155 -0
  129. package/lib/embed/cli.mjs +408 -0
  130. package/lib/embed/config.mjs +355 -0
  131. package/lib/embed/conflict-detection.mjs +264 -0
  132. package/lib/embed/customer-profiles.mjs +480 -0
  133. package/lib/embed/daemon.mjs +1309 -0
  134. package/lib/embed/demand-fetch.mjs +449 -0
  135. package/lib/embed/docs-lifecycle.mjs +349 -0
  136. package/lib/embed/inbox-live-watcher.mjs +119 -0
  137. package/lib/embed/inbox.mjs +343 -0
  138. package/lib/embed/intake-metrics.mjs +190 -0
  139. package/lib/embed/jobs/vector-sync.mjs +198 -0
  140. package/lib/embed/notifications.mjs +75 -0
  141. package/lib/embed/output.mjs +79 -0
  142. package/lib/embed/providers/github.mjs +295 -0
  143. package/lib/embed/providers/jira.mjs +192 -0
  144. package/lib/embed/providers/linear.mjs +186 -0
  145. package/lib/embed/providers/registry.mjs +115 -0
  146. package/lib/embed/providers/slack.mjs +203 -0
  147. package/lib/embed/recommendation-store.mjs +378 -0
  148. package/lib/embed/roadmap.mjs +374 -0
  149. package/lib/embed/role-framing.mjs +110 -0
  150. package/lib/embed/scheduler.mjs +99 -0
  151. package/lib/embed/semantic.mjs +325 -0
  152. package/lib/embed/snapshot.mjs +191 -0
  153. package/lib/embed/supervision.mjs +235 -0
  154. package/lib/embed/target-resolver.mjs +186 -0
  155. package/lib/embed/worker.mjs +62 -0
  156. package/lib/embed/workspaces.mjs +297 -0
  157. package/lib/engine/chunker-headings.mjs +110 -0
  158. package/lib/engine/compressor-heuristic.mjs +100 -0
  159. package/lib/engine/consolidate.mjs +287 -0
  160. package/lib/engine/contracts.mjs +126 -0
  161. package/lib/engine/defaults.mjs +129 -0
  162. package/lib/engine/eval-retrieval.mjs +148 -0
  163. package/lib/engine/fuser-rrf.mjs +62 -0
  164. package/lib/engine/index.mjs +37 -0
  165. package/lib/engine/registry.mjs +146 -0
  166. package/lib/engine/reranker-mmr.mjs +90 -0
  167. package/lib/engine/tokens.mjs +77 -0
  168. package/lib/entity-store.mjs +280 -0
  169. package/lib/env-config.mjs +69 -4
  170. package/lib/evals/retrieval-bench.mjs +159 -0
  171. package/lib/evaluator-optimizer.mjs +317 -0
  172. package/lib/features.mjs +159 -29
  173. package/lib/gates-audit.mjs +236 -0
  174. package/lib/git-hooks/prepare-commit-msg +58 -0
  175. package/lib/handoffs/cleanup.mjs +159 -0
  176. package/lib/handoffs/contract.mjs +162 -0
  177. package/lib/handoffs/inventory.mjs +120 -0
  178. package/lib/headhunt.mjs +28 -47
  179. package/lib/health-check.mjs +399 -0
  180. package/lib/hook-health.mjs +442 -0
  181. package/lib/hooks/_lib/log.mjs +82 -0
  182. package/lib/hooks/adaptive-lint.mjs +26 -2
  183. package/lib/hooks/agent-tracker.mjs +171 -14
  184. package/lib/hooks/audit-reads.mjs +109 -0
  185. package/lib/hooks/audit-trail.mjs +153 -0
  186. package/lib/hooks/bash-output-logger.mjs +71 -0
  187. package/lib/hooks/block-no-verify.mjs +41 -0
  188. package/lib/hooks/ci-status-check.mjs +82 -0
  189. package/lib/hooks/comment-lint.mjs +29 -7
  190. package/lib/hooks/config-protection.mjs +39 -18
  191. package/lib/hooks/context-watch.mjs +137 -0
  192. package/lib/hooks/context-window-recovery.mjs +4 -20
  193. package/lib/hooks/dep-audit.mjs +16 -0
  194. package/lib/hooks/doc-coupling-check.mjs +80 -0
  195. package/lib/hooks/edit-accumulator.mjs +3 -0
  196. package/lib/hooks/edit-error-recovery.mjs +3 -0
  197. package/lib/hooks/edit-guard.mjs +45 -4
  198. package/lib/hooks/env-check.mjs +3 -0
  199. package/lib/hooks/guard-bash.mjs +58 -1
  200. package/lib/hooks/mcp-audit.mjs +18 -20
  201. package/lib/hooks/mcp-health-check.mjs +36 -0
  202. package/lib/hooks/model-fallback.mjs +42 -56
  203. package/lib/hooks/policy-engine.mjs +209 -0
  204. package/lib/hooks/post-merge-docs-check.mjs +63 -0
  205. package/lib/hooks/pre-compact.mjs +4 -24
  206. package/lib/hooks/pre-push-gate.mjs +200 -37
  207. package/lib/hooks/proactive-activation.mjs +284 -0
  208. package/lib/hooks/read-tracker.mjs +27 -4
  209. package/lib/hooks/readme-age-check.mjs +77 -0
  210. package/lib/hooks/registry-sync.mjs +12 -5
  211. package/lib/hooks/scan-secrets.mjs +50 -7
  212. package/lib/hooks/session-optimize.mjs +311 -0
  213. package/lib/hooks/session-start.mjs +287 -28
  214. package/lib/hooks/stop-notify.mjs +236 -96
  215. package/lib/hooks/stop-typecheck.mjs +13 -1
  216. package/lib/hooks/test-watch.mjs +68 -0
  217. package/lib/host-capabilities.mjs +31 -10
  218. package/lib/init-docs.mjs +731 -291
  219. package/lib/init-unified.mjs +1116 -0
  220. package/lib/init-update.mjs +168 -0
  221. package/lib/init.mjs +107 -0
  222. package/lib/install/first-invocation.mjs +119 -0
  223. package/lib/install/stage-project.mjs +69 -0
  224. package/lib/intake/classify.mjs +278 -0
  225. package/lib/intake/feedback.mjs +273 -0
  226. package/lib/intake/filesystem-queue.mjs +158 -0
  227. package/lib/intake/intake-config.mjs +132 -0
  228. package/lib/intake/postgres-queue.mjs +198 -0
  229. package/lib/intake/prepare.mjs +139 -0
  230. package/lib/intake/queue.mjs +83 -0
  231. package/lib/intake/session-prelude.mjs +50 -0
  232. package/lib/integrations/intake-integrations.mjs +740 -0
  233. package/lib/intent-classifier.mjs +253 -0
  234. package/lib/knowledge/layout.mjs +72 -0
  235. package/lib/knowledge/rag.mjs +331 -0
  236. package/lib/knowledge/search.mjs +315 -0
  237. package/lib/knowledge/trends.mjs +261 -0
  238. package/lib/logger.mjs +85 -0
  239. package/lib/mcp/broker.mjs +124 -0
  240. package/lib/mcp/server.mjs +554 -854
  241. package/lib/mcp/tools/document.mjs +132 -0
  242. package/lib/mcp/tools/memory.mjs +209 -0
  243. package/lib/mcp/tools/project.mjs +349 -0
  244. package/lib/mcp/tools/skills.mjs +409 -0
  245. package/lib/mcp/tools/storage.mjs +60 -0
  246. package/lib/mcp/tools/telemetry.mjs +315 -0
  247. package/lib/mcp/tools/workflow.mjs +112 -0
  248. package/lib/mcp-catalog.json +54 -3
  249. package/lib/mcp-manager.mjs +96 -48
  250. package/lib/mcp-platform-config.mjs +22 -22
  251. package/lib/memory-stats.mjs +121 -0
  252. package/lib/mode-commands.mjs +124 -0
  253. package/lib/model-free-selector.mjs +184 -0
  254. package/lib/model-pricing.mjs +152 -0
  255. package/lib/model-registry.mjs +226 -0
  256. package/lib/model-router.mjs +362 -386
  257. package/lib/observation-store.mjs +391 -0
  258. package/lib/ollama-manager.mjs +428 -0
  259. package/lib/opencode-config.mjs +13 -3
  260. package/lib/opencode-runtime-plugin.mjs +70 -38
  261. package/lib/opencode-telemetry.mjs +64 -6
  262. package/lib/orchestration-policy.mjs +509 -6
  263. package/lib/overrides/resolver.mjs +207 -0
  264. package/lib/parity.mjs +147 -0
  265. package/lib/paths.mjs +33 -0
  266. package/lib/performance/generate.mjs +212 -0
  267. package/lib/plugin-registry.mjs +268 -0
  268. package/lib/policy/engine.mjs +130 -0
  269. package/lib/policy/unified-gates.mjs +96 -0
  270. package/lib/project-detection.mjs +129 -0
  271. package/lib/project-init-shared.mjs +272 -0
  272. package/lib/project-profile.mjs +447 -0
  273. package/lib/prompt-composer.js +434 -0
  274. package/lib/prompt-metadata.mjs +1 -1
  275. package/lib/provider-capabilities-anthropic.js +44 -0
  276. package/lib/provider-capabilities-deepseek.js +37 -0
  277. package/lib/provider-capabilities-generic.js +26 -0
  278. package/lib/provider-capabilities-google.js +47 -0
  279. package/lib/provider-capabilities-openai.js +45 -0
  280. package/lib/provider-capabilities.js +142 -0
  281. package/lib/providers/atlassian-confluence/index.mjs +103 -0
  282. package/lib/providers/atlassian-jira/index.mjs +100 -0
  283. package/lib/providers/auth-manager.mjs +126 -0
  284. package/lib/providers/circuit-breaker.mjs +124 -0
  285. package/lib/providers/contract.mjs +93 -0
  286. package/lib/providers/github/index.mjs +126 -0
  287. package/lib/providers/registry.mjs +184 -0
  288. package/lib/providers/salesforce/index.mjs +100 -0
  289. package/lib/providers/slack/index.mjs +80 -0
  290. package/lib/reflect.mjs +137 -0
  291. package/lib/research-lint.mjs +164 -0
  292. package/lib/resources/budget.mjs +259 -0
  293. package/lib/role-preload.mjs +21 -6
  294. package/lib/roles/approval-surface.mjs +54 -0
  295. package/lib/roles/cli.mjs +118 -0
  296. package/lib/roles/event-bus.mjs +79 -0
  297. package/lib/roles/fence.mjs +84 -0
  298. package/lib/roles/gateway.mjs +260 -0
  299. package/lib/roles/hook-emit.mjs +37 -0
  300. package/lib/roles/manifest.mjs +48 -0
  301. package/lib/roles/router.mjs +27 -0
  302. package/lib/runtime-pressure.mjs +360 -0
  303. package/lib/schema-artifact.mjs +134 -0
  304. package/lib/schema-infer.mjs +551 -0
  305. package/lib/server/auth.mjs +168 -0
  306. package/lib/server/chat.mjs +336 -0
  307. package/lib/server/cors.mjs +77 -0
  308. package/lib/server/csrf.mjs +91 -0
  309. package/lib/server/index.mjs +1927 -78
  310. package/lib/server/insights.mjs +765 -0
  311. package/lib/server/rate-limit.mjs +91 -0
  312. package/lib/server/static/assets/index-ab25c707.js +70 -0
  313. package/lib/server/static/assets/index-f0c80a2b.css +1 -0
  314. package/lib/server/static/index.html +12 -817
  315. package/lib/server/telemetry-login.mjs +108 -0
  316. package/lib/server/webhook.mjs +510 -0
  317. package/lib/service-manager.mjs +522 -58
  318. package/lib/services/pattern-promotion-service.mjs +167 -0
  319. package/lib/services/telemetry-backend.mjs +178 -0
  320. package/lib/session-store.mjs +374 -0
  321. package/lib/setup-prompts.mjs +96 -0
  322. package/lib/setup.mjs +525 -38
  323. package/lib/skills-apply.mjs +280 -0
  324. package/lib/skills-scope.mjs +118 -0
  325. package/lib/status.mjs +261 -70
  326. package/lib/storage/admin.mjs +355 -0
  327. package/lib/storage/backend.mjs +2 -1
  328. package/lib/storage/backup.mjs +347 -0
  329. package/lib/storage/embeddings-engine.mjs +133 -0
  330. package/lib/storage/embeddings-legacy.mjs +85 -0
  331. package/lib/storage/embeddings-local.mjs +108 -0
  332. package/lib/storage/embeddings-ollama.mjs +78 -0
  333. package/lib/storage/embeddings-openai.mjs +85 -0
  334. package/lib/storage/embeddings.mjs +92 -33
  335. package/lib/storage/file-lock.mjs +130 -0
  336. package/lib/storage/fusion.mjs +95 -0
  337. package/lib/storage/hybrid-query.mjs +34 -27
  338. package/lib/storage/migrations.mjs +187 -0
  339. package/lib/storage/postgres-backup.mjs +124 -0
  340. package/lib/storage/sql-store.mjs +5 -15
  341. package/lib/storage/state-source.mjs +12 -13
  342. package/lib/storage/store-version.mjs +115 -0
  343. package/lib/storage/sync.mjs +144 -35
  344. package/lib/storage/unified-storage.mjs +550 -0
  345. package/lib/storage/vector-client.mjs +286 -0
  346. package/lib/storage/vector-store.mjs +71 -30
  347. package/lib/task-graph/generate.mjs +135 -0
  348. package/lib/task-graph/schema.mjs +81 -0
  349. package/lib/task-graph/store.mjs +71 -0
  350. package/lib/telemetry/backends/local.mjs +62 -0
  351. package/lib/telemetry/backends/{langfuse.mjs → remote.mjs} +27 -14
  352. package/lib/telemetry/backfill.mjs +180 -0
  353. package/lib/telemetry/eval-datasets.mjs +203 -0
  354. package/lib/telemetry/{langfuse-ingest.mjs → ingest.mjs} +26 -20
  355. package/lib/telemetry/intent-verifications.mjs +86 -0
  356. package/lib/telemetry/llm-judge.mjs +350 -0
  357. package/lib/telemetry/model-pricing-catalog.mjs +557 -0
  358. package/lib/telemetry/setup.mjs +151 -0
  359. package/lib/telemetry/skill-calls.mjs +78 -0
  360. package/lib/telemetry/team-rollup.mjs +4 -4
  361. package/lib/token-engine.js +117 -0
  362. package/lib/token-estimator-anthropic.js +15 -0
  363. package/lib/token-estimator-deepseek.js +13 -0
  364. package/lib/token-estimator-default.js +13 -0
  365. package/lib/token-estimator-google.js +13 -0
  366. package/lib/token-estimator-openai.js +13 -0
  367. package/lib/toolkit-env.mjs +1 -1
  368. package/lib/tty-prompts.mjs +211 -0
  369. package/lib/uninstall/uninstall.mjs +423 -0
  370. package/lib/update.mjs +115 -0
  371. package/lib/upgrade.mjs +141 -0
  372. package/lib/validator.mjs +51 -2
  373. package/lib/validators/skills.mjs +142 -0
  374. package/lib/wireframe.mjs +422 -0
  375. package/lib/worker/entrypoint.mjs +241 -0
  376. package/lib/worker/evidence.mjs +107 -0
  377. package/lib/worker/run.mjs +154 -0
  378. package/lib/worker/trace.mjs +182 -0
  379. package/lib/workflow-state.mjs +14 -18
  380. package/package.json +21 -8
  381. package/personas/construct.md +53 -51
  382. package/platforms/claude/CLAUDE.md +1 -1
  383. package/platforms/claude/settings.template.json +115 -68
  384. package/platforms/opencode/config.template.json +3 -7
  385. package/rules/common/agents.md +11 -38
  386. package/rules/common/beads-hygiene.md +75 -0
  387. package/rules/common/code-review.md +11 -100
  388. package/rules/common/coding-style.md +5 -3
  389. package/rules/common/comments.md +30 -111
  390. package/rules/common/commit-approval.md +53 -0
  391. package/rules/common/cx-agent-routing.md +1 -0
  392. package/rules/common/development-workflow.md +23 -41
  393. package/rules/common/doc-ownership.md +54 -0
  394. package/rules/common/efficiency.md +57 -0
  395. package/rules/common/framing.md +76 -0
  396. package/rules/common/git-workflow.md +2 -4
  397. package/rules/common/patterns.md +3 -7
  398. package/rules/common/performance.md +15 -31
  399. package/rules/common/release-gates.md +69 -0
  400. package/rules/common/research.md +107 -0
  401. package/rules/common/security.md +6 -6
  402. package/rules/common/skill-composition.md +67 -0
  403. package/rules/common/testing.md +15 -27
  404. package/rules/golang/hooks.md +0 -4
  405. package/rules/policy/bootstrap.yaml +11 -0
  406. package/rules/policy/drive.yaml +8 -0
  407. package/rules/policy/task.yaml +9 -0
  408. package/rules/policy/workflow.yaml +9 -0
  409. package/rules/python/hooks.md +0 -4
  410. package/rules/swift/hooks.md +0 -4
  411. package/rules/typescript/hooks.md +0 -4
  412. package/rules/web/hooks.md +0 -2
  413. package/{sync-agents.mjs → scripts/sync-agents.mjs} +306 -61
  414. package/skills/ai/prompt-optimizer.md +7 -7
  415. package/skills/compliance/ai-disclosure.md +58 -0
  416. package/skills/compliance/data-privacy.md +46 -0
  417. package/skills/compliance/license-audit.md +40 -0
  418. package/skills/compliance/regulatory-review.md +61 -0
  419. package/skills/docs/document-ingest-workflow.md +52 -0
  420. package/skills/docs/evidence-ingest-workflow.md +9 -0
  421. package/skills/docs/init-docs.md +51 -18
  422. package/skills/docs/product-intelligence-workflow.md +12 -0
  423. package/skills/docs/product-signal-workflow.md +4 -0
  424. package/skills/docs/research-workflow.md +23 -8
  425. package/skills/docs/runbook-workflow.md +1 -1
  426. package/skills/operating/orchestration-reference.md +151 -0
  427. package/skills/roles/architect.md +5 -0
  428. package/skills/roles/engineer.md +32 -0
  429. package/skills/roles/operator.md +12 -0
  430. package/skills/roles/reviewer.md +23 -0
  431. package/skills/routing.md +1 -1
  432. package/templates/devcontainer/Dockerfile.devcontainer +38 -0
  433. package/templates/devcontainer/devcontainer.json +31 -0
  434. package/templates/distribution/bootstrap.ps1 +142 -0
  435. package/templates/distribution/bootstrap.sh +196 -0
  436. package/templates/distribution/run.mjs +187 -0
  437. package/templates/docs/adr.md +44 -8
  438. package/templates/docs/changelog-entry.md +43 -0
  439. package/templates/docs/construct_guide.md +149 -0
  440. package/templates/docs/evidence-brief.md +2 -2
  441. package/templates/docs/meta-prd.md +140 -19
  442. package/templates/docs/onboarding.md +57 -0
  443. package/templates/docs/prd.md +159 -15
  444. package/templates/docs/research-brief.md +4 -4
  445. package/templates/homebrew/construct.rb +67 -0
  446. package/langfuse/docker-compose.yml +0 -82
  447. package/lib/eval-harness.mjs +0 -59
  448. package/lib/hooks/bootstrap-guard.mjs +0 -90
  449. package/lib/hooks/console-warn.mjs +0 -43
  450. package/lib/hooks/continuation-enforcer.mjs +0 -72
  451. package/lib/hooks/drive-guard.mjs +0 -89
  452. package/lib/hooks/mcp-task-scope.mjs +0 -47
  453. package/lib/hooks/task-completed-guard.mjs +0 -43
  454. package/lib/hooks/teammate-idle-guard.mjs +0 -54
  455. package/lib/hooks/workflow-guard.mjs +0 -62
  456. package/lib/prompt-composer.mjs +0 -196
  457. package/lib/review.mjs +0 -429
  458. package/lib/server/static/app.js +0 -841
  459. package/lib/telemetry/langfuse-model-sync.mjs +0 -270
  460. package/rules/common/hooks.md +0 -35
  461. package/rules/zh/README.md +0 -113
  462. package/rules/zh/agents.md +0 -55
  463. package/rules/zh/code-review.md +0 -129
  464. package/rules/zh/coding-style.md +0 -53
  465. package/rules/zh/development-workflow.md +0 -49
  466. package/rules/zh/git-workflow.md +0 -29
  467. package/rules/zh/hooks.md +0 -35
  468. package/rules/zh/patterns.md +0 -36
  469. package/rules/zh/performance.md +0 -60
  470. package/rules/zh/security.md +0 -34
  471. package/rules/zh/testing.md +0 -34
@@ -1,26 +1,249 @@
1
1
  /**
2
- * lib/server/index.mjs — <one-line purpose>
2
+ * lib/server/index.mjs — Construct dashboard HTTP server.
3
3
  *
4
- * <2–6 line summary: what it does, who calls it, key side effects.>
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, and SSE-streamed chat via the claude CLI.
8
+ * Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
5
9
  */
6
10
  import { createServer } from 'http';
7
- import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync } from 'fs';
8
- import { join, extname } from 'path';
11
+ import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
12
+ import { spawnSync } from 'child_process';
13
+ import { join, extname, relative, normalize, dirname } from 'path';
9
14
  import { homedir } from 'os';
10
15
  import { fileURLToPath } from 'url';
16
+ import { spawn } from 'child_process';
11
17
  import { buildStatus as buildSharedStatus } from '../status.mjs';
18
+ import { generateArtifact, listArtifacts } from '../embed/artifact.mjs';
19
+ import { ApprovalQueue } from '../embed/approval-queue.mjs';
20
+ import { resolveEmbedStatus } from '../embed/cli.mjs';
21
+ import { loadConstructEnv } from '../env-config.mjs';
22
+ import {
23
+ isAuthConfigured, isAuthenticated, rejectUnauthorized,
24
+ validateToken, createSession, sessionCookieHeader, clearSessionCookieHeader,
25
+ getDashboardToken, getAuthConfig,
26
+ } from './auth.mjs';
27
+ import { handleChatStream, handleChat, handleChatHistory } from './chat.mjs';
28
+ import { createWebhookHandler, createSlackCommandHandler } from './webhook.mjs';
29
+ import { onEmbedNotification } from '../embed/notifications.mjs';
12
30
 
13
31
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
14
32
  const ROOT_DIR = join(__dirname, '..', '..');
15
33
  const HOME = homedir();
16
34
  const PORT = parseInt(process.env.PORT ?? '4242', 10);
35
+ const BIND_HOST = process.env.BIND_HOST ?? (process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1');
17
36
 
18
37
  const STATIC_DIR = join(__dirname, 'static');
19
38
  const REGISTRY_FILE = join(ROOT_DIR, 'agents', 'registry.json');
20
39
  const FEATURES_FILE = join(HOME, '.construct', 'features.json');
21
- const WORKFLOW_FILE = join(process.cwd(), '.cx', 'workflow.json');
40
+ const WORKFLOW_FILE = join(ROOT_DIR, 'plan.md');
22
41
  const SKILLS_DIR = join(ROOT_DIR, 'skills');
23
42
  const COMMANDS_DIR = join(ROOT_DIR, 'commands');
43
+ const SNAPSHOTS_FILE = join(HOME, '.cx', 'snapshots.jsonl');
44
+ const APPROVAL_QUEUE_FILE = join(HOME, '.cx', 'approval-queue.jsonl');
45
+ const CONFIG_ENV_FILE = join(HOME, '.construct', 'config.env');
46
+ const EMBED_YAML_FILE = join(HOME, '.construct', 'embed.yaml');
47
+ const CREDENTIAL_AUDIT_FILE = join(HOME, '.cx', 'credential-audit.jsonl');
48
+
49
+ // Provider → expected env vars. Source of truth for the credentials surface,
50
+ // the per-provider health classification ("not configured" vs "unhealthy"),
51
+ // and the write-side allowlist — POST refuses env vars not listed here.
52
+
53
+ const BUILTIN_CREDENTIAL_MAP = [
54
+ { provider: 'anthropic', label: 'Anthropic', kind: 'llm', envVars: ['ANTHROPIC_API_KEY'] },
55
+ { provider: 'openai', label: 'OpenAI', kind: 'llm', envVars: ['OPENAI_API_KEY'] },
56
+ { provider: 'openrouter', label: 'OpenRouter', kind: 'llm', envVars: ['OPENROUTER_API_KEY'] },
57
+ { provider: 'gemini', label: 'Google Gemini', kind: 'llm', envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
58
+ { provider: 'github', label: 'GitHub', kind: 'integration', envVars: ['GITHUB_TOKEN', 'GH_TOKEN'] },
59
+ { provider: 'atlassian-jira', label: 'Jira', kind: 'integration', envVars: ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'] },
60
+ { provider: 'atlassian-confluence',label: 'Confluence', kind: 'integration', envVars: ['CONFLUENCE_BASE_URL', 'CONFLUENCE_EMAIL', 'CONFLUENCE_API_TOKEN'] },
61
+ { provider: 'slack', label: 'Slack', kind: 'integration', envVars: ['SLACK_BOT_TOKEN', 'SLACK_USER_TOKEN'] },
62
+ { provider: 'salesforce', label: 'Salesforce', kind: 'integration', envVars: ['SALESFORCE_INSTANCE_URL', 'SALESFORCE_ACCESS_TOKEN'] },
63
+ ];
64
+
65
+ const CUSTOM_CREDENTIALS_FILE = join(HOME, '.construct', 'custom-credentials.json');
66
+
67
+ // Env-var name validator: must match POSIX shell variable syntax and avoid
68
+ // stomping built-in entries. Refusing $PATH / $HOME / OS-special prefixes
69
+ // keeps a custom provider from being a foot-gun for the shell.
70
+ const ENV_VAR_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
71
+ const ENV_VAR_BLOCKLIST = new Set(['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'PWD', 'TERM']);
72
+
73
+ function readCustomCredentials() {
74
+ if (!existsSync(CUSTOM_CREDENTIALS_FILE)) return [];
75
+ try {
76
+ const raw = JSON.parse(readFileSync(CUSTOM_CREDENTIALS_FILE, 'utf8'));
77
+ const list = Array.isArray(raw?.providers) ? raw.providers : [];
78
+ return list.filter((entry) => {
79
+ if (!entry || typeof entry.provider !== 'string') return false;
80
+ if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === entry.provider)) return false;
81
+ if (!Array.isArray(entry.envVars)) return false;
82
+ return entry.envVars.every((name) => ENV_VAR_PATTERN.test(name) && !ENV_VAR_BLOCKLIST.has(name));
83
+ });
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function writeCustomCredentials(list) {
90
+ mkdirSync(dirname(CUSTOM_CREDENTIALS_FILE), { recursive: true });
91
+ writeFileSync(CUSTOM_CREDENTIALS_FILE, JSON.stringify({ providers: list }, null, 2) + '\n');
92
+ try { chmodSync(CUSTOM_CREDENTIALS_FILE, 0o600); } catch { /* perms best-effort */ }
93
+ }
94
+
95
+ function credentialMap() {
96
+ return [...BUILTIN_CREDENTIAL_MAP, ...readCustomCredentials()];
97
+ }
98
+
99
+ function allowedCredentialEnvVars() {
100
+ return new Set(credentialMap().flatMap((c) => c.envVars));
101
+ }
102
+
103
+ function maskValue(value) {
104
+ if (!value) return null;
105
+ if (value.length <= 10) return `${value.slice(0, 2)}…${value.slice(-2)}`;
106
+ return `${value.slice(0, 6)}…${value.slice(-4)}`;
107
+ }
108
+
109
+ function buildCredentialsView(env = process.env) {
110
+ return credentialMap().map(c => {
111
+ const vars = c.envVars.map(name => {
112
+ const val = resolveCredential(name, env);
113
+ const set = Boolean(val);
114
+ return { envVar: name, set, preview: set ? maskValue(val) : null };
115
+ });
116
+ const setCount = vars.filter(v => v.set).length;
117
+ let configured;
118
+ if (setCount === 0) configured = 'none';
119
+ else if (setCount === c.envVars.length) configured = 'full';
120
+ else configured = 'partial';
121
+ return { provider: c.provider, label: c.label, kind: c.kind, vars, configured };
122
+ });
123
+ }
124
+
125
+ // A provider with no required env vars set is "not_configured" regardless of
126
+ // whether the probe succeeds — some probes (e.g. GitHub anonymous rate_limit)
127
+ // return ok:true even with zero auth, which would otherwise read as a green
128
+ // "Healthy" while the credentials column shows everything blank.
129
+
130
+ /**
131
+ * Resolve a credential from env, config.env, ~/.env, and shell rc files.
132
+ * Mirrors the logic in model-router.mjs isProviderConfigured.
133
+ */
134
+ function resolveCredential(varName, env) {
135
+ if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
136
+ const homeDir = HOME || homedir();
137
+
138
+ // Special case: Ollama on default port
139
+ if (varName === 'OLLAMA_BASE_URL') {
140
+ try {
141
+ const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
142
+ if (r.status === 0 && r.stdout?.trim() === '200') return 'http://localhost:11434';
143
+ } catch { /* not available */ }
144
+ try {
145
+ const r = spawnSync('ollama', ['--version'], { encoding: 'utf8', timeout: 2000 });
146
+ if (r.status === 0) return 'found-ollama-binary';
147
+ } catch { /* not available */ }
148
+ }
149
+
150
+ const paths = [join(homeDir, '.construct', 'config.env'), join(homeDir, '.env')];
151
+ for (const p of paths) {
152
+ try {
153
+ if (existsSync(p)) {
154
+ const content = readFileSync(p, 'utf8');
155
+ const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
156
+ if (m && m[1]) return m[1].trim();
157
+ }
158
+ } catch { /* skip */ }
159
+ }
160
+ // Check shell rc files for op:// refs
161
+ const shellFiles = [join(homeDir, '.zshrc'), join(homeDir, '.bashrc'), join(homeDir, '.bash_profile'), join(homeDir, '.profile')];
162
+ for (const rc of shellFiles) {
163
+ if (!existsSync(rc)) continue;
164
+ try {
165
+ const content = readFileSync(rc, 'utf8');
166
+ const directRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
167
+ if (directRe.test(content)) return 'found-in-rc';
168
+ const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
169
+ const m = content.match(opRe);
170
+ if (m) {
171
+ const r = spawnSync('op', ['read', m[1]], { encoding: 'utf8', timeout: 5000 });
172
+ if (r.status === 0 && r.stdout?.trim()) return r.stdout.trim();
173
+ }
174
+ } catch { /* skip */ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ function classifyProviderStatus(providerId, healthOk, env = process.env) {
180
+ const entry = credentialMap().find(c => c.provider === providerId);
181
+ const anySet = entry ? entry.envVars.some(name => Boolean(resolveCredential(name, env))) : false;
182
+ if (!anySet) return 'not_configured';
183
+ return healthOk ? 'healthy' : 'unhealthy';
184
+ }
185
+
186
+ function describeMissingCredentials(providerId, env = process.env) {
187
+ const entry = credentialMap().find(c => c.provider === providerId);
188
+ if (!entry) return null;
189
+ const missing = entry.envVars.filter(name => !resolveCredential(name, env));
190
+ if (missing.length === 0) return null;
191
+ return `missing: ${missing.join(', ')}`;
192
+ }
193
+
194
+ const approvalQueue = new ApprovalQueue({ path: APPROVAL_QUEUE_FILE });
195
+ const sseClients = new Set();
196
+
197
+ // ── Terraform config ───────────────────────────────────────────────────────
198
+ const TERRAFORM_DIR = join(ROOT_DIR, 'deploy', 'terraform');
199
+ const TERRAFORM_ALLOWED_EXTS = new Set(['.tf', '.tfvars', '.json']);
200
+
201
+ function terraformFiles(dir, base) {
202
+ const results = [];
203
+ if (!existsSync(dir)) return results;
204
+ for (const entry of readdirSync(dir)) {
205
+ const full = join(dir, entry);
206
+ let stat;
207
+ try { stat = statSync(full); } catch { continue; }
208
+ if (stat.isDirectory()) {
209
+ results.push(...terraformFiles(full, base));
210
+ } else {
211
+ const ext = extname(entry);
212
+ if (TERRAFORM_ALLOWED_EXTS.has(ext)) {
213
+ results.push(relative(base, full));
214
+ }
215
+ }
216
+ }
217
+ return results.sort();
218
+ }
219
+
220
+ function assertTerraformPath(relPath) {
221
+ const abs = normalize(join(TERRAFORM_DIR, relPath));
222
+ if (!abs.startsWith(TERRAFORM_DIR + '/') && abs !== TERRAFORM_DIR) {
223
+ throw new Error('Path traversal not allowed');
224
+ }
225
+ if (!TERRAFORM_ALLOWED_EXTS.has(extname(abs))) {
226
+ throw new Error('Only .tf, .tfvars, and .json files are editable');
227
+ }
228
+ return abs;
229
+ }
230
+
231
+ // Webhook handler — created after approvalQueue so it can share the instance
232
+ let _webhookHandler = null;
233
+ function getWebhookHandler() {
234
+ if (!_webhookHandler) {
235
+ _webhookHandler = createWebhookHandler({ approvalQueue, notifyClients });
236
+ }
237
+ return _webhookHandler;
238
+ }
239
+
240
+ let _slackCommandHandler = null;
241
+ function getSlackCommandHandler() {
242
+ if (!_slackCommandHandler) {
243
+ _slackCommandHandler = createSlackCommandHandler();
244
+ }
245
+ return _slackCommandHandler;
246
+ }
24
247
 
25
248
  const MIME = {
26
249
  '.html': 'text/html; charset=utf-8',
@@ -72,6 +295,10 @@ async function buildStatus() {
72
295
  rootDir: ROOT_DIR,
73
296
  cwd: process.cwd(),
74
297
  homeDir: HOME,
298
+ // The dashboard is long-lived and `construct up` rewrites managed values in
299
+ // ~/.construct/config.env. Read fresh config on every request instead of
300
+ // letting inherited process env shadow updated ports/credentials.
301
+ env: {},
75
302
  dashboardPort: PORT,
76
303
  selfDashboard: true,
77
304
  });
@@ -89,6 +316,7 @@ async function handleSessionUsage(_req, res) {
89
316
  rootDir: ROOT_DIR,
90
317
  cwd: process.cwd(),
91
318
  homeDir: HOME,
319
+ env: {},
92
320
  });
93
321
 
94
322
  const payload = {
@@ -108,13 +336,160 @@ async function handleSessionUsage(_req, res) {
108
336
  }
109
337
  }
110
338
 
111
- const sseClients = new Set();
339
+ // ── Artifacts handler ─────────────────────────────────────────────────────
340
+
341
+ async function handleArtifacts(req, res) {
342
+ const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
343
+
344
+ if (req.method === 'GET') {
345
+ const type = url.searchParams.get('type') || undefined;
346
+ try {
347
+ const artifacts = listArtifacts({ type, rootDir: ROOT_DIR });
348
+ res.writeHead(200, { 'Content-Type': 'application/json' });
349
+ res.end(JSON.stringify({ artifacts }));
350
+ } catch (err) {
351
+ res.writeHead(500, { 'Content-Type': 'application/json' });
352
+ res.end(JSON.stringify({ error: err.message }));
353
+ }
354
+ return;
355
+ }
356
+
357
+ if (req.method === 'POST') {
358
+ let body = '';
359
+ req.on('data', chunk => { body += chunk; });
360
+ req.on('end', () => {
361
+ try {
362
+ const data = JSON.parse(body || '{}');
363
+ const result = generateArtifact({
364
+ type: data.type,
365
+ title: data.title,
366
+ rootDir: ROOT_DIR,
367
+ fields: data.fields || {},
368
+ dryRun: data.dryRun === true,
369
+ });
370
+ res.writeHead(200, { 'Content-Type': 'application/json' });
371
+ res.end(JSON.stringify({ success: true, ...result }));
372
+ if (!data.dryRun) notifyClients();
373
+ } catch (err) {
374
+ res.writeHead(400, { 'Content-Type': 'application/json' });
375
+ res.end(JSON.stringify({ error: err.message }));
376
+ }
377
+ });
378
+ return;
379
+ }
380
+
381
+ res.writeHead(405);
382
+ res.end('Method Not Allowed');
383
+ }
384
+
385
+ // ── Approval queue handler ────────────────────────────────────────────────
386
+
387
+ async function handleApprovals(req, res) {
388
+ const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
389
+
390
+ if (req.method === 'GET') {
391
+ const pending = approvalQueue.list('pending');
392
+ res.writeHead(200, { 'Content-Type': 'application/json' });
393
+ res.end(JSON.stringify({ items: pending }));
394
+ return;
395
+ }
396
+
397
+ if (req.method === 'POST') {
398
+ let body = '';
399
+ req.on('data', chunk => { body += chunk; });
400
+ req.on('end', () => {
401
+ try {
402
+ const data = JSON.parse(body || '{}');
403
+ const { action, id, note } = data;
404
+ if (!id) throw new Error('Missing id');
405
+ if (action === 'approve') {
406
+ approvalQueue.approve(id);
407
+ } else if (action === 'reject') {
408
+ approvalQueue.reject(id, { reason: note || 'Rejected via dashboard' });
409
+ } else {
410
+ throw new Error('action must be approve or reject');
411
+ }
412
+ res.writeHead(200, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ success: true }));
414
+ notifyClients();
415
+ } catch (err) {
416
+ res.writeHead(400, { 'Content-Type': 'application/json' });
417
+ res.end(JSON.stringify({ error: err.message }));
418
+ }
419
+ });
420
+ return;
421
+ }
422
+
423
+ res.writeHead(405);
424
+ res.end('Method Not Allowed');
425
+ }
426
+
427
+ // ── Config handler ────────────────────────────────────────────────────────
428
+
429
+ function handleConfig(req, res) {
430
+ if (req.method === 'GET') {
431
+ const env = existsSync(CONFIG_ENV_FILE) ? readFileSync(CONFIG_ENV_FILE, 'utf8') : '';
432
+ const embed = existsSync(EMBED_YAML_FILE) ? readFileSync(EMBED_YAML_FILE, 'utf8') : '';
433
+ // Extract roles from embed YAML (simple regex — works for flat keys)
434
+ let roles = { primary: null, secondary: null };
435
+ const primaryMatch = embed.match(/^\s*primary:\s*(.+)$/m);
436
+ const secondaryMatch = embed.match(/^\s*secondary:\s*(.+)$/m);
437
+ if (primaryMatch) roles.primary = primaryMatch[1].trim() || null;
438
+ if (secondaryMatch) roles.secondary = secondaryMatch[1].trim() || null;
439
+ res.writeHead(200, { 'Content-Type': 'application/json' });
440
+ res.end(JSON.stringify({ env, embed, roles }));
441
+ return;
442
+ }
443
+
444
+ if (req.method === 'POST') {
445
+ let body = '';
446
+ req.on('data', chunk => { body += chunk; });
447
+ req.on('end', () => {
448
+ try {
449
+ const { type, content } = JSON.parse(body || '{}');
450
+ if (type !== 'env' && type !== 'embed') throw new Error('type must be env or embed');
451
+ if (typeof content !== 'string') throw new Error('content must be a string');
452
+ mkdirSync(join(HOME, '.construct'), { recursive: true });
453
+ const target = type === 'env' ? CONFIG_ENV_FILE : EMBED_YAML_FILE;
454
+ writeFileSync(target, content, 'utf8');
455
+ res.writeHead(200, { 'Content-Type': 'application/json' });
456
+ res.end(JSON.stringify({ success: true }));
457
+ } catch (err) {
458
+ res.writeHead(400, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: err.message }));
460
+ }
461
+ });
462
+ return;
463
+ }
464
+
465
+ res.writeHead(405);
466
+ res.end('Method Not Allowed');
467
+ }
468
+
469
+ // ── Snapshots handler ─────────────────────────────────────────────────────
470
+
471
+ function handleSnapshots(req, res) {
472
+ if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return; }
473
+
474
+ const snapshots = [];
475
+ if (existsSync(SNAPSHOTS_FILE)) {
476
+ const lines = readFileSync(SNAPSHOTS_FILE, 'utf8').split('\n').filter(Boolean);
477
+ // Return last 20 snapshots most-recent-first
478
+ for (const line of lines.slice(-20).reverse()) {
479
+ try { snapshots.push(JSON.parse(line)); } catch { /* skip malformed */ }
480
+ }
481
+ }
482
+
483
+ res.writeHead(200, { 'Content-Type': 'application/json' });
484
+ res.end(JSON.stringify({ snapshots }));
485
+ }
112
486
  const activeWatchers = [];
113
487
  let watchRefreshTimer = null;
114
488
 
115
- function notifyClients() {
489
+ function notifyClients(event) {
490
+ const payload = event ? `data: ${JSON.stringify(event)}\n\n` : 'data: refresh\n\n';
116
491
  for (const res of sseClients) {
117
- try { res.write('data: refresh\n\n'); }
492
+ try { res.write(payload); }
118
493
  catch { sseClients.delete(res); }
119
494
  }
120
495
  }
@@ -169,7 +544,109 @@ function watchFiles() {
169
544
  }
170
545
 
171
546
  const server = createServer(async (req, res) => {
172
- const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
547
+ const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
548
+
549
+ // ── Security middleware ────────────────────────────────────────────────
550
+ // Imported lazily so a missing module never breaks the server boot.
551
+ let applyCors, ensureCsrfCookie, verifyCsrf, csrfDefaultSkip, checkRateLimit, logger;
552
+ try {
553
+ ({ applyCors } = await import('./cors.mjs'));
554
+ ({ ensureCsrfCookie, verifyCsrf, defaultSkip: csrfDefaultSkip } = await import('./csrf.mjs'));
555
+ ({ checkRateLimit } = await import('./rate-limit.mjs'));
556
+ ({ logger } = await import('../logger.mjs'));
557
+ } catch { /* run without the middleware if any module is unavailable */ }
558
+
559
+ if (applyCors) {
560
+ if (applyCors(req, res, { env: process.env })) return;
561
+ }
562
+ if (ensureCsrfCookie && req.method === 'GET') {
563
+ ensureCsrfCookie(req, res);
564
+ }
565
+ const rateTier = url.pathname === '/api/chat/stream' || url.pathname === '/api/chat'
566
+ ? 'chat'
567
+ : (req.method !== 'GET' && req.method !== 'HEAD' ? 'write' : 'read');
568
+ if (checkRateLimit) {
569
+ const limit = checkRateLimit(req, rateTier, { env: process.env });
570
+ if (!limit.allowed) {
571
+ res.writeHead(429, {
572
+ 'Content-Type': 'application/json',
573
+ 'Retry-After': Math.ceil(limit.retryAfterMs / 1000),
574
+ });
575
+ res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: limit.retryAfterMs }));
576
+ logger?.().warn('http.rate_limited', { route: url.pathname, tier: rateTier });
577
+ return;
578
+ }
579
+ }
580
+ if (verifyCsrf && !verifyCsrf(req, { skip: csrfDefaultSkip })) {
581
+ res.writeHead(403, { 'Content-Type': 'application/json' });
582
+ res.end(JSON.stringify({ error: 'csrf_token_missing_or_invalid' }));
583
+ logger?.().warn('http.csrf_blocked', { route: url.pathname });
584
+ return;
585
+ }
586
+
587
+ // ── Auth endpoints (always public) ──────────────────────────────────────
588
+ if (url.pathname === '/api/auth/status' && req.method === 'GET') {
589
+ const auth = getAuthConfig();
590
+ res.writeHead(200, { 'Content-Type': 'application/json' });
591
+ res.end(JSON.stringify({
592
+ configured: auth.tokenConfigured,
593
+ authenticated: isAuthenticated(req),
594
+ auth,
595
+ }));
596
+ return;
597
+ }
598
+
599
+ if (url.pathname === '/api/auth/login' && req.method === 'POST') {
600
+ let body = '';
601
+ req.on('data', c => { body += c; });
602
+ req.on('end', () => {
603
+ try {
604
+ const { token } = JSON.parse(body || '{}');
605
+ if (!validateToken(token)) {
606
+ res.writeHead(401, { 'Content-Type': 'application/json' });
607
+ res.end(JSON.stringify({ error: 'Invalid token' }));
608
+ return;
609
+ }
610
+ const sessionToken = createSession();
611
+ res.writeHead(200, {
612
+ 'Content-Type': 'application/json',
613
+ 'Set-Cookie': sessionCookieHeader(sessionToken),
614
+ });
615
+ res.end(JSON.stringify({ success: true }));
616
+ } catch {
617
+ res.writeHead(400, { 'Content-Type': 'application/json' });
618
+ res.end(JSON.stringify({ error: 'Bad request' }));
619
+ }
620
+ });
621
+ return;
622
+ }
623
+
624
+ if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
625
+ res.writeHead(200, {
626
+ 'Content-Type': 'application/json',
627
+ 'Set-Cookie': clearSessionCookieHeader(),
628
+ });
629
+ res.end(JSON.stringify({ success: true }));
630
+ return;
631
+ }
632
+
633
+ // ── Webhook ingestion (public — signature-verified per provider) ─────────
634
+ if (url.pathname.startsWith('/api/webhooks/') && req.method === 'POST') {
635
+ await getWebhookHandler()(req, res);
636
+ return;
637
+ }
638
+
639
+ // ── Slack slash commands (public — HMAC-verified) ────────────────────────
640
+ if (url.pathname === '/api/slack/commands' && req.method === 'POST') {
641
+ await getSlackCommandHandler()(req, res);
642
+ return;
643
+ }
644
+
645
+ // ── Auth gate for all /api/* routes ─────────────────────────────────────
646
+ if (url.pathname.startsWith('/api/') && !isAuthenticated(req)) {
647
+ rejectUnauthorized(res);
648
+ return;
649
+ }
173
650
 
174
651
  if (url.pathname === '/events') {
175
652
  res.writeHead(200, {
@@ -208,80 +685,1104 @@ const server = createServer(async (req, res) => {
208
685
  return;
209
686
  }
210
687
 
211
- if (url.pathname === '/api/session-usage') {
212
- await handleSessionUsage(req, res);
688
+ if (url.pathname === '/api/doctor') {
689
+ try {
690
+ const { readState } = await import('../doctor/index.mjs');
691
+ const { recent } = await import('../doctor/audit.mjs');
692
+ const { getTotalDailySpend, getDailySpend, totalBudget, personaBudget, dayKey } = await import('../cost-ledger.mjs');
693
+ const { listOnboardedPersonas } = await import('../roles/manifest.mjs');
694
+ const { listPending } = await import('../roles/gateway.mjs');
695
+ const { existsSync: fsExists, readFileSync: fsRead } = await import('node:fs');
696
+ const { join: pathJoin } = await import('node:path');
697
+ const { homedir: osHome } = await import('node:os');
698
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
699
+ const watcher = url.searchParams.get('watcher') || undefined;
700
+ const personas = listOnboardedPersonas();
701
+ const costsByPersona = {};
702
+ for (const p of personas) {
703
+ const spend = getDailySpend({ personaId: p });
704
+ if (spend.invocations > 0 || spend.costUsd > 0) {
705
+ costsByPersona[p] = { spent: spend.costUsd, cap: personaBudget(p), invocations: spend.invocations };
706
+ }
707
+ }
708
+ const total = getTotalDailySpend();
709
+ const approvalPath = pathJoin(osHome(), '.cx', 'approval-pending.jsonl');
710
+ let approvals = [];
711
+ if (fsExists(approvalPath)) {
712
+ approvals = fsRead(approvalPath, 'utf8').split('\n').filter(Boolean)
713
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
714
+ .filter(Boolean)
715
+ .slice(-25).reverse();
716
+ }
717
+ const pendingRoleInvocations = listPending({ unresolved: true }).slice(-25).reverse();
718
+ res.writeHead(200, { 'Content-Type': 'application/json' });
719
+ res.end(JSON.stringify({
720
+ daemon: readState(),
721
+ audit: recent({ watcher, limit }),
722
+ cost: { dayKey: dayKey(), total: { spent: total.costUsd, cap: totalBudget(), invocations: total.invocations }, byPersona: costsByPersona },
723
+ approvals,
724
+ pendingRoleInvocations,
725
+ onboardedPersonas: personas,
726
+ }));
727
+ } catch (err) {
728
+ res.writeHead(500, { 'Content-Type': 'application/json' });
729
+ res.end(JSON.stringify({ error: err.message }));
730
+ }
213
731
  return;
214
732
  }
215
733
 
216
- if (req.method === 'POST') {
734
+ if (url.pathname.startsWith('/api/overrides/') && req.method === 'GET') {
735
+ try {
736
+ const { describeOverrides, resolveOverride, readResolved, listBackups, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
737
+ const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
738
+ const category = segs[0];
739
+ if (!category || !SUPPORTED_CATEGORIES.includes(category)) {
740
+ res.writeHead(400, { 'Content-Type': 'application/json' });
741
+ res.end(JSON.stringify({ error: `unknown category: ${category}. Use ${SUPPORTED_CATEGORIES.join(', ')}` }));
742
+ return;
743
+ }
744
+ if (segs.length === 1) {
745
+ const root = ROOT_DIR;
746
+ const overrides = describeOverrides(root)[category] || [];
747
+ const cwdOverrides = describeOverrides(process.cwd())[category] || [];
748
+ let originals = [];
749
+ try {
750
+ const dir =
751
+ category === 'contracts' || category === 'role-manifests'
752
+ ? join(root, 'agents')
753
+ : category === 'agents'
754
+ ? join(root, 'agents', 'prompts')
755
+ : join(root, category);
756
+ if (existsSync(dir)) {
757
+ const walk = (rel = '') => {
758
+ const cur = join(dir, rel);
759
+ for (const name of readdirSync(cur)) {
760
+ const full = join(cur, name);
761
+ const relPath = rel ? `${rel}/${name}` : name;
762
+ if (statSync(full).isDirectory()) { walk(relPath); continue; }
763
+ if (!name.endsWith('.md') && !name.endsWith('.json')) continue;
764
+ originals.push(relPath);
765
+ }
766
+ };
767
+ walk();
768
+ }
769
+ } catch { /* best effort */ }
770
+ const items = originals.map((name) => ({
771
+ name,
772
+ hasOverride: overrides.includes(name) || cwdOverrides.includes(name),
773
+ source: overrides.includes(name) || cwdOverrides.includes(name) ? 'override' : 'original',
774
+ }));
775
+ for (const ov of [...overrides, ...cwdOverrides]) {
776
+ if (!originals.includes(ov)) items.push({ name: ov, hasOverride: true, source: 'override', custom: true });
777
+ }
778
+ items.sort((a, b) => a.name.localeCompare(b.name));
779
+ res.writeHead(200, { 'Content-Type': 'application/json' });
780
+ res.end(JSON.stringify({ category, items }));
781
+ return;
782
+ }
783
+ const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
784
+ const action = url.searchParams.get('action');
785
+ if (action === 'backups') {
786
+ const backups = listBackups(process.cwd(), category, name);
787
+ res.writeHead(200, { 'Content-Type': 'application/json' });
788
+ res.end(JSON.stringify({ category, name, backups: backups.map((b) => ({ filename: b.filename, mtimeMs: b.mtimeMs, size: b.size })) }));
789
+ return;
790
+ }
791
+ const tryRoot = (root) => {
792
+ const r = resolveOverride(root, category, name);
793
+ if (r.path) return { ...r, content: readFileSync(r.path, 'utf8') };
794
+ return null;
795
+ };
796
+ const result = tryRoot(process.cwd()) || tryRoot(ROOT_DIR);
797
+ if (!result) {
798
+ res.writeHead(404, { 'Content-Type': 'application/json' });
799
+ res.end(JSON.stringify({ error: 'not found', category, name }));
800
+ return;
801
+ }
802
+ res.writeHead(200, { 'Content-Type': 'application/json' });
803
+ res.end(JSON.stringify({ category, name, source: result.source, content: result.content, path: result.path }));
804
+ } catch (err) {
805
+ res.writeHead(500, { 'Content-Type': 'application/json' });
806
+ res.end(JSON.stringify({ error: err.message }));
807
+ }
808
+ return;
809
+ }
810
+
811
+ if (url.pathname.startsWith('/api/overrides/') && (req.method === 'PUT' || req.method === 'POST')) {
217
812
  let body = '';
218
- req.on('data', chunk => { body += chunk; });
813
+ req.on('data', (chunk) => { body += chunk; });
219
814
  req.on('end', async () => {
220
815
  try {
221
- const data = JSON.parse(body || '{}');
222
- const { approveWorkflow, approveTask } = await import('../workflow-state.mjs');
223
- const { promoteHeadhunt, updatePromotionChallenge } = await import('../headhunt.mjs');
224
-
225
- if (url.pathname === '/api/workflow/approve') {
226
- const workflow = approveWorkflow(process.cwd(), data.note);
227
- res.writeHead(200, { 'Content-Type': 'application/json' });
228
- res.end(JSON.stringify({ success: true, workflow }));
229
- notifyClients();
230
- } else if (url.pathname === '/api/workflow/approve-task') {
231
- if (!data.key) throw new Error('Missing task key');
232
- const workflow = approveTask(process.cwd(), data.key, data.note);
233
- res.writeHead(200, { 'Content-Type': 'application/json' });
234
- res.end(JSON.stringify({ success: true, workflow }));
235
- notifyClients();
236
- } else if (url.pathname === '/api/headhunt/promote') {
237
- if (!data.id) throw new Error('Missing overlay id');
238
- const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
239
- res.writeHead(200, { 'Content-Type': 'application/json' });
240
- res.end(JSON.stringify({ success: true, request }));
241
- notifyClients();
242
- } else if (url.pathname === '/api/headhunt/challenge') {
243
- if (!data.id) throw new Error('Missing overlay id');
244
- if (!data.status) throw new Error('Missing challenge status');
245
- const request = updatePromotionChallenge(data.id, {
246
- cwd: process.cwd(),
247
- status: data.status,
248
- note: data.note || null,
249
- });
250
- res.writeHead(200, { 'Content-Type': 'application/json' });
251
- res.end(JSON.stringify({ success: true, request }));
252
- notifyClients();
253
- } else if (url.pathname === '/api/registry/mcp') {
254
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
255
- if (!registry.mcpServers) registry.mcpServers = {};
256
- const { action, id, server } = data;
257
- if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
258
- if (action === 'delete') {
259
- delete registry.mcpServers[id];
260
- } else if (action === 'save' && server && typeof server === 'object') {
261
- const entry = {};
262
- if (server.type === 'url') {
263
- if (!server.url) throw new Error('URL required for type=url');
264
- entry.type = 'url';
265
- entry.url = String(server.url);
266
- if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
267
- } else {
268
- if (!server.command) throw new Error('command required');
269
- entry.command = String(server.command);
270
- entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
271
- }
272
- if (server.description) entry.description = String(server.description);
273
- registry.mcpServers[id] = entry;
274
- } else {
275
- throw new Error('action must be save or delete');
276
- }
277
- writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
816
+ const { applyEdit, restoreFromBackup, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
817
+ const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
818
+ const category = segs[0];
819
+ if (!SUPPORTED_CATEGORIES.includes(category)) {
820
+ res.writeHead(400, { 'Content-Type': 'application/json' });
821
+ res.end(JSON.stringify({ error: `unknown category: ${category}` }));
822
+ return;
823
+ }
824
+ const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
825
+ const action = url.searchParams.get('action');
826
+ const payload = JSON.parse(body || '{}');
827
+ if (action === 'restore') {
828
+ if (!payload.backupFilename) { res.writeHead(400); res.end(JSON.stringify({ error: 'backupFilename required' })); return; }
829
+ const result = restoreFromBackup(process.cwd(), category, name, payload.backupFilename);
278
830
  res.writeHead(200, { 'Content-Type': 'application/json' });
279
- res.end(JSON.stringify({ success: true }));
280
- notifyClients();
281
- } else if (url.pathname === '/api/registry/models') {
282
- const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
283
- const { tier, primary, fallback } = data;
284
- const VALID_TIERS = ['reasoning', 'standard', 'fast'];
831
+ res.end(JSON.stringify({ ok: true, ...result }));
832
+ return;
833
+ }
834
+ if (typeof payload.content !== 'string') {
835
+ res.writeHead(400, { 'Content-Type': 'application/json' });
836
+ res.end(JSON.stringify({ error: 'content (string) required' }));
837
+ return;
838
+ }
839
+ const result = applyEdit(process.cwd(), category, name, payload.content);
840
+ try {
841
+ const auditDir = join(process.cwd(), '.cx');
842
+ mkdirSync(auditDir, { recursive: true });
843
+ appendFileSync(join(auditDir, 'audit.jsonl'), JSON.stringify({
844
+ ts: new Date().toISOString(),
845
+ action: `override.${category}.${name}.write`,
846
+ backupPath: result.backupPath,
847
+ bytes: result.wrote,
848
+ }) + '\n');
849
+ } catch { /* audit best-effort */ }
850
+ res.writeHead(200, { 'Content-Type': 'application/json' });
851
+ res.end(JSON.stringify({ ok: true, ...result }));
852
+ } catch (err) {
853
+ res.writeHead(400, { 'Content-Type': 'application/json' });
854
+ res.end(JSON.stringify({ error: err.message }));
855
+ }
856
+ });
857
+ return;
858
+ }
859
+
860
+ if (url.pathname === '/api/project-config' && req.method === 'GET') {
861
+ try {
862
+ const { loadProjectConfig } = await import('../config/project-config.mjs');
863
+ const result = loadProjectConfig(process.cwd(), process.env);
864
+ res.writeHead(200, { 'Content-Type': 'application/json' });
865
+ res.end(JSON.stringify(result));
866
+ } catch (err) {
867
+ res.writeHead(500, { 'Content-Type': 'application/json' });
868
+ res.end(JSON.stringify({ error: err.message }));
869
+ }
870
+ return;
871
+ }
872
+
873
+ if (url.pathname === '/api/project-config' && (req.method === 'PUT' || req.method === 'PATCH')) {
874
+ let body = '';
875
+ req.on('data', (chunk) => { body += chunk; });
876
+ req.on('end', async () => {
877
+ try {
878
+ const payload = JSON.parse(body || '{}');
879
+ const { writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
880
+ const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
881
+ writeProjectConfig(cfgPath, payload);
882
+ try {
883
+ const auditDir = join(process.cwd(), '.cx');
884
+ mkdirSync(auditDir, { recursive: true });
885
+ const entry = JSON.stringify({
886
+ ts: new Date().toISOString(),
887
+ action: 'project-config.write',
888
+ path: cfgPath,
889
+ keys: Object.keys(payload || {}),
890
+ }) + '\n';
891
+ writeFileSync(join(auditDir, 'audit.jsonl'), entry, { flag: 'a' });
892
+ } catch { /* audit is best-effort */ }
893
+ res.writeHead(200, { 'Content-Type': 'application/json' });
894
+ res.end(JSON.stringify({ ok: true, path: cfgPath }));
895
+ } catch (err) {
896
+ res.writeHead(400, { 'Content-Type': 'application/json' });
897
+ res.end(JSON.stringify({ error: err.message }));
898
+ }
899
+ });
900
+ return;
901
+ }
902
+
903
+ if (url.pathname === '/api/performance/reviews' && req.method === 'GET') {
904
+ try {
905
+ const dir = join(HOME, '.cx', 'performance-reviews');
906
+ if (!existsSync(dir)) {
907
+ res.writeHead(200, { 'Content-Type': 'application/json' });
908
+ res.end(JSON.stringify({ reviews: [], total: 0, mockFiltered: 0, generatorImplemented: false }));
909
+ return;
910
+ }
911
+ const allFiles = readdirSync(dir).filter((f) => f.endsWith('.json'));
912
+ // Filter out test/mock fixtures — anything matching `test-*-mock.json` or
913
+ // `*-mock.json` is a seeded fixture, not a real session-end review.
914
+ // The real generator isn't wired yet; until it is, this endpoint should
915
+ // return an honest empty list rather than parade fake data.
916
+ const realFiles = allFiles.filter((f) => !/(^test-|-mock\.json$)/i.test(f));
917
+ const files = realFiles
918
+ .map((f) => {
919
+ try {
920
+ const full = join(dir, f);
921
+ const stat = statSync(full);
922
+ const body = JSON.parse(readFileSync(full, 'utf8'));
923
+ return { filename: f, mtimeMs: stat.mtimeMs, ...body };
924
+ } catch { return null; }
925
+ })
926
+ .filter(Boolean)
927
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
928
+ res.writeHead(200, { 'Content-Type': 'application/json' });
929
+ res.end(JSON.stringify({
930
+ reviews: files,
931
+ total: files.length,
932
+ mockFiltered: allFiles.length - realFiles.length,
933
+ generatorImplemented: true,
934
+ }));
935
+ } catch (err) {
936
+ res.writeHead(500, { 'Content-Type': 'application/json' });
937
+ res.end(JSON.stringify({ error: err.message }));
938
+ }
939
+ return;
940
+ }
941
+
942
+ if (url.pathname === '/api/performance/feedback' && req.method === 'POST') {
943
+ let body = '';
944
+ req.on('data', (chunk) => { body += chunk; });
945
+ req.on('end', () => {
946
+ try {
947
+ const payload = JSON.parse(body || '{}');
948
+ if (!payload.agent || !payload.text) {
949
+ res.writeHead(400, { 'Content-Type': 'application/json' });
950
+ res.end(JSON.stringify({ error: 'agent + text required' }));
951
+ return;
952
+ }
953
+ const dir = join(process.cwd(), '.cx', 'feedback');
954
+ mkdirSync(dir, { recursive: true });
955
+ const ts = new Date().toISOString();
956
+ const filename = `${ts.replace(/[:.]/g, '-')}-${payload.agent}.json`;
957
+ writeFileSync(join(dir, filename), JSON.stringify({
958
+ ts,
959
+ agent: payload.agent,
960
+ text: payload.text,
961
+ proposedChanges: payload.proposedChanges || null,
962
+ }, null, 2));
963
+ appendFileSync(join(process.cwd(), '.cx', 'audit.jsonl'), JSON.stringify({
964
+ ts,
965
+ action: 'performance.feedback.submitted',
966
+ agent: payload.agent,
967
+ filename,
968
+ }) + '\n');
969
+ res.writeHead(200, { 'Content-Type': 'application/json' });
970
+ res.end(JSON.stringify({ ok: true, filename }));
971
+ } catch (err) {
972
+ res.writeHead(400, { 'Content-Type': 'application/json' });
973
+ res.end(JSON.stringify({ error: err.message }));
974
+ }
975
+ });
976
+ return;
977
+ }
978
+
979
+ if (url.pathname === '/api/audit' && req.method === 'GET') {
980
+ try {
981
+ const auditPath = join(process.cwd(), '.cx', 'audit.jsonl');
982
+ if (!existsSync(auditPath)) {
983
+ res.writeHead(200, { 'Content-Type': 'application/json' });
984
+ res.end(JSON.stringify({ entries: [], total: 0 }));
985
+ return;
986
+ }
987
+ const limit = Math.min(500, Number(url.searchParams.get('limit')) || 200);
988
+ const raw = readFileSync(auditPath, 'utf8');
989
+ const lines = raw.split('\n').filter(Boolean);
990
+ const entries = [];
991
+ for (let i = Math.max(0, lines.length - limit); i < lines.length; i++) {
992
+ try { entries.push(JSON.parse(lines[i])); } catch { /* skip malformed */ }
993
+ }
994
+ res.writeHead(200, { 'Content-Type': 'application/json' });
995
+ res.end(JSON.stringify({ entries: entries.reverse(), total: lines.length }));
996
+ } catch (err) {
997
+ res.writeHead(500, { 'Content-Type': 'application/json' });
998
+ res.end(JSON.stringify({ error: err.message }));
999
+ }
1000
+ return;
1001
+ }
1002
+
1003
+ if (url.pathname === '/api/insights' && req.method === 'GET') {
1004
+ try {
1005
+ const { buildInsights } = await import('./insights.mjs');
1006
+ const data = await buildInsights({ env: process.env, cwd: process.cwd(), rootDir: ROOT_DIR });
1007
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1008
+ res.end(JSON.stringify(data));
1009
+ } catch (err) {
1010
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1011
+ res.end(JSON.stringify({ error: err.message }));
1012
+ }
1013
+ return;
1014
+ }
1015
+
1016
+ if (url.pathname === '/api/alias' && req.method === 'GET') {
1017
+ try {
1018
+ const { resolveAlias } = await import('../config/alias.mjs');
1019
+ const r = resolveAlias({ cwd: process.cwd(), env: process.env });
1020
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1021
+ res.end(JSON.stringify(r));
1022
+ } catch (err) {
1023
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1024
+ res.end(JSON.stringify({ error: err.message, value: 'Construct', source: 'default' }));
1025
+ }
1026
+ return;
1027
+ }
1028
+
1029
+ if (url.pathname === '/api/mode' && req.method === 'GET') {
1030
+ try {
1031
+ const env = loadConstructEnv();
1032
+ const embedYamlPath = join(HOME, '.construct', 'embed.yaml');
1033
+ const embedStatus = resolveEmbedStatus(env);
1034
+
1035
+ // Determine mode based on embed status and configuration
1036
+ let mode = 'init';
1037
+ if (embedStatus.level === 'running') {
1038
+ mode = 'embed';
1039
+ } else if (existsSync(embedYamlPath)) {
1040
+ mode = 'live';
1041
+ }
1042
+
1043
+ // Get instance ID if set
1044
+ const instanceId = env.CONSTRUCT_INSTANCE_ID || null;
1045
+
1046
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1047
+ res.end(JSON.stringify({
1048
+ mode,
1049
+ instanceId,
1050
+ embedStatus: embedStatus.level,
1051
+ embedYamlExists: existsSync(embedYamlPath),
1052
+ embedYamlPath
1053
+ }));
1054
+ } catch (err) {
1055
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1056
+ res.end(JSON.stringify({ error: err.message }));
1057
+ }
1058
+ return;
1059
+ }
1060
+
1061
+ if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
1062
+ try {
1063
+ const env = loadConstructEnv();
1064
+ const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1065
+
1066
+ // Check if we're running inside another Construct instance
1067
+ const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1068
+ const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1069
+
1070
+ // Determine embedding boundary status
1071
+ const isEmbedded = !!parentConstruct;
1072
+ const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1073
+
1074
+ // Get current embed status
1075
+ const embedStatus = resolveEmbedStatus(env);
1076
+
1077
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1078
+ res.end(JSON.stringify({
1079
+ boundaryStatus,
1080
+ instanceId,
1081
+ parentConstruct,
1082
+ parentUrl,
1083
+ isEmbedded,
1084
+ embedStatus: embedStatus.level,
1085
+ embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1086
+ // Boundary capabilities that could be exposed to parent
1087
+ capabilities: {
1088
+ modeDetection: true,
1089
+ snapshotStatus: true,
1090
+ approvalQueue: true,
1091
+ configManagement: true
1092
+ }
1093
+ }));
1094
+ } catch (err) {
1095
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1096
+ res.end(JSON.stringify({ error: err.message }));
1097
+ }
1098
+ return;
1099
+ }
1100
+
1101
+ if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
1102
+ try {
1103
+ const env = loadConstructEnv();
1104
+ const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1105
+
1106
+ // Check if we're running inside another Construct instance
1107
+ const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1108
+ const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1109
+
1110
+ // Determine embedding boundary status
1111
+ const isEmbedded = !!parentConstruct;
1112
+ const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1113
+
1114
+ // Get current embed status
1115
+ const embedStatus = resolveEmbedStatus(env);
1116
+
1117
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1118
+ res.end(JSON.stringify({
1119
+ boundaryStatus,
1120
+ instanceId,
1121
+ parentConstruct,
1122
+ parentUrl,
1123
+ isEmbedded,
1124
+ embedStatus: embedStatus.level,
1125
+ embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1126
+ // Boundary capabilities that could be exposed to parent
1127
+ capabilities: {
1128
+ modeDetection: true,
1129
+ snapshotStatus: true,
1130
+ approvalQueue: true,
1131
+ configManagement: true
1132
+ }
1133
+ }));
1134
+ } catch (err) {
1135
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1136
+ res.end(JSON.stringify({ error: err.message }));
1137
+ }
1138
+ return;
1139
+ }
1140
+
1141
+ if (url.pathname === '/api/embed/boundary/register' && req.method === 'POST') {
1142
+ try {
1143
+ const chunks = [];
1144
+ await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1145
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
1146
+
1147
+ const { parentInstance, parentUrl, childInstanceId } = body;
1148
+
1149
+ if (!parentInstance || !parentUrl) {
1150
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1151
+ res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
1152
+ return;
1153
+ }
1154
+
1155
+ // In a real implementation, this would:
1156
+ // - Validate the parent is a legitimate Construct instance
1157
+ // - Store the parent registration in a boundary configuration
1158
+ // - Set up communication channels between parent and child
1159
+ // - Configure isolation boundaries
1160
+
1161
+ // For now, just acknowledge the registration
1162
+ const configDir = join(HOME, '.construct');
1163
+ if (!existsSync(configDir)) {
1164
+ mkdirSync(configDir, { recursive: true });
1165
+ }
1166
+
1167
+ const boundaryConfig = {
1168
+ parentInstance,
1169
+ parentUrl,
1170
+ childInstanceId: childInstanceId || env.CONSTRUCT_INSTANCE_ID || 'default',
1171
+ registeredAt: new Date().toISOString(),
1172
+ boundaryVersion: '1.0'
1173
+ };
1174
+
1175
+ const boundaryConfigPath = join(configDir, 'boundary-config.json');
1176
+ writeFileSync(boundaryConfigPath, JSON.stringify(boundaryConfig, null, 2));
1177
+
1178
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1179
+ res.end(JSON.stringify({
1180
+ success: true,
1181
+ message: 'Boundary registration accepted',
1182
+ boundaryConfig,
1183
+ boundaryConfigPath
1184
+ }));
1185
+ } catch (err) {
1186
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1187
+ res.end(JSON.stringify({ error: err.message }));
1188
+ }
1189
+ return;
1190
+ }
1191
+
1192
+ if (url.pathname === '/api/embed/status' && req.method === 'GET') {
1193
+ try {
1194
+ const env = loadConstructEnv();
1195
+ const status = resolveEmbedStatus(env);
1196
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1197
+ res.end(JSON.stringify(status));
1198
+ } catch (err) {
1199
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1200
+ res.end(JSON.stringify({ error: err.message }));
1201
+ }
1202
+ return;
1203
+ }
1204
+
1205
+ if (url.pathname === '/api/models/providers' && req.method === 'GET') {
1206
+ try {
1207
+ const { getProviderModelCatalog } = await import('../model-router.mjs');
1208
+ const catalog = getProviderModelCatalog({ env: process.env });
1209
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1210
+ res.end(JSON.stringify(catalog));
1211
+ } catch (err) {
1212
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1213
+ res.end(JSON.stringify({ error: err.message }));
1214
+ }
1215
+ return;
1216
+ }
1217
+
1218
+ if (url.pathname === '/api/models/pricing' && req.method === 'GET') {
1219
+ try {
1220
+ const idsParam = url.searchParams.get('ids') || '';
1221
+ const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean);
1222
+ const { getPricingForModels } = await import('../model-pricing.mjs');
1223
+ const pricing = await getPricingForModels(ids);
1224
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1225
+ res.end(JSON.stringify({ pricing }));
1226
+ } catch (err) {
1227
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1228
+ res.end(JSON.stringify({ error: err.message }));
1229
+ }
1230
+ return;
1231
+ }
1232
+
1233
+ if (url.pathname === '/api/providers/credentials/custom' && req.method === 'GET') {
1234
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1235
+ res.end(JSON.stringify({ providers: readCustomCredentials(), path: CUSTOM_CREDENTIALS_FILE }));
1236
+ return;
1237
+ }
1238
+
1239
+ if (url.pathname === '/api/providers/billing' && req.method === 'GET') {
1240
+ try {
1241
+ const { loadProjectConfig } = await import('../config/project-config.mjs');
1242
+ const cfg = loadProjectConfig(process.cwd(), process.env);
1243
+ const costsCfg = cfg?.config?.costs ?? {};
1244
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1245
+ res.end(JSON.stringify({
1246
+ global: costsCfg.billingMode ?? 'metered',
1247
+ providers: costsCfg.providers || {},
1248
+ }));
1249
+ } catch (err) {
1250
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1251
+ res.end(JSON.stringify({ error: err.message }));
1252
+ }
1253
+ return;
1254
+ }
1255
+
1256
+ if (url.pathname === '/api/providers/credentials/op-status' && req.method === 'GET') {
1257
+ // Tells the dashboard whether the 1Password CLI is installed and an account
1258
+ // is signed in. No vault content is read here; this is just the availability
1259
+ // probe. The pull endpoint below is the only thing that touches secret data.
1260
+ try {
1261
+ const which = spawnSync('which', ['op'], { encoding: 'utf8' });
1262
+ if (which.status !== 0) {
1263
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1264
+ res.end(JSON.stringify({ available: false, signedIn: false, version: null, accounts: [] }));
1265
+ return;
1266
+ }
1267
+ const versionProc = spawnSync('op', ['--version'], { encoding: 'utf8', timeout: 2000 });
1268
+ const version = versionProc.status === 0 ? versionProc.stdout.trim() : null;
1269
+ const accountsProc = spawnSync('op', ['account', 'list', '--format=json'], { encoding: 'utf8', timeout: 3000 });
1270
+ let accounts = [];
1271
+ if (accountsProc.status === 0) {
1272
+ try {
1273
+ const parsed = JSON.parse(accountsProc.stdout || '[]');
1274
+ accounts = (Array.isArray(parsed) ? parsed : []).map((a) => ({
1275
+ url: a.url || null,
1276
+ email: a.email || null,
1277
+ user_uuid: a.user_uuid || null,
1278
+ }));
1279
+ } catch { accounts = []; }
1280
+ }
1281
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1282
+ res.end(JSON.stringify({ available: true, signedIn: accounts.length > 0, version, accounts }));
1283
+ } catch (err) {
1284
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1285
+ res.end(JSON.stringify({ error: err.message }));
1286
+ }
1287
+ return;
1288
+ }
1289
+
1290
+ if (url.pathname === '/api/fs/browse' && req.method === 'GET') {
1291
+ try {
1292
+ const { readdirSync, statSync } = await import('node:fs');
1293
+ const { resolve: pathResolve, dirname: pathDirname, join: pathJoin, sep: pathSep } = await import('node:path');
1294
+ const os = await import('node:os');
1295
+ const HOME_DIR = os.homedir();
1296
+ // Only roots the dashboard is allowed to expose. Loopback + same-user
1297
+ // already gates file access at the OS level, but cap the reach to the
1298
+ // user's HOME and the active project root anyway — surprise reads
1299
+ // outside those are almost certainly a typo.
1300
+ const allowedRoots = [HOME_DIR, ROOT_DIR];
1301
+ const rawPath = url.searchParams.get('path') || HOME_DIR;
1302
+ const target = pathResolve(rawPath.startsWith('~') ? pathJoin(HOME_DIR, rawPath.slice(1)) : rawPath);
1303
+ const inAllowed = allowedRoots.some((root) => target === root || target.startsWith(root + pathSep));
1304
+ if (!inAllowed) {
1305
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1306
+ res.end(JSON.stringify({ error: `path outside allowed roots (${allowedRoots.join(', ')})` }));
1307
+ return;
1308
+ }
1309
+ let entries = [];
1310
+ try {
1311
+ entries = readdirSync(target, { withFileTypes: true })
1312
+ .filter((e) => !e.name.startsWith('.'))
1313
+ .map((e) => ({
1314
+ name: e.name,
1315
+ type: e.isDirectory() ? 'dir' : e.isFile() ? 'file' : 'other',
1316
+ }))
1317
+ .sort((a, b) => {
1318
+ if (a.type === b.type) return a.name.localeCompare(b.name);
1319
+ return a.type === 'dir' ? -1 : 1;
1320
+ });
1321
+ } catch (err) {
1322
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1323
+ res.end(JSON.stringify({ error: err.message }));
1324
+ return;
1325
+ }
1326
+ const parent = pathDirname(target);
1327
+ const parentAllowed = allowedRoots.some((root) => parent === root || parent.startsWith(root + pathSep));
1328
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1329
+ res.end(JSON.stringify({
1330
+ path: target,
1331
+ parent: parentAllowed && parent !== target ? parent : null,
1332
+ roots: allowedRoots,
1333
+ entries,
1334
+ }));
1335
+ } catch (err) {
1336
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1337
+ res.end(JSON.stringify({ error: err.message }));
1338
+ }
1339
+ return;
1340
+ }
1341
+
1342
+ if (url.pathname === '/api/intake/config' && req.method === 'GET') {
1343
+ try {
1344
+ const { loadIntakeConfig, INTAKE_DEPTH_GUIDANCE, INTAKE_HARD_MAX_DEPTH } = await import('../intake/intake-config.mjs');
1345
+ const config = loadIntakeConfig(ROOT_DIR, process.env);
1346
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1347
+ res.end(JSON.stringify({
1348
+ config,
1349
+ rootDir: ROOT_DIR,
1350
+ guidance: INTAKE_DEPTH_GUIDANCE,
1351
+ hardMaxDepth: INTAKE_HARD_MAX_DEPTH,
1352
+ defaults: { projectInbox: '.cx/inbox', docsIntake: 'docs/intake' },
1353
+ }));
1354
+ } catch (err) {
1355
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1356
+ res.end(JSON.stringify({ error: err.message }));
1357
+ }
1358
+ return;
1359
+ }
1360
+
1361
+ if (url.pathname === '/api/providers' && req.method === 'GET') {
1362
+ try {
1363
+ const probe = url.searchParams.get('probe') === '1';
1364
+ const { resolveProviders } = await import('../providers/registry.mjs');
1365
+ const { providers, sources, errors } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
1366
+ const summary = await Promise.all(
1367
+ Object.entries(providers).map(async ([id, p]) => {
1368
+ const base = {
1369
+ id,
1370
+ displayName: p.meta.displayName,
1371
+ description: p.meta.description || null,
1372
+ capabilities: [...p.meta.capabilities],
1373
+ source: sources[id],
1374
+ configSchema: p.configSchema || null,
1375
+ };
1376
+ if (!probe) {
1377
+ return { ...base, health: null, status: 'unknown' };
1378
+ }
1379
+ let health = { ok: false, detail: 'health probe failed' };
1380
+ try { health = await p.health({}); } catch (err) { health = { ok: false, detail: err.message }; }
1381
+ const status = classifyProviderStatus(id, health.ok, process.env);
1382
+ // For not_configured, surface what's missing instead of the probe detail
1383
+ // (which may incorrectly read as healthy from an anonymous code path).
1384
+ const missing = status === 'not_configured' ? describeMissingCredentials(id, process.env) : null;
1385
+ const finalHealth = missing ? { ...health, ok: false, detail: missing } : health;
1386
+ return { ...base, health: finalHealth, status };
1387
+ })
1388
+ );
1389
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1390
+ res.end(JSON.stringify({ summary, errors }));
1391
+ } catch (err) {
1392
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1393
+ res.end(JSON.stringify({ error: err.message }));
1394
+ }
1395
+ return;
1396
+ }
1397
+
1398
+ if (url.pathname === '/api/beads' && req.method === 'GET') {
1399
+ try {
1400
+ // Resolve to the main repo when running from a worktree — the bd
1401
+ // database lives next to the canonical .git/, not the worktree's
1402
+ // private .git/ stub. git rev-parse --git-common-dir returns the
1403
+ // shared .git for both main and worktrees; its parent is the
1404
+ // main repo root.
1405
+ let bdCwd = ROOT_DIR;
1406
+ try {
1407
+ const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
1408
+ cwd: ROOT_DIR,
1409
+ encoding: 'utf8',
1410
+ });
1411
+ if (commonDir.status === 0) {
1412
+ const trimmed = commonDir.stdout.trim();
1413
+ const resolved = trimmed.startsWith('/') ? trimmed : join(ROOT_DIR, trimmed);
1414
+ const candidate = dirname(resolved);
1415
+ if (existsSync(join(candidate, '.beads'))) bdCwd = candidate;
1416
+ }
1417
+ } catch { /* fall back to ROOT_DIR */ }
1418
+ const result = spawnSync('bd', ['list', '--status', 'all', '--json'], {
1419
+ cwd: bdCwd,
1420
+ encoding: 'utf8',
1421
+ env: { ...process.env, NO_COLOR: '1' },
1422
+ timeout: 5000,
1423
+ });
1424
+ if (result.status !== 0) {
1425
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1426
+ res.end(JSON.stringify({ error: `bd list failed: ${(result.stderr || result.stdout || '').slice(0, 300)}` }));
1427
+ return;
1428
+ }
1429
+ let raw = [];
1430
+ try { raw = JSON.parse(result.stdout); } catch (parseErr) {
1431
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1432
+ res.end(JSON.stringify({ error: `bd list JSON parse failed: ${parseErr.message}` }));
1433
+ return;
1434
+ }
1435
+ const issues = raw.map(obj => ({
1436
+ id: obj.id,
1437
+ title: obj.title,
1438
+ description: obj.description || null,
1439
+ status: obj.status,
1440
+ priority: obj.priority,
1441
+ issue_type: obj.issue_type || null,
1442
+ owner: obj.owner || null,
1443
+ created_at: obj.created_at,
1444
+ updated_at: obj.updated_at,
1445
+ dependency_count: obj.dependency_count || 0,
1446
+ dependent_count: obj.dependent_count || 0,
1447
+ comment_count: obj.comment_count || 0,
1448
+ labels: Array.isArray(obj.labels) ? obj.labels : [],
1449
+ }));
1450
+ const byStatus = {};
1451
+ const byPriority = {};
1452
+ for (const it of issues) {
1453
+ byStatus[it.status] = (byStatus[it.status] || 0) + 1;
1454
+ byPriority[`P${it.priority}`] = (byPriority[`P${it.priority}`] || 0) + 1;
1455
+ }
1456
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1457
+ res.end(JSON.stringify({ issues, counts: { total: issues.length, byStatus, byPriority } }));
1458
+ } catch (err) {
1459
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1460
+ res.end(JSON.stringify({ error: err.message }));
1461
+ }
1462
+ return;
1463
+ }
1464
+
1465
+ if (url.pathname === '/api/providers/subscriptions' && req.method === 'GET') {
1466
+ try {
1467
+ const file = join(HOME, '.construct', 'provider-subscriptions.json');
1468
+ let data = { subscriptions: [] };
1469
+ if (existsSync(file)) {
1470
+ try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { /* fall back to empty */ }
1471
+ if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
1472
+ }
1473
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1474
+ res.end(JSON.stringify(data));
1475
+ } catch (err) {
1476
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1477
+ res.end(JSON.stringify({ error: err.message }));
1478
+ }
1479
+ return;
1480
+ }
1481
+
1482
+ if (url.pathname === '/api/providers/credentials' && req.method === 'GET') {
1483
+ try {
1484
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1485
+ res.end(JSON.stringify({ credentials: buildCredentialsView(process.env) }));
1486
+ } catch (err) {
1487
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1488
+ res.end(JSON.stringify({ error: err.message }));
1489
+ }
1490
+ return;
1491
+ }
1492
+
1493
+ if (url.pathname === '/api/providers/config-path' && req.method === 'GET') {
1494
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1495
+ res.end(JSON.stringify({
1496
+ envPath: CONFIG_ENV_FILE,
1497
+ overridesPath: join(HOME, '.construct', 'providers.json'),
1498
+ }));
1499
+ return;
1500
+ }
1501
+
1502
+ if (url.pathname === '/api/session-usage') {
1503
+ await handleSessionUsage(req, res);
1504
+ return;
1505
+ }
1506
+
1507
+ if (url.pathname === '/api/artifacts') {
1508
+ await handleArtifacts(req, res);
1509
+ return;
1510
+ }
1511
+
1512
+ if (url.pathname === '/api/approvals') {
1513
+ await handleApprovals(req, res);
1514
+ return;
1515
+ }
1516
+
1517
+ if (url.pathname === '/api/snapshots') {
1518
+ handleSnapshots(req, res);
1519
+ return;
1520
+ }
1521
+
1522
+ // ── Knowledge API ──────────────────────────────────────────────────────
1523
+ if (url.pathname === '/api/knowledge/trends') {
1524
+ const { buildTrendReport } = await import('../knowledge/trends.mjs');
1525
+ const report = buildTrendReport(ROOT_DIR);
1526
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1527
+ res.end(JSON.stringify(report));
1528
+ return;
1529
+ }
1530
+
1531
+ if (url.pathname === '/api/knowledge/index') {
1532
+ const { buildCorpus } = await import('../knowledge/rag.mjs');
1533
+ const corpus = buildCorpus(ROOT_DIR);
1534
+ const sources = {};
1535
+ for (const c of corpus) sources[c.source] = (sources[c.source] || 0) + 1;
1536
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1537
+ res.end(JSON.stringify({ total: corpus.length, sources }));
1538
+ return;
1539
+ }
1540
+
1541
+ if (url.pathname === '/api/knowledge/ask' && req.method === 'POST') {
1542
+ const chunks = [];
1543
+ await new Promise((resolve, reject) => {
1544
+ req.on('data', (c) => chunks.push(c));
1545
+ req.on('end', resolve);
1546
+ req.on('error', reject);
1547
+ });
1548
+ let body;
1549
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1550
+ const question = (body.question || '').trim();
1551
+ if (!question) {
1552
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1553
+ res.end(JSON.stringify({ error: 'question is required' }));
1554
+ return;
1555
+ }
1556
+ const { ask } = await import('../knowledge/rag.mjs');
1557
+ const result = await ask(question, { rootDir: ROOT_DIR });
1558
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1559
+ res.end(JSON.stringify(result));
1560
+ return;
1561
+ }
1562
+
1563
+ // ── Workflow API ──────────────────────────────────────────────────────────
1564
+ if (url.pathname === '/api/workflow' && req.method === 'GET') {
1565
+ try {
1566
+ const planMd = existsSync(WORKFLOW_FILE) ? readFileSync(WORKFLOW_FILE, 'utf8') : '';
1567
+ const { loadWorkflow } = await import('../workflow-state.mjs');
1568
+ const wf = loadWorkflow(ROOT_DIR);
1569
+ const tasks = wf?.tasks ?? [];
1570
+ const phase = wf?.phase ?? null;
1571
+ const phases = wf?.phases ?? {};
1572
+ const status = wf?.status ?? null;
1573
+ const summary = planMd
1574
+ ? planMd.split('\n').slice(0, 30).filter(l => l.trim()).slice(0, 8).join('\n')
1575
+ : '';
1576
+
1577
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1578
+ res.end(JSON.stringify({
1579
+ hasPlan: Boolean(planMd),
1580
+ planSummary: summary,
1581
+ planPath: WORKFLOW_FILE,
1582
+ workflowState: wf ? { status, phase, phases, tasks, currentTaskKey: wf.currentTaskKey } : null,
1583
+ taskCount: tasks.length,
1584
+ taskStatusCounts: {
1585
+ todo: tasks.filter(t => t.status === 'todo' || !t.status).length,
1586
+ inProgress: tasks.filter(t => t.status === 'in-progress').length,
1587
+ blocked: tasks.filter(t => t.status?.startsWith('blocked')).length,
1588
+ done: tasks.filter(t => t.status === 'done').length,
1589
+ skipped: tasks.filter(t => t.status === 'skipped').length,
1590
+ },
1591
+ }));
1592
+ } catch (err) {
1593
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1594
+ res.end(JSON.stringify({ error: err.message }));
1595
+ }
1596
+ return;
1597
+ }
1598
+
1599
+ if (url.pathname === '/api/config') {
1600
+ handleConfig(req, res);
1601
+ return;
1602
+ }
1603
+
1604
+ // ── Terraform API ─────────────────────────────────────────────────────────
1605
+ if (url.pathname === '/api/terraform/files' && req.method === 'GET') {
1606
+ try {
1607
+ const files = terraformFiles(TERRAFORM_DIR, TERRAFORM_DIR);
1608
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1609
+ res.end(JSON.stringify({ files, terraformDir: TERRAFORM_DIR }));
1610
+ } catch (err) {
1611
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1612
+ res.end(JSON.stringify({ error: err.message }));
1613
+ }
1614
+ return;
1615
+ }
1616
+
1617
+ if (url.pathname === '/api/terraform/file') {
1618
+ if (req.method === 'GET') {
1619
+ const relPath = url.searchParams.get('path');
1620
+ if (!relPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return; }
1621
+ try {
1622
+ const abs = assertTerraformPath(relPath);
1623
+ const content = readFileSync(abs, 'utf8');
1624
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1625
+ res.end(JSON.stringify({ path: relPath, content }));
1626
+ } catch (err) {
1627
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1628
+ res.end(JSON.stringify({ error: err.message }));
1629
+ }
1630
+ return;
1631
+ }
1632
+
1633
+ if (req.method === 'POST') {
1634
+ const chunks = [];
1635
+ await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1636
+ let body;
1637
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1638
+ try {
1639
+ const abs = assertTerraformPath(body.path || '');
1640
+ writeFileSync(abs, body.content ?? '', 'utf8');
1641
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1642
+ res.end(JSON.stringify({ success: true }));
1643
+ } catch (err) {
1644
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1645
+ res.end(JSON.stringify({ error: err.message }));
1646
+ }
1647
+ return;
1648
+ }
1649
+ }
1650
+
1651
+ if (url.pathname === '/api/terraform/run' && req.method === 'POST') {
1652
+ const chunks = [];
1653
+ await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1654
+ let body;
1655
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1656
+
1657
+ const subcommand = body.subcommand; // 'plan' | 'apply'
1658
+ const environment = body.environment || 'staging'; // 'staging' | 'production'
1659
+
1660
+ if (!['plan', 'apply', 'validate', 'output'].includes(subcommand)) {
1661
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1662
+ res.end(JSON.stringify({ error: "subcommand must be 'plan', 'apply', 'validate', or 'output'" }));
1663
+ return;
1664
+ }
1665
+
1666
+ const envDir = join(TERRAFORM_DIR, 'environments', environment);
1667
+ if (!existsSync(envDir)) {
1668
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1669
+ res.end(JSON.stringify({ error: `Environment '${environment}' not found at ${envDir}` }));
1670
+ return;
1671
+ }
1672
+
1673
+ // Stream output via SSE-style chunked response
1674
+ res.writeHead(200, {
1675
+ 'Content-Type': 'text/plain; charset=utf-8',
1676
+ 'Transfer-Encoding': 'chunked',
1677
+ 'Cache-Control': 'no-cache',
1678
+ 'X-Accel-Buffering': 'no',
1679
+ });
1680
+
1681
+ const args = subcommand === 'plan'
1682
+ ? ['plan', `-chdir=${envDir}`, '-no-color']
1683
+ : subcommand === 'apply'
1684
+ ? ['apply', `-chdir=${envDir}`, '-no-color', '-auto-approve']
1685
+ : subcommand === 'validate'
1686
+ ? ['validate', `-chdir=${envDir}`, '-no-color']
1687
+ : ['output', `-chdir=${envDir}`, '-no-color'];
1688
+
1689
+ res.write(`\u001b[90m$ terraform ${args.join(' ')}\u001b[0m\n\n`);
1690
+
1691
+ const tf = spawn('terraform', args, {
1692
+ cwd: envDir,
1693
+ env: { ...process.env, TF_IN_AUTOMATION: '1' },
1694
+ });
1695
+
1696
+ tf.stdout.on('data', chunk => { try { res.write(chunk); } catch {} });
1697
+ tf.stderr.on('data', chunk => { try { res.write(chunk); } catch {} });
1698
+
1699
+ tf.on('close', (code) => {
1700
+ try {
1701
+ res.write(`\n\u001b[${code === 0 ? '32' : '31'}m\nExit code: ${code}\u001b[0m\n`);
1702
+ res.end();
1703
+ } catch {}
1704
+ notifyClients();
1705
+ });
1706
+
1707
+ tf.on('error', (err) => {
1708
+ try { res.write(`\n❌ Failed to run terraform: ${err.message}\n`); res.end(); } catch {}
1709
+ });
1710
+
1711
+ return;
1712
+ }
1713
+
1714
+ if (url.pathname === '/api/chat/stream' && req.method === 'GET') {
1715
+ handleChatStream(req, res, { rootDir: ROOT_DIR });
1716
+ return;
1717
+ }
1718
+
1719
+ if (url.pathname === '/api/chat/history' && req.method === 'GET') {
1720
+ handleChatHistory(req, res);
1721
+ return;
1722
+ }
1723
+
1724
+ if (url.pathname === '/api/chat' && req.method === 'POST') {
1725
+ handleChat(req, res, { rootDir: ROOT_DIR });
1726
+ return;
1727
+ }
1728
+
1729
+ if (req.method === 'POST') {
1730
+ let body = '';
1731
+ req.on('data', chunk => { body += chunk; });
1732
+ req.on('end', async () => {
1733
+ try {
1734
+ const data = JSON.parse(body || '{}');
1735
+ const { promoteHeadhunt, updatePromotionChallenge } = await import('../headhunt.mjs');
1736
+
1737
+ if (url.pathname === '/api/headhunt/promote') {
1738
+ if (!data.id) throw new Error('Missing overlay id');
1739
+ const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
1740
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1741
+ res.end(JSON.stringify({ success: true, request }));
1742
+ notifyClients();
1743
+ } else if (url.pathname === '/api/headhunt/challenge') {
1744
+ if (!data.id) throw new Error('Missing overlay id');
1745
+ if (!data.status) throw new Error('Missing challenge status');
1746
+ const request = updatePromotionChallenge(data.id, {
1747
+ cwd: process.cwd(),
1748
+ status: data.status,
1749
+ note: data.note || null,
1750
+ });
1751
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1752
+ res.end(JSON.stringify({ success: true, request }));
1753
+ notifyClients();
1754
+ } else if (url.pathname === '/api/registry/mcp') {
1755
+ const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
1756
+ if (!registry.mcpServers) registry.mcpServers = {};
1757
+ const { action, id, server } = data;
1758
+ if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
1759
+ if (action === 'delete') {
1760
+ delete registry.mcpServers[id];
1761
+ } else if (action === 'save' && server && typeof server === 'object') {
1762
+ const entry = {};
1763
+ if (server.type === 'url') {
1764
+ if (!server.url) throw new Error('URL required for type=url');
1765
+ entry.type = 'url';
1766
+ entry.url = String(server.url);
1767
+ if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
1768
+ } else {
1769
+ if (!server.command) throw new Error('command required');
1770
+ entry.command = String(server.command);
1771
+ entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
1772
+ }
1773
+ if (server.description) entry.description = String(server.description);
1774
+ registry.mcpServers[id] = entry;
1775
+ } else {
1776
+ throw new Error('action must be save or delete');
1777
+ }
1778
+ writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
1779
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1780
+ res.end(JSON.stringify({ success: true }));
1781
+ notifyClients();
1782
+ } else if (url.pathname === '/api/registry/models') {
1783
+ const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
1784
+ const { tier, primary, fallback } = data;
1785
+ const VALID_TIERS = ['reasoning', 'standard', 'fast'];
285
1786
  if (!VALID_TIERS.includes(tier)) throw new Error('tier must be reasoning, standard, or fast');
286
1787
  if (!primary || typeof primary !== 'string') throw new Error('primary model required');
287
1788
  if (!registry.models) registry.models = {};
@@ -293,6 +1794,290 @@ const server = createServer(async (req, res) => {
293
1794
  res.writeHead(200, { 'Content-Type': 'application/json' });
294
1795
  res.end(JSON.stringify({ success: true }));
295
1796
  notifyClients();
1797
+ } else if (url.pathname === '/api/providers/billing') {
1798
+ // Persists per-provider billing mode into construct.config.json under
1799
+ // costs.providers.<id>.billingMode. Validates ids against the known
1800
+ // CREDENTIAL_MAP (built-in + custom) so we don't accept arbitrary
1801
+ // strings into the schema.
1802
+ const { provider, billingMode } = data;
1803
+ if (typeof provider !== 'string' || !provider) throw new Error('provider id required');
1804
+ const validProviders = new Set([
1805
+ ...credentialMap().map((c) => c.provider),
1806
+ 'ollama', 'local', 'mistral', 'groq',
1807
+ ]);
1808
+ if (!validProviders.has(provider)) {
1809
+ throw new Error(`unknown provider '${provider}'. Add a custom provider first or use one of: ${[...validProviders].join(', ')}`);
1810
+ }
1811
+ if (!['metered', 'subscription', 'mixed'].includes(billingMode)) {
1812
+ throw new Error("billingMode must be 'metered', 'subscription', or 'mixed'");
1813
+ }
1814
+ const { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
1815
+ const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
1816
+ const { config } = loadProjectConfig(process.cwd(), process.env);
1817
+ const next = {
1818
+ ...config,
1819
+ costs: {
1820
+ ...(config.costs || {}),
1821
+ providers: {
1822
+ ...(config.costs?.providers || {}),
1823
+ [provider]: { billingMode },
1824
+ },
1825
+ },
1826
+ };
1827
+ writeProjectConfig(cfgPath, next);
1828
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1829
+ res.end(JSON.stringify({ success: true, providers: next.costs.providers }));
1830
+ notifyClients();
1831
+ } else if (url.pathname === '/api/providers/credentials/op-pull') {
1832
+ // Resolve a 1Password secret reference (op://vault/item/field) and
1833
+ // store the result into ~/.construct/config.env via the same write
1834
+ // path as the manual editor. The op:// URI never leaves the machine
1835
+ // and the resolved value flows secret server-side → config.env
1836
+ // (chmod 0600) → process.env, identical to a manual paste.
1837
+ const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
1838
+ if (isAuthConfigured()) {
1839
+ if (!isAuthenticated(req)) {
1840
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1841
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1842
+ return;
1843
+ }
1844
+ } else if (!isLocal) {
1845
+ throw new Error('op-pull on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
1846
+ }
1847
+ const { envVar, opRef } = data;
1848
+ if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
1849
+ if (!allowedCredentialEnvVars().has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
1850
+ if (typeof opRef !== 'string' || !opRef.startsWith('op://')) {
1851
+ throw new Error("opRef must be a 1Password secret reference starting with 'op://'");
1852
+ }
1853
+ if (!/^op:\/\/[\w\s\-._]+\/[\w\s\-._]+\/[\w\s\-._]+$/.test(opRef)) {
1854
+ throw new Error("opRef must look like 'op://vault/item/field' (letters, digits, spaces, _-.)");
1855
+ }
1856
+ const opRead = spawnSync('op', ['read', '--no-newline', opRef], { encoding: 'utf8', timeout: 10000 });
1857
+ if (opRead.status !== 0) {
1858
+ throw new Error(`op read failed: ${(opRead.stderr || opRead.stdout || '').trim().slice(0, 240)}`);
1859
+ }
1860
+ const resolved = opRead.stdout;
1861
+ if (!resolved) throw new Error('op read returned an empty value');
1862
+
1863
+ const { writeEnvValues, loadConstructEnv } = await import('../env-config.mjs');
1864
+ mkdirSync(join(HOME, '.construct'), { recursive: true });
1865
+ writeEnvValues(CONFIG_ENV_FILE, { [envVar]: resolved });
1866
+ process.env[envVar] = resolved;
1867
+ try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms best-effort */ }
1868
+
1869
+ const allowed = allowedCredentialEnvVars();
1870
+ const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
1871
+ for (const [k, v] of Object.entries(merged)) {
1872
+ if (allowed.has(k) && k !== envVar) process.env[k] = v;
1873
+ }
1874
+
1875
+ try {
1876
+ mkdirSync(join(HOME, '.cx'), { recursive: true });
1877
+ appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action: 'set', envVar, source: '1password', opRef }) + '\n');
1878
+ } catch { /* audit best-effort */ }
1879
+
1880
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1881
+ res.end(JSON.stringify({ success: true, envVar }));
1882
+ notifyClients();
1883
+ } else if (url.pathname === '/api/providers/credentials/custom') {
1884
+ const { action, provider, label, kind, envVars } = data;
1885
+ const cleanedId = typeof provider === 'string' ? provider.trim().toLowerCase() : '';
1886
+ if (!cleanedId || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(cleanedId)) {
1887
+ throw new Error('provider id required: lowercase letters, digits, hyphens (max 63 chars)');
1888
+ }
1889
+ if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === cleanedId)) {
1890
+ throw new Error(`provider id '${cleanedId}' is built-in; pick a different id`);
1891
+ }
1892
+ const existing = readCustomCredentials();
1893
+ if (action === 'delete') {
1894
+ const next = existing.filter((e) => e.provider !== cleanedId);
1895
+ writeCustomCredentials(next);
1896
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1897
+ res.end(JSON.stringify({ success: true, providers: next }));
1898
+ notifyClients();
1899
+ } else if (action === 'save') {
1900
+ if (typeof label !== 'string' || !label.trim()) throw new Error('label required');
1901
+ if (kind !== 'llm' && kind !== 'integration') throw new Error("kind must be 'llm' or 'integration'");
1902
+ if (!Array.isArray(envVars) || envVars.length === 0) throw new Error('envVars must be a non-empty array');
1903
+ const normalizedVars = envVars.map((v) => String(v || '').trim().toUpperCase());
1904
+ for (const v of normalizedVars) {
1905
+ if (!ENV_VAR_PATTERN.test(v)) throw new Error(`envVar '${v}' must match /^[A-Z][A-Z0-9_]{1,63}$/`);
1906
+ if (ENV_VAR_BLOCKLIST.has(v)) throw new Error(`envVar '${v}' is reserved by the OS shell and not allowed`);
1907
+ if (allowedCredentialEnvVars().has(v)) {
1908
+ // Only block when the existing owner is a different provider.
1909
+ const owner = credentialMap().find((c) => c.envVars.includes(v));
1910
+ if (owner && owner.provider !== cleanedId) {
1911
+ throw new Error(`envVar '${v}' already declared by provider '${owner.provider}'`);
1912
+ }
1913
+ }
1914
+ }
1915
+ const next = existing.filter((e) => e.provider !== cleanedId).concat([{
1916
+ provider: cleanedId,
1917
+ label: label.trim(),
1918
+ kind,
1919
+ envVars: normalizedVars,
1920
+ }]);
1921
+ writeCustomCredentials(next);
1922
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1923
+ res.end(JSON.stringify({ success: true, providers: next }));
1924
+ notifyClients();
1925
+ } else {
1926
+ throw new Error("action must be 'save' or 'delete'");
1927
+ }
1928
+ } else if (url.pathname === '/api/intake/config') {
1929
+ const { saveIntakeConfig } = await import('../intake/intake-config.mjs');
1930
+ const next = saveIntakeConfig(ROOT_DIR, {
1931
+ parentDirs: Array.isArray(data.parentDirs) ? data.parentDirs : undefined,
1932
+ maxDepth: data.maxDepth,
1933
+ includeProjectInbox: data.includeProjectInbox,
1934
+ includeDocsIntake: data.includeDocsIntake,
1935
+ });
1936
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1937
+ res.end(JSON.stringify({ success: true, config: next }));
1938
+ notifyClients();
1939
+ } else if (url.pathname === '/api/models/free/apply') {
1940
+ const { pollFreeModels, selectForTier } = await import('../model-free-selector.mjs');
1941
+ const { readOpenRouterApiKeyFromOpenCodeConfig } = await import('../model-router.mjs');
1942
+ const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
1943
+ if (!apiKey) {
1944
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1945
+ res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured. Set it under Providers → Credentials.' }));
1946
+ } else {
1947
+ const freeModels = await pollFreeModels(apiKey);
1948
+ if (!freeModels || freeModels.length === 0) {
1949
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1950
+ res.end(JSON.stringify({ error: 'OpenRouter returned no free models. Check the key and try again.' }));
1951
+ } else {
1952
+ const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
1953
+ if (!registry.models) registry.models = {};
1954
+ const selections = {};
1955
+ for (const tier of ['reasoning', 'standard', 'fast']) {
1956
+ const fallback = registry.models?.[tier]?.fallback ?? [];
1957
+ const id = selectForTier(freeModels, tier, fallback);
1958
+ if (id) {
1959
+ selections[tier] = id;
1960
+ registry.models[tier] = { primary: id, fallback };
1961
+ }
1962
+ }
1963
+ writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
1964
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1965
+ res.end(JSON.stringify({ success: true, selections, polledCount: freeModels.length }));
1966
+ notifyClients();
1967
+ }
1968
+ }
1969
+ } else if (url.pathname === '/api/providers/registry') {
1970
+ const { BUILT_INS } = await import('../providers/registry.mjs');
1971
+ const overridesPath = join(HOME, '.construct', 'providers.json');
1972
+ const { action, id, package: pkg, options } = data;
1973
+ if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid provider id');
1974
+ if (BUILT_INS.includes(id)) throw new Error(`cannot override built-in provider '${id}'`);
1975
+
1976
+ let current = { providers: [] };
1977
+ if (existsSync(overridesPath)) {
1978
+ try { current = JSON.parse(readFileSync(overridesPath, 'utf8')); } catch { current = { providers: [] }; }
1979
+ if (!Array.isArray(current.providers)) current.providers = [];
1980
+ }
1981
+
1982
+ if (action === 'delete') {
1983
+ current.providers = current.providers.filter(p => p?.id !== id);
1984
+ } else if (action === 'save') {
1985
+ if (!pkg || typeof pkg !== 'string') throw new Error('package required');
1986
+ const { validateProviderEntry } = await import('../providers/registry.mjs');
1987
+ const verdict = await Promise.race([
1988
+ validateProviderEntry({ id, package: pkg, options: options || {} }, { rootDir: ROOT_DIR, env: process.env }),
1989
+ new Promise((resolve) => setTimeout(() => resolve({ ok: false, error: 'provider load timeout (5s)' }), 5000)),
1990
+ ]);
1991
+ if (!verdict.ok) throw new Error(`provider '${id}' failed to load: ${verdict.error}`);
1992
+ current.providers = current.providers.filter(p => p?.id !== id).concat([{ id, package: pkg, options: options || {} }]);
1993
+ } else {
1994
+ throw new Error('action must be save or delete');
1995
+ }
1996
+
1997
+ mkdirSync(join(HOME, '.construct'), { recursive: true });
1998
+ const tmp = overridesPath + '.tmp';
1999
+ writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
2000
+ renameSync(tmp, overridesPath);
2001
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2002
+ res.end(JSON.stringify({ success: true }));
2003
+ notifyClients();
2004
+ } else if (url.pathname === '/api/providers/credentials') {
2005
+ // 127.0.0.1 already requires same-user file access to write config.env,
2006
+ // so no extra security comes from forcing a token on localhost. On any
2007
+ // non-loopback bind, require the dashboard token to gate writes.
2008
+ const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
2009
+ if (isAuthConfigured()) {
2010
+ if (!isAuthenticated(req)) {
2011
+ res.writeHead(401, { 'Content-Type': 'application/json' });
2012
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
2013
+ return;
2014
+ }
2015
+ } else if (!isLocal) {
2016
+ throw new Error('credential management on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
2017
+ }
2018
+ const { envVar, value } = data;
2019
+ if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
2020
+ const allowed = allowedCredentialEnvVars();
2021
+ if (!allowed.has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
2022
+ if (value !== '' && typeof value !== 'string') throw new Error('value must be a string');
2023
+
2024
+ const { writeEnvValues, parseEnvFile, loadConstructEnv } = await import('../env-config.mjs');
2025
+ mkdirSync(join(HOME, '.construct'), { recursive: true });
2026
+ const action = value === '' ? 'unset' : 'set';
2027
+
2028
+ if (action === 'unset') {
2029
+ // writeEnvValues drops empty values, so passing '' deletes the key.
2030
+ writeEnvValues(CONFIG_ENV_FILE, { ...parseEnvFile(CONFIG_ENV_FILE), [envVar]: '' });
2031
+ delete process.env[envVar];
2032
+ } else {
2033
+ writeEnvValues(CONFIG_ENV_FILE, { [envVar]: value });
2034
+ process.env[envVar] = value;
2035
+ }
2036
+ try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms set best-effort */ }
2037
+
2038
+ // Re-overlay so any non-targeted keys edited out-of-band also refresh.
2039
+ const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
2040
+ for (const [k, v] of Object.entries(merged)) {
2041
+ if (allowed.has(k) && k !== envVar) process.env[k] = v;
2042
+ }
2043
+
2044
+ try {
2045
+ mkdirSync(join(HOME, '.cx'), { recursive: true });
2046
+ appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action, envVar }) + '\n');
2047
+ } catch { /* audit append best-effort */ }
2048
+
2049
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2050
+ res.end(JSON.stringify({ success: true }));
2051
+ notifyClients();
2052
+ } else if (url.pathname === '/api/providers/subscriptions') {
2053
+ const subsPath = join(HOME, '.construct', 'provider-subscriptions.json');
2054
+ const { action, id, provider, name, config } = data;
2055
+ if (!id || typeof id !== 'string' || !/^[\w.-]+$/.test(id)) throw new Error('Invalid subscription id');
2056
+
2057
+ let current = { subscriptions: [] };
2058
+ if (existsSync(subsPath)) {
2059
+ try { current = JSON.parse(readFileSync(subsPath, 'utf8')); } catch { current = { subscriptions: [] }; }
2060
+ if (!Array.isArray(current.subscriptions)) current.subscriptions = [];
2061
+ }
2062
+
2063
+ if (action === 'delete') {
2064
+ current.subscriptions = current.subscriptions.filter(s => s?.id !== id);
2065
+ } else if (action === 'save') {
2066
+ if (!provider || typeof provider !== 'string') throw new Error('provider required');
2067
+ if (config && typeof config !== 'object') throw new Error('config must be an object');
2068
+ const entry = { id, provider, name: name || id, config: config || {} };
2069
+ current.subscriptions = current.subscriptions.filter(s => s?.id !== id).concat([entry]);
2070
+ } else {
2071
+ throw new Error('action must be save or delete');
2072
+ }
2073
+
2074
+ mkdirSync(join(HOME, '.construct'), { recursive: true });
2075
+ const tmp = subsPath + '.tmp';
2076
+ writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
2077
+ renameSync(tmp, subsPath);
2078
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2079
+ res.end(JSON.stringify({ success: true }));
2080
+ notifyClients();
296
2081
  } else {
297
2082
  res.writeHead(404);
298
2083
  res.end('Not found');
@@ -329,6 +2114,70 @@ const server = createServer(async (req, res) => {
329
2114
 
330
2115
  watchFiles();
331
2116
 
332
- server.listen(PORT, '127.0.0.1', () => {
333
- console.log(`Construct dashboard running at http://127.0.0.1:${PORT}`);
2117
+ // ── Embed scheduler ──────────────────────────────────────────────────────────
2118
+ // When CX_AUTO_EMBED=1, run `construct sync` at startup and on a fixed interval
2119
+ // to keep the knowledge base current without manual intervention.
2120
+ // Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
2121
+ function runEmbedSync() {
2122
+ const syncScript = join(ROOT_DIR, 'scripts', 'sync-agents.mjs');
2123
+ if (!existsSync(syncScript)) return;
2124
+ try {
2125
+ spawnSync(process.execPath, [syncScript], {
2126
+ cwd: ROOT_DIR,
2127
+ stdio: 'ignore',
2128
+ timeout: 120_000,
2129
+ env: { ...process.env },
2130
+ });
2131
+ } catch {
2132
+ // non-fatal — next interval will retry
2133
+ }
2134
+ }
2135
+
2136
+ // Load user env config into process.env so dashboard API endpoints that read
2137
+ // from process.env (telemetry credentials, model provider keys, etc.) find them
2138
+ // even when the server is started directly (not through bin/construct).
2139
+ try {
2140
+ const { loadConstructEnv, getUserEnvPath } = await import('../env-config.mjs');
2141
+ const homeDir = HOME || homedir();
2142
+ const envPath = getUserEnvPath(homeDir);
2143
+ if (existsSync(envPath)) {
2144
+ const ENV = loadConstructEnv({ rootDir: ROOT_DIR, homeDir, env: process.env });
2145
+ for (const [key, value] of Object.entries(ENV)) {
2146
+ if (!(key in process.env)) process.env[key] = value;
2147
+ }
2148
+ }
2149
+ } catch { /* non-fatal — env loading is best-effort */ }
2150
+
2151
+ if (process.env.CX_AUTO_EMBED === '1') {
2152
+ const intervalMs = parseInt(process.env.CX_EMBED_INTERVAL_MS ?? '', 10) || 30 * 60 * 1000;
2153
+ runEmbedSync();
2154
+ setInterval(runEmbedSync, intervalMs).unref();
2155
+ console.log(`Embed scheduler active — syncing every ${intervalMs / 60_000} min`);
2156
+ }
2157
+ server.listen(PORT, BIND_HOST, () => {
2158
+ console.log(`Construct dashboard running at http://${BIND_HOST}:${PORT}`);
2159
+ });
2160
+
2161
+ // SIGTERM/SIGINT close the server cleanly so the process exits 0 instead of
2162
+ // 143 — keeps shell tooling and supervisors from flagging normal shutdowns
2163
+ // as failures.
2164
+
2165
+ let shuttingDown = false;
2166
+ function shutdown(signal) {
2167
+ if (shuttingDown) return;
2168
+ shuttingDown = true;
2169
+ console.log(`Received ${signal}, shutting down dashboard...`);
2170
+ closeWatchers();
2171
+ for (const client of sseClients) {
2172
+ try { client.end(); } catch { /* ignore */ }
2173
+ }
2174
+ server.close(() => process.exit(0));
2175
+ setTimeout(() => process.exit(0), 2000).unref();
2176
+ }
2177
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
2178
+ process.on('SIGINT', () => shutdown('SIGINT'));
2179
+
2180
+ // ── Subscribe to embed notification bus → SSE toast events ──────────────────
2181
+ onEmbedNotification((event) => {
2182
+ notifyClients({ type: 'toast', ...event });
334
2183
  });