@geraldmaron/construct 1.0.12 → 1.0.15
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/README.md +14 -2
- package/bin/construct +262 -14
- package/bin/construct-postinstall.mjs +23 -2
- package/commands/build/feature.md +0 -6
- package/commands/build/fix.md +0 -6
- package/commands/design/access.md +0 -6
- package/commands/design/flow.md +0 -6
- package/commands/design/ui.md +0 -6
- package/commands/measure/experiment.md +0 -6
- package/commands/measure/metrics.md +0 -6
- package/commands/measure/results.md +0 -6
- package/commands/plan/api.md +0 -6
- package/commands/plan/challenge.md +0 -6
- package/commands/plan/decide.md +0 -6
- package/commands/plan/feature.md +0 -7
- package/commands/plan/requirements.md +0 -6
- package/commands/remember/context.md +0 -6
- package/commands/remember/handoff.md +0 -6
- package/commands/remember/runbook.md +0 -6
- package/commands/review/code.md +0 -6
- package/commands/review/quality.md +0 -6
- package/commands/review/security.md +0 -6
- package/commands/ship/ready.md +0 -6
- package/commands/ship/release.md +0 -6
- package/commands/ship/status.md +0 -6
- package/commands/understand/docs.md +0 -6
- package/commands/understand/research.md +0 -6
- package/commands/understand/this.md +0 -6
- package/commands/understand/why.md +0 -6
- package/commands/work/clean.md +0 -6
- package/commands/work/drive.md +0 -6
- package/commands/work/optimize-prompts.md +0 -6
- package/commands/work/parallel-review.md +0 -6
- package/lib/auto-docs.mjs +11 -7
- package/lib/beads-client.mjs +19 -1
- package/lib/comment-lint.mjs +5 -1
- package/lib/context-state.mjs +15 -3
- package/lib/contracts/validate.mjs +54 -10
- package/lib/contracts/violation-log.mjs +154 -0
- package/lib/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- package/lib/document-extract/docling-client.mjs +114 -0
- package/lib/document-extract/docling-sidecar.py +122 -0
- package/lib/document-extract/whisper-client.mjs +79 -0
- package/lib/document-extract.mjs +73 -8
- package/lib/document-ingest.mjs +26 -4
- package/lib/embed/daemon.mjs +26 -1
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/flavors/loader.mjs +1 -1
- package/lib/gates-audit.mjs +18 -12
- package/lib/handoffs/contract.mjs +2 -2
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +49 -2
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/ingest/chunker.mjs +94 -0
- package/lib/ingest/pipeline.mjs +53 -0
- package/lib/ingest/store.mjs +82 -0
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/knowledge/search.mjs +12 -0
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/server.mjs +142 -1
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/skills.mjs +6 -3
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/mcp/tools/workflow.mjs +6 -2
- package/lib/observation-store.mjs +14 -5
- package/lib/ollama-manager.mjs +32 -28
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/prompt-composer.js +11 -1
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/runtime/uv-bootstrap.mjs +129 -0
- package/lib/runtime/whisper-bootstrap.mjs +102 -0
- package/lib/server/index.mjs +53 -10
- package/lib/server/static/index.html +1 -15
- package/lib/specialists/postconditions.mjs +1 -1
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/sync/skill-frontmatter.mjs +113 -21
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +377 -0
- package/lib/validators/skills.mjs +86 -54
- package/lib/worker/trace.mjs +19 -2
- package/package.json +10 -6
- package/personas/construct.md +4 -7
- package/platforms/claude/settings.template.json +29 -28
- package/rules/common/beads-hygiene.md +3 -9
- package/rules/common/code-review.md +3 -6
- package/rules/common/coding-style.md +3 -6
- package/rules/common/comments.md +3 -7
- package/rules/common/commit-approval.md +3 -6
- package/rules/common/cx-agent-routing.md +3 -7
- package/rules/common/cx-skill-routing.md +3 -3
- package/rules/common/doc-ownership.md +3 -8
- package/rules/common/efficiency.md +3 -8
- package/rules/common/framing.md +3 -8
- package/rules/common/git-workflow.md +3 -5
- package/rules/common/no-fabrication.md +4 -12
- package/rules/common/patterns.md +3 -5
- package/rules/common/release-gates.md +3 -8
- package/rules/common/research.md +3 -8
- package/rules/common/review-before-change.md +3 -9
- package/rules/common/security.md +3 -6
- package/rules/common/skill-composition.md +3 -7
- package/rules/common/testing.md +3 -6
- package/rules/golang/coding-style.md +1 -5
- package/rules/golang/hooks.md +1 -5
- package/rules/golang/patterns.md +1 -5
- package/rules/golang/security.md +1 -5
- package/rules/golang/testing.md +1 -5
- package/rules/python/coding-style.md +1 -5
- package/rules/python/hooks.md +1 -5
- package/rules/python/patterns.md +1 -5
- package/rules/python/security.md +1 -5
- package/rules/python/testing.md +1 -5
- package/rules/swift/coding-style.md +1 -5
- package/rules/swift/hooks.md +1 -5
- package/rules/swift/patterns.md +1 -5
- package/rules/swift/security.md +1 -5
- package/rules/swift/testing.md +1 -5
- package/rules/typescript/coding-style.md +1 -5
- package/rules/typescript/hooks.md +1 -5
- package/rules/typescript/patterns.md +1 -5
- package/rules/typescript/security.md +1 -5
- package/rules/typescript/testing.md +1 -5
- package/rules/web/coding-style.md +3 -5
- package/rules/web/design-quality.md +3 -5
- package/rules/web/hooks.md +3 -5
- package/rules/web/patterns.md +3 -5
- package/rules/web/performance.md +3 -5
- package/rules/web/security.md +3 -5
- package/rules/web/testing.md +3 -5
- package/scripts/sync-specialists.mjs +269 -75
- package/skills/ai/agent-dev.md +4 -5
- package/skills/ai/llm-security.md +4 -5
- package/skills/ai/ml-ops.md +4 -5
- package/skills/ai/orchestration-workflow.md +4 -5
- package/skills/ai/prompt-and-eval.md +4 -5
- package/skills/ai/prompt-optimizer.md +4 -6
- package/skills/ai/rag-system.md +4 -5
- package/skills/architecture/api-design.md +4 -5
- package/skills/architecture/caching.md +4 -5
- package/skills/architecture/cloud-native.md +4 -5
- package/skills/architecture/message-queue.md +4 -5
- package/skills/architecture/security-arch.md +4 -5
- package/skills/compliance/ai-disclosure.md +4 -5
- package/skills/compliance/data-privacy.md +4 -5
- package/skills/compliance/license-audit.md +4 -5
- package/skills/compliance/regulatory-review.md +4 -5
- package/skills/development/cpp.md +4 -5
- package/skills/development/go.md +4 -5
- package/skills/development/java.md +4 -5
- package/skills/development/kotlin.md +4 -5
- package/skills/development/mobile-crossplatform.md +4 -5
- package/skills/development/python.md +4 -5
- package/skills/development/rust.md +4 -5
- package/skills/development/shell.md +4 -5
- package/skills/development/swift.md +4 -5
- package/skills/development/typescript.md +4 -5
- package/skills/devops/ci-cd.md +4 -5
- package/skills/devops/containerization.md +4 -5
- package/skills/devops/cost-optimization.md +4 -5
- package/skills/devops/data-engineering.md +4 -5
- package/skills/devops/database.md +4 -5
- package/skills/devops/dependency-management.md +4 -5
- package/skills/devops/devsecops.md +4 -5
- package/skills/devops/git-workflow.md +4 -5
- package/skills/devops/incident-response.md +4 -5
- package/skills/devops/monorepo.md +4 -5
- package/skills/devops/observability.md +4 -5
- package/skills/devops/performance.md +4 -5
- package/skills/devops/testing.md +4 -5
- package/skills/docs/adr-workflow.md +4 -7
- package/skills/docs/backlog-proposal-workflow.md +4 -3
- package/skills/docs/customer-profile-workflow.md +4 -3
- package/skills/docs/document-ingest-workflow.md +4 -3
- package/skills/docs/evidence-ingest-workflow.md +4 -3
- package/skills/docs/init-docs.md +4 -5
- package/skills/docs/init-project.md +4 -5
- package/skills/docs/prd-workflow.md +4 -7
- package/skills/docs/prfaq-workflow.md +4 -3
- package/skills/docs/product-intelligence-review.md +4 -3
- package/skills/docs/product-intelligence-workflow.md +4 -6
- package/skills/docs/product-signal-workflow.md +4 -5
- package/skills/docs/research-workflow.md +4 -5
- package/skills/docs/runbook-workflow.md +4 -5
- package/skills/docs/strategy-workflow.md +4 -5
- package/skills/exploration/dependency-graph-reading.md +4 -7
- package/skills/exploration/repo-map.md +4 -5
- package/skills/exploration/tracer-bullet-method.md +4 -7
- package/skills/exploration/unknown-codebase-onboarding.md +4 -7
- package/skills/frameworks/django.md +4 -5
- package/skills/frameworks/nextjs.md +4 -5
- package/skills/frameworks/react.md +4 -5
- package/skills/frameworks/spring-boot.md +4 -5
- package/skills/frontend-design/accessibility.md +4 -5
- package/skills/frontend-design/component-patterns.md +4 -5
- package/skills/frontend-design/engineering.md +4 -5
- package/skills/frontend-design/state-management.md +4 -5
- package/skills/frontend-design/ui-aesthetics.md +4 -5
- package/skills/frontend-design/ux-principles.md +4 -5
- package/skills/operating/change-management.md +4 -8
- package/skills/operating/incident-response.md +4 -8
- package/skills/operating/oncall-rotation.md +4 -7
- package/skills/operating/orchestration-reference.md +4 -10
- package/skills/quality-gates/review-work.md +4 -5
- package/skills/quality-gates/verify-change.md +4 -5
- package/skills/quality-gates/verify-module.md +4 -5
- package/skills/quality-gates/verify-quality.md +4 -5
- package/skills/quality-gates/verify-security.md +4 -5
- package/skills/roles/architect.ai-systems.md +6 -9
- package/skills/roles/architect.data.md +6 -9
- package/skills/roles/architect.enterprise.md +6 -9
- package/skills/roles/architect.integration.md +6 -9
- package/skills/roles/architect.md +7 -14
- package/skills/roles/architect.platform.md +6 -9
- package/skills/roles/data-analyst.experiment.md +6 -9
- package/skills/roles/data-analyst.md +6 -9
- package/skills/roles/data-analyst.product-intelligence.md +7 -9
- package/skills/roles/data-analyst.product.md +6 -9
- package/skills/roles/data-analyst.telemetry.md +7 -9
- package/skills/roles/data-engineer.pipeline.md +6 -9
- package/skills/roles/data-engineer.vector-retrieval.md +7 -9
- package/skills/roles/data-engineer.warehouse.md +6 -9
- package/skills/roles/debugger.md +6 -9
- package/skills/roles/designer.accessibility.md +6 -9
- package/skills/roles/designer.md +7 -9
- package/skills/roles/engineer.ai.md +6 -9
- package/skills/roles/engineer.data.md +6 -9
- package/skills/roles/engineer.md +9 -9
- package/skills/roles/engineer.platform.md +6 -9
- package/skills/roles/operator.docs.md +6 -9
- package/skills/roles/operator.md +9 -9
- package/skills/roles/operator.release.md +6 -9
- package/skills/roles/operator.sre.md +6 -9
- package/skills/roles/orchestrator.md +6 -9
- package/skills/roles/product-manager.ai-product.md +6 -9
- package/skills/roles/product-manager.business-strategy.md +6 -9
- package/skills/roles/product-manager.enterprise.md +6 -9
- package/skills/roles/product-manager.growth.md +7 -9
- package/skills/roles/product-manager.md +7 -9
- package/skills/roles/product-manager.platform.md +6 -9
- package/skills/roles/product-manager.product.md +6 -9
- package/skills/roles/qa.ai-eval.md +8 -9
- package/skills/roles/qa.api-contract.md +7 -9
- package/skills/roles/qa.data-pipeline.md +7 -9
- package/skills/roles/qa.md +7 -9
- package/skills/roles/qa.test-automation.md +6 -9
- package/skills/roles/qa.web-ui.md +7 -9
- package/skills/roles/researcher.explorer.md +6 -9
- package/skills/roles/researcher.md +8 -9
- package/skills/roles/researcher.ux.md +6 -9
- package/skills/roles/reviewer.devil-advocate.md +6 -9
- package/skills/roles/reviewer.evaluator.md +6 -9
- package/skills/roles/reviewer.md +9 -9
- package/skills/roles/reviewer.trace.md +6 -9
- package/skills/roles/security.ai.md +7 -9
- package/skills/roles/security.appsec.md +6 -9
- package/skills/roles/security.cloud.md +6 -9
- package/skills/roles/security.legal-compliance.md +6 -9
- package/skills/roles/security.md +7 -9
- package/skills/roles/security.privacy.md +7 -9
- package/skills/roles/security.supply-chain.md +7 -9
- package/skills/security/blue-team.md +4 -5
- package/skills/security/code-audit.md +4 -5
- package/skills/security/pentest.md +4 -5
- package/skills/security/red-team.md +4 -5
- package/skills/security/threat-intel.md +4 -5
- package/skills/security/vuln-research.md +4 -5
- package/skills/strategy/competitive-landscape.md +4 -8
- package/skills/strategy/market-research-methods.md +4 -8
- package/skills/strategy/narrative-arc.md +4 -8
- package/skills/strategy/pricing-positioning.md +4 -8
- package/skills/utility/clean-code.md +4 -5
- package/specialists/policy-inventory.json +14 -0
- package/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- package/lib/server/static/assets/index-f0c80a2b.css +0 -1
- package/lib/specialist-contracts-enforce.mjs +0 -158
- package/rules/common/agents.md +0 -28
- package/rules/common/development-workflow.md +0 -32
- package/rules/common/performance.md +0 -55
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A profile is a curated description of an org's work loop, intake taxonomy,
|
|
5
5
|
* role set, and rebrand language. Creating one is a research task, not a
|
|
6
|
-
* code task.
|
|
6
|
+
* code task. The module enforces that by producing a draft + a requirements
|
|
7
7
|
* brief that names which existing Construct specialists are expected to
|
|
8
8
|
* complete each section. Operators dispatch those specialists, collect the
|
|
9
9
|
* answers, then promote the draft.
|
|
@@ -16,12 +16,17 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Health: per-profile observation and outcome counts pulled from the existing
|
|
18
18
|
* stores, so profile health travels alongside the rest of the learning loops.
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle events: createDraftProfile and archiveProfile each fire a
|
|
21
|
+
* profile.updated role event so subscribers can react to taxonomy changes
|
|
22
|
+
* without polling the filesystem.
|
|
19
23
|
*/
|
|
20
24
|
import fs from 'node:fs';
|
|
21
25
|
import path from 'node:path';
|
|
22
26
|
import { fileURLToPath } from 'node:url';
|
|
23
27
|
|
|
24
28
|
import { listProfiles, loadProfile } from './loader.mjs';
|
|
29
|
+
import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
|
|
25
30
|
|
|
26
31
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
27
32
|
const REPO_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
@@ -256,9 +261,21 @@ export function createDraftProfile({ cwd, id, displayName, seedRoles = [], seedD
|
|
|
256
261
|
departmentPaths.push(p);
|
|
257
262
|
}
|
|
258
263
|
|
|
264
|
+
emitProfileUpdated({ id, stage: 'draft', dir });
|
|
259
265
|
return { dir, briefPath, draftPath, personaPaths, departmentPaths };
|
|
260
266
|
}
|
|
261
267
|
|
|
268
|
+
// Lifecycle bridge: emits profile.updated whenever the curated catalog gains
|
|
269
|
+
// or loses an entry, so subscribers (none by default; project overlays can
|
|
270
|
+
// bind) can react to taxonomy changes.
|
|
271
|
+
|
|
272
|
+
function emitProfileUpdated(context) {
|
|
273
|
+
emitRoleEvent('profile.updated', {
|
|
274
|
+
summary: `profile ${context.stage}: ${context.id}`,
|
|
275
|
+
context,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
262
279
|
/**
|
|
263
280
|
* List drafts under .cx/profiles/. Returns [{ id, dir, hasProfile, hasBrief }].
|
|
264
281
|
*/
|
|
@@ -320,6 +337,7 @@ export function archiveProfile({ id, reason }) {
|
|
|
320
337
|
'Observations and outcomes recorded under this profile remain in `.cx/observations/` and `.cx/outcomes/` and continue to be searchable. The intake table and profile JSON were moved into this directory. To restore: move the files back to their original paths and re-run `npm run lint:profiles`.',
|
|
321
338
|
'',
|
|
322
339
|
].join('\n'));
|
|
340
|
+
emitProfileUpdated({ id, stage: 'archived', dir: dstDir, reason: reason.trim() });
|
|
323
341
|
return { archived: dstDir };
|
|
324
342
|
}
|
|
325
343
|
|
|
@@ -11,14 +11,14 @@ import path from "node:path";
|
|
|
11
11
|
|
|
12
12
|
import { stampFrontmatter } from "./doc-stamp.mjs";
|
|
13
13
|
|
|
14
|
-
export function writeStampedIfMissing({ targetRoot, created, skipped, filePath, content, generator }) {
|
|
14
|
+
export function writeStampedIfMissing({ targetRoot, created, skipped, filePath, content, generator, attribution = null }) {
|
|
15
15
|
if (fs.existsSync(filePath)) {
|
|
16
16
|
skipped.push(path.relative(targetRoot, filePath));
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
20
20
|
const stamped = filePath.endsWith(".md")
|
|
21
|
-
? stampFrontmatter(content, { generator })
|
|
21
|
+
? stampFrontmatter(content, { generator, attribution })
|
|
22
22
|
: content;
|
|
23
23
|
fs.writeFileSync(filePath, stamped, "utf8");
|
|
24
24
|
created.push(path.relative(targetRoot, filePath));
|
|
@@ -270,8 +270,8 @@ instead of letting it turn into a historical log. Durable task status belongs in
|
|
|
270
270
|
`;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
export function buildContextJson(projectName) {
|
|
274
|
-
|
|
273
|
+
export function buildContextJson(projectName, { attribution = null } = {}) {
|
|
274
|
+
const base = {
|
|
275
275
|
format: "json",
|
|
276
276
|
savedAt: new Date().toISOString(),
|
|
277
277
|
source: "construct-init",
|
|
@@ -280,5 +280,11 @@ export function buildContextJson(projectName) {
|
|
|
280
280
|
recentDecisions: [],
|
|
281
281
|
architectureNotes: [],
|
|
282
282
|
openQuestions: [],
|
|
283
|
-
}
|
|
283
|
+
};
|
|
284
|
+
if (attribution) {
|
|
285
|
+
base.createdBy = attribution.createdBy;
|
|
286
|
+
base.createdByAgent = attribution.createdByAgent;
|
|
287
|
+
base.createdAt = attribution.createdAt;
|
|
288
|
+
}
|
|
289
|
+
return `${JSON.stringify(base, null, 2)}\n`;
|
|
284
290
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/project-root.mjs — resolve "what Construct project am I in?" from any cwd.
|
|
3
|
+
*
|
|
4
|
+
* Writers that own per-project state (contract violations, audit reads, agent
|
|
5
|
+
* dispatches, intent verifications) call `resolveProjectScope()` to decide
|
|
6
|
+
* which `<project>/.cx/<file>.jsonl` to write to. When no project is
|
|
7
|
+
* detected, the call returns `null` and writers fall back to the legacy
|
|
8
|
+
* user-scope path (`~/.cx/<file>.jsonl`) so existing standalone hook
|
|
9
|
+
* invocations don't lose data.
|
|
10
|
+
*
|
|
11
|
+
* Writers that own cross-project telemetry (skill calls, role-pending,
|
|
12
|
+
* session cost) keep using `~/.cx/` but tag each entry with the
|
|
13
|
+
* `projectId` from `resolveProjectId()` so a reader can attribute the
|
|
14
|
+
* entry to a specific project later.
|
|
15
|
+
*
|
|
16
|
+
* The project marker is `.cx/` or `.construct/` at the project root. The
|
|
17
|
+
* lookup walks upward from cwd; the first ancestor matching either marker
|
|
18
|
+
* wins. Result is memoized per cwd so the hot path doesn't restat the
|
|
19
|
+
* filesystem on every append.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
const HOME = os.homedir();
|
|
28
|
+
const MARKERS = ['.cx', '.construct'];
|
|
29
|
+
const cache = new Map(); // cwd → { projectRoot, projectId, scope } | null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk upward from `start` until a directory containing `.cx/` or
|
|
33
|
+
* `.construct/` is found. Returns the absolute project-root path or null.
|
|
34
|
+
* Stops at the filesystem root and at $HOME (so `~/.cx/` doesn't make
|
|
35
|
+
* the user's entire home directory look like a project).
|
|
36
|
+
*/
|
|
37
|
+
export function findProjectRoot(start = process.cwd()) {
|
|
38
|
+
let dir = path.resolve(start);
|
|
39
|
+
const stop = path.resolve(HOME);
|
|
40
|
+
while (true) {
|
|
41
|
+
if (MARKERS.some((m) => fs.existsSync(path.join(dir, m)))) return dir;
|
|
42
|
+
if (dir === stop) return null;
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir) return null;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deterministic short identifier for a project. Stable across sessions on
|
|
51
|
+
* the same machine, derived from the absolute project-root path. Useful for
|
|
52
|
+
* tagging cross-project telemetry entries (`projectId: "a7c4f2"`) without
|
|
53
|
+
* leaking the absolute filesystem path.
|
|
54
|
+
*/
|
|
55
|
+
export function projectIdFor(projectRoot) {
|
|
56
|
+
if (!projectRoot) return null;
|
|
57
|
+
return createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the project scope for a writer call site. Returns:
|
|
62
|
+
* { projectRoot, projectId, cxDir }
|
|
63
|
+
* when the cwd (or a parent) is a Construct project. `cxDir` is the
|
|
64
|
+
* absolute path to `<projectRoot>/.cx/` (created on demand by callers).
|
|
65
|
+
* null
|
|
66
|
+
* when no project is detected; the caller should fall back to the
|
|
67
|
+
* legacy user-scope path.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveProjectScope(cwd = process.cwd()) {
|
|
70
|
+
if (cache.has(cwd)) return cache.get(cwd);
|
|
71
|
+
const projectRoot = findProjectRoot(cwd);
|
|
72
|
+
if (!projectRoot) {
|
|
73
|
+
cache.set(cwd, null);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const result = {
|
|
77
|
+
projectRoot,
|
|
78
|
+
projectId: projectIdFor(projectRoot),
|
|
79
|
+
cxDir: path.join(projectRoot, '.cx'),
|
|
80
|
+
};
|
|
81
|
+
cache.set(cwd, result);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* For project-scoped writers: returns the `<project>/.cx/<basename>` path
|
|
87
|
+
* when cwd is inside a Construct project, otherwise the legacy
|
|
88
|
+
* `~/.cx/<basename>` path. Callers pass just the file basename; this helper
|
|
89
|
+
* resolves the directory side. ensureDir=true creates the parent dir.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveProjectScopedPath(basename, { cwd, ensureDir = true } = {}) {
|
|
92
|
+
const scope = resolveProjectScope(cwd ?? process.cwd());
|
|
93
|
+
const dir = scope ? scope.cxDir : path.join(HOME, '.cx');
|
|
94
|
+
if (ensureDir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
return path.join(dir, basename);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Clear the memoization cache. Test-only — every other caller benefits
|
|
100
|
+
* from the cache surviving across calls in the same process.
|
|
101
|
+
*/
|
|
102
|
+
export function _resetCache() {
|
|
103
|
+
cache.clear();
|
|
104
|
+
}
|
package/lib/prompt-composer.js
CHANGED
|
@@ -94,7 +94,17 @@ function compactTokens(text, tokenLimit = 300, { modelId = 'default' } = {}) {
|
|
|
94
94
|
function readPromptBody(promptFile, rootDir) {
|
|
95
95
|
const filePath = path.join(rootDir, promptFile);
|
|
96
96
|
if (!fs.existsSync(filePath)) return '';
|
|
97
|
-
|
|
97
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
98
|
+
return stripLeadingYamlFrontmatter(raw).trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stripLeadingYamlFrontmatter(content) {
|
|
102
|
+
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return content;
|
|
103
|
+
const closeIdx = content.indexOf('\n---', 4);
|
|
104
|
+
if (closeIdx === -1) return content;
|
|
105
|
+
const afterClose = content.indexOf('\n', closeIdx + 1);
|
|
106
|
+
if (afterClose === -1) return '';
|
|
107
|
+
return content.slice(afterClose + 1);
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
function buildLearnedPatternsBlock(agentName, {
|
package/lib/roles/catalog.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const REGISTRY_PATH = join(__dirname, '..', '..', '
|
|
14
|
+
const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
|
|
15
15
|
|
|
16
16
|
let cached = null;
|
|
17
17
|
|
package/lib/roles/event-bus.mjs
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/roles/event-bus.mjs — append-only event log
|
|
2
|
+
* lib/roles/event-bus.mjs — append-only event log per project.
|
|
3
3
|
*
|
|
4
4
|
* Hook processes emit failure/signal events here. The router + gateway read
|
|
5
5
|
* recent events to decide escalation. Lines are rotated to keep the file
|
|
6
6
|
* bounded. Fingerprint = sha1(type + project + first summary line) so the
|
|
7
7
|
* same failure dedups across emits.
|
|
8
|
+
*
|
|
9
|
+
* Path resolution: events.jsonl lives at <project>/.cx/ when the cwd sits
|
|
10
|
+
* inside a Construct project, otherwise at ~/.cx/. Without per-project
|
|
11
|
+
* isolation, fingerprints from project A suppress real events in project B.
|
|
12
|
+
* Override the directory with CONSTRUCT_ROLES_ROOT for tests.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import { createHash } from 'node:crypto';
|
|
11
16
|
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
12
17
|
import { homedir } from 'node:os';
|
|
13
|
-
import { join } from 'node:path';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { resolveProjectScopedPath } from '../project-root.mjs';
|
|
14
20
|
|
|
15
21
|
const MAX_LINES = 1000;
|
|
16
22
|
|
|
17
|
-
function rootDir() {
|
|
18
|
-
return process.env.CONSTRUCT_ROLES_ROOT || join(homedir(), '.cx');
|
|
19
|
-
}
|
|
20
23
|
function eventsPath() {
|
|
21
|
-
|
|
24
|
+
if (process.env.CONSTRUCT_ROLES_ROOT) {
|
|
25
|
+
return join(process.env.CONSTRUCT_ROLES_ROOT, 'events.jsonl');
|
|
26
|
+
}
|
|
27
|
+
return resolveProjectScopedPath('events.jsonl', { ensureDir: false });
|
|
22
28
|
}
|
|
23
29
|
function ensureDir() {
|
|
24
|
-
const
|
|
25
|
-
if (!existsSync(
|
|
30
|
+
const dir = dirname(eventsPath());
|
|
31
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export function fingerprintOf(type, project, summary) {
|
|
@@ -50,6 +56,20 @@ export function emit(eventType, payload = {}) {
|
|
|
50
56
|
return line;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
// Best-effort variant for writers that emit lifecycle events as a side
|
|
60
|
+
// effect of their primary work (e.g. recommendation-store.createRecommendation,
|
|
61
|
+
// docs-lifecycle.runDocsLifecycle). A bus write failure must not surface as
|
|
62
|
+
// an error in the writer that just succeeded — the primary artifact already
|
|
63
|
+
// landed. Returns the emitted line on success, null on failure.
|
|
64
|
+
|
|
65
|
+
export function emitBestEffort(eventType, payload = {}) {
|
|
66
|
+
try {
|
|
67
|
+
return emit(eventType, payload);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
export function recent({ since = 0, type, fingerprint, limit = 200 } = {}) {
|
|
54
74
|
const ep = eventsPath();
|
|
55
75
|
if (!existsSync(ep)) return [];
|
|
@@ -76,4 +96,4 @@ function rotate() {
|
|
|
76
96
|
} catch { /* rotate is best-effort */ }
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
export const _paths = {
|
|
99
|
+
export const _paths = { eventsPath };
|
package/lib/roles/router.mjs
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/roles/router.mjs — pure event → persona resolver.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Reads ownership from the declarative routing tables built off of
|
|
5
|
+
* specialists/registry.json, then validates against the loaded role
|
|
6
|
+
* manifest. Returns null silently when the event has no declared owner
|
|
7
|
+
* or the persona is not yet onboarded — letting ownership be declared
|
|
8
|
+
* ahead of full wiring.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import { ownerForEvent } from '../orchestration/routing-tables.mjs';
|
|
11
12
|
import { loadManifest, isOnboarded } from './manifest.mjs';
|
|
12
13
|
|
|
13
14
|
export function route(event) {
|
|
14
15
|
const type = event?.type;
|
|
15
16
|
if (!type) return null;
|
|
16
|
-
const ownerCxId =
|
|
17
|
+
const ownerCxId = ownerForEvent(type);
|
|
17
18
|
if (!ownerCxId) return null;
|
|
18
19
|
const personaId = ownerCxId.replace(/^cx-/, '');
|
|
19
20
|
if (!isOnboarded(personaId)) return null;
|
|
@@ -23,5 +24,5 @@ export function route(event) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function ownerOf(eventType) {
|
|
26
|
-
return
|
|
27
|
+
return ownerForEvent(eventType);
|
|
27
28
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/runtime/uv-bootstrap.mjs — idempotent uv + docling venv provisioner.
|
|
3
|
+
*
|
|
4
|
+
* On first use: downloads uv via the official Astral installer, creates
|
|
5
|
+
* `<rootDir>/.cx/runtime/docling/.venv`, installs the pinned docling release.
|
|
6
|
+
* Subsequent calls are no-ops if the venv is already present.
|
|
7
|
+
*
|
|
8
|
+
* Best-practice notes (2026-06):
|
|
9
|
+
* - uv (Astral) is the consensus Python tooling, ~10× faster than pip, single
|
|
10
|
+
* binary, lockfile-based. OpenAI acquired Astral on 2026-03-19; the project
|
|
11
|
+
* remains Apache-2.0 and actively maintained, but single-vendor governance
|
|
12
|
+
* is a documented concern — flagged for re-evaluation if licensing shifts.
|
|
13
|
+
* - docling (IBM, MIT, donated to LF AI & Data early 2026) is the consensus
|
|
14
|
+
* multi-format doc parser for layout-aware MD/JSON output.
|
|
15
|
+
*
|
|
16
|
+
* Returns the path to the venv's python binary so callers can spawn the
|
|
17
|
+
* sidecar without relying on PATH state.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
|
|
24
|
+
const DOCLING_PIN = '2.45.0';
|
|
25
|
+
const UV_INSTALL_URL = 'https://astral.sh/uv/install.sh';
|
|
26
|
+
const UV_TIMEOUT_MS = 120_000;
|
|
27
|
+
const VENV_TIMEOUT_MS = 240_000;
|
|
28
|
+
const INSTALL_TIMEOUT_MS = 600_000;
|
|
29
|
+
|
|
30
|
+
export function defaultRuntimeDir(cwd = process.cwd()) {
|
|
31
|
+
return path.join(cwd, '.cx', 'runtime', 'docling');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function which(bin) {
|
|
35
|
+
const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
36
|
+
if (result.status !== 0) return null;
|
|
37
|
+
return result.stdout.trim().split('\n')[0] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureUv(installDir) {
|
|
41
|
+
const fromPath = which('uv');
|
|
42
|
+
if (fromPath) return fromPath;
|
|
43
|
+
const cachedUv = path.join(installDir, 'bin', 'uv');
|
|
44
|
+
if (existsSync(cachedUv)) return cachedUv;
|
|
45
|
+
mkdirSync(installDir, { recursive: true });
|
|
46
|
+
const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
|
|
47
|
+
const sh = spawnSync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], {
|
|
48
|
+
env,
|
|
49
|
+
timeout: UV_TIMEOUT_MS,
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
});
|
|
52
|
+
if (sh.status !== 0) {
|
|
53
|
+
throw new Error(`uv install failed (${sh.status}): ${sh.stderr || sh.stdout}`);
|
|
54
|
+
}
|
|
55
|
+
if (!existsSync(cachedUv)) {
|
|
56
|
+
throw new Error(`uv install reported success but binary not found at ${cachedUv}`);
|
|
57
|
+
}
|
|
58
|
+
return cachedUv;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pythonBinFor(venvDir) {
|
|
62
|
+
const candidates = process.platform === 'win32'
|
|
63
|
+
? [path.join(venvDir, 'Scripts', 'python.exe')]
|
|
64
|
+
: [path.join(venvDir, 'bin', 'python'), path.join(venvDir, 'bin', 'python3')];
|
|
65
|
+
return candidates.find((p) => existsSync(p));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readMarker(markerPath) {
|
|
69
|
+
try { return JSON.parse(readFileSync(markerPath, 'utf8')); }
|
|
70
|
+
catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeMarker(markerPath, payload) {
|
|
74
|
+
mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
75
|
+
writeFileSync(markerPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = false } = {}) {
|
|
79
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
80
|
+
const venvDir = path.join(runtimeDir, '.venv');
|
|
81
|
+
const markerPath = path.join(runtimeDir, '.install-marker.json');
|
|
82
|
+
const existingMarker = readMarker(markerPath);
|
|
83
|
+
const existingPython = pythonBinFor(venvDir);
|
|
84
|
+
|
|
85
|
+
if (!force && existingPython && existingMarker?.doclingVersion === DOCLING_PIN) {
|
|
86
|
+
return { pythonBin: existingPython, venvDir, runtimeDir, fresh: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const uv = ensureUv(runtimeDir);
|
|
90
|
+
|
|
91
|
+
const venvResult = spawnSync(uv, ['venv', venvDir, '--python', '3.11'], {
|
|
92
|
+
timeout: VENV_TIMEOUT_MS,
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
});
|
|
95
|
+
if (venvResult.status !== 0) {
|
|
96
|
+
throw new Error(`uv venv failed (${venvResult.status}): ${venvResult.stderr}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installResult = spawnSync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], {
|
|
100
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
});
|
|
103
|
+
if (installResult.status !== 0) {
|
|
104
|
+
throw new Error(`docling install failed (${installResult.status}): ${installResult.stderr}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const pythonBin = pythonBinFor(venvDir);
|
|
108
|
+
writeMarker(markerPath, {
|
|
109
|
+
doclingVersion: DOCLING_PIN,
|
|
110
|
+
installedAt: new Date().toISOString(),
|
|
111
|
+
pythonBin,
|
|
112
|
+
uvVersion: spawnSync(uv, ['--version'], { encoding: 'utf8' }).stdout.trim() || null,
|
|
113
|
+
platform: `${process.platform}-${process.arch}`,
|
|
114
|
+
node: process.version,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { pythonBin, venvDir, runtimeDir, fresh: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function describeDoclingRuntime({ runtimeDir = defaultRuntimeDir() } = {}) {
|
|
121
|
+
const venvDir = path.join(runtimeDir, '.venv');
|
|
122
|
+
return {
|
|
123
|
+
runtimeDir,
|
|
124
|
+
venvDir,
|
|
125
|
+
pythonBin: pythonBinFor(venvDir),
|
|
126
|
+
marker: readMarker(path.join(runtimeDir, '.install-marker.json')),
|
|
127
|
+
available: !!pythonBinFor(venvDir),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/runtime/whisper-bootstrap.mjs — provision whisper.cpp binary + ASR model.
|
|
3
|
+
*
|
|
4
|
+
* Strategy (2026-06):
|
|
5
|
+
* 1. Prefer system-installed whisper-cli (or legacy `main`) on PATH —
|
|
6
|
+
* `brew install whisper-cpp` (macOS, Metal-accelerated) or distro
|
|
7
|
+
* package on Linux.
|
|
8
|
+
* 2. Fall back to bundled `whisper-cli` cached at <runtimeDir>/bin/ if a
|
|
9
|
+
* previous bootstrap fetched one.
|
|
10
|
+
* 3. If neither, return a structured "not-available" descriptor — caller
|
|
11
|
+
* surfaces a user-actionable install hint rather than crashing.
|
|
12
|
+
*
|
|
13
|
+
* Model files are downloaded directly from Hugging Face the first time the
|
|
14
|
+
* default `base.en` (or env-overridden) model is requested. Models cache at
|
|
15
|
+
* <runtimeDir>/models/. base.en is ~150MB; smaller than the small.en or
|
|
16
|
+
* medium.en model that callers can opt into via CONSTRUCT_WHISPER_MODEL.
|
|
17
|
+
*
|
|
18
|
+
* No bundled distribution: whisper.cpp upstream does not publish stable
|
|
19
|
+
* pre-built CLI binaries across platforms, and building from source via
|
|
20
|
+
* cmake adds a heavyweight first-run path. Defer that to a future opt-in.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, statSync, createWriteStream } from 'node:fs';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { pipeline } from 'node:stream/promises';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
|
|
28
|
+
const HF_MODEL_BASE_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main';
|
|
29
|
+
const DEFAULT_MODEL = process.env.CONSTRUCT_WHISPER_MODEL || 'base.en';
|
|
30
|
+
const KNOWN_MODELS = new Set(['tiny', 'tiny.en', 'base', 'base.en', 'small', 'small.en', 'medium', 'medium.en', 'large-v3', 'large-v3-turbo']);
|
|
31
|
+
|
|
32
|
+
export function defaultRuntimeDir(cwd = process.cwd()) {
|
|
33
|
+
return path.join(cwd, '.cx', 'runtime', 'whisper');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function which(bin) {
|
|
37
|
+
const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
38
|
+
if (result.status !== 0) return null;
|
|
39
|
+
return result.stdout.trim().split('\n')[0] || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function locateWhisperBinary({ runtimeDir = defaultRuntimeDir() } = {}) {
|
|
43
|
+
for (const candidate of ['whisper-cli', 'whisper-cpp', 'main']) {
|
|
44
|
+
const found = which(candidate);
|
|
45
|
+
if (found) return { binary: found, source: 'system', name: candidate };
|
|
46
|
+
}
|
|
47
|
+
const cached = path.join(runtimeDir, 'bin', 'whisper-cli');
|
|
48
|
+
if (existsSync(cached)) return { binary: cached, source: 'cached', name: 'whisper-cli' };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function describeWhisperRuntime({ runtimeDir = defaultRuntimeDir() } = {}) {
|
|
53
|
+
const located = locateWhisperBinary({ runtimeDir });
|
|
54
|
+
const modelPath = modelPathFor(DEFAULT_MODEL, runtimeDir);
|
|
55
|
+
return {
|
|
56
|
+
runtimeDir,
|
|
57
|
+
binary: located,
|
|
58
|
+
defaultModel: DEFAULT_MODEL,
|
|
59
|
+
modelPath,
|
|
60
|
+
modelInstalled: existsSync(modelPath),
|
|
61
|
+
available: !!located,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function installHint() {
|
|
66
|
+
if (process.platform === 'darwin') return 'brew install whisper-cpp';
|
|
67
|
+
if (process.platform === 'linux') return 'See https://github.com/ggml-org/whisper.cpp#quick-start (build from source via cmake or install via your distro package manager).';
|
|
68
|
+
return 'See https://github.com/ggml-org/whisper.cpp#quick-start';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function modelPathFor(modelName, runtimeDir) {
|
|
72
|
+
return path.join(runtimeDir, 'models', `ggml-${modelName}.bin`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function ensureWhisperModel({ runtimeDir = defaultRuntimeDir(), model = DEFAULT_MODEL } = {}) {
|
|
76
|
+
if (!KNOWN_MODELS.has(model)) {
|
|
77
|
+
throw new Error(`unknown whisper model: ${model} (allowed: ${[...KNOWN_MODELS].join(', ')})`);
|
|
78
|
+
}
|
|
79
|
+
const target = modelPathFor(model, runtimeDir);
|
|
80
|
+
if (existsSync(target) && statSync(target).size > 1024) return target;
|
|
81
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
82
|
+
const url = `${HF_MODEL_BASE_URL}/ggml-${model}.bin`;
|
|
83
|
+
const response = await fetch(url, { redirect: 'follow' });
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`failed to download whisper model ${model}: ${response.status} ${response.statusText} (${url})`);
|
|
86
|
+
}
|
|
87
|
+
await pipeline(response.body, createWriteStream(target));
|
|
88
|
+
return target;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function ensureWhisperRuntime({ runtimeDir = defaultRuntimeDir(), model = DEFAULT_MODEL } = {}) {
|
|
92
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
93
|
+
const located = locateWhisperBinary({ runtimeDir });
|
|
94
|
+
if (!located) {
|
|
95
|
+
const err = new Error(`whisper-cli not found; install via: ${installHint()}`);
|
|
96
|
+
err.code = 'WHISPER_BINARY_MISSING';
|
|
97
|
+
err.installHint = installHint();
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
const modelPath = await ensureWhisperModel({ runtimeDir, model });
|
|
101
|
+
return { binary: located.binary, source: located.source, modelPath, model };
|
|
102
|
+
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -33,10 +33,30 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
|
33
33
|
const ROOT_DIR = join(__dirname, '..', '..');
|
|
34
34
|
const HOME = homedir();
|
|
35
35
|
const PORT = parseInt(process.env.PORT ?? '4242', 10);
|
|
36
|
-
|
|
36
|
+
|
|
37
|
+
// Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
|
|
38
|
+
// non-loopback interfaces is opt-in via BIND_HOST and gated on configured
|
|
39
|
+
// auth (see assertSafeBind below).
|
|
40
|
+
|
|
41
|
+
const BIND_HOST = process.env.BIND_HOST ?? '127.0.0.1';
|
|
42
|
+
|
|
43
|
+
function isLoopbackHost(host) {
|
|
44
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertSafeBind() {
|
|
48
|
+
if (isLoopbackHost(BIND_HOST)) return;
|
|
49
|
+
if (!isAuthConfigured()) {
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
`[dashboard] refuses to bind to non-loopback host ${BIND_HOST} without auth configured. ` +
|
|
52
|
+
`Set CONSTRUCT_DASHBOARD_TOKEN (or run \`construct dashboard auth init\`) before exposing the dashboard.\n`,
|
|
53
|
+
);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
const STATIC_DIR = join(__dirname, 'static');
|
|
39
|
-
const REGISTRY_FILE = join(ROOT_DIR, '
|
|
59
|
+
const REGISTRY_FILE = join(ROOT_DIR, 'specialists', 'registry.json');
|
|
40
60
|
const FEATURES_FILE = join(HOME, '.construct', 'features.json');
|
|
41
61
|
const WORKFLOW_FILE = join(ROOT_DIR, 'plan.md');
|
|
42
62
|
const SKILLS_DIR = join(ROOT_DIR, 'skills');
|
|
@@ -775,9 +795,9 @@ const server = createServer(async (req, res) => {
|
|
|
775
795
|
try {
|
|
776
796
|
const dir =
|
|
777
797
|
category === 'contracts' || category === 'role-manifests'
|
|
778
|
-
? join(root, '
|
|
798
|
+
? join(root, 'specialists')
|
|
779
799
|
: category === 'agents'
|
|
780
|
-
? join(root, '
|
|
800
|
+
? join(root, 'specialists', 'prompts')
|
|
781
801
|
: join(root, category);
|
|
782
802
|
if (existsSync(dir)) {
|
|
783
803
|
const walk = (rel = '') => {
|
|
@@ -1406,15 +1426,32 @@ const server = createServer(async (req, res) => {
|
|
|
1406
1426
|
env: { ...process.env, NO_COLOR: '1' },
|
|
1407
1427
|
timeout: 5000,
|
|
1408
1428
|
});
|
|
1429
|
+
// bd can return non-zero for legitimate reasons that don't warrant a
|
|
1430
|
+
// server 500: bd not installed, dolt lock held by a concurrent bd, no
|
|
1431
|
+
// .beads/ in cwd. Degrade gracefully — return an empty issue list with
|
|
1432
|
+
// `degraded: true` so the dashboard UI shows "no beads" instead of
|
|
1433
|
+
// breaking, and the dashboard-endpoints test (which sweeps every read
|
|
1434
|
+
// endpoint for 5xx) stays green when bd's runtime is wobbly.
|
|
1435
|
+
|
|
1409
1436
|
if (result.status !== 0) {
|
|
1410
|
-
res.writeHead(
|
|
1411
|
-
res.end(JSON.stringify({
|
|
1437
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1438
|
+
res.end(JSON.stringify({
|
|
1439
|
+
issues: [],
|
|
1440
|
+
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1441
|
+
degraded: true,
|
|
1442
|
+
reason: (result.stderr || result.stdout || 'bd list failed').slice(0, 300).trim(),
|
|
1443
|
+
}));
|
|
1412
1444
|
return;
|
|
1413
1445
|
}
|
|
1414
1446
|
let raw = [];
|
|
1415
1447
|
try { raw = JSON.parse(result.stdout); } catch (parseErr) {
|
|
1416
|
-
res.writeHead(
|
|
1417
|
-
res.end(JSON.stringify({
|
|
1448
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1449
|
+
res.end(JSON.stringify({
|
|
1450
|
+
issues: [],
|
|
1451
|
+
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1452
|
+
degraded: true,
|
|
1453
|
+
reason: `bd list JSON parse failed: ${parseErr.message}`,
|
|
1454
|
+
}));
|
|
1418
1455
|
return;
|
|
1419
1456
|
}
|
|
1420
1457
|
const issues = raw.map(obj => ({
|
|
@@ -1441,8 +1478,13 @@ const server = createServer(async (req, res) => {
|
|
|
1441
1478
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1442
1479
|
res.end(JSON.stringify({ issues, counts: { total: issues.length, byStatus, byPriority } }));
|
|
1443
1480
|
} catch (err) {
|
|
1444
|
-
res.writeHead(
|
|
1445
|
-
res.end(JSON.stringify({
|
|
1481
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1482
|
+
res.end(JSON.stringify({
|
|
1483
|
+
issues: [],
|
|
1484
|
+
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1485
|
+
degraded: true,
|
|
1486
|
+
reason: err.message,
|
|
1487
|
+
}));
|
|
1446
1488
|
}
|
|
1447
1489
|
return;
|
|
1448
1490
|
}
|
|
@@ -2215,6 +2257,7 @@ if (process.env.CX_AUTO_EMBED === '1') {
|
|
|
2215
2257
|
setInterval(runEmbedSync, intervalMs).unref();
|
|
2216
2258
|
console.log(`Embed scheduler active — syncing every ${intervalMs / 60_000} min`);
|
|
2217
2259
|
}
|
|
2260
|
+
assertSafeBind();
|
|
2218
2261
|
server.listen(PORT, BIND_HOST, () => {
|
|
2219
2262
|
console.log(`Construct dashboard running at http://${BIND_HOST}:${PORT}`);
|
|
2220
2263
|
});
|