@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
|
@@ -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
|
+
}
|