@geraldmaron/construct 1.0.0 → 1.0.2

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 +9 -6
  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 +1000 -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 +523 -36
  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
@@ -0,0 +1,1309 @@
1
+ /**
2
+ * lib/embed/daemon.mjs — embed mode daemon.
3
+ *
4
+ * Ties together: config, provider registry, scheduler, snapshot engine,
5
+ * approval queue, and output dispatch. This is what `construct embed start`
6
+ * runs.
7
+ *
8
+ * Self-healing jobs (all scheduled, all fire-and-forget):
9
+ * - snapshot — polls providers, records errors as observations
10
+ * - provider-health — exponential backoff for consistently failing providers
11
+ * - session-distill — extracts session summaries into the observation store
12
+ * - self-repair — detects degraded state, fixes stale locks and state files
13
+ * - approval-expiry — expires stale approval queue items
14
+ * - eval-dataset-sync — syncs scored traces to telemetry dataset items
15
+ * - prompt-regression-check — detects low-quality score clusters per promptHash
16
+ * - inbox-watcher — ingests new files from .cx/inbox/ + CX_INBOX_DIRS into
17
+ * observations (agnostic: specs, ADRs, meeting notes, etc.)
18
+ * - roadmap — cross-references open items + observations → .cx/roadmap.md
19
+ *
20
+ * Usage:
21
+ * const daemon = new EmbedDaemon({ configPath, registry });
22
+ * await daemon.start();
23
+ * // ...
24
+ * daemon.stop();
25
+ */
26
+
27
+ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { join } from 'node:path';
30
+
31
+ import { loadEmbedConfig } from './config.mjs';
32
+ import { Scheduler } from './scheduler.mjs';
33
+ import { SnapshotEngine, renderMarkdown } from './snapshot.mjs';
34
+ import { ApprovalQueue } from './approval-queue.mjs';
35
+ import { dispatchOutputs } from './output.mjs';
36
+ import { AuthorityGuard } from './authority-guard.mjs';
37
+ import { ProviderRegistry } from './providers/registry.mjs';
38
+ import { addObservation, searchObservations } from '../observation-store.mjs';
39
+ import { listSessions, loadSession } from '../session-store.mjs';
40
+ import { syncEvalDatasets } from '../telemetry/eval-datasets.mjs';
41
+ import { runLLMJudgeEvaluations } from '../telemetry/llm-judge.mjs';
42
+ import { InboxWatcher } from './inbox.mjs';
43
+ import { InboxLiveWatcher } from './inbox-live-watcher.mjs';
44
+ import { generateRoadmap } from './roadmap.mjs';
45
+ import { runDocsLifecycle } from './docs-lifecycle.mjs';
46
+ import { emitEmbedNotification, notifySlack } from './notifications.mjs';
47
+ import { intentToCategory } from './providers/slack.mjs';
48
+ import { runTelemetrySetup } from '../telemetry/setup.mjs';
49
+ import { emitTraceEvent, newTraceId, newSpanId } from '../worker/trace.mjs';
50
+
51
+ /**
52
+ * Log a non-fatal error to stderr. Use instead of empty catch {} blocks
53
+ * so transient failures produce a visible trail without crashing the daemon.
54
+ */
55
+ function logCatch(source, err) {
56
+ process.stderr.write(`[embed] ${source}: ${err?.message ?? String(err)}\n`);
57
+ }
58
+
59
+ /**
60
+ * Resolve an LLM API key from multiple sources: env → config.env → ~/.env → shell rc.
61
+ * Used by the telemetry-generation probe so it can find keys stored via 1Password
62
+ * op:// refs in .zshrc or other shell config files.
63
+ */
64
+ async function resolveLLMKey(varName, env) {
65
+ if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
66
+ const homeDir = homedir();
67
+ const files = [join(homeDir, '.construct', 'config.env'), join(homeDir, '.env')];
68
+ for (const file of files) {
69
+ try {
70
+ if (!existsSync(file)) continue;
71
+ const content = readFileSync(file, 'utf8');
72
+ const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
73
+ if (m && m[1]) return m[1].trim();
74
+ } catch { /* skip */ }
75
+ }
76
+ // Check shell rc files for exports and command substitutions (including op:// refs)
77
+ const rcs = [join(homeDir, '.zshrc'), join(homeDir, '.bashrc'), join(homeDir, '.bash_profile'), join(homeDir, '.profile')];
78
+ const { spawnSync } = await import('child_process');
79
+ for (const rc of rcs) {
80
+ if (!existsSync(rc)) continue;
81
+ try {
82
+ const content = readFileSync(rc, 'utf8');
83
+
84
+ // op:// ref via 1Password CLI
85
+ const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
86
+ const opMatch = content.match(opRe);
87
+ if (opMatch) {
88
+ try {
89
+ const r = spawnSync('op', ['read', opMatch[1]], { encoding: 'utf8', timeout: 5000 });
90
+ if (r.status === 0 && r.stdout?.trim()) return r.stdout.trim();
91
+ } catch { /* op read failed */ }
92
+ }
93
+
94
+ // Source the rc file and echo — handles any $(...) command substitution
95
+ const anyExportRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
96
+ if (anyExportRe.test(content)) {
97
+ try {
98
+ const r = spawnSync('zsh', ['-c', `source "${rc}" 2>/dev/null; echo "\${${varName}}"`], { encoding: 'utf8', timeout: 5000 });
99
+ if (r.status === 0 && r.stdout?.trim()) {
100
+ const val = r.stdout.trim().split('\n').pop()?.trim();
101
+ if (val && !val.includes('ERROR') && !val.includes('op read') && !val.startsWith('$(')) return val;
102
+ }
103
+ } catch { /* shell eval failed */ }
104
+ }
105
+ } catch { /* skip */ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // ─── Root dir resolution ─────────────────────────────────────────────────────
111
+
112
+ function resolveRootDir(env = process.env) {
113
+ return (env.CX_DATA_DIR ?? '').trim() || homedir();
114
+ }
115
+
116
+ function resolveWorkspaceDir(env = process.env) {
117
+ return (env.CX_WORKSPACE ?? '').trim() || process.cwd();
118
+ }
119
+
120
+ function resolveLockPath(env = process.env) {
121
+ return join(resolveRootDir(env), '.cx', 'sync.lock');
122
+ }
123
+
124
+ function resolveDaemonStatePath(env = process.env) {
125
+ return join(resolveRootDir(env), '.cx', 'runtime', 'embed-daemon.json');
126
+ }
127
+
128
+ // ─── Self-repair helpers ────────────────────────────────────────────────────
129
+
130
+ const LOCK_PATH = join(homedir(), '.cx', 'sync.lock');
131
+ const DAEMON_STATE_PATH = join(homedir(), '.cx', 'runtime', 'embed-daemon.json');
132
+
133
+ /**
134
+ * Run in-process health checks and fix what can be fixed.
135
+ * Returns an array of { issue, action } describing what was done.
136
+ */
137
+ function runSelfRepair(lockPath = LOCK_PATH, daemonStatePath = DAEMON_STATE_PATH) {
138
+ const actions = [];
139
+
140
+ // Check for stale sync lock held by dead process
141
+ if (existsSync(lockPath)) {
142
+ try {
143
+ const holder = readFileSync(lockPath, 'utf8').trim();
144
+ let holderAlive = false;
145
+ try { process.kill(Number(holder), 0); holderAlive = true; } catch { /* dead */ }
146
+ if (!holderAlive) {
147
+ unlinkSync(lockPath);
148
+ actions.push({ issue: `stale sync.lock (pid ${holder})`, action: 'removed' });
149
+ }
150
+ } catch (err) { logCatch('self-repair:lock', err); }
151
+ }
152
+
153
+ // Check for stale daemon state pointing to dead PID
154
+ if (existsSync(daemonStatePath)) {
155
+ try {
156
+ const state = JSON.parse(readFileSync(daemonStatePath, 'utf8'));
157
+ if (state.status === 'running' && state.pid && state.pid !== process.pid) {
158
+ let alive = false;
159
+ try { process.kill(state.pid, 0); alive = true; } catch { /* dead */ }
160
+ if (!alive) {
161
+ writeFileSync(daemonStatePath, JSON.stringify({ ...state, status: 'stopped', repairedAt: new Date().toISOString() }, null, 2) + '\n');
162
+ actions.push({ issue: `stale daemon state (pid ${state.pid})`, action: 'marked stopped' });
163
+ }
164
+ }
165
+ } catch (err) { logCatch('daemon.mjs:self-repair-state', err); }
166
+ }
167
+
168
+ // Check heap pressure and record warning observation if critical
169
+ try {
170
+ const mem = process.memoryUsage();
171
+ const heapPct = mem.heapUsed / mem.heapTotal;
172
+ if (heapPct >= 0.80) {
173
+ actions.push({ issue: `heap ${Math.round(heapPct * 100)}%`, action: 'observation recorded' });
174
+ // Observation is recorded below when actions are written to the store
175
+ }
176
+ } catch (err) { logCatch('daemon.mjs:heap-check', err); }
177
+
178
+ return actions;
179
+ }
180
+
181
+ // ─── Session distillation helpers ────────────────────────────────────────────
182
+
183
+ /**
184
+ * For each recently completed session that has a summary but no existing
185
+ * session-summary observation, write one into the observation store.
186
+ */
187
+ function distillRecentSessions(rootDir) {
188
+ const distilled = [];
189
+ try {
190
+ const recent = listSessions(rootDir, { status: 'completed', limit: 10 });
191
+ for (const entry of recent) {
192
+ if (!entry.summary) continue;
193
+
194
+ // Check if we already have a session-summary observation for this session
195
+ const existing = searchObservations(rootDir, entry.id, { category: 'session-summary', limit: 1 });
196
+ const alreadyRecorded = existing.some((o) => o.summary?.includes(entry.id));
197
+ if (alreadyRecorded) continue;
198
+
199
+ const full = loadSession(rootDir, entry.id);
200
+ if (!full?.summary) continue;
201
+
202
+ const tags = ['session-distill'];
203
+ if (full.project) tags.push(full.project);
204
+ if (Array.isArray(full.filesChanged)) {
205
+ const modules = [...new Set(full.filesChanged.map((f) => f.path?.split('/')[1]).filter(Boolean))];
206
+ tags.push(...modules.slice(0, 4));
207
+ }
208
+
209
+ addObservation(rootDir, {
210
+ role: 'construct',
211
+ category: 'session-summary',
212
+ summary: `[${entry.id}] ${full.summary.slice(0, 200)}`,
213
+ content: [
214
+ full.summary,
215
+ full.decisions?.length ? `Decisions: ${full.decisions.join('; ')}` : '',
216
+ full.openQuestions?.length ? `Open: ${full.openQuestions.join('; ')}` : '',
217
+ ].filter(Boolean).join('\n\n'),
218
+ tags,
219
+ project: full.project ?? null,
220
+ confidence: 0.9,
221
+ source: 'session-distill',
222
+ });
223
+ distilled.push(entry.id);
224
+ }
225
+ } catch (err) { logCatch('daemon.mjs:session-distill', err); }
226
+ return distilled;
227
+ }
228
+
229
+ /**
230
+ * Distill snapshot items (issues, PRs, messages) into the observation store.
231
+ * Skips items already recorded in this snapshot cycle using a dedup key.
232
+ * Returns the count of new observations written.
233
+ */
234
+ function distillSnapshotItems(rootDir, sections) {
235
+ let written = 0;
236
+ for (const section of sections ?? []) {
237
+ for (const item of section.items ?? []) {
238
+ try {
239
+ const dedupKey = item.key ?? item.id ?? item.url ?? item.hash;
240
+ if (!dedupKey) continue;
241
+
242
+ // Check if we already have a recent observation for this exact item key
243
+ const existing = searchObservations(rootDir, dedupKey, { limit: 1 });
244
+ if (existing.some((o) => o.tags?.includes(`item:${dedupKey}`))) continue;
245
+
246
+ const summary = buildItemSummary(item, section.provider);
247
+ if (!summary) continue;
248
+
249
+ // Determine category: Slack messages use channel intent; issues use status signals
250
+ let category = 'insight';
251
+ if (item.intent) {
252
+ category = intentToCategory(item.intent);
253
+ } else if (item.type === 'issue') {
254
+ const s = String(item.status ?? '').toLowerCase();
255
+ if (/blocked|critical|urgent/.test(s)) category = 'anti-pattern';
256
+ }
257
+
258
+ const tags = ['snapshot-item', section.provider, `item:${dedupKey}`];
259
+ if (item.intent) tags.push(`intent:${item.intent}`);
260
+ if (item.channelName) tags.push(`channel:${item.channelName}`);
261
+ if (item.status) tags.push(`status:${item.status}`);
262
+ if (item.priority) tags.push(`priority:${item.priority}`);
263
+ if (Array.isArray(item.labels)) tags.push(...item.labels.map((l) => `label:${l}`));
264
+ if (item.author) tags.push(`author:${item.author}`);
265
+
266
+ addObservation(rootDir, {
267
+ role: 'construct',
268
+ category,
269
+ summary,
270
+ content: buildItemContent(item, section.provider),
271
+ tags: tags.slice(0, 10),
272
+ confidence: 0.8,
273
+ source: 'snapshot-distill',
274
+ });
275
+ written += 1;
276
+ } catch (err) { logCatch('daemon.mjs:snapshot-distill', err); }
277
+ }
278
+ }
279
+ return written;
280
+ }
281
+
282
+ function buildItemSummary(item, provider) {
283
+ if (item.type === 'issue') {
284
+ return `[${provider}] ${item.key ?? ''} ${item.summary ?? item.title ?? ''} (${item.status ?? 'unknown'})`.trim();
285
+ }
286
+ if (item.type === 'commit') {
287
+ return `[${provider}] commit ${item.hash?.slice(0, 7) ?? ''}: ${item.subject ?? ''}`.trim();
288
+ }
289
+ if (item.type === 'message') {
290
+ return `[${provider}] message from ${item.user ?? 'unknown'}: ${(item.text ?? '').slice(0, 80)}`.trim();
291
+ }
292
+ if (item.type === 'page') {
293
+ return `[${provider}] page: ${item.title ?? ''}`.trim();
294
+ }
295
+ if (item.title || item.summary) {
296
+ return `[${provider}] ${item.title ?? item.summary ?? ''}`.trim();
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function buildItemContent(item, provider) {
302
+ const lines = [`provider: ${provider}`];
303
+ if (item.type) lines.push(`type: ${item.type}`);
304
+ if (item.key) lines.push(`key: ${item.key}`);
305
+ if (item.url) lines.push(`url: ${item.url}`);
306
+ if (item.status) lines.push(`status: ${item.status}`);
307
+ if (item.priority) lines.push(`priority: ${item.priority}`);
308
+ if (item.assignee) lines.push(`assignee: ${item.assignee}`);
309
+ if (item.author) lines.push(`author: ${item.author}`);
310
+ if (Array.isArray(item.labels) && item.labels.length) lines.push(`labels: ${item.labels.join(', ')}`);
311
+ if (item.description) lines.push(`\ndescription:\n${String(item.description).slice(0, 500)}`);
312
+ if (item.body) lines.push(`\nbody:\n${String(item.body).slice(0, 500)}`);
313
+ return lines.join('\n');
314
+ }
315
+
316
+ // ─── Provider health tracker ─────────────────────────────────────────────────
317
+
318
+ const BACKOFF_STEPS_MS = [5 * 60_000, 15 * 60_000, 30 * 60_000];
319
+
320
+ class ProviderHealthTracker {
321
+ #errors = new Map(); // provider → { count, suppressUntil }
322
+
323
+ recordSuccess(provider) {
324
+ this.#errors.delete(provider);
325
+ }
326
+
327
+ recordError(provider) {
328
+ const prev = this.#errors.get(provider) ?? { count: 0, suppressUntil: 0 };
329
+ const count = prev.count + 1;
330
+ const step = Math.min(count - 1, BACKOFF_STEPS_MS.length - 1);
331
+ const suppressUntil = Date.now() + BACKOFF_STEPS_MS[step];
332
+ this.#errors.set(provider, { count, suppressUntil });
333
+ return count;
334
+ }
335
+
336
+ isSuppressed(provider) {
337
+ const entry = this.#errors.get(provider);
338
+ if (!entry) return false;
339
+ return Date.now() < entry.suppressUntil;
340
+ }
341
+
342
+ summary() {
343
+ return [...this.#errors.entries()].map(([provider, e]) => ({
344
+ provider,
345
+ consecutiveErrors: e.count,
346
+ suppressedUntil: new Date(e.suppressUntil).toISOString(),
347
+ }));
348
+ }
349
+ }
350
+
351
+ // ─── Daemon ───────────────────────────────────────────────────────────────────
352
+
353
+ export class EmbedDaemon {
354
+ #config = null;
355
+ #registry = null;
356
+ #env = null;
357
+ #scheduler = null;
358
+ #snapshotEngine = null;
359
+ #approvalQueue = null;
360
+ #authorityGuard = null;
361
+ #status = 'stopped';
362
+ #lastSnapshot = null;
363
+ #configPath = null;
364
+ #persistPath = null;
365
+ #rootDir = null;
366
+ #workspaceDir = null;
367
+ #lockPath = null;
368
+ #daemonStatePath = null;
369
+ #inboxWatcher = null;
370
+ #inboxLiveWatcher = null;
371
+ #providerHealth = new ProviderHealthTracker();
372
+ #daemonTraceId = null;
373
+
374
+ /**
375
+ * @param {object} opts
376
+ * @param {string} opts.configPath - Path to embed.yaml
377
+ * @param {ProviderRegistry} [opts.registry] - Pre-built registry (default: fromEnv)
378
+ * @param {object} [opts.env] - Env override (default: process.env)
379
+ * @param {string} [opts.persistPath] - Approval queue persistence path
380
+ * @param {string} [opts.rootDir] - Root dir for observation/session stores (default: homedir())
381
+ * @param {string} [opts.workspaceDir] - Project workspace dir for project-relative focal resources (default: cwd or CX_WORKSPACE)
382
+ */
383
+ constructor({ configPath, config, registry, env, persistPath, rootDir, workspaceDir } = {}) {
384
+ this.#configPath = configPath;
385
+ this.#config = config ?? null;
386
+ this.#registry = registry ?? null;
387
+ this.#env = env ?? process.env;
388
+ this.#persistPath = persistPath;
389
+ this.#rootDir = rootDir ?? resolveRootDir(env ?? process.env);
390
+ this.#workspaceDir = workspaceDir ?? resolveWorkspaceDir(env ?? process.env);
391
+ this.#lockPath = resolveLockPath(this.#env);
392
+ this.#daemonStatePath = resolveDaemonStatePath(this.#env);
393
+ this.#inboxWatcher = new InboxWatcher({ rootDir: this.#rootDir, env: this.#env, cwd: this.#rootDir });
394
+ this.#inboxLiveWatcher = null;
395
+ }
396
+
397
+ async start() {
398
+ if (this.#status === 'running') throw new Error('EmbedDaemon is already running');
399
+
400
+ // Use injected config, or load from file path
401
+ if (!this.#config) {
402
+ this.#config = loadEmbedConfig(this.#configPath);
403
+ }
404
+
405
+ // Build the provider registry from env if one wasn't injected (normal runtime path)
406
+ if (!this.#registry) {
407
+ this.#registry = await ProviderRegistry.fromEnv(this.#env);
408
+ }
409
+ this.#scheduler = new Scheduler();
410
+ this.#snapshotEngine = new SnapshotEngine(this.#registry, this.#config, { rootDir: this.#rootDir, workspaceDir: this.#workspaceDir });
411
+ this.#approvalQueue = new ApprovalQueue({
412
+ require: this.#config.approval.require,
413
+ timeoutMs: this.#config.approval.timeout_ms,
414
+ fallback: this.#config.approval.fallback,
415
+ persistPath: this.#persistPath,
416
+ });
417
+ this.#authorityGuard = new AuthorityGuard(
418
+ this.#config.operatingProfile,
419
+ this.#approvalQueue,
420
+ );
421
+
422
+ // Initialize providers for all sources
423
+ for (const source of this.#config.sources) {
424
+ const provider = this.#registry.get(source.provider);
425
+ if (!provider) {
426
+ process.stderr.write(`[embed] Warning: provider "${source.provider}" not registered — skipping\n`);
427
+ }
428
+ }
429
+
430
+ // ── Job 1: snapshot ───────────────────────────────────────────────────────
431
+ // Generate snapshot, record provider errors as observations, apply backoff.
432
+ this.#scheduler.register(
433
+ 'snapshot',
434
+ this.#config.snapshot.intervalMs,
435
+ async () => {
436
+ const snapshot = await this.#snapshotEngine.generate();
437
+ this.#lastSnapshot = snapshot;
438
+ await dispatchOutputs(snapshot, this.#config.outputs, this.#registry, this.#authorityGuard, this.#rootDir);
439
+ process.stderr.write(`[embed] Snapshot generated at ${snapshot.completedAt} — ${snapshot.summary.totalItems} items, ${snapshot.summary.errorCount} errors\n`);
440
+
441
+ // Record provider errors as observations so they surface in future sessions
442
+ for (const err of snapshot.errors ?? []) {
443
+ const count = this.#providerHealth.recordError(err.source);
444
+ const summary = `Provider ${err.source} error (${count} consecutive): ${err.error}`;
445
+ addObservation(this.#rootDir, {
446
+ role: 'construct',
447
+ category: count >= 3 ? 'anti-pattern' : 'insight',
448
+ summary,
449
+ content: `Source: ${err.source}\nRef: ${err.ref ?? 'n/a'}\nError: ${err.error}\nConsecutive failures: ${count}`,
450
+ tags: ['embed', 'provider-error', err.source],
451
+ confidence: 0.95,
452
+ source: 'embed-daemon',
453
+ });
454
+ if (count >= 3) {
455
+ process.stderr.write(`[embed] Provider "${err.source}" has failed ${count} times — backing off\n`);
456
+ }
457
+ }
458
+
459
+ // Record success for providers that came back clean
460
+ for (const section of snapshot.sections ?? []) {
461
+ this.#providerHealth.recordSuccess(section.provider);
462
+ }
463
+
464
+ // Distill snapshot items (issues, PRs, messages) into observation store
465
+ const itemsWritten = distillSnapshotItems(this.#rootDir, snapshot.sections ?? []);
466
+ if (itemsWritten) {
467
+ process.stderr.write(`[embed] Distilled ${itemsWritten} snapshot item(s) into observation store\n`);
468
+ }
469
+
470
+ // Regenerate roadmap each tick so .cx/roadmap.md stays current and the
471
+ // missing-focal-resource gap clears on the next snapshot.
472
+ try {
473
+ const roadmap = generateRoadmap({ targetPath: this.#rootDir, snapshot, roles: this.#config?.roles });
474
+ if (!roadmap.skipped) {
475
+ process.stderr.write(`[embed] Roadmap refreshed: ${roadmap.itemCount} open item(s) → ${roadmap.path}\n`);
476
+ }
477
+ } catch (err) {
478
+ process.stderr.write(`[embed] Roadmap refresh failed: ${err.message}\n`);
479
+ }
480
+ },
481
+ { runImmediately: true },
482
+ );
483
+
484
+ // ── Job 2: provider-health ─────────────────────────────────────────────────
485
+ // Every 5 min — log suppressed providers and write a health observation if any
486
+ // have been suppressed for a full backoff cycle.
487
+ this.#scheduler.register(
488
+ 'provider-health',
489
+ 5 * 60_000,
490
+ async () => {
491
+ const degraded = this.#providerHealth.summary();
492
+ if (!degraded.length) return;
493
+ process.stderr.write(`[embed] Provider health: ${JSON.stringify(degraded)}\n`);
494
+ const criticalProviders = degraded.filter((p) => p.consecutiveErrors >= 3);
495
+ if (criticalProviders.length) {
496
+ addObservation(this.#rootDir, {
497
+ role: 'construct',
498
+ category: 'anti-pattern',
499
+ summary: `${criticalProviders.length} provider(s) in backoff: ${criticalProviders.map((p) => p.provider).join(', ')}`,
500
+ content: JSON.stringify(criticalProviders, null, 2),
501
+ tags: ['embed', 'provider-health', 'backoff'],
502
+ confidence: 0.9,
503
+ source: 'embed-daemon',
504
+ });
505
+ }
506
+ },
507
+ );
508
+
509
+ // ── Job 3: session-distill ─────────────────────────────────────────────────
510
+ // Every 30 min — extract completed session summaries into the observation store.
511
+ this.#scheduler.register(
512
+ 'session-distill',
513
+ 30 * 60_000,
514
+ async () => {
515
+ const distilled = distillRecentSessions(this.#rootDir);
516
+ if (distilled.length) {
517
+ process.stderr.write(`[embed] Distilled ${distilled.length} session(s) into observation store\n`);
518
+ }
519
+ },
520
+ { runImmediately: true },
521
+ );
522
+
523
+
524
+
525
+ // ── Job 4: self-repair ────────────────────────────────────────────────────
526
+ // Every 5 min — detect and fix stale locks, stale state files, heap pressure.
527
+ this.#scheduler.register(
528
+ 'self-repair',
529
+ 5 * 60_000,
530
+ async () => {
531
+ const actions = runSelfRepair(this.#lockPath, this.#daemonStatePath);
532
+ for (const { issue, action } of actions) {
533
+ process.stderr.write(`[embed] Self-repair: ${issue} → ${action}\n`);
534
+ addObservation(this.#rootDir, {
535
+ role: 'construct',
536
+ category: action === 'observation recorded' ? 'insight' : 'pattern',
537
+ summary: `Self-repair: ${issue} → ${action}`,
538
+ content: `Daemon self-repair triggered at ${new Date().toISOString()}\nIssue: ${issue}\nAction: ${action}`,
539
+ tags: ['self-repair', 'daemon'],
540
+ confidence: 0.85,
541
+ source: 'embed-daemon',
542
+ });
543
+ }
544
+ },
545
+ { runImmediately: true },
546
+ );
547
+
548
+ // ── Job 5: approval-expiry ────────────────────────────────────────────────
549
+ this.#scheduler.register(
550
+ 'approval-expiry',
551
+ 60_000,
552
+ async () => {
553
+ const expired = this.#approvalQueue.expireStale();
554
+ if (expired.length) {
555
+ process.stderr.write(`[embed] Expired ${expired.length} stale approval item(s)\n`);
556
+ }
557
+ },
558
+ );
559
+
560
+ // ── Job 0: telemetry-setup ───────────────────────────────────────────────────
561
+ // On startup — ensure annotation queues and eval configs exist (idempotent)
562
+ this.#scheduler.register(
563
+ 'telemetry-setup',
564
+ 0, // run immediately
565
+ async () => {
566
+ try {
567
+ const result = await runTelemetrySetup({
568
+ publicKey: this.#env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
569
+ secretKey: this.#env.CONSTRUCT_TELEMETRY_SECRET_KEY,
570
+ baseUrl: this.#env.CONSTRUCT_TELEMETRY_URL,
571
+ env: this.#env,
572
+ });
573
+
574
+ if (result.ok && result.results?.length) {
575
+ const ok = result.results.filter(r => r.success).length;
576
+ const total = result.results.length;
577
+ process.stderr.write(`[embed] Telemetry setup: ${ok}/${total} resources configured\n`);
578
+ } else if (!result.ok) {
579
+ process.stderr.write(`[embed] Telemetry setup skipped: ${result.error}\n`);
580
+ } else if (result.results?.some(r => !r.success)) {
581
+ const failures = result.results.filter(r => !r.success);
582
+ process.stderr.write(`[embed] Telemetry setup: ${failures.length} resource(s) failed\n`);
583
+ }
584
+ } catch (err) {
585
+ process.stderr.write(`[embed] Telemetry setup error: ${err.message}\n`);
586
+ }
587
+ },
588
+ { runImmediately: true, repeat: false } // one-time startup job
589
+ );
590
+
591
+ // ── Job 6: eval-dataset-sync ──────────────────────────────────────────────
592
+ // Every 6h — read scored traces and upsert as dataset items
593
+ // so prompt regressions are visible in the telemetry backend UI.
594
+ this.#scheduler.register(
595
+ 'eval-dataset-sync',
596
+ 6 * 60 * 60_000,
597
+ async () => {
598
+ const result = await syncEvalDatasets({ env: this.#env, bestEffort: true });
599
+ if (result.synced || result.errors.length) {
600
+ process.stderr.write(`[embed] Eval datasets: synced=${result.synced} skipped=${result.skipped} errors=${result.errors.length}\n`);
601
+ }
602
+ if (result.synced) {
603
+ addObservation(this.#rootDir, {
604
+ role: 'construct',
605
+ category: 'insight',
606
+ summary: `Eval dataset sync: ${result.synced} trace(s) upserted to telemetry datasets`,
607
+ content: `synced=${result.synced} skipped=${result.skipped} errors=${result.errors.length}\n${result.errors.join('\n')}`,
608
+ tags: ['eval', 'telemetry', 'dataset-sync'],
609
+ confidence: 0.9,
610
+ source: 'embed-daemon',
611
+ });
612
+ }
613
+ },
614
+ );
615
+
616
+ // ── Job 7: prompt-regression-check ───────────────────────────────────────
617
+ // Every 12h — scan recent observations for clusters of low-quality scores
618
+ // that correlate with a specific promptHash. If found, record an anti-pattern
619
+ // so future sessions know a prompt change degraded quality.
620
+ this.#scheduler.register(
621
+ 'prompt-regression-check',
622
+ 12 * 60 * 60_000,
623
+ async () => {
624
+ try {
625
+ const rootDir = this.#rootDir;
626
+ // Get recent low-score observations
627
+ const lowScores = searchObservations(rootDir, 'low quality score', {
628
+ category: 'anti-pattern',
629
+ limit: 20,
630
+ });
631
+ if (lowScores.length < 3) return; // not enough signal
632
+
633
+ // Check if multiple low scores share a prompt version tag
634
+ const promptVersionCounts = {};
635
+ for (const obs of lowScores) {
636
+ const match = obs.content?.match(/promptVersion[:\s]+([a-f0-9]{8,12})/i);
637
+ if (match) {
638
+ const v = match[1];
639
+ promptVersionCounts[v] = (promptVersionCounts[v] ?? 0) + 1;
640
+ }
641
+ }
642
+ for (const [version, count] of Object.entries(promptVersionCounts)) {
643
+ if (count >= 3) {
644
+ // Check we haven't already recorded this regression
645
+ const existing = searchObservations(rootDir, `prompt regression ${version}`, { limit: 1 });
646
+ if (existing.some((o) => o.summary?.includes(version))) continue;
647
+
648
+ addObservation(rootDir, {
649
+ role: 'construct',
650
+ category: 'anti-pattern',
651
+ summary: `Prompt regression detected: version ${version} has ${count} low-quality scores`,
652
+ content: `Prompt version ${version} appears in ${count} low-quality score observations.\nThis may indicate a prompt change degraded agent output quality.\nReview traces with promptVersion=${version} in the telemetry backend.`,
653
+ tags: ['prompt-regression', 'quality', version],
654
+ confidence: 0.85,
655
+ source: 'embed-daemon',
656
+ });
657
+ process.stderr.write(`[embed] Prompt regression detected: version ${version} (${count} low scores)\n`);
658
+ }
659
+ }
660
+ } catch (err) { logCatch('daemon.mjs:prompt-regression', err); }
661
+ },
662
+ );
663
+
664
+ // ── Job 8: llm-judge-evaluations ──────────────────────────────────────────
665
+ // Every 3h — automatically evaluate unscored traces using LLM-as-a-judge
666
+ this.#scheduler.register(
667
+ 'llm-judge-evaluations',
668
+ 3 * 60 * 60_000,
669
+ async () => {
670
+ try {
671
+ const result = await runLLMJudgeEvaluations({
672
+ env: this.#env,
673
+ bestEffort: true,
674
+ limit: 5, // Conservative limit to manage LLM costs
675
+ });
676
+
677
+ if (result.evaluated || result.errors.length) {
678
+ process.stderr.write(`[embed] LLM judge: evaluated=${result.evaluated} errors=${result.errors.length}\n`);
679
+ }
680
+
681
+ if (result.evaluated) {
682
+ addObservation(this.#rootDir, {
683
+ role: 'construct',
684
+ category: 'insight',
685
+ summary: `LLM judge evaluated ${result.evaluated} trace(s) for quality`,
686
+ content: `evaluated=${result.evaluated} errors=${result.errors.length}\n${result.errors.join('\n')}`,
687
+ tags: ['llm-judge', 'auto-evaluation', 'quality'],
688
+ confidence: 0.9,
689
+ source: 'embed-daemon',
690
+ });
691
+ }
692
+ } catch (err) {
693
+ process.stderr.write(`[embed] LLM judge job failed: ${err.message}\n`);
694
+ }
695
+ },
696
+ );
697
+
698
+ // ── Job 9: inbox-watcher ──────────────────────────────────────────────────
699
+ // Every 2 min — scan configured inbox dirs for new files, ingest them, and
700
+ // record observations. Agnostic to content: specs, ADRs, meeting notes,
701
+ // internal docs, PDFs, Office files — anything on the local filesystem.
702
+ // Dirs: <rootDir>/.cx/inbox/ always; CX_INBOX_DIRS env for extra paths.
703
+ this.#scheduler.register(
704
+ 'inbox-watcher',
705
+ 2 * 60_000,
706
+ async () => {
707
+ const result = await this.#inboxWatcher.poll();
708
+ if (result.processed.length) {
709
+ process.stderr.write(`[embed] Inbox: ingested ${result.processed.length} file(s) from [${result.dirs.join(', ')}]\n`);
710
+ addObservation(this.#rootDir, {
711
+ role: 'construct',
712
+ category: 'insight',
713
+ summary: `Inbox ingest: ${result.processed.length} file(s) processed from local filesystem`,
714
+ content: result.processed.map((f) => `${f.path} → ${f.outputPath} (${f.characters} chars)`).join('\n'),
715
+ tags: ['inbox', 'ingest-batch'],
716
+ confidence: 0.85,
717
+ source: 'inbox-watcher',
718
+ });
719
+ }
720
+ if (result.errors.length) {
721
+ process.stderr.write(`[embed] Inbox errors: ${result.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}\n`);
722
+ for (const e of result.errors) {
723
+ addObservation(this.#rootDir, {
724
+ role: 'construct',
725
+ category: 'anti-pattern',
726
+ summary: `Inbox ingest error: ${e.path.split('/').pop()}: ${e.error}`,
727
+ content: `path: ${e.path}\nerror: ${e.error}`,
728
+ tags: ['inbox', 'ingest-error'],
729
+ confidence: 0.9,
730
+ source: 'inbox-watcher',
731
+ });
732
+ }
733
+ }
734
+ },
735
+ { runImmediately: true },
736
+ );
737
+
738
+ // ── Job 10: roadmap ───────────────────────────────────────────────────────
739
+ // Every hour — cross-reference open items from last snapshot with observation
740
+ // store (risks, decisions, patterns) and write a prioritised roadmap to
741
+ // <rootDir>/.cx/roadmap.md. Optionally posts a summary to Slack.
742
+ this.#scheduler.register(
743
+ 'roadmap',
744
+ 60 * 60_000,
745
+ async () => {
746
+ if (!this.#lastSnapshot) return; // wait until at least one snapshot exists
747
+ try {
748
+ const result = generateRoadmap({ targetPath: this.#rootDir, snapshot: this.#lastSnapshot, roles: this.#config?.roles });
749
+ if (!result.skipped) {
750
+ process.stderr.write(`[embed] Roadmap updated: ${result.itemCount} open item(s) → ${result.path}\n`);
751
+ emitEmbedNotification({
752
+ type: 'success',
753
+ source: 'roadmap',
754
+ message: `Roadmap updated: ${result.itemCount} open item(s)`,
755
+ meta: { path: result.path, itemCount: result.itemCount },
756
+ });
757
+ addObservation(this.#rootDir, {
758
+ role: 'construct',
759
+ category: 'insight',
760
+ summary: `Roadmap generated: ${result.itemCount} open item(s) prioritised`,
761
+ content: `path: ${result.path}\nitems: ${result.itemCount}\nupdatedAt: ${result.updatedAt}`,
762
+ tags: ['roadmap', 'prioritisation'],
763
+ confidence: 0.8,
764
+ source: 'roadmap-job',
765
+ });
766
+
767
+ // Post to Slack if provider is available and authority allows
768
+ const slackProvider = this.#registry?.get('slack');
769
+ if (slackProvider) {
770
+ const channel = this.#env.SLACK_CHANNELS?.split(',')[0]?.trim()
771
+ ?? this.#env.SLACK_CHANNEL?.trim();
772
+ if (channel) {
773
+ try {
774
+ const authorityResult = await this.#authorityGuard.check('externalPost', {
775
+ description: `Post roadmap summary to Slack channel ${channel}`,
776
+ });
777
+ if (authorityResult.allowed) {
778
+ const { roadmapSlackSummary } = await import('./roadmap.mjs');
779
+ const text = roadmapSlackSummary({ targetPath: this.#rootDir, snapshot: this.#lastSnapshot, roles: this.#config?.roles });
780
+ if (text) await slackProvider.write({ channel, text });
781
+ } else {
782
+ process.stderr.write(`[embed] Roadmap Slack post queued for approval (id: ${authorityResult.queueId})\n`);
783
+ }
784
+ } catch (err) { logCatch('daemon.mjs:roadmap-slack', err); }
785
+ }
786
+ }
787
+ }
788
+ } catch (err) {
789
+ process.stderr.write(`[embed] Roadmap job error: ${err.message}\n`);
790
+ }
791
+ },
792
+ );
793
+
794
+ // ── Job 11: docs-lifecycle ──────────────────────────────────────────────────
795
+ // Every 30 minutes — detect stale/missing docs, auto-fix low-risk gaps,
796
+ // queue high-risk changes for approval.
797
+ this.#scheduler.register(
798
+ 'docs-lifecycle',
799
+ 30 * 60_000,
800
+ async () => {
801
+ if (!this.#lastSnapshot) return;
802
+ try {
803
+ // Load pending intake packets for conflict detection
804
+ const pendingPackets = [];
805
+ try {
806
+ const pendingDir = join(this.#rootDir, '.cx', 'intake', 'pending');
807
+ if (existsSync(pendingDir)) {
808
+ for (const file of readdirSync(pendingDir)) {
809
+ if (!file.endsWith('.json')) continue;
810
+ try {
811
+ const data = JSON.parse(readFileSync(join(pendingDir, file), 'utf8'));
812
+ data.id = file.replace('.json', '');
813
+ pendingPackets.push(data);
814
+ } catch { /* skip malformed */ }
815
+ }
816
+ }
817
+ } catch { /* intake dir not available */ }
818
+
819
+ const snapshotWithIntake = this.#lastSnapshot
820
+ ? { ...this.#lastSnapshot, intakePackets: pendingPackets }
821
+ : null;
822
+
823
+ const result = await runDocsLifecycle({
824
+ config: this.#config,
825
+ providerRegistry: this.#registry,
826
+ snapshot: snapshotWithIntake,
827
+ authorityGuard: this.#authorityGuard,
828
+ signals: { rootDir: this.#rootDir },
829
+ });
830
+ const autoFixed = result.actions.filter((a) => a.action === 'auto-fix').length;
831
+ const queued = result.actions.filter((a) => a.action === 'queued').length;
832
+ if (result.gaps.length > 0 || result.conflicts?.length) {
833
+ process.stderr.write(
834
+ `[embed] Docs lifecycle: ${result.gaps.length} gap(s), ${result.conflicts?.length || 0} conflict(s), ${result.recommendations?.length || 0} recommendation(s), ${autoFixed} auto-fixed, ${queued} queued\n`,
835
+ );
836
+ emitEmbedNotification({
837
+ type: queued > 0 || result.conflicts?.length > 0 ? 'warning' : 'info',
838
+ source: 'docs-lifecycle',
839
+ message: `${result.gaps.length} gap(s), ${result.recommendations?.length || 0} active recommendation(s)`,
840
+ meta: { gaps: result.gaps.length, conflicts: result.conflicts?.length || 0, recommendations: result.recommendations?.length || 0, autoFixed, queued },
841
+ });
842
+ }
843
+ emitTraceEvent({
844
+ rootDir: this.#rootDir,
845
+ eventType: 'lifecycle.completed',
846
+ traceId: this.#daemonTraceId,
847
+ spanId: newSpanId(),
848
+ env: this.#env,
849
+ metadata: {
850
+ gaps: result.gaps.length,
851
+ conflicts: result.conflicts?.length || 0,
852
+ recommendations: result.recommendations?.length || 0,
853
+ autoFixed,
854
+ queued,
855
+ targets: result.targets,
856
+ },
857
+ }).catch(() => {});
858
+ } catch (err) {
859
+ process.stderr.write(`[embed] Docs lifecycle error: ${err.message}\n`);
860
+ }
861
+ },
862
+ );
863
+
864
+ // ── Job 12: execution-gap ──────────────────────────────────────
865
+ // Every 2 hours — compare strategy/PRDs/RFCs with Jira tickets,
866
+ // identify execution gaps, create补缺 tickets via operator role (TPM function).
867
+ this.#scheduler.register(
868
+ 'execution-gap',
869
+ 2 * 60 * 60_000,
870
+ async () => {
871
+ try {
872
+ const result = await this.#runExecutionGapAnalysis();
873
+ // Store gaps in the last snapshot for reporting
874
+ if (this.#lastSnapshot) {
875
+ this.#lastSnapshot.executionGaps = result.gaps;
876
+ }
877
+ if (result.gaps.length > 0) {
878
+ process.stderr.write(
879
+ `[embed] Execution gap: ${result.gaps.length} gap(s) found, ${result.ticketsCreated} ticket(s) created\n`,
880
+ );
881
+ emitEmbedNotification({
882
+ type: result.highRiskCount > 0 ? 'warning' : 'info',
883
+ source: 'execution-gap',
884
+ message: `${result.gaps.length} execution gap(s): ${result.ticketsCreated} ticket(s) created`,
885
+ meta: { gaps: result.gaps.length, ticketsCreated: result.ticketsCreated, highRisk: result.highRiskCount },
886
+ });
887
+ } else {
888
+ process.stderr.write(`[embed] Execution gap: no gaps detected\n`);
889
+ }
890
+ } catch (err) {
891
+ process.stderr.write(`[embed] Execution gap error: ${err.message}\n`);
892
+ }
893
+ },
894
+ { runImmediately: true },
895
+ );
896
+
897
+ // ── Job 13: telemetry-heartbeat ──────────────────────────────────────
898
+ // Every 5 min — emit a daemon.heartbeat trace event so the
899
+ // dashboard always has recent data, even when no agent/worker activity
900
+ // has occurred. The heartbeat is fire-and-forget; failure is non-fatal.
901
+ this.#scheduler.register(
902
+ 'telemetry-heartbeat',
903
+ 5 * 60_000,
904
+ async () => {
905
+ try {
906
+ const daemonId = this.#daemonTraceId || `daemon-${process.pid}`;
907
+ emitTraceEvent({
908
+ rootDir: this.#rootDir,
909
+ eventType: 'daemon.heartbeat',
910
+ traceId: this.#daemonTraceId,
911
+ spanId: newSpanId(),
912
+ env: this.#env,
913
+ metadata: {
914
+ pid: process.pid,
915
+ uptimeMs: process.uptime() * 1000,
916
+ schedulerTasks: this.#scheduler?.status()?.length ?? 0,
917
+ },
918
+ });
919
+ } catch (err) {
920
+ process.stderr.write(`[embed] Telemetry heartbeat: ${err.message}\n`);
921
+ }
922
+ },
923
+ { runImmediately: true },
924
+ );
925
+
926
+ // ── Job 14: telemetry-generation ─────────────────────────────────────────
927
+ // Every 5 min — make a tiny LLM call and record it as a telemetry generation
928
+ // observation so the dashboard's Latency/TTFT/Throughput/Error sections
929
+ // always have recent data to display, even when no agent sessions are active.
930
+ this.#scheduler.register(
931
+ 'telemetry-generation',
932
+ 5 * 60_000,
933
+ async () => {
934
+ try {
935
+ const { randomUUID } = await import('node:crypto');
936
+ const { readCurrentModels } = await import('../../lib/model-router.mjs');
937
+ const { createIngestClient } = await import('../../lib/telemetry/ingest.mjs');
938
+ const env = this.#env;
939
+
940
+ // Pick the cheapest configured model (fast tier) for the probe call
941
+ let modelId = null;
942
+ try {
943
+ const models = readCurrentModels(null, {});
944
+ modelId = models.fast || models.standard || null;
945
+ } catch { /* no models configured */ }
946
+
947
+ // Multi-source credential resolution — env → config.env → shell rc → 1Password
948
+ const orKey = await resolveLLMKey('OPENROUTER_API_KEY', env)
949
+ || await resolveLLMKey('OPEN_ROUTER_API_KEY', env)
950
+ || process.env.OPENROUTER_API_KEY;
951
+ const anthKey = await resolveLLMKey('ANTHROPIC_API_KEY', env) || process.env.ANTHROPIC_API_KEY;
952
+ if (!modelId || !(orKey || anthKey)) return; // no LLM available — skip
953
+
954
+ const traceId = randomUUID();
955
+ const genId = randomUUID();
956
+ const startTime = Date.now();
957
+
958
+ // Build the ingest client to write generation observations to telemetry backend
959
+ const ingest = createIngestClient({
960
+ publicKey: env.CONSTRUCT_TELEMETRY_PUBLIC_KEY || process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
961
+ secretKey: env.CONSTRUCT_TELEMETRY_SECRET_KEY || process.env.CONSTRUCT_TELEMETRY_SECRET_KEY,
962
+ baseUrl: env.CONSTRUCT_TELEMETRY_URL || process.env.CONSTRUCT_TELEMETRY_URL,
963
+ onError: () => {},
964
+ });
965
+
966
+ if (!ingest.available) return;
967
+
968
+ const isAnthropic = modelId.startsWith('anthropic/');
969
+ const stripped = modelId.replace(/^(openrouter\/)?/, '');
970
+ const probePrompt = 'Respond with exactly one word: ping.';
971
+ let output = '';
972
+ let inputTokens = 0;
973
+ let outputTokens = 0;
974
+
975
+ ingest.trace({
976
+ id: traceId,
977
+ name: 'telemetry.probe',
978
+ input: probePrompt,
979
+ metadata: { source: 'daemon', model: modelId },
980
+ timestamp: new Date(startTime).toISOString(),
981
+ });
982
+
983
+ ingest.generation({
984
+ id: genId,
985
+ traceId,
986
+ name: 'llm.probe',
987
+ model: modelId,
988
+ modelParameters: { maxOutputTokens: 16 },
989
+ startTime: new Date(startTime).toISOString(),
990
+ input: probePrompt,
991
+ });
992
+
993
+ try {
994
+ if (isAnthropic && anthKey) {
995
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
996
+ method: 'POST',
997
+ headers: {
998
+ 'x-api-key': anthKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json',
999
+ },
1000
+ body: JSON.stringify({
1001
+ model: stripped, max_tokens: 16,
1002
+ messages: [{ role: 'user', content: probePrompt }],
1003
+ }),
1004
+ });
1005
+ if (res.ok) {
1006
+ const data = await res.json();
1007
+ output = data.content?.[0]?.text || '';
1008
+ inputTokens = data.usage?.input_tokens || 0;
1009
+ outputTokens = data.usage?.output_tokens || 0;
1010
+ }
1011
+ } else if (orKey) {
1012
+ const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
1013
+ method: 'POST',
1014
+ headers: {
1015
+ Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
1016
+ 'HTTP-Referer': 'https://github.com/construct',
1017
+ },
1018
+ body: JSON.stringify({
1019
+ model: modelId.replace(/^openrouter\//, ''),
1020
+ max_tokens: 16,
1021
+ messages: [
1022
+ { role: 'system', content: 'You are a health check probe.' },
1023
+ { role: 'user', content: probePrompt },
1024
+ ],
1025
+ }),
1026
+ });
1027
+ if (res.ok) {
1028
+ const data = await res.json();
1029
+ output = data.choices?.[0]?.message?.content || '';
1030
+ inputTokens = data.usage?.prompt_tokens || 0;
1031
+ outputTokens = data.usage?.completion_tokens || 0;
1032
+ }
1033
+ }
1034
+ } catch { /* probe call failed — still record timing */ }
1035
+
1036
+ const endTime = Date.now();
1037
+
1038
+ ingest.generationUpdate({
1039
+ id: genId,
1040
+ traceId,
1041
+ endTime: new Date(endTime).toISOString(),
1042
+ output: output || '(empty)',
1043
+ usage: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens, unit: 'TOKENS' },
1044
+ metadata: { latencyMs: endTime - startTime },
1045
+ });
1046
+
1047
+ await ingest.flush();
1048
+ } catch (err) {
1049
+ process.stderr.write(`[embed] Telemetry generation probe: ${err.message}\n`);
1050
+ }
1051
+ },
1052
+ { runImmediately: false }, // first run after 5 min delay
1053
+ );
1054
+
1055
+ // ── Job 15: auto-backup ──────────────────────────────────────────
1056
+ // Opt-in via CONSTRUCT_AUTO_BACKUP_DAYS=N (interval in days). Off by
1057
+ // default. Runs `createBackup` then `pruneBackups` to keep the most
1058
+ // recent CONSTRUCT_BACKUP_RETAIN archives (default 10).
1059
+ const autoBackupDays = Number(process.env.CONSTRUCT_AUTO_BACKUP_DAYS || 0);
1060
+ if (autoBackupDays > 0) {
1061
+ const intervalMs = autoBackupDays * 24 * 60 * 60_000;
1062
+ const keep = Number(process.env.CONSTRUCT_BACKUP_RETAIN || 10);
1063
+ this.#scheduler.register(
1064
+ 'auto-backup',
1065
+ intervalMs,
1066
+ async () => {
1067
+ try {
1068
+ const { createBackup, pruneBackups } = await import('../storage/backup.mjs');
1069
+ const result = await createBackup({});
1070
+ const pruned = pruneBackups({ keep });
1071
+ process.stderr.write(
1072
+ `[embed] Auto-backup: ${result.path} (${pruned.removed.length} pruned, ${pruned.kept} kept)\n`,
1073
+ );
1074
+ emitEmbedNotification({
1075
+ type: 'info',
1076
+ source: 'auto-backup',
1077
+ message: `Backup created${pruned.removed.length ? `, ${pruned.removed.length} pruned` : ''}`,
1078
+ meta: { path: result.path, kept: pruned.kept, pruned: pruned.removed.length },
1079
+ });
1080
+ } catch (err) {
1081
+ process.stderr.write(`[embed] Auto-backup error: ${err.message}\n`);
1082
+ }
1083
+ },
1084
+ );
1085
+ }
1086
+
1087
+ this.#scheduler.start();
1088
+
1089
+ // Reactive inbox watching: low-latency trigger backed by the scheduler
1090
+ // poll for correctness on platforms where fs.watch silently misses
1091
+ // events (network mounts, certain Docker volume drivers).
1092
+ // Opt out with CX_INBOX_LIVE_WATCH=off.
1093
+
1094
+ if (this.#env.CX_INBOX_LIVE_WATCH !== 'off') {
1095
+ try {
1096
+ this.#inboxLiveWatcher = new InboxLiveWatcher({
1097
+ inboxWatcher: this.#inboxWatcher,
1098
+ onPoll: (result) => {
1099
+ if (result?.processed?.length) {
1100
+ process.stderr.write(`[embed] Inbox (live): ingested ${result.processed.length} file(s)\n`);
1101
+ }
1102
+ },
1103
+ onError: (err, dir) => {
1104
+ process.stderr.write(`[embed] Inbox live watcher error on ${dir || 'unknown'}: ${err.message}\n`);
1105
+ },
1106
+ });
1107
+ const startInfo = this.#inboxLiveWatcher.start();
1108
+ process.stderr.write(`[embed] Inbox live watcher: watching ${startInfo.watched} dir(s)\n`);
1109
+ } catch (err) {
1110
+ process.stderr.write(`[embed] Inbox live watcher failed to start (${err.message}); falling back to 2m poll only\n`);
1111
+ this.#inboxLiveWatcher = null;
1112
+ }
1113
+ }
1114
+
1115
+ this.#status = 'running';
1116
+ this.#daemonTraceId = newTraceId();
1117
+ emitTraceEvent({
1118
+ rootDir: this.#rootDir,
1119
+ eventType: 'daemon.started',
1120
+ traceId: this.#daemonTraceId,
1121
+ spanId: newSpanId(),
1122
+ env: this.#env,
1123
+ metadata: {
1124
+ pid: process.pid,
1125
+ configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1126
+ version: process.env.CONSTRUCT_VERSION || '0.0.0',
1127
+ },
1128
+ }).catch(() => {});
1129
+ process.stderr.write(`[embed] Daemon started. Snapshot interval: ${this.#config.snapshot.intervalMs}ms\n`);
1130
+ }
1131
+
1132
+ stop() {
1133
+ this.#inboxLiveWatcher?.stop?.();
1134
+ this.#inboxLiveWatcher = null;
1135
+ this.#scheduler?.stop();
1136
+ this.#status = 'stopped';
1137
+ emitTraceEvent({
1138
+ rootDir: this.#rootDir,
1139
+ eventType: 'daemon.stopped',
1140
+ traceId: this.#daemonTraceId,
1141
+ spanId: newSpanId(),
1142
+ env: this.#env,
1143
+ metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1144
+ }).catch(() => {});
1145
+ process.stderr.write('[embed] Daemon stopped.\n');
1146
+ }
1147
+
1148
+ status() {
1149
+ return {
1150
+ status: this.#status,
1151
+ lastSnapshot: this.#lastSnapshot
1152
+ ? { generatedAt: this.#lastSnapshot.generatedAt, summary: this.#lastSnapshot.summary }
1153
+ : null,
1154
+ pendingApprovals: this.#approvalQueue?.list('pending').length ?? 0,
1155
+ schedulerTasks: this.#scheduler?.status() ?? [],
1156
+ providerHealth: this.#providerHealth.summary(),
1157
+ inboxDirs: this.#inboxWatcher?.dirs() ?? [],
1158
+ };
1159
+ }
1160
+
1161
+ approvalQueue() {
1162
+ return this.#approvalQueue;
1163
+ }
1164
+
1165
+ authorityGuard() {
1166
+ return this.#authorityGuard;
1167
+ }
1168
+
1169
+ lastSnapshot() {
1170
+ return this.#lastSnapshot;
1171
+ }
1172
+
1173
+ /**
1174
+ * Run execution gap analysis: compare strategy/PRDs/RFCs with Jira tickets.
1175
+ * Returns { gaps, ticketsCreated, highRiskCount }.
1176
+ */
1177
+ async #runExecutionGapAnalysis() {
1178
+ const gaps = [];
1179
+ let ticketsCreated = 0;
1180
+ let highRiskCount = 0;
1181
+
1182
+ try {
1183
+ // Query strategy/PRDs/RFCs from knowledge base
1184
+
1185
+ const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1186
+ category: 'knowledge',
1187
+ limit: 50,
1188
+ });
1189
+
1190
+ // Query existing Jira tickets via Atlassian provider
1191
+
1192
+ const jiraProvider = this.#registry?.get('jira');
1193
+ if (!jiraProvider) {
1194
+ process.stderr.write('[embed] Execution gap: Jira provider not available\n');
1195
+ return { gaps, ticketsCreated, highRiskCount };
1196
+ }
1197
+
1198
+ // Get project key from config or use default
1199
+ const projectKey = this.#config?.providers?.jira?.projectKey ?? 'PLATFORM';
1200
+
1201
+ // Search for open issues
1202
+ let existingTickets = [];
1203
+ try {
1204
+ const jql = `project = ${projectKey} AND status != Done ORDER BY created DESC`;
1205
+ existingTickets = await jiraProvider.search(jql, { maxResults: 100 });
1206
+ } catch (err) {
1207
+ process.stderr.write(`[embed] Execution gap: Jira query failed: ${err.message}\n`);
1208
+ return { gaps, ticketsCreated, highRiskCount };
1209
+ }
1210
+
1211
+ // Compare strategy docs with tickets to identify gaps
1212
+ for (const doc of strategyDocs) {
1213
+ if (!doc.content) continue;
1214
+
1215
+ // Extract requirements/key points from the document
1216
+ const docText = doc.content.toLowerCase();
1217
+ const docSummary = doc.summary?.toLowerCase() ?? '';
1218
+
1219
+ // Check if there's a corresponding Jira ticket
1220
+ const hasMatchingTicket = existingTickets.some((ticket) => {
1221
+ const ticketText = `${ticket.summary ?? ''} ${ticket.description ?? ''}`.toLowerCase();
1222
+ // Simple matching: check if key terms from doc appear in ticket
1223
+ const docWords = [...new Set([...docSummary.split(/\W+/).filter(w => w.length > 4)])];
1224
+ return docWords.some(word => ticketText.includes(word));
1225
+ });
1226
+
1227
+ if (!hasMatchingTicket) {
1228
+ const severity = doc.tags?.includes('high-priority') ? 'high' : 'medium';
1229
+ gaps.push({
1230
+ severity,
1231
+ kind: 'missing-ticket',
1232
+ summary: `No Jira ticket found for: ${doc.summary?.slice(0, 80) ?? 'Unknown document'}`,
1233
+ docId: doc.id,
1234
+ source: doc.source,
1235
+ });
1236
+ if (severity === 'high') highRiskCount++;
1237
+ }
1238
+ }
1239
+
1240
+ // Create Jira tickets for gaps (or queue for approval)
1241
+ for (const gap of gaps) {
1242
+ try {
1243
+ const authorityResult = await this.#authorityGuard.check('externalPost', {
1244
+ description: `Create Jira ticket for execution gap: ${gap.summary}`,
1245
+ });
1246
+
1247
+ if (authorityResult.allowed) {
1248
+ // Create the Jira ticket
1249
+ const newTicket = await jiraProvider.write({
1250
+ type: 'issue',
1251
+ project: projectKey,
1252
+ issueType: 'Story',
1253
+ summary: `[Auto] Execution gap: ${gap.summary}`,
1254
+ description: [
1255
+ `This ticket was auto-created by Construct's TPM gap analysis.`,
1256
+ ``,
1257
+ `**Source document**: ${gap.docId ?? 'Unknown'}`,
1258
+ `**Gap type**: ${gap.kind}`,
1259
+ `**Severity**: ${gap.severity}`,
1260
+ ``,
1261
+ `Please review the source document and break this into actionable work items.`,
1262
+ ].join('\n'),
1263
+ labels: ['auto-generated', 'execution-gap', gap.severity],
1264
+ });
1265
+
1266
+ if (newTicket?.key) {
1267
+ ticketsCreated++;
1268
+ process.stderr.write(`[embed] Execution gap: created ticket ${newTicket.key} for: ${gap.summary}\n`);
1269
+
1270
+ // Record observation about the created ticket
1271
+ addObservation(this.#rootDir, {
1272
+ role: 'operator',
1273
+ category: 'insight',
1274
+ summary: `Auto-created Jira ticket ${newTicket.key} for execution gap`,
1275
+ content: `Ticket: ${newTicket.key}\nGap: ${gap.summary}\nSeverity: ${gap.severity}`,
1276
+ tags: ['execution-gap', 'auto-ticket', gap.severity, `ticket:${newTicket.key}`],
1277
+ confidence: 0.9,
1278
+ source: 'execution-gap-job',
1279
+ });
1280
+ }
1281
+ } else {
1282
+ // Queue for approval
1283
+ process.stderr.write(`[embed] Execution gap: ticket creation queued for approval (gap: ${gap.summary})\n`);
1284
+ }
1285
+ } catch (err) {
1286
+ process.stderr.write(`[embed] Execution gap: failed to create ticket: ${err.message}\n`);
1287
+ }
1288
+ }
1289
+
1290
+ // Record observation about the gap analysis run
1291
+ if (gaps.length > 0) {
1292
+ addObservation(this.#rootDir, {
1293
+ role: 'operator',
1294
+ category: gaps.some(g => g.severity === 'high') ? 'anti-pattern' : 'insight',
1295
+ summary: `Execution gap analysis: ${gaps.length} gap(s) found, ${ticketsCreated} ticket(s) created`,
1296
+ content: `Gaps: ${gaps.length}\nTickets created: ${ticketsCreated}\nHigh risk: ${highRiskCount}`,
1297
+ tags: ['execution-gap', 'tpm-analysis', `gaps:${gaps.length}`],
1298
+ confidence: 0.85,
1299
+ source: 'execution-gap-job',
1300
+ });
1301
+ }
1302
+
1303
+ } catch (err) {
1304
+ process.stderr.write(`[embed] Execution gap analysis error: ${err.message}\n`);
1305
+ }
1306
+
1307
+ return { gaps, ticketsCreated, highRiskCount };
1308
+ }
1309
+ }