@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.
- package/.env.example +9 -6
- package/LICENSE +201 -21
- package/README.md +132 -257
- package/agents/contracts.json +387 -0
- package/agents/prompts/cx-accessibility.md +8 -0
- package/agents/prompts/cx-ai-engineer.md +92 -0
- package/agents/prompts/cx-architect.md +10 -2
- package/agents/prompts/cx-business-strategist.md +13 -0
- package/agents/prompts/cx-data-analyst.md +80 -0
- package/agents/prompts/cx-data-engineer.md +4 -0
- package/agents/prompts/cx-debugger.md +8 -0
- package/agents/prompts/cx-designer.md +19 -0
- package/agents/prompts/cx-devil-advocate.md +4 -0
- package/agents/prompts/cx-docs-keeper.md +128 -0
- package/agents/prompts/cx-engineer.md +13 -0
- package/agents/prompts/cx-evaluator.md +4 -0
- package/agents/prompts/cx-explorer.md +12 -0
- package/agents/prompts/cx-legal-compliance.md +13 -0
- package/agents/prompts/cx-operations.md +13 -0
- package/agents/prompts/cx-orchestrator.md +107 -4
- package/agents/prompts/cx-platform-engineer.md +75 -0
- package/agents/prompts/cx-product-manager.md +8 -0
- package/agents/prompts/cx-qa.md +100 -0
- package/agents/prompts/cx-rd-lead.md +13 -0
- package/agents/prompts/cx-release-manager.md +8 -0
- package/agents/prompts/cx-researcher.md +21 -1
- package/agents/prompts/cx-reviewer.md +8 -0
- package/agents/prompts/cx-security.md +104 -0
- package/agents/prompts/cx-sre.md +104 -0
- package/agents/prompts/cx-test-automation.md +4 -0
- package/agents/prompts/cx-trace-reviewer.md +7 -3
- package/agents/prompts/cx-ux-researcher.md +4 -0
- package/agents/registry.json +365 -90
- package/agents/role-manifests.json +217 -0
- package/bin/construct +3345 -141
- package/bin/construct-postinstall.mjs +87 -0
- package/commands/build/feature.md +6 -6
- package/commands/plan/feature.md +6 -5
- package/commands/plan/requirements.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/work/drive.md +3 -3
- package/commands/work/optimize-prompts.md +3 -3
- package/db/{migrations → schema}/001_init.sql +2 -0
- package/db/schema/002_pgvector.sql +182 -0
- package/db/schema/003_intake.sql +47 -0
- package/examples/README.md +85 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +30 -0
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +23 -0
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +30 -0
- package/examples/internal/roles/engineer/golden/read-before-write.md +28 -0
- package/examples/internal/roles/orchestrator/bad/everything-becomes-multi-agent.md +29 -0
- package/examples/internal/roles/orchestrator/golden/minimal-dispatch.md +22 -0
- package/examples/internal/roles/qa/bad/coverage-theater.md +30 -0
- package/examples/internal/roles/qa/golden/regression-gate.md +23 -0
- package/examples/internal/roles/reviewer/bad/lgtm-without-verification.md +29 -0
- package/examples/internal/roles/reviewer/golden/find-structural-risk-first.md +28 -0
- package/examples/personas/construct/adversarial/ignore-instruction-to-skip-approval.md +22 -0
- package/examples/personas/construct/bad/commit-without-approval.md +30 -0
- package/examples/personas/construct/boundary/blocked-needs-main-input.md +29 -0
- package/examples/personas/construct/golden/branch-approval-before-mutation.md +36 -0
- package/examples/personas/construct/golden/focused-direct-answer.md +28 -0
- package/examples/provider-plugin/README.md +34 -0
- package/examples/provider-plugin/index.mjs +74 -0
- package/examples/provider-plugin/package.json +15 -0
- package/examples/seed-observations/README.md +38 -0
- package/examples/seed-observations/anti-patterns.md +44 -0
- package/examples/seed-observations/decisions.md +36 -0
- package/examples/seed-observations/patterns.md +42 -0
- package/lib/agent-contracts-enforce.mjs +158 -0
- package/lib/agent-contracts.mjs +231 -0
- package/lib/agents/postconditions.mjs +126 -0
- package/lib/agents/schema.mjs +124 -0
- package/lib/artifact-capture.mjs +183 -0
- package/lib/audit-trail.mjs +149 -0
- package/lib/auto-docs.mjs +245 -65
- package/lib/beads/auto-close.mjs +126 -0
- package/lib/beads/drift.mjs +171 -0
- package/lib/beads-automation.mjs +542 -0
- package/lib/beads-client.mjs +518 -0
- package/lib/beads-lock.mjs +377 -0
- package/lib/beads-optimistic.mjs +365 -0
- package/lib/bootstrap/built-ins.mjs +136 -0
- package/lib/bootstrap/lazy-install.mjs +161 -0
- package/lib/bootstrap/resources.mjs +120 -0
- package/lib/bootstrap.mjs +105 -0
- package/lib/cache-governor.js +213 -0
- package/lib/cache-strategy-anthropic.js +62 -0
- package/lib/cache-strategy-google.js +79 -0
- package/lib/cache-strategy-none.js +30 -0
- package/lib/cache-strategy-openai.js +48 -0
- package/lib/cache-strategy.js +91 -0
- package/lib/claude-allow.mjs +149 -0
- package/lib/cli-commands.mjs +413 -258
- package/lib/codex-config.mjs +3 -1
- package/lib/comment-lint.mjs +55 -6
- package/lib/completions.mjs +21 -6
- package/lib/config/alias.mjs +56 -0
- package/lib/config/project-config.mjs +335 -0
- package/lib/config/schema.mjs +159 -0
- package/lib/context-router.mjs +308 -0
- package/lib/cost-ledger.mjs +177 -0
- package/lib/cost.mjs +171 -10
- package/lib/dashboard-static.mjs +158 -0
- package/lib/deployment-mode.mjs +86 -0
- package/lib/deprecate.mjs +49 -0
- package/lib/dispatch-batch.js +183 -0
- package/lib/distill.mjs +21 -8
- package/lib/doc-stamp.mjs +164 -0
- package/lib/doc-verify.mjs +119 -0
- package/lib/docs-routing.mjs +89 -0
- package/lib/docs-verify.mjs +417 -0
- package/lib/doctor/audit.mjs +71 -0
- package/lib/doctor/cli.mjs +99 -0
- package/lib/doctor/escalate.mjs +29 -0
- package/lib/doctor/index.mjs +140 -0
- package/lib/doctor/report.mjs +170 -0
- package/lib/doctor/watchers/bd-watch.mjs +117 -0
- package/lib/doctor/watchers/cost.mjs +130 -0
- package/lib/doctor/watchers/disk.mjs +122 -0
- package/lib/doctor/watchers/handoffs.mjs +33 -0
- package/lib/doctor/watchers/process-pressure.mjs +60 -0
- package/lib/doctor/watchers/service-health.mjs +188 -0
- package/lib/document-extract.mjs +288 -0
- package/lib/document-ingest.mjs +230 -0
- package/lib/drop.mjs +282 -0
- package/lib/embed/approval-queue.mjs +176 -0
- package/lib/embed/artifact.mjs +349 -0
- package/lib/embed/authority-guard.mjs +155 -0
- package/lib/embed/cli.mjs +408 -0
- package/lib/embed/config.mjs +355 -0
- package/lib/embed/conflict-detection.mjs +264 -0
- package/lib/embed/customer-profiles.mjs +480 -0
- package/lib/embed/daemon.mjs +1309 -0
- package/lib/embed/demand-fetch.mjs +449 -0
- package/lib/embed/docs-lifecycle.mjs +349 -0
- package/lib/embed/inbox-live-watcher.mjs +119 -0
- package/lib/embed/inbox.mjs +343 -0
- package/lib/embed/intake-metrics.mjs +190 -0
- package/lib/embed/jobs/vector-sync.mjs +198 -0
- package/lib/embed/notifications.mjs +75 -0
- package/lib/embed/output.mjs +79 -0
- package/lib/embed/providers/github.mjs +295 -0
- package/lib/embed/providers/jira.mjs +192 -0
- package/lib/embed/providers/linear.mjs +186 -0
- package/lib/embed/providers/registry.mjs +115 -0
- package/lib/embed/providers/slack.mjs +203 -0
- package/lib/embed/recommendation-store.mjs +378 -0
- package/lib/embed/roadmap.mjs +374 -0
- package/lib/embed/role-framing.mjs +110 -0
- package/lib/embed/scheduler.mjs +99 -0
- package/lib/embed/semantic.mjs +325 -0
- package/lib/embed/snapshot.mjs +191 -0
- package/lib/embed/supervision.mjs +235 -0
- package/lib/embed/target-resolver.mjs +186 -0
- package/lib/embed/worker.mjs +62 -0
- package/lib/embed/workspaces.mjs +297 -0
- package/lib/engine/chunker-headings.mjs +110 -0
- package/lib/engine/compressor-heuristic.mjs +100 -0
- package/lib/engine/consolidate.mjs +287 -0
- package/lib/engine/contracts.mjs +126 -0
- package/lib/engine/defaults.mjs +129 -0
- package/lib/engine/eval-retrieval.mjs +148 -0
- package/lib/engine/fuser-rrf.mjs +62 -0
- package/lib/engine/index.mjs +37 -0
- package/lib/engine/registry.mjs +146 -0
- package/lib/engine/reranker-mmr.mjs +90 -0
- package/lib/engine/tokens.mjs +77 -0
- package/lib/entity-store.mjs +280 -0
- package/lib/env-config.mjs +69 -4
- package/lib/evals/retrieval-bench.mjs +159 -0
- package/lib/evaluator-optimizer.mjs +317 -0
- package/lib/features.mjs +159 -29
- package/lib/gates-audit.mjs +236 -0
- package/lib/git-hooks/prepare-commit-msg +58 -0
- package/lib/handoffs/cleanup.mjs +159 -0
- package/lib/handoffs/contract.mjs +162 -0
- package/lib/handoffs/inventory.mjs +120 -0
- package/lib/headhunt.mjs +28 -47
- package/lib/health-check.mjs +399 -0
- package/lib/hook-health.mjs +442 -0
- package/lib/hooks/_lib/log.mjs +82 -0
- package/lib/hooks/adaptive-lint.mjs +26 -2
- package/lib/hooks/agent-tracker.mjs +171 -14
- package/lib/hooks/audit-reads.mjs +109 -0
- package/lib/hooks/audit-trail.mjs +153 -0
- package/lib/hooks/bash-output-logger.mjs +71 -0
- package/lib/hooks/block-no-verify.mjs +41 -0
- package/lib/hooks/ci-status-check.mjs +82 -0
- package/lib/hooks/comment-lint.mjs +29 -7
- package/lib/hooks/config-protection.mjs +39 -18
- package/lib/hooks/context-watch.mjs +137 -0
- package/lib/hooks/context-window-recovery.mjs +4 -20
- package/lib/hooks/dep-audit.mjs +16 -0
- package/lib/hooks/doc-coupling-check.mjs +80 -0
- package/lib/hooks/edit-accumulator.mjs +3 -0
- package/lib/hooks/edit-error-recovery.mjs +3 -0
- package/lib/hooks/edit-guard.mjs +45 -4
- package/lib/hooks/env-check.mjs +3 -0
- package/lib/hooks/guard-bash.mjs +58 -1
- package/lib/hooks/mcp-audit.mjs +18 -20
- package/lib/hooks/mcp-health-check.mjs +36 -0
- package/lib/hooks/model-fallback.mjs +42 -56
- package/lib/hooks/policy-engine.mjs +209 -0
- package/lib/hooks/post-merge-docs-check.mjs +63 -0
- package/lib/hooks/pre-compact.mjs +4 -24
- package/lib/hooks/pre-push-gate.mjs +200 -37
- package/lib/hooks/proactive-activation.mjs +284 -0
- package/lib/hooks/read-tracker.mjs +27 -4
- package/lib/hooks/readme-age-check.mjs +77 -0
- package/lib/hooks/registry-sync.mjs +12 -5
- package/lib/hooks/scan-secrets.mjs +50 -7
- package/lib/hooks/session-optimize.mjs +311 -0
- package/lib/hooks/session-start.mjs +287 -28
- package/lib/hooks/stop-notify.mjs +236 -96
- package/lib/hooks/stop-typecheck.mjs +13 -1
- package/lib/hooks/test-watch.mjs +68 -0
- package/lib/host-capabilities.mjs +31 -10
- package/lib/init-docs.mjs +731 -291
- package/lib/init-unified.mjs +1000 -0
- package/lib/init-update.mjs +168 -0
- package/lib/init.mjs +107 -0
- package/lib/install/first-invocation.mjs +119 -0
- package/lib/install/stage-project.mjs +69 -0
- package/lib/intake/classify.mjs +278 -0
- package/lib/intake/feedback.mjs +273 -0
- package/lib/intake/filesystem-queue.mjs +158 -0
- package/lib/intake/intake-config.mjs +132 -0
- package/lib/intake/postgres-queue.mjs +198 -0
- package/lib/intake/prepare.mjs +139 -0
- package/lib/intake/queue.mjs +83 -0
- package/lib/intake/session-prelude.mjs +50 -0
- package/lib/integrations/intake-integrations.mjs +740 -0
- package/lib/intent-classifier.mjs +253 -0
- package/lib/knowledge/layout.mjs +72 -0
- package/lib/knowledge/rag.mjs +331 -0
- package/lib/knowledge/search.mjs +315 -0
- package/lib/knowledge/trends.mjs +261 -0
- package/lib/logger.mjs +85 -0
- package/lib/mcp/broker.mjs +124 -0
- package/lib/mcp/server.mjs +554 -854
- package/lib/mcp/tools/document.mjs +132 -0
- package/lib/mcp/tools/memory.mjs +209 -0
- package/lib/mcp/tools/project.mjs +349 -0
- package/lib/mcp/tools/skills.mjs +409 -0
- package/lib/mcp/tools/storage.mjs +60 -0
- package/lib/mcp/tools/telemetry.mjs +315 -0
- package/lib/mcp/tools/workflow.mjs +112 -0
- package/lib/mcp-catalog.json +54 -3
- package/lib/mcp-manager.mjs +96 -48
- package/lib/mcp-platform-config.mjs +22 -22
- package/lib/memory-stats.mjs +121 -0
- package/lib/mode-commands.mjs +124 -0
- package/lib/model-free-selector.mjs +184 -0
- package/lib/model-pricing.mjs +152 -0
- package/lib/model-registry.mjs +226 -0
- package/lib/model-router.mjs +362 -386
- package/lib/observation-store.mjs +391 -0
- package/lib/ollama-manager.mjs +428 -0
- package/lib/opencode-config.mjs +13 -3
- package/lib/opencode-runtime-plugin.mjs +70 -38
- package/lib/opencode-telemetry.mjs +64 -6
- package/lib/orchestration-policy.mjs +509 -6
- package/lib/overrides/resolver.mjs +207 -0
- package/lib/parity.mjs +147 -0
- package/lib/paths.mjs +33 -0
- package/lib/performance/generate.mjs +212 -0
- package/lib/plugin-registry.mjs +268 -0
- package/lib/policy/engine.mjs +130 -0
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-detection.mjs +129 -0
- package/lib/project-init-shared.mjs +272 -0
- package/lib/project-profile.mjs +447 -0
- package/lib/prompt-composer.js +434 -0
- package/lib/prompt-metadata.mjs +1 -1
- package/lib/provider-capabilities-anthropic.js +44 -0
- package/lib/provider-capabilities-deepseek.js +37 -0
- package/lib/provider-capabilities-generic.js +26 -0
- package/lib/provider-capabilities-google.js +47 -0
- package/lib/provider-capabilities-openai.js +45 -0
- package/lib/provider-capabilities.js +142 -0
- package/lib/providers/atlassian-confluence/index.mjs +103 -0
- package/lib/providers/atlassian-jira/index.mjs +100 -0
- package/lib/providers/auth-manager.mjs +126 -0
- package/lib/providers/circuit-breaker.mjs +124 -0
- package/lib/providers/contract.mjs +93 -0
- package/lib/providers/github/index.mjs +126 -0
- package/lib/providers/registry.mjs +184 -0
- package/lib/providers/salesforce/index.mjs +100 -0
- package/lib/providers/slack/index.mjs +80 -0
- package/lib/reflect.mjs +137 -0
- package/lib/research-lint.mjs +164 -0
- package/lib/resources/budget.mjs +259 -0
- package/lib/role-preload.mjs +21 -6
- package/lib/roles/approval-surface.mjs +54 -0
- package/lib/roles/cli.mjs +118 -0
- package/lib/roles/event-bus.mjs +79 -0
- package/lib/roles/fence.mjs +84 -0
- package/lib/roles/gateway.mjs +260 -0
- package/lib/roles/hook-emit.mjs +37 -0
- package/lib/roles/manifest.mjs +48 -0
- package/lib/roles/router.mjs +27 -0
- package/lib/runtime-pressure.mjs +360 -0
- package/lib/schema-artifact.mjs +134 -0
- package/lib/schema-infer.mjs +551 -0
- package/lib/server/auth.mjs +168 -0
- package/lib/server/chat.mjs +336 -0
- package/lib/server/cors.mjs +77 -0
- package/lib/server/csrf.mjs +91 -0
- package/lib/server/index.mjs +1927 -78
- package/lib/server/insights.mjs +765 -0
- package/lib/server/rate-limit.mjs +91 -0
- package/lib/server/static/assets/index-ab25c707.js +70 -0
- package/lib/server/static/assets/index-f0c80a2b.css +1 -0
- package/lib/server/static/index.html +12 -817
- package/lib/server/telemetry-login.mjs +108 -0
- package/lib/server/webhook.mjs +510 -0
- package/lib/service-manager.mjs +522 -58
- package/lib/services/pattern-promotion-service.mjs +167 -0
- package/lib/services/telemetry-backend.mjs +178 -0
- package/lib/session-store.mjs +374 -0
- package/lib/setup-prompts.mjs +96 -0
- package/lib/setup.mjs +523 -36
- package/lib/skills-apply.mjs +280 -0
- package/lib/skills-scope.mjs +118 -0
- package/lib/status.mjs +261 -70
- package/lib/storage/admin.mjs +355 -0
- package/lib/storage/backend.mjs +2 -1
- package/lib/storage/backup.mjs +347 -0
- package/lib/storage/embeddings-engine.mjs +133 -0
- package/lib/storage/embeddings-legacy.mjs +85 -0
- package/lib/storage/embeddings-local.mjs +108 -0
- package/lib/storage/embeddings-ollama.mjs +78 -0
- package/lib/storage/embeddings-openai.mjs +85 -0
- package/lib/storage/embeddings.mjs +92 -33
- package/lib/storage/file-lock.mjs +130 -0
- package/lib/storage/fusion.mjs +95 -0
- package/lib/storage/hybrid-query.mjs +34 -27
- package/lib/storage/migrations.mjs +187 -0
- package/lib/storage/postgres-backup.mjs +124 -0
- package/lib/storage/sql-store.mjs +5 -15
- package/lib/storage/state-source.mjs +12 -13
- package/lib/storage/store-version.mjs +115 -0
- package/lib/storage/sync.mjs +144 -35
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/storage/vector-client.mjs +286 -0
- package/lib/storage/vector-store.mjs +71 -30
- package/lib/task-graph/generate.mjs +135 -0
- package/lib/task-graph/schema.mjs +81 -0
- package/lib/task-graph/store.mjs +71 -0
- package/lib/telemetry/backends/local.mjs +62 -0
- package/lib/telemetry/backends/{langfuse.mjs → remote.mjs} +27 -14
- package/lib/telemetry/backfill.mjs +180 -0
- package/lib/telemetry/eval-datasets.mjs +203 -0
- package/lib/telemetry/{langfuse-ingest.mjs → ingest.mjs} +26 -20
- package/lib/telemetry/intent-verifications.mjs +86 -0
- package/lib/telemetry/llm-judge.mjs +350 -0
- package/lib/telemetry/model-pricing-catalog.mjs +557 -0
- package/lib/telemetry/setup.mjs +151 -0
- package/lib/telemetry/skill-calls.mjs +78 -0
- package/lib/telemetry/team-rollup.mjs +4 -4
- package/lib/token-engine.js +117 -0
- package/lib/token-estimator-anthropic.js +15 -0
- package/lib/token-estimator-deepseek.js +13 -0
- package/lib/token-estimator-default.js +13 -0
- package/lib/token-estimator-google.js +13 -0
- package/lib/token-estimator-openai.js +13 -0
- package/lib/toolkit-env.mjs +1 -1
- package/lib/tty-prompts.mjs +211 -0
- package/lib/uninstall/uninstall.mjs +423 -0
- package/lib/update.mjs +115 -0
- package/lib/upgrade.mjs +141 -0
- package/lib/validator.mjs +51 -2
- package/lib/validators/skills.mjs +142 -0
- package/lib/wireframe.mjs +422 -0
- package/lib/worker/entrypoint.mjs +241 -0
- package/lib/worker/evidence.mjs +107 -0
- package/lib/worker/run.mjs +154 -0
- package/lib/worker/trace.mjs +182 -0
- package/lib/workflow-state.mjs +14 -18
- package/package.json +21 -8
- package/personas/construct.md +53 -51
- package/platforms/claude/CLAUDE.md +1 -1
- package/platforms/claude/settings.template.json +115 -68
- package/platforms/opencode/config.template.json +3 -7
- package/rules/common/agents.md +11 -38
- package/rules/common/beads-hygiene.md +75 -0
- package/rules/common/code-review.md +11 -100
- package/rules/common/coding-style.md +5 -3
- package/rules/common/comments.md +30 -111
- package/rules/common/commit-approval.md +53 -0
- package/rules/common/cx-agent-routing.md +1 -0
- package/rules/common/development-workflow.md +23 -41
- package/rules/common/doc-ownership.md +54 -0
- package/rules/common/efficiency.md +57 -0
- package/rules/common/framing.md +76 -0
- package/rules/common/git-workflow.md +2 -4
- package/rules/common/patterns.md +3 -7
- package/rules/common/performance.md +15 -31
- package/rules/common/release-gates.md +69 -0
- package/rules/common/research.md +107 -0
- package/rules/common/security.md +6 -6
- package/rules/common/skill-composition.md +67 -0
- package/rules/common/testing.md +15 -27
- package/rules/golang/hooks.md +0 -4
- package/rules/policy/bootstrap.yaml +11 -0
- package/rules/policy/drive.yaml +8 -0
- package/rules/policy/task.yaml +9 -0
- package/rules/policy/workflow.yaml +9 -0
- package/rules/python/hooks.md +0 -4
- package/rules/swift/hooks.md +0 -4
- package/rules/typescript/hooks.md +0 -4
- package/rules/web/hooks.md +0 -2
- package/{sync-agents.mjs → scripts/sync-agents.mjs} +306 -61
- package/skills/ai/prompt-optimizer.md +7 -7
- package/skills/compliance/ai-disclosure.md +58 -0
- package/skills/compliance/data-privacy.md +46 -0
- package/skills/compliance/license-audit.md +40 -0
- package/skills/compliance/regulatory-review.md +61 -0
- package/skills/docs/document-ingest-workflow.md +52 -0
- package/skills/docs/evidence-ingest-workflow.md +9 -0
- package/skills/docs/init-docs.md +51 -18
- package/skills/docs/product-intelligence-workflow.md +12 -0
- package/skills/docs/product-signal-workflow.md +4 -0
- package/skills/docs/research-workflow.md +23 -8
- package/skills/docs/runbook-workflow.md +1 -1
- package/skills/operating/orchestration-reference.md +151 -0
- package/skills/roles/architect.md +5 -0
- package/skills/roles/engineer.md +32 -0
- package/skills/roles/operator.md +12 -0
- package/skills/roles/reviewer.md +23 -0
- package/skills/routing.md +1 -1
- package/templates/devcontainer/Dockerfile.devcontainer +38 -0
- package/templates/devcontainer/devcontainer.json +31 -0
- package/templates/distribution/bootstrap.ps1 +142 -0
- package/templates/distribution/bootstrap.sh +196 -0
- package/templates/distribution/run.mjs +187 -0
- package/templates/docs/adr.md +44 -8
- package/templates/docs/changelog-entry.md +43 -0
- package/templates/docs/construct_guide.md +149 -0
- package/templates/docs/evidence-brief.md +2 -2
- package/templates/docs/meta-prd.md +140 -19
- package/templates/docs/onboarding.md +57 -0
- package/templates/docs/prd.md +159 -15
- package/templates/docs/research-brief.md +4 -4
- package/templates/homebrew/construct.rb +67 -0
- package/langfuse/docker-compose.yml +0 -82
- package/lib/eval-harness.mjs +0 -59
- package/lib/hooks/bootstrap-guard.mjs +0 -90
- package/lib/hooks/console-warn.mjs +0 -43
- package/lib/hooks/continuation-enforcer.mjs +0 -72
- package/lib/hooks/drive-guard.mjs +0 -89
- package/lib/hooks/mcp-task-scope.mjs +0 -47
- package/lib/hooks/task-completed-guard.mjs +0 -43
- package/lib/hooks/teammate-idle-guard.mjs +0 -54
- package/lib/hooks/workflow-guard.mjs +0 -62
- package/lib/prompt-composer.mjs +0 -196
- package/lib/review.mjs +0 -429
- package/lib/server/static/app.js +0 -841
- package/lib/telemetry/langfuse-model-sync.mjs +0 -270
- package/rules/common/hooks.md +0 -35
- package/rules/zh/README.md +0 -113
- package/rules/zh/agents.md +0 -55
- package/rules/zh/code-review.md +0 -129
- package/rules/zh/coding-style.md +0 -53
- package/rules/zh/development-workflow.md +0 -49
- package/rules/zh/git-workflow.md +0 -29
- package/rules/zh/hooks.md +0 -35
- package/rules/zh/patterns.md +0 -36
- package/rules/zh/performance.md +0 -60
- package/rules/zh/security.md +0 -34
- package/rules/zh/testing.md +0 -34
package/lib/service-manager.mjs
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/service-manager.mjs — start, stop, and describe Construct runtime services.
|
|
3
3
|
*
|
|
4
|
-
* Manages the dashboard process, local
|
|
5
|
-
* and OpenCode. startServices() is the single entry point for `construct up
|
|
6
|
-
*
|
|
4
|
+
* Manages the dashboard process, local Postgres (Docker), memory server (cm),
|
|
5
|
+
* and OpenCode. startServices() is the single entry point for `construct up` —
|
|
6
|
+
* spawns whatever is available and returns a result per service.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
|
+
import net from 'node:net';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
|
|
13
14
|
import { findAvailablePort } from './host-capabilities.mjs';
|
|
14
|
-
import { getUserEnvPath, loadConstructEnv, writeEnvValues } from './env-config.mjs';
|
|
15
|
+
import { getUserEnvPath, loadConstructEnv, parseEnvFile, writeEnvValues } from './env-config.mjs';
|
|
15
16
|
import { detectDockerCompose } from './setup.mjs';
|
|
17
|
+
import { stashConstructDb, restoreConstructDb } from './storage/postgres-backup.mjs';
|
|
18
|
+
import { runPressureRelease } from './runtime-pressure.mjs';
|
|
19
|
+
import {
|
|
20
|
+
pruneStashDir,
|
|
21
|
+
verifyTelemetryKeys,
|
|
22
|
+
isRemoteTelemetry,
|
|
23
|
+
startManagedServices,
|
|
24
|
+
} from './services/telemetry-backend.mjs';
|
|
25
|
+
|
|
26
|
+
const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
|
|
27
|
+
const CONSTRUCT_PG_CONTAINER = 'construct-postgres';
|
|
28
|
+
const CONSTRUCT_PG_PORT = 54329;
|
|
29
|
+
const CONSTRUCT_PG_HEALTH_RETRIES = 12;
|
|
30
|
+
const CONSTRUCT_PG_HEALTH_INTERVAL_MS = 2000;
|
|
16
31
|
|
|
17
32
|
const DASHBOARD_STATE_FILE = 'dashboard.json';
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
// pruneStashDir and verifyTelemetryKeys now live in lib/services/telemetry-backend.mjs.
|
|
35
|
+
// Re-exported below at module bottom for tests that already import from here.
|
|
36
|
+
|
|
22
37
|
|
|
23
38
|
function runtimeStateDir(homeDir = os.homedir()) {
|
|
24
39
|
return path.join(homeDir, '.construct', 'runtime');
|
|
@@ -42,6 +57,70 @@ function writeJson(filePath, value) {
|
|
|
42
57
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
function latestHandoffPath(rootDir) {
|
|
61
|
+
const handoffsDir = path.join(rootDir, '.cx', 'handoffs');
|
|
62
|
+
try {
|
|
63
|
+
const entries = fs.readdirSync(handoffsDir, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
65
|
+
.map((entry) => {
|
|
66
|
+
const fullPath = path.join(handoffsDir, entry.name);
|
|
67
|
+
const stat = fs.statSync(fullPath);
|
|
68
|
+
return { path: fullPath, mtimeMs: stat.mtimeMs };
|
|
69
|
+
})
|
|
70
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
71
|
+
return entries[0]?.path || null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function relativeOrNull(rootDir, filePath) {
|
|
78
|
+
return filePath ? path.relative(rootDir, filePath) || path.basename(filePath) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildRuntimeRecoverySummary({ rootDir, homeDir = os.homedir(), results = [], env = process.env } = {}) {
|
|
82
|
+
const unavailable = results
|
|
83
|
+
.filter((service) => ['unavailable', 'degraded', 'error', 'failed'].includes(service.status))
|
|
84
|
+
.map((service) => ({
|
|
85
|
+
name: service.name,
|
|
86
|
+
status: service.status,
|
|
87
|
+
note: service.note || null,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const durable = {
|
|
91
|
+
plan: fs.existsSync(path.join(rootDir, 'plan.md')) ? 'plan.md' : null,
|
|
92
|
+
context: fs.existsSync(path.join(rootDir, '.cx', 'context.md')) ? '.cx/context.md' : null,
|
|
93
|
+
contextJson: fs.existsSync(path.join(rootDir, '.cx', 'context.json')) ? '.cx/context.json' : null,
|
|
94
|
+
latestHandoff: relativeOrNull(rootDir, latestHandoffPath(rootDir)),
|
|
95
|
+
beads: fs.existsSync(path.join(rootDir, '.beads', 'metadata.json')) ? '.beads/metadata.json' : null,
|
|
96
|
+
userConfig: fs.existsSync(getUserEnvPath(homeDir)) ? getUserEnvPath(homeDir) : null,
|
|
97
|
+
userDataRoot: env.CX_DATA_DIR || path.join(homeDir, '.cx'),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
durable,
|
|
102
|
+
degraded: unavailable,
|
|
103
|
+
canResumeFromFiles: Boolean(durable.plan || durable.context || durable.latestHandoff || durable.beads),
|
|
104
|
+
message: unavailable.length
|
|
105
|
+
? 'Runtime is partially degraded; resume from durable project files and restart services when dependencies return.'
|
|
106
|
+
: 'Runtime services are available; durable project files remain the source of truth.',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isManagedConstructPostgresUrl(databaseUrl = '') {
|
|
111
|
+
if (!databaseUrl) return false;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(databaseUrl);
|
|
114
|
+
const host = parsed.hostname;
|
|
115
|
+
return (
|
|
116
|
+
parsed.port === String(CONSTRUCT_PG_PORT) &&
|
|
117
|
+
['127.0.0.1', 'localhost', '::1'].includes(host)
|
|
118
|
+
) || host === CONSTRUCT_PG_CONTAINER;
|
|
119
|
+
} catch {
|
|
120
|
+
return databaseUrl.includes(`:${CONSTRUCT_PG_PORT}/`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
45
124
|
function processExists(pid) {
|
|
46
125
|
if (!pid || Number(pid) <= 0) return false;
|
|
47
126
|
try {
|
|
@@ -52,13 +131,72 @@ function processExists(pid) {
|
|
|
52
131
|
}
|
|
53
132
|
}
|
|
54
133
|
|
|
55
|
-
function
|
|
134
|
+
async function probeRuntimePort(port, { host = '127.0.0.1', timeoutMs = 750 } = {}) {
|
|
135
|
+
if (!Number.isInteger(port) || port <= 0) return false;
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const socket = net.createConnection({ host, port });
|
|
138
|
+
let settled = false;
|
|
139
|
+
const settle = (value) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
socket.destroy();
|
|
143
|
+
resolve(value);
|
|
144
|
+
};
|
|
145
|
+
socket.setTimeout(timeoutMs);
|
|
146
|
+
socket.once('connect', () => settle(true));
|
|
147
|
+
socket.once('timeout', () => settle(false));
|
|
148
|
+
socket.once('error', () => settle(false));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function probeRuntimeHttp(url, {
|
|
153
|
+
method = 'GET',
|
|
154
|
+
headers = undefined,
|
|
155
|
+
body = undefined,
|
|
156
|
+
timeoutMs = 1000,
|
|
157
|
+
} = {}) {
|
|
158
|
+
try {
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
161
|
+
const res = await fetch(url, { method, headers, body, signal: controller.signal });
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
return res.ok;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function isDashboardRunning(port) {
|
|
170
|
+
return probeRuntimeHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 1000 });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function isMemoryRunning(port) {
|
|
174
|
+
const mcpReady = await probeRuntimeHttp(`http://127.0.0.1:${port}/`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
178
|
+
timeoutMs: 1000,
|
|
179
|
+
});
|
|
180
|
+
if (mcpReady) return true;
|
|
181
|
+
return probeRuntimePort(port);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function isOpenCodeRunning(port) {
|
|
185
|
+
return probeRuntimeHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 1000 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function spawnDetached(command, args, homeDir, logFile, options = {}) {
|
|
56
189
|
const logPath = path.join(runtimeStateDir(homeDir), logFile);
|
|
57
190
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
58
191
|
const fd = fs.openSync(logPath, 'a');
|
|
59
|
-
const child = spawn(command, args, {
|
|
192
|
+
const child = spawn(command, args, {
|
|
193
|
+
detached: true,
|
|
194
|
+
stdio: ['ignore', fd, fd],
|
|
195
|
+
cwd: options.cwd,
|
|
196
|
+
env: options.env,
|
|
197
|
+
});
|
|
60
198
|
child.unref();
|
|
61
|
-
return child;
|
|
199
|
+
return { child, logPath };
|
|
62
200
|
}
|
|
63
201
|
|
|
64
202
|
export function readDashboardState(homeDir = os.homedir()) {
|
|
@@ -90,9 +228,51 @@ export function stopDashboard(homeDir = os.homedir()) {
|
|
|
90
228
|
return { stopped: true, pid: Number(state.pid), port: state.port };
|
|
91
229
|
}
|
|
92
230
|
|
|
231
|
+
function doctorStatePath(homeDir) {
|
|
232
|
+
return path.join(homeDir, '.construct', 'doctor.json');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readDoctorState(homeDir = os.homedir()) {
|
|
236
|
+
const state = readJson(doctorStatePath(homeDir));
|
|
237
|
+
if (!state) return null;
|
|
238
|
+
if (!processExists(state.pid)) return null;
|
|
239
|
+
return state;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function stopDoctor(homeDir = os.homedir()) {
|
|
243
|
+
const state = readJson(doctorStatePath(homeDir));
|
|
244
|
+
const file = doctorStatePath(homeDir);
|
|
245
|
+
if (!state?.pid) {
|
|
246
|
+
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
247
|
+
return { stopped: false, reason: 'not-running' };
|
|
248
|
+
}
|
|
249
|
+
if (!processExists(state.pid)) {
|
|
250
|
+
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
251
|
+
return { stopped: false, reason: 'stale-state' };
|
|
252
|
+
}
|
|
253
|
+
try { process.kill(Number(state.pid), 'SIGTERM'); } catch { /* gone */ }
|
|
254
|
+
return { stopped: true, pid: Number(state.pid) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function startDoctor({ rootDir, homeDir = os.homedir() } = {}) {
|
|
258
|
+
if (process.env.CONSTRUCT_DOCTOR === 'off') {
|
|
259
|
+
return { started: false, reason: 'disabled' };
|
|
260
|
+
}
|
|
261
|
+
const existing = readDoctorState(homeDir);
|
|
262
|
+
if (existing) return { started: false, reused: true, pid: existing.pid };
|
|
263
|
+
const indexPath = path.join(rootDir, 'lib', 'doctor', 'index.mjs');
|
|
264
|
+
if (!fs.existsSync(indexPath)) return { started: false, reason: 'missing-binary' };
|
|
265
|
+
const { logPath } = spawnDetached('node', [indexPath], homeDir, 'doctor.log', { cwd: rootDir, env: { ...process.env } });
|
|
266
|
+
return { started: true, logPath };
|
|
267
|
+
}
|
|
268
|
+
|
|
93
269
|
export async function startDashboard({ rootDir, homeDir = os.homedir(), preferredPort = 4242 } = {}) {
|
|
94
270
|
const existing = readDashboardState(homeDir);
|
|
95
|
-
if (existing
|
|
271
|
+
if (existing && await isDashboardRunning(existing.port)) {
|
|
272
|
+
return { started: false, reused: true, pid: existing.pid, port: existing.port, url: existing.url };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (existing) clearDashboardState(homeDir);
|
|
96
276
|
|
|
97
277
|
const port = await findAvailablePort(preferredPort);
|
|
98
278
|
const outPath = path.join(runtimeStateDir(homeDir), 'dashboard.log');
|
|
@@ -117,12 +297,31 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
|
|
|
117
297
|
return { started: true, reused: false, ...state };
|
|
118
298
|
}
|
|
119
299
|
|
|
120
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Resolve runtime ports. Re-uses ports from config.env when the service is
|
|
302
|
+
* already listening there (avoids port drift on repeated `construct up`).
|
|
303
|
+
*/
|
|
304
|
+
export async function getRuntimePorts(homeDir = os.homedir(), {
|
|
305
|
+
dashboardProbeFn = isDashboardRunning,
|
|
306
|
+
memoryProbeFn = isMemoryRunning,
|
|
307
|
+
openCodeProbeFn = isOpenCodeRunning,
|
|
308
|
+
findAvailablePortFn = findAvailablePort,
|
|
309
|
+
} = {}) {
|
|
121
310
|
const dashboard = readDashboardState(homeDir);
|
|
311
|
+
const existing = parseEnvFile(getUserEnvPath(homeDir));
|
|
312
|
+
|
|
313
|
+
async function resolvePort(key, fallback, probe = async (port) => probeRuntimePort(port)) {
|
|
314
|
+
const saved = Number(existing[key]);
|
|
315
|
+
if (Number.isFinite(saved) && saved > 0) {
|
|
316
|
+
if (await probe(saved)) return saved;
|
|
317
|
+
}
|
|
318
|
+
return findAvailablePortFn(saved || fallback);
|
|
319
|
+
}
|
|
320
|
+
|
|
122
321
|
return {
|
|
123
|
-
dashboard: dashboard?.port ?? await
|
|
124
|
-
memory: await
|
|
125
|
-
bridge: await
|
|
322
|
+
dashboard: dashboard?.port ?? await resolvePort('DASHBOARD_PORT', 4242, dashboardProbeFn),
|
|
323
|
+
memory: await resolvePort('MEMORY_PORT', 8765, memoryProbeFn),
|
|
324
|
+
bridge: await resolvePort('BRIDGE_PORT', 5173, openCodeProbeFn),
|
|
126
325
|
};
|
|
127
326
|
}
|
|
128
327
|
|
|
@@ -143,83 +342,348 @@ export async function describeRuntimeSupport() {
|
|
|
143
342
|
};
|
|
144
343
|
}
|
|
145
344
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
345
|
+
// ── Construct Postgres management ──────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
function constructPgComposePath(rootDir) {
|
|
348
|
+
return path.join(rootDir, CONSTRUCT_PG_COMPOSE_DIR, 'docker-compose.yml');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isConstructPostgresRunning(spawnSyncFn = spawnSync) {
|
|
352
|
+
const result = spawnSyncFn('docker', ['inspect', '--format', '{{.State.Running}}', CONSTRUCT_PG_CONTAINER], {
|
|
353
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
354
|
+
});
|
|
355
|
+
return result.stdout?.trim() === 'true';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isConstructPostgresHealthy(spawnSyncFn = spawnSync) {
|
|
359
|
+
const result = spawnSyncFn('docker', ['exec', CONSTRUCT_PG_CONTAINER, 'pg_isready', '-U', 'construct'], {
|
|
360
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
361
|
+
});
|
|
362
|
+
return result.status === 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function waitForConstructPostgresHealthy({
|
|
366
|
+
spawnSyncFn = spawnSync,
|
|
367
|
+
maxRetries = CONSTRUCT_PG_HEALTH_RETRIES,
|
|
368
|
+
intervalMs = CONSTRUCT_PG_HEALTH_INTERVAL_MS,
|
|
369
|
+
} = {}) {
|
|
370
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
371
|
+
if (isConstructPostgresHealthy(spawnSyncFn)) return true;
|
|
372
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
|
|
378
|
+
const composeRunner = detectDockerComposeFn();
|
|
379
|
+
if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
|
|
380
|
+
|
|
381
|
+
const composeFile = constructPgComposePath(rootDir);
|
|
382
|
+
if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct setup first' };
|
|
383
|
+
|
|
384
|
+
const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'up', '-d'];
|
|
385
|
+
const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
386
|
+
if (r.status === 0) return { status: 'started' };
|
|
387
|
+
return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose up failed' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function stopConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
|
|
391
|
+
const composeRunner = detectDockerComposeFn();
|
|
392
|
+
if (!composeRunner) return { status: 'skipped', note: 'Docker not available' };
|
|
393
|
+
|
|
394
|
+
const composeFile = constructPgComposePath(rootDir);
|
|
395
|
+
if (!fs.existsSync(composeFile)) return { status: 'skipped', note: 'no compose file' };
|
|
396
|
+
|
|
397
|
+
const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'down'];
|
|
398
|
+
const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
399
|
+
if (r.status === 0) return { status: 'stopped' };
|
|
400
|
+
return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose down failed' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function checkPgvectorEnabled(spawnSyncFn = spawnSync) {
|
|
404
|
+
const result = spawnSyncFn('docker', [
|
|
405
|
+
'exec', CONSTRUCT_PG_CONTAINER, 'psql', '-U', 'construct', '-d', 'construct',
|
|
406
|
+
'-tAc', "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'vector')",
|
|
407
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
408
|
+
return result.stdout?.trim() === 't';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* On macOS, when the Docker CLI is installed but the daemon is down,
|
|
413
|
+
* launch Docker Desktop and poll `docker info` up to `timeoutMs` for
|
|
414
|
+
* the daemon to come up. Opt-out via `CONSTRUCT_DISABLE_DOCKER_AUTOSTART=1`
|
|
415
|
+
* so users who keep Docker off aren't forced into it. No-ops on Linux
|
|
416
|
+
* (systemd needs sudo) and Windows (different launcher).
|
|
417
|
+
*/
|
|
418
|
+
async function tryStartDockerDaemon({
|
|
419
|
+
env = process.env,
|
|
420
|
+
spawnSyncFn = spawnSync,
|
|
421
|
+
timeoutMs = 60_000,
|
|
422
|
+
pollMs = 1500,
|
|
423
|
+
} = {}) {
|
|
424
|
+
if (env.CONSTRUCT_DISABLE_DOCKER_AUTOSTART === '1') return false;
|
|
425
|
+
if (process.platform !== 'darwin') return false;
|
|
426
|
+
|
|
427
|
+
// CLI must be present; without it, nothing to start.
|
|
428
|
+
if (spawnSyncFn('command -v docker', { stdio: 'ignore', shell: true }).status !== 0) return false;
|
|
429
|
+
|
|
430
|
+
// If the daemon is already up, no work needed.
|
|
431
|
+
if (spawnSyncFn('docker', ['info'], { stdio: 'ignore' }).status === 0) return true;
|
|
432
|
+
|
|
433
|
+
// Best-effort launch. `open -a Docker` returns immediately; the daemon
|
|
434
|
+
// takes 5-30s to come fully up so we poll.
|
|
435
|
+
spawnSyncFn('open', ['-a', 'Docker'], { stdio: 'ignore' });
|
|
436
|
+
|
|
437
|
+
const deadline = Date.now() + timeoutMs;
|
|
438
|
+
while (Date.now() < deadline) {
|
|
439
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
440
|
+
if (spawnSyncFn('docker', ['info'], { stdio: 'ignore' }).status === 0) return true;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function startServices({
|
|
446
|
+
rootDir,
|
|
447
|
+
homeDir = os.homedir(),
|
|
448
|
+
describeRuntimeSupportFn = describeRuntimeSupport,
|
|
449
|
+
getRuntimePortsFn = getRuntimePorts,
|
|
450
|
+
startDashboardFn = startDashboard,
|
|
451
|
+
detectDockerComposeFn = detectDockerCompose,
|
|
452
|
+
loadConstructEnvFn = loadConstructEnv,
|
|
453
|
+
spawnDetachedFn = spawnDetached,
|
|
454
|
+
verifyTelemetryKeysFn = verifyTelemetryKeys,
|
|
455
|
+
memoryProbeFn = isMemoryRunning,
|
|
456
|
+
openCodeProbeFn = isOpenCodeRunning,
|
|
457
|
+
runPressureReleaseFn = runPressureRelease,
|
|
458
|
+
tryStartDockerDaemonFn = tryStartDockerDaemon,
|
|
459
|
+
} = {}) {
|
|
460
|
+
const support = await describeRuntimeSupportFn();
|
|
461
|
+
const ports = await getRuntimePortsFn(homeDir);
|
|
149
462
|
const envPath = getUserEnvPath(homeDir);
|
|
150
463
|
|
|
464
|
+
// On macOS, auto-launch Docker Desktop when the CLI is present but the
|
|
465
|
+
// daemon is down. Polls up to 60s so downstream Postgres startup sees a working
|
|
466
|
+
// compose runner instead of "Docker not available".
|
|
467
|
+
await tryStartDockerDaemonFn({ env: process.env });
|
|
468
|
+
|
|
151
469
|
writeEnvValues(envPath, {
|
|
152
470
|
DASHBOARD_PORT: String(ports.dashboard),
|
|
153
471
|
MEMORY_PORT: String(ports.memory),
|
|
154
472
|
BRIDGE_PORT: String(ports.bridge),
|
|
155
473
|
});
|
|
156
474
|
|
|
475
|
+
// Construct Postgres — start if DATABASE_URL points to managed container
|
|
476
|
+
const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
|
|
157
477
|
const results = [];
|
|
478
|
+
const pressureReport = runPressureReleaseFn({ env: liveEnv });
|
|
479
|
+
if (pressureReport?.killed?.length) {
|
|
480
|
+
results.push({
|
|
481
|
+
name: 'Pressure Guard',
|
|
482
|
+
url: 'local://cleanup',
|
|
483
|
+
status: pressureReport.pressureTriggered ? 'started' : 'reused',
|
|
484
|
+
note: `terminated ${pressureReport.killed.length} stale process(es)`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const databaseUrl = liveEnv.DATABASE_URL || '';
|
|
488
|
+
const usesManagedPostgres = isManagedConstructPostgresUrl(databaseUrl);
|
|
489
|
+
|
|
490
|
+
if (usesManagedPostgres) {
|
|
491
|
+
if (!isConstructPostgresRunning()) {
|
|
492
|
+
const pgStart = startConstructPostgres({ rootDir, homeDir, spawnSyncFn: spawnSync, detectDockerComposeFn: detectDockerComposeFn });
|
|
493
|
+
if (pgStart.status === 'started') {
|
|
494
|
+
const healthy = await waitForConstructPostgresHealthy();
|
|
495
|
+
if (healthy) {
|
|
496
|
+
const pgvector = checkPgvectorEnabled();
|
|
497
|
+
results.push({
|
|
498
|
+
name: 'Postgres',
|
|
499
|
+
url: `postgresql://127.0.0.1:${CONSTRUCT_PG_PORT}/construct`,
|
|
500
|
+
status: pgvector ? 'started' : 'degraded',
|
|
501
|
+
note: pgvector ? 'pgvector enabled' : 'pgvector not installed — semantic search unavailable',
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
results.push({ name: 'Postgres', status: 'degraded', note: 'container started but health check timed out' });
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
results.push({ name: 'Postgres', status: 'error', note: pgStart.note });
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
const pgvector = checkPgvectorEnabled();
|
|
511
|
+
results.push({
|
|
512
|
+
name: 'Postgres',
|
|
513
|
+
url: `postgresql://127.0.0.1:${CONSTRUCT_PG_PORT}/construct`,
|
|
514
|
+
status: 'reused',
|
|
515
|
+
note: pgvector ? 'pgvector enabled' : 'pgvector not installed',
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
} else if (databaseUrl) {
|
|
519
|
+
results.push({ name: 'Postgres', url: databaseUrl, status: 'configured', note: 'external database' });
|
|
520
|
+
}
|
|
158
521
|
|
|
159
522
|
// Dashboard
|
|
160
|
-
const dashboard = await
|
|
523
|
+
const dashboard = await startDashboardFn({ rootDir, homeDir, preferredPort: ports.dashboard });
|
|
161
524
|
results.push({
|
|
162
525
|
name: 'Dashboard',
|
|
163
526
|
url: dashboard.url,
|
|
164
527
|
status: dashboard.reused ? 'reused' : 'started',
|
|
165
528
|
});
|
|
166
529
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
const langfuseUrl = liveEnv.LANGFUSE_BASEURL ?? '';
|
|
170
|
-
const isRemoteLangfuse = langfuseUrl && !langfuseUrl.includes('localhost') && !langfuseUrl.includes('127.0.0.1');
|
|
530
|
+
// Telemetry backend — delegated to lib/services/telemetry-backend.mjs.
|
|
531
|
+
// Remote URLs short-circuit inside startManagedServices and return `configured`.
|
|
171
532
|
|
|
172
|
-
|
|
173
|
-
|
|
533
|
+
const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
|
|
534
|
+
if (telemetryUrl && isRemoteTelemetry(telemetryUrl)) {
|
|
535
|
+
results.push({
|
|
536
|
+
name: 'Telemetry',
|
|
537
|
+
url: telemetryUrl,
|
|
538
|
+
status: 'configured',
|
|
539
|
+
note: `remote — ${telemetryUrl}`,
|
|
540
|
+
});
|
|
174
541
|
} else {
|
|
175
|
-
const composeRunner =
|
|
542
|
+
const composeRunner = detectDockerComposeFn();
|
|
176
543
|
if (composeRunner) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
LANGFUSE_BASEURL: LANGFUSE_LOCAL_BASEURL,
|
|
186
|
-
LANGFUSE_PUBLIC_KEY: LANGFUSE_LOCAL_PUBLIC_KEY,
|
|
187
|
-
LANGFUSE_SECRET_KEY: LANGFUSE_LOCAL_SECRET_KEY,
|
|
188
|
-
});
|
|
189
|
-
results.push({ name: 'Langfuse', url: LANGFUSE_LOCAL_BASEURL, status: 'started', note: 'containers starting — allow 30-60s' });
|
|
190
|
-
} else {
|
|
191
|
-
const msg = (r.stderr || r.stdout || 'docker compose failed').trim().split('\n')[0];
|
|
192
|
-
results.push({ name: 'Langfuse', status: 'failed', note: msg });
|
|
193
|
-
}
|
|
544
|
+
const svc = await startManagedServices({
|
|
545
|
+
rootDir,
|
|
546
|
+
homeDir,
|
|
547
|
+
env: liveEnv,
|
|
548
|
+
composeRunner,
|
|
549
|
+
spawnDetached: spawnDetachedFn,
|
|
550
|
+
});
|
|
551
|
+
results.push({ name: 'Telemetry', url: svc.url, status: svc.status, note: svc.note });
|
|
194
552
|
} else {
|
|
195
|
-
results.push({ name: '
|
|
553
|
+
results.push({ name: 'Telemetry', status: 'unavailable', note: 'Docker not available' });
|
|
196
554
|
}
|
|
197
555
|
}
|
|
198
556
|
|
|
199
557
|
// Memory (cm)
|
|
200
558
|
if (support.cm) {
|
|
201
|
-
|
|
202
|
-
|
|
559
|
+
if (await memoryProbeFn(ports.memory)) {
|
|
560
|
+
results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'reused' });
|
|
561
|
+
} else {
|
|
562
|
+
spawnDetachedFn('cm', ['serve', '--port', String(ports.memory)], homeDir, 'cm.log');
|
|
563
|
+
results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'started' });
|
|
564
|
+
}
|
|
203
565
|
} else {
|
|
204
|
-
results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run:
|
|
566
|
+
results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run: construct setup or brew install dicklesworthstone/tap/cm' });
|
|
205
567
|
}
|
|
206
568
|
|
|
207
569
|
// OpenCode (optional)
|
|
208
570
|
if (support.opencode) {
|
|
209
|
-
|
|
210
|
-
|
|
571
|
+
if (await openCodeProbeFn(ports.bridge)) {
|
|
572
|
+
results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'reused' });
|
|
573
|
+
} else {
|
|
574
|
+
spawnDetachedFn('opencode', ['serve', '--port', String(ports.bridge)], homeDir, 'opencode.log');
|
|
575
|
+
results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'started' });
|
|
576
|
+
}
|
|
211
577
|
}
|
|
212
578
|
|
|
213
|
-
|
|
214
|
-
|
|
579
|
+
const doctor = startDoctor({ rootDir, homeDir });
|
|
580
|
+
if (doctor.started) {
|
|
581
|
+
results.push({ name: 'Doctor', status: 'started', note: `L0 daemon · logs: ${doctor.logPath}` });
|
|
582
|
+
} else if (doctor.reused) {
|
|
583
|
+
results.push({ name: 'Doctor', status: 'reused', note: `pid ${doctor.pid}` });
|
|
584
|
+
} else if (doctor.reason !== 'disabled') {
|
|
585
|
+
results.push({ name: 'Doctor', status: 'unavailable', note: doctor.reason });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
emitServiceFailures(results);
|
|
215
589
|
|
|
216
|
-
export async function stopServices({ homeDir = os.homedir() } = {}) {
|
|
217
|
-
const dashboard = stopDashboard(homeDir);
|
|
218
590
|
return {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
dashboard,
|
|
591
|
+
support,
|
|
592
|
+
ports,
|
|
593
|
+
results,
|
|
594
|
+
recovery: buildRuntimeRecoverySummary({ rootDir, homeDir, results, env: liveEnv }),
|
|
224
595
|
};
|
|
225
596
|
}
|
|
597
|
+
|
|
598
|
+
// Surface service probe failures into the role-framework event bus so cx-sre
|
|
599
|
+
// can be invoked when a critical service is down. Non-blocking, best effort.
|
|
600
|
+
|
|
601
|
+
async function emitServiceFailures(results) {
|
|
602
|
+
if (process.env.CONSTRUCT_ROLES === 'off') return;
|
|
603
|
+
const failed = results.filter((r) => r.status === 'error');
|
|
604
|
+
if (failed.length === 0) return;
|
|
605
|
+
try {
|
|
606
|
+
const { emitRoleEvent } = await import('./roles/hook-emit.mjs');
|
|
607
|
+
for (const r of failed) {
|
|
608
|
+
emitRoleEvent({
|
|
609
|
+
type: 'service.down',
|
|
610
|
+
summary: `${r.name} ${r.status}: ${r.note || 'no detail'}`,
|
|
611
|
+
hookInput: {},
|
|
612
|
+
context: { service: r.name, note: r.note, url: r.url },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
} catch { /* best effort */ }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Exported for testing only
|
|
619
|
+
export { verifyTelemetryKeys as _verifyTelemetryKeys, pruneStashDir as _pruneStashDir };
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Kill every process listening on a given TCP port (best-effort, non-fatal).
|
|
623
|
+
* Returns true if at least one PID was killed.
|
|
624
|
+
*/
|
|
625
|
+
function killPortOwners(port, spawnSyncFn = spawnSync) {
|
|
626
|
+
if (!port || !Number.isInteger(port) || port <= 0) return false;
|
|
627
|
+
try {
|
|
628
|
+
// lsof works on macOS and Linux; -t returns just PIDs
|
|
629
|
+
const result = spawnSyncFn('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
|
|
630
|
+
const pids = (result.stdout || '').trim().split(/\s+/).filter(Boolean).map(Number).filter((n) => n > 0);
|
|
631
|
+
if (pids.length === 0) return false;
|
|
632
|
+
for (const pid of pids) {
|
|
633
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
} catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function stopServices({
|
|
642
|
+
homeDir = os.homedir(),
|
|
643
|
+
rootDir,
|
|
644
|
+
spawnSyncFn = spawnSync,
|
|
645
|
+
detectDockerComposeFn = detectDockerCompose,
|
|
646
|
+
} = {}) {
|
|
647
|
+
const results = [];
|
|
648
|
+
|
|
649
|
+
// Stash construct Postgres data before any container is stopped so data
|
|
650
|
+
// survives Docker restarts and machine reboots.
|
|
651
|
+
const constructDbStash = stashConstructDb({ homeDir, spawnSyncFn });
|
|
652
|
+
|
|
653
|
+
// Construct Postgres
|
|
654
|
+
const pgStop = stopConstructPostgres({ rootDir, homeDir, spawnSyncFn, detectDockerComposeFn });
|
|
655
|
+
results.push({ name: 'Postgres', status: pgStop.status });
|
|
656
|
+
|
|
657
|
+
// Doctor (L0 daemon)
|
|
658
|
+
const doctor = stopDoctor(homeDir);
|
|
659
|
+
if (doctor.stopped) {
|
|
660
|
+
results.push({ name: 'Doctor', status: 'stopped', note: `pid ${doctor.pid}` });
|
|
661
|
+
} else if (doctor.reason === 'stale-state') {
|
|
662
|
+
results.push({ name: 'Doctor', status: 'cleaned', note: 'stale state file removed' });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Dashboard
|
|
666
|
+
const dashboard = stopDashboard(homeDir);
|
|
667
|
+
if (dashboard.stopped) {
|
|
668
|
+
results.push({ name: 'Dashboard', status: 'stopped', note: `pid ${dashboard.pid}, port ${dashboard.port}` });
|
|
669
|
+
} else if (dashboard.reason === 'stale-state') {
|
|
670
|
+
results.push({ name: 'Dashboard', status: 'cleaned', note: 'stale state file removed (process already gone)' });
|
|
671
|
+
} else {
|
|
672
|
+
results.push({ name: 'Dashboard', status: 'not-running' });
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Memory (cm) — find port from config.env or fall back to default
|
|
676
|
+
const envPath = getUserEnvPath(homeDir);
|
|
677
|
+
const envValues = parseEnvFile(envPath);
|
|
678
|
+
const memoryPort = Number(envValues.MEMORY_PORT) || 8765;
|
|
679
|
+
const cmKilled = killPortOwners(memoryPort, spawnSyncFn);
|
|
680
|
+
results.push({ name: 'Memory (cm)', status: cmKilled ? 'stopped' : 'not-running' });
|
|
681
|
+
|
|
682
|
+
// OpenCode — find port from config.env or fall back to default
|
|
683
|
+
const bridgePort = Number(envValues.BRIDGE_PORT) || 5173;
|
|
684
|
+
const openCodeKilled = killPortOwners(bridgePort, spawnSyncFn);
|
|
685
|
+
results.push({ name: 'OpenCode', status: openCodeKilled ? 'stopped' : 'not-running' });
|
|
686
|
+
|
|
687
|
+
const stopped = results.filter((r) => r.status === 'stopped' || r.status === 'cleaned').map((r) => r.name);
|
|
688
|
+
return { stopped, results, constructDbStash };
|
|
689
|
+
}
|