@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.
Files changed (337) hide show
  1. package/README.md +14 -2
  2. package/bin/construct +262 -14
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/commands/build/feature.md +0 -6
  5. package/commands/build/fix.md +0 -6
  6. package/commands/design/access.md +0 -6
  7. package/commands/design/flow.md +0 -6
  8. package/commands/design/ui.md +0 -6
  9. package/commands/measure/experiment.md +0 -6
  10. package/commands/measure/metrics.md +0 -6
  11. package/commands/measure/results.md +0 -6
  12. package/commands/plan/api.md +0 -6
  13. package/commands/plan/challenge.md +0 -6
  14. package/commands/plan/decide.md +0 -6
  15. package/commands/plan/feature.md +0 -7
  16. package/commands/plan/requirements.md +0 -6
  17. package/commands/remember/context.md +0 -6
  18. package/commands/remember/handoff.md +0 -6
  19. package/commands/remember/runbook.md +0 -6
  20. package/commands/review/code.md +0 -6
  21. package/commands/review/quality.md +0 -6
  22. package/commands/review/security.md +0 -6
  23. package/commands/ship/ready.md +0 -6
  24. package/commands/ship/release.md +0 -6
  25. package/commands/ship/status.md +0 -6
  26. package/commands/understand/docs.md +0 -6
  27. package/commands/understand/research.md +0 -6
  28. package/commands/understand/this.md +0 -6
  29. package/commands/understand/why.md +0 -6
  30. package/commands/work/clean.md +0 -6
  31. package/commands/work/drive.md +0 -6
  32. package/commands/work/optimize-prompts.md +0 -6
  33. package/commands/work/parallel-review.md +0 -6
  34. package/lib/auto-docs.mjs +11 -7
  35. package/lib/beads-client.mjs +19 -1
  36. package/lib/comment-lint.mjs +5 -1
  37. package/lib/context-state.mjs +15 -3
  38. package/lib/contracts/validate.mjs +54 -10
  39. package/lib/contracts/violation-log.mjs +154 -0
  40. package/lib/dashboard-static.mjs +7 -4
  41. package/lib/doc-stamp.mjs +16 -0
  42. package/lib/docs-verify.mjs +1 -8
  43. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  44. package/lib/document-extract/docling-client.mjs +114 -0
  45. package/lib/document-extract/docling-sidecar.py +122 -0
  46. package/lib/document-extract/whisper-client.mjs +79 -0
  47. package/lib/document-extract.mjs +73 -8
  48. package/lib/document-ingest.mjs +26 -4
  49. package/lib/embed/daemon.mjs +26 -1
  50. package/lib/embed/docs-lifecycle.mjs +19 -0
  51. package/lib/embed/inbox.mjs +85 -2
  52. package/lib/embed/recommendation-store.mjs +29 -0
  53. package/lib/flavors/loader.mjs +1 -1
  54. package/lib/gates-audit.mjs +18 -12
  55. package/lib/handoffs/contract.mjs +2 -2
  56. package/lib/hooks/_lib/input.mjs +52 -0
  57. package/lib/hooks/adaptive-lint.mjs +4 -0
  58. package/lib/hooks/agent-tracker.mjs +59 -15
  59. package/lib/hooks/audit-reads.mjs +83 -42
  60. package/lib/hooks/audit-trail.mjs +28 -18
  61. package/lib/hooks/bash-output-logger.mjs +8 -2
  62. package/lib/hooks/block-no-verify.mjs +4 -0
  63. package/lib/hooks/ci-status-check.mjs +4 -0
  64. package/lib/hooks/comment-lint.mjs +6 -4
  65. package/lib/hooks/config-protection.mjs +4 -0
  66. package/lib/hooks/context-watch.mjs +4 -0
  67. package/lib/hooks/context-window-recovery.mjs +4 -0
  68. package/lib/hooks/dep-audit.mjs +12 -5
  69. package/lib/hooks/doc-coupling-check.mjs +5 -1
  70. package/lib/hooks/edit-accumulator.mjs +25 -10
  71. package/lib/hooks/edit-error-recovery.mjs +4 -0
  72. package/lib/hooks/edit-guard.mjs +4 -0
  73. package/lib/hooks/guard-bash.mjs +4 -0
  74. package/lib/hooks/mcp-audit.mjs +4 -0
  75. package/lib/hooks/mcp-health-check.mjs +4 -0
  76. package/lib/hooks/model-fallback.mjs +7 -11
  77. package/lib/hooks/policy-engine.mjs +4 -0
  78. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  79. package/lib/hooks/post-merge-tracking.mjs +82 -0
  80. package/lib/hooks/pre-compact.mjs +4 -0
  81. package/lib/hooks/pre-push-gate.mjs +84 -231
  82. package/lib/hooks/proactive-activation.mjs +5 -2
  83. package/lib/hooks/readme-age-check.mjs +4 -0
  84. package/lib/hooks/registry-sync.mjs +35 -20
  85. package/lib/hooks/rule-verifier.mjs +3 -0
  86. package/lib/hooks/scan-secrets.mjs +4 -0
  87. package/lib/hooks/session-optimize.mjs +4 -0
  88. package/lib/hooks/session-reflect.mjs +4 -0
  89. package/lib/hooks/session-start.mjs +49 -2
  90. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  91. package/lib/hooks/stop-notify.mjs +13 -2
  92. package/lib/hooks/stop-typecheck.mjs +4 -0
  93. package/lib/hooks/test-watch.mjs +8 -4
  94. package/lib/ingest/chunker.mjs +94 -0
  95. package/lib/ingest/pipeline.mjs +53 -0
  96. package/lib/ingest/store.mjs +82 -0
  97. package/lib/init-unified.mjs +90 -23
  98. package/lib/intake/attribution.mjs +77 -0
  99. package/lib/intake/intake-config.mjs +3 -0
  100. package/lib/intake/manifest.mjs +107 -0
  101. package/lib/intake/poll-lock.mjs +136 -0
  102. package/lib/intake/prepare.mjs +42 -4
  103. package/lib/knowledge/search.mjs +12 -0
  104. package/lib/logging/rotate.mjs +394 -0
  105. package/lib/mcp/server.mjs +142 -1
  106. package/lib/mcp/tools/project.mjs +2 -2
  107. package/lib/mcp/tools/skills.mjs +6 -3
  108. package/lib/mcp/tools/telemetry.mjs +1 -1
  109. package/lib/mcp/tools/workflow.mjs +6 -2
  110. package/lib/observation-store.mjs +14 -5
  111. package/lib/ollama-manager.mjs +32 -28
  112. package/lib/opencode-config.mjs +10 -3
  113. package/lib/orchestration/routing-tables.mjs +176 -0
  114. package/lib/orchestration-policy.mjs +18 -106
  115. package/lib/parity.mjs +48 -7
  116. package/lib/profiles/lifecycle.mjs +19 -1
  117. package/lib/project-init-shared.mjs +11 -5
  118. package/lib/project-root.mjs +104 -0
  119. package/lib/prompt-composer.js +11 -1
  120. package/lib/roles/catalog.mjs +1 -1
  121. package/lib/roles/event-bus.mjs +29 -9
  122. package/lib/roles/router.mjs +8 -7
  123. package/lib/runtime/uv-bootstrap.mjs +129 -0
  124. package/lib/runtime/whisper-bootstrap.mjs +102 -0
  125. package/lib/server/index.mjs +53 -10
  126. package/lib/server/static/index.html +1 -15
  127. package/lib/specialists/postconditions.mjs +1 -1
  128. package/lib/status.mjs +1 -1
  129. package/lib/storage/backup.mjs +2 -2
  130. package/lib/sync/skill-frontmatter.mjs +113 -21
  131. package/lib/telemetry/intent-verifications.mjs +16 -3
  132. package/lib/telemetry/skill-calls.mjs +12 -3
  133. package/lib/tracking-surfaces.mjs +377 -0
  134. package/lib/validators/skills.mjs +86 -54
  135. package/lib/worker/trace.mjs +19 -2
  136. package/package.json +10 -6
  137. package/personas/construct.md +4 -7
  138. package/platforms/claude/settings.template.json +29 -28
  139. package/rules/common/beads-hygiene.md +3 -9
  140. package/rules/common/code-review.md +3 -6
  141. package/rules/common/coding-style.md +3 -6
  142. package/rules/common/comments.md +3 -7
  143. package/rules/common/commit-approval.md +3 -6
  144. package/rules/common/cx-agent-routing.md +3 -7
  145. package/rules/common/cx-skill-routing.md +3 -3
  146. package/rules/common/doc-ownership.md +3 -8
  147. package/rules/common/efficiency.md +3 -8
  148. package/rules/common/framing.md +3 -8
  149. package/rules/common/git-workflow.md +3 -5
  150. package/rules/common/no-fabrication.md +4 -12
  151. package/rules/common/patterns.md +3 -5
  152. package/rules/common/release-gates.md +3 -8
  153. package/rules/common/research.md +3 -8
  154. package/rules/common/review-before-change.md +3 -9
  155. package/rules/common/security.md +3 -6
  156. package/rules/common/skill-composition.md +3 -7
  157. package/rules/common/testing.md +3 -6
  158. package/rules/golang/coding-style.md +1 -5
  159. package/rules/golang/hooks.md +1 -5
  160. package/rules/golang/patterns.md +1 -5
  161. package/rules/golang/security.md +1 -5
  162. package/rules/golang/testing.md +1 -5
  163. package/rules/python/coding-style.md +1 -5
  164. package/rules/python/hooks.md +1 -5
  165. package/rules/python/patterns.md +1 -5
  166. package/rules/python/security.md +1 -5
  167. package/rules/python/testing.md +1 -5
  168. package/rules/swift/coding-style.md +1 -5
  169. package/rules/swift/hooks.md +1 -5
  170. package/rules/swift/patterns.md +1 -5
  171. package/rules/swift/security.md +1 -5
  172. package/rules/swift/testing.md +1 -5
  173. package/rules/typescript/coding-style.md +1 -5
  174. package/rules/typescript/hooks.md +1 -5
  175. package/rules/typescript/patterns.md +1 -5
  176. package/rules/typescript/security.md +1 -5
  177. package/rules/typescript/testing.md +1 -5
  178. package/rules/web/coding-style.md +3 -5
  179. package/rules/web/design-quality.md +3 -5
  180. package/rules/web/hooks.md +3 -5
  181. package/rules/web/patterns.md +3 -5
  182. package/rules/web/performance.md +3 -5
  183. package/rules/web/security.md +3 -5
  184. package/rules/web/testing.md +3 -5
  185. package/scripts/sync-specialists.mjs +269 -75
  186. package/skills/ai/agent-dev.md +4 -5
  187. package/skills/ai/llm-security.md +4 -5
  188. package/skills/ai/ml-ops.md +4 -5
  189. package/skills/ai/orchestration-workflow.md +4 -5
  190. package/skills/ai/prompt-and-eval.md +4 -5
  191. package/skills/ai/prompt-optimizer.md +4 -6
  192. package/skills/ai/rag-system.md +4 -5
  193. package/skills/architecture/api-design.md +4 -5
  194. package/skills/architecture/caching.md +4 -5
  195. package/skills/architecture/cloud-native.md +4 -5
  196. package/skills/architecture/message-queue.md +4 -5
  197. package/skills/architecture/security-arch.md +4 -5
  198. package/skills/compliance/ai-disclosure.md +4 -5
  199. package/skills/compliance/data-privacy.md +4 -5
  200. package/skills/compliance/license-audit.md +4 -5
  201. package/skills/compliance/regulatory-review.md +4 -5
  202. package/skills/development/cpp.md +4 -5
  203. package/skills/development/go.md +4 -5
  204. package/skills/development/java.md +4 -5
  205. package/skills/development/kotlin.md +4 -5
  206. package/skills/development/mobile-crossplatform.md +4 -5
  207. package/skills/development/python.md +4 -5
  208. package/skills/development/rust.md +4 -5
  209. package/skills/development/shell.md +4 -5
  210. package/skills/development/swift.md +4 -5
  211. package/skills/development/typescript.md +4 -5
  212. package/skills/devops/ci-cd.md +4 -5
  213. package/skills/devops/containerization.md +4 -5
  214. package/skills/devops/cost-optimization.md +4 -5
  215. package/skills/devops/data-engineering.md +4 -5
  216. package/skills/devops/database.md +4 -5
  217. package/skills/devops/dependency-management.md +4 -5
  218. package/skills/devops/devsecops.md +4 -5
  219. package/skills/devops/git-workflow.md +4 -5
  220. package/skills/devops/incident-response.md +4 -5
  221. package/skills/devops/monorepo.md +4 -5
  222. package/skills/devops/observability.md +4 -5
  223. package/skills/devops/performance.md +4 -5
  224. package/skills/devops/testing.md +4 -5
  225. package/skills/docs/adr-workflow.md +4 -7
  226. package/skills/docs/backlog-proposal-workflow.md +4 -3
  227. package/skills/docs/customer-profile-workflow.md +4 -3
  228. package/skills/docs/document-ingest-workflow.md +4 -3
  229. package/skills/docs/evidence-ingest-workflow.md +4 -3
  230. package/skills/docs/init-docs.md +4 -5
  231. package/skills/docs/init-project.md +4 -5
  232. package/skills/docs/prd-workflow.md +4 -7
  233. package/skills/docs/prfaq-workflow.md +4 -3
  234. package/skills/docs/product-intelligence-review.md +4 -3
  235. package/skills/docs/product-intelligence-workflow.md +4 -6
  236. package/skills/docs/product-signal-workflow.md +4 -5
  237. package/skills/docs/research-workflow.md +4 -5
  238. package/skills/docs/runbook-workflow.md +4 -5
  239. package/skills/docs/strategy-workflow.md +4 -5
  240. package/skills/exploration/dependency-graph-reading.md +4 -7
  241. package/skills/exploration/repo-map.md +4 -5
  242. package/skills/exploration/tracer-bullet-method.md +4 -7
  243. package/skills/exploration/unknown-codebase-onboarding.md +4 -7
  244. package/skills/frameworks/django.md +4 -5
  245. package/skills/frameworks/nextjs.md +4 -5
  246. package/skills/frameworks/react.md +4 -5
  247. package/skills/frameworks/spring-boot.md +4 -5
  248. package/skills/frontend-design/accessibility.md +4 -5
  249. package/skills/frontend-design/component-patterns.md +4 -5
  250. package/skills/frontend-design/engineering.md +4 -5
  251. package/skills/frontend-design/state-management.md +4 -5
  252. package/skills/frontend-design/ui-aesthetics.md +4 -5
  253. package/skills/frontend-design/ux-principles.md +4 -5
  254. package/skills/operating/change-management.md +4 -8
  255. package/skills/operating/incident-response.md +4 -8
  256. package/skills/operating/oncall-rotation.md +4 -7
  257. package/skills/operating/orchestration-reference.md +4 -10
  258. package/skills/quality-gates/review-work.md +4 -5
  259. package/skills/quality-gates/verify-change.md +4 -5
  260. package/skills/quality-gates/verify-module.md +4 -5
  261. package/skills/quality-gates/verify-quality.md +4 -5
  262. package/skills/quality-gates/verify-security.md +4 -5
  263. package/skills/roles/architect.ai-systems.md +6 -9
  264. package/skills/roles/architect.data.md +6 -9
  265. package/skills/roles/architect.enterprise.md +6 -9
  266. package/skills/roles/architect.integration.md +6 -9
  267. package/skills/roles/architect.md +7 -14
  268. package/skills/roles/architect.platform.md +6 -9
  269. package/skills/roles/data-analyst.experiment.md +6 -9
  270. package/skills/roles/data-analyst.md +6 -9
  271. package/skills/roles/data-analyst.product-intelligence.md +7 -9
  272. package/skills/roles/data-analyst.product.md +6 -9
  273. package/skills/roles/data-analyst.telemetry.md +7 -9
  274. package/skills/roles/data-engineer.pipeline.md +6 -9
  275. package/skills/roles/data-engineer.vector-retrieval.md +7 -9
  276. package/skills/roles/data-engineer.warehouse.md +6 -9
  277. package/skills/roles/debugger.md +6 -9
  278. package/skills/roles/designer.accessibility.md +6 -9
  279. package/skills/roles/designer.md +7 -9
  280. package/skills/roles/engineer.ai.md +6 -9
  281. package/skills/roles/engineer.data.md +6 -9
  282. package/skills/roles/engineer.md +9 -9
  283. package/skills/roles/engineer.platform.md +6 -9
  284. package/skills/roles/operator.docs.md +6 -9
  285. package/skills/roles/operator.md +9 -9
  286. package/skills/roles/operator.release.md +6 -9
  287. package/skills/roles/operator.sre.md +6 -9
  288. package/skills/roles/orchestrator.md +6 -9
  289. package/skills/roles/product-manager.ai-product.md +6 -9
  290. package/skills/roles/product-manager.business-strategy.md +6 -9
  291. package/skills/roles/product-manager.enterprise.md +6 -9
  292. package/skills/roles/product-manager.growth.md +7 -9
  293. package/skills/roles/product-manager.md +7 -9
  294. package/skills/roles/product-manager.platform.md +6 -9
  295. package/skills/roles/product-manager.product.md +6 -9
  296. package/skills/roles/qa.ai-eval.md +8 -9
  297. package/skills/roles/qa.api-contract.md +7 -9
  298. package/skills/roles/qa.data-pipeline.md +7 -9
  299. package/skills/roles/qa.md +7 -9
  300. package/skills/roles/qa.test-automation.md +6 -9
  301. package/skills/roles/qa.web-ui.md +7 -9
  302. package/skills/roles/researcher.explorer.md +6 -9
  303. package/skills/roles/researcher.md +8 -9
  304. package/skills/roles/researcher.ux.md +6 -9
  305. package/skills/roles/reviewer.devil-advocate.md +6 -9
  306. package/skills/roles/reviewer.evaluator.md +6 -9
  307. package/skills/roles/reviewer.md +9 -9
  308. package/skills/roles/reviewer.trace.md +6 -9
  309. package/skills/roles/security.ai.md +7 -9
  310. package/skills/roles/security.appsec.md +6 -9
  311. package/skills/roles/security.cloud.md +6 -9
  312. package/skills/roles/security.legal-compliance.md +6 -9
  313. package/skills/roles/security.md +7 -9
  314. package/skills/roles/security.privacy.md +7 -9
  315. package/skills/roles/security.supply-chain.md +7 -9
  316. package/skills/security/blue-team.md +4 -5
  317. package/skills/security/code-audit.md +4 -5
  318. package/skills/security/pentest.md +4 -5
  319. package/skills/security/red-team.md +4 -5
  320. package/skills/security/threat-intel.md +4 -5
  321. package/skills/security/vuln-research.md +4 -5
  322. package/skills/strategy/competitive-landscape.md +4 -8
  323. package/skills/strategy/market-research-methods.md +4 -8
  324. package/skills/strategy/narrative-arc.md +4 -8
  325. package/skills/strategy/pricing-positioning.md +4 -8
  326. package/skills/utility/clean-code.md +4 -5
  327. package/specialists/policy-inventory.json +14 -0
  328. package/specialists/registry.json +158 -13
  329. package/lib/hooks/env-check.mjs +0 -83
  330. package/lib/hooks/read-tracker.mjs +0 -61
  331. package/lib/policy/unified-gates.mjs +0 -96
  332. package/lib/server/static/assets/index-ab25c707.js +0 -70
  333. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
  334. package/lib/specialist-contracts-enforce.mjs +0 -158
  335. package/rules/common/agents.md +0 -28
  336. package/rules/common/development-workflow.md +0 -32
  337. 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. This module enforces that by producing a draft + a requirements
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
- return `${JSON.stringify({
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
- }, null, 2)}\n`;
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
+ }
@@ -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
- return fs.readFileSync(filePath, 'utf8').trim();
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, {
@@ -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, '..', '..', 'agents', 'registry.json');
14
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
15
15
 
16
16
  let cached = null;
17
17
 
@@ -1,28 +1,34 @@
1
1
  /**
2
- * lib/roles/event-bus.mjs — append-only event log at ~/.cx/events.jsonl.
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
- return join(rootDir(), 'events.jsonl');
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 r = rootDir();
25
- if (!existsSync(r)) mkdirSync(r, { recursive: true });
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 = { rootDir, eventsPath };
99
+ export const _paths = { eventsPath };
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * lib/roles/router.mjs — pure event → persona resolver.
3
3
  *
4
- * Uses EVENT_OWNERSHIP from orchestration-policy and validates against
5
- * the loaded role manifest. Returns null (no owner) silently if the
6
- * event has no declared owner or the persona is not yet onboarded —
7
- * letting us declare ownership ahead of full wiring.
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 { EVENT_OWNERSHIP } from '../orchestration-policy.mjs';
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 = EVENT_OWNERSHIP[type];
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 EVENT_OWNERSHIP[eventType] || null;
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
+ }
@@ -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
- const BIND_HOST = process.env.BIND_HOST ?? (process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1');
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, 'agents', 'registry.json');
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, 'agents')
798
+ ? join(root, 'specialists')
779
799
  : category === 'agents'
780
- ? join(root, 'agents', 'prompts')
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(500, { 'Content-Type': 'application/json' });
1411
- res.end(JSON.stringify({ error: `bd list failed: ${(result.stderr || result.stdout || '').slice(0, 300)}` }));
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(500, { 'Content-Type': 'application/json' });
1417
- res.end(JSON.stringify({ error: `bd list JSON parse failed: ${parseErr.message}` }));
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(500, { 'Content-Type': 'application/json' });
1445
- res.end(JSON.stringify({ error: err.message }));
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
  });