@geraldmaron/construct 1.0.7 → 1.0.8

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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -1,17 +1,17 @@
1
1
  /**
2
- * lib/contracts/validate.mjs — Validate agents/contracts.json at sync time + at handoff.
2
+ * lib/contracts/validate.mjs — Validate specialists/contracts.json at sync time + at handoff.
3
3
  *
4
4
  * Three validation tiers:
5
5
  * 1. Schema shape: contracts.json conforms to contracts.schema.json (minimal
6
6
  * validator — top-level required fields, contract entry required fields).
7
7
  * 2. Cross-file refs: every output.schema points to a real file in lib/schemas/,
8
8
  * every producer/consumer name resolves to an agent or persona in
9
- * agents/registry.json, every well-known event/intake string is reachable.
9
+ * specialists/registry.json, every well-known event/intake string is reachable.
10
10
  * 3. Runtime handoff: a single artifact validated against the schema referenced
11
11
  * by a producer→consumer contract, with mustContain post-conditions.
12
12
  *
13
13
  * Surfaces:
14
- * - scripts/sync-agents.mjs invokes validateContractsFile at sync time.
14
+ * - scripts/sync-specialists.mjs invokes validateContractsFile at sync time.
15
15
  * - bin/construct lint:contracts invokes the same path in CI.
16
16
  * - workflowContractValidate (runtime) invokes validateHandoff per handoff.
17
17
  */
@@ -21,9 +21,9 @@ import { join, dirname, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
 
23
23
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
24
- const CONTRACTS_PATH = join(REPO_ROOT, 'agents', 'contracts.json');
25
- const CONTRACTS_SCHEMA_PATH = join(REPO_ROOT, 'agents', 'contracts.schema.json');
26
- const REGISTRY_PATH = join(REPO_ROOT, 'agents', 'registry.json');
24
+ const CONTRACTS_PATH = join(REPO_ROOT, 'specialists', 'contracts.json');
25
+ const CONTRACTS_SCHEMA_PATH = join(REPO_ROOT, 'specialists', 'contracts.schema.json');
26
+ const REGISTRY_PATH = join(REPO_ROOT, 'specialists', 'registry.json');
27
27
 
28
28
  const WELL_KNOWN_PRODUCERS = new Set(['user', 'oncall', 'incident-system', '*']);
29
29
  const WELL_KNOWN_CONSUMERS = new Set(['user', 'construct']);
@@ -33,7 +33,7 @@ function readJson(path) {
33
33
  }
34
34
 
35
35
  /**
36
- * Validate agents/contracts.json shape and cross-file references.
36
+ * Validate specialists/contracts.json shape and cross-file references.
37
37
  * Returns { ok, errors[] } — errors is empty on success.
38
38
  */
39
39
  export function validateContractsFile({ contractsPath, registryPath, repoRoot = REPO_ROOT } = {}) {
@@ -42,9 +42,9 @@ export function validateContractsFile({ contractsPath, registryPath, repoRoot =
42
42
  // When repoRoot is supplied but explicit paths are not, derive both from it
43
43
  // so test fixtures and the consistency watcher can validate slices in a
44
44
  // tmpdir without pointing at the live repo.
45
- const cPath = contractsPath || join(repoRoot, 'agents', 'contracts.json');
46
- const rPath = registryPath || join(repoRoot, 'agents', 'registry.json');
47
- const schemaPath = join(repoRoot, 'agents', 'contracts.schema.json');
45
+ const cPath = contractsPath || join(repoRoot, 'specialists', 'contracts.json');
46
+ const rPath = registryPath || join(repoRoot, 'specialists', 'registry.json');
47
+ const schemaPath = join(repoRoot, 'specialists', 'contracts.schema.json');
48
48
 
49
49
  if (!existsSync(cPath)) {
50
50
  return { ok: false, errors: [`contracts file not found: ${cPath}`] };
@@ -116,13 +116,11 @@ function collectAgentNames(registryPath) {
116
116
  if (!existsSync(registryPath)) return names;
117
117
  try {
118
118
  const registry = readJson(registryPath);
119
- for (const a of registry.agents || []) {
120
- if (a?.name) names.add(a.name);
121
- if (a?.displayName) names.add(a.displayName);
122
- }
123
- for (const p of registry.personas || []) {
124
- if (p?.name) names.add(p.name);
125
- if (p?.displayName) names.add(p.displayName);
119
+ if (registry.orchestrator?.name) names.add(registry.orchestrator.name);
120
+ if (registry.orchestrator?.displayName) names.add(registry.orchestrator.displayName);
121
+ for (const s of registry.specialists || []) {
122
+ if (s?.name) names.add(s.name);
123
+ if (s?.displayName) names.add(s.displayName);
126
124
  }
127
125
  } catch { /* fall through with whatever names we collected */ }
128
126
  return names;
@@ -321,3 +319,53 @@ export function validatePostconditions({ contract, artifactPath }) {
321
319
  }
322
320
  return errors;
323
321
  }
322
+
323
+ // Minimum evidence required: either a PR reference (#N or PR #N) or a file
324
+ // reference of the form `path:<line>` or `file:<line>` where line is a number.
325
+ const CLOSE_REASON_PR_PATTERN = /#\d+/;
326
+ const CLOSE_REASON_FILE_PATTERN = /[\w./\\-]+(?:\.(?:mjs|ts|js|tsx|jsx|py|go|rs|java|md|json|yml|yaml)):[0-9]+/i;
327
+ const CLOSE_REASON_MIN_LENGTH = 20;
328
+
329
+ /**
330
+ * Validate a beads close reason for evidence quality.
331
+ *
332
+ * Returns { ok: boolean, message: string|null }. On failure, `message` describes
333
+ * what is missing. The caller decides whether to warn or block based on
334
+ * CONSTRUCT_BEADS_HYGIENE (warn | block). Default behavior is warn.
335
+ *
336
+ * A passing reason must:
337
+ * - Be at least 20 characters.
338
+ * - Contain a PR reference (#N) OR a file:line reference (path.mjs:42).
339
+ */
340
+ export function validateBeadsCloseReason(reason) {
341
+ if (!reason || typeof reason !== 'string') {
342
+ return { ok: false, message: 'Close reason is empty. Add evidence: PR reference (#N) or file:line reference.' };
343
+ }
344
+ const trimmed = reason.trim();
345
+ if (trimmed.length < CLOSE_REASON_MIN_LENGTH) {
346
+ return { ok: false, message: `Close reason too short (${trimmed.length} chars). Minimum: ${CLOSE_REASON_MIN_LENGTH}. Add evidence.` };
347
+ }
348
+ const hasPr = CLOSE_REASON_PR_PATTERN.test(trimmed);
349
+ const hasFile = CLOSE_REASON_FILE_PATTERN.test(trimmed);
350
+ if (!hasPr && !hasFile) {
351
+ return {
352
+ ok: false,
353
+ message: `Close reason lacks evidence. Include a PR reference (#N) or a file:line reference (e.g. lib/foo.mjs:42). Got: "${trimmed.slice(0, 80)}"`,
354
+ };
355
+ }
356
+ return { ok: true, message: null };
357
+ }
358
+
359
+ /**
360
+ * Apply the beads close-reason policy: warn or block based on
361
+ * CONSTRUCT_BEADS_HYGIENE env var. Returns exit code (0 = ok, 1 = block).
362
+ */
363
+ export function applyBeadsHygienePolicy(reason, { stderr = process.stderr } = {}) {
364
+ const { ok, message } = validateBeadsCloseReason(reason);
365
+ if (ok) return 0;
366
+
367
+ const mode = (process.env.CONSTRUCT_BEADS_HYGIENE || 'warn').toLowerCase();
368
+ const prefix = mode === 'block' ? '[beads-hygiene] BLOCK' : '[beads-hygiene] WARN';
369
+ stderr.write(`${prefix}: ${message}\n`);
370
+ return mode === 'block' ? 1 : 0;
371
+ }
package/lib/distill.mjs CHANGED
@@ -11,6 +11,7 @@ import { join, extname, relative, dirname, basename } from 'path';
11
11
  import { execSync, spawnSync } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import { getActiveOverlays } from './headhunt.mjs';
14
+ import { withGenAiSpan, GenAiAttrs } from './telemetry/otel-tracer.mjs';
14
15
  import {
15
16
  EXTRACTABLE_DOCUMENT_EXTS,
16
17
  UTF8_TEXT_EXTS,
@@ -355,10 +356,27 @@ export async function distill(dir, { format = 'summary', query = '', mode = 'aut
355
356
  }
356
357
 
357
358
  process.stderr.write(' Invoking claude --print …\n\n');
358
- const result = spawnSync('claude', ['--print', prompt], {
359
- encoding: 'utf8',
360
- maxBuffer: 10 * 1024 * 1024,
361
- timeout: 120_000,
359
+ const result = await withGenAiSpan('chat', {
360
+ [GenAiAttrs.SYSTEM]: 'anthropic',
361
+ [GenAiAttrs.OPERATION_NAME]: 'chat',
362
+ [GenAiAttrs.REQUEST_MODEL]: process.env.CONSTRUCT_MODEL || 'claude-3-5-sonnet',
363
+ 'construct.operation': 'distill',
364
+ 'construct.prompt_chars': prompt.length,
365
+ }, async (span) => {
366
+ const r = spawnSync('claude', ['--print', prompt], {
367
+ encoding: 'utf8',
368
+ maxBuffer: 10 * 1024 * 1024,
369
+ timeout: 120_000,
370
+ });
371
+ if (r.error) throw r.error;
372
+ if (r.status !== 0) {
373
+ const errOut = r.stderr || '';
374
+ if (errOut) process.stderr.write(`${errOut}\n`);
375
+ throw new Error(`claude exited with status ${r.status}`);
376
+ }
377
+ const out = r.stdout || '';
378
+ span.setAttribute(GenAiAttrs.USAGE_OUTPUT_TOKENS, Math.ceil(out.length / 4));
379
+ return r;
362
380
  });
363
381
 
364
382
  if (result.error) throw result.error;
package/lib/doc-stamp.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * stampFrontmatter — inject/update audit block at top of a markdown string
8
8
  * verifyStamp — verify body_hash against current content
9
9
  * parseStamp — extract stamp fields from a document string
10
+ * validateTags — validate frontmatter tags against a loaded vocabulary
10
11
  *
11
12
  * Design:
12
13
  * - Zero npm dependencies (Node built-ins only)
@@ -16,9 +17,12 @@
16
17
  * trimmed, so whitespace-only edits do not break verification.
17
18
  * - generator field is intentionally short (no version pinning) so the
18
19
  * stamp survives construct upgrades without false-positive hash mismatches.
20
+ * - validateTags does NOT throw: it returns { errors, warnings } for callers
21
+ * to decide how to handle violations.
19
22
  */
20
23
 
21
24
  import { createHash, randomBytes } from 'node:crypto';
25
+ import { validateTags as vocabValidateTags, isTagDeprecated, isTagArchived } from './tags/vocabulary.mjs';
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // UUIDv7 — RFC 9562 §5.7
@@ -147,6 +151,50 @@ export function parseStamp(content) {
147
151
  return fields;
148
152
  }
149
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Tag validation — uses vocabulary module, returns errors/warnings for callers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Validates frontmatter tags against a loaded vocabulary object.
160
+ * Returns { errors: string[], warnings: string[] }.
161
+ *
162
+ * Errors (hard violations):
163
+ * - Two or more tags from the same exclusive facet (duplicateFacets)
164
+ * - Writing a tag with status 'archived'
165
+ *
166
+ * Warnings (soft violations):
167
+ * - Tag ID not found in vocabulary (unknown)
168
+ * - Writing a tag with status 'deprecated'
169
+ */
170
+ export function validateTags(frontmatter, vocab) {
171
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
172
+ const errors = [];
173
+ const warnings = [];
174
+
175
+ if (tags.length === 0) return { errors, warnings };
176
+
177
+ const { unknown, duplicateFacets } = vocabValidateTags(tags, vocab);
178
+
179
+ for (const id of duplicateFacets) {
180
+ errors.push(`exclusive-facet violation: tag "${id}" shares a facet with an earlier tag`);
181
+ }
182
+
183
+ for (const id of unknown) {
184
+ warnings.push(`unknown tag: "${id}" is not in the controlled vocabulary`);
185
+ }
186
+
187
+ for (const id of tags) {
188
+ if (isTagArchived(id, vocab)) {
189
+ errors.push(`archived-tag write: tag "${id}" has status archived and must not be applied`);
190
+ } else if (isTagDeprecated(id, vocab)) {
191
+ warnings.push(`deprecated-tag write: tag "${id}" has status deprecated`);
192
+ }
193
+ }
194
+
195
+ return { errors, warnings };
196
+ }
197
+
150
198
  /**
151
199
  * Verify that the stored body_hash matches the current body.
152
200
  * Returns { valid: true } or { valid: false, reason, stored, computed }.
@@ -7,11 +7,11 @@
7
7
  * 2. MCP registration ↔ impl: every `name === '<tool>'` branch in the MCP
8
8
  * dispatch resolves to an imported function, and every exported tool
9
9
  * function in lib/mcp/tools/*.mjs is wired into the dispatch.
10
- * 3. Roles ↔ registry: every persona key in agents/role-manifests.json
11
- * resolves to a persona or agent name in agents/registry.json.
10
+ * 3. Roles ↔ registry: every persona key in specialists/role-manifests.json
11
+ * resolves to a persona or agent name in specialists/registry.json.
12
12
  * 4. Persona promptFiles: every registry.personas[].promptFile and
13
13
  * registry.agents[].promptFile exists on disk.
14
- * 5. Contracts ↔ schemas: agents/contracts.json passes validateContractsFile
14
+ * 5. Contracts ↔ schemas: specialists/contracts.json passes validateContractsFile
15
15
  * (cross-file refs, producer/consumer names, schema paths).
16
16
  *
17
17
  * Each violation produces an audit record. Blocking severity escalates to a
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
23
  import { join, dirname, resolve } from 'node:path';
24
+ import os from 'node:os';
24
25
  import { fileURLToPath } from 'node:url';
25
26
 
26
27
  import { record } from '../audit.mjs';
@@ -61,7 +62,7 @@ export async function tick() {
61
62
  }
62
63
 
63
64
  /**
64
- * Run all five consistency checks. Returns { findings: [], passed: [] }.
65
+ * Run all six consistency checks. Returns { findings: [], passed: [] }.
65
66
  * Pure function — no audit/escalate side effects. Surfaced separately so the
66
67
  * `construct doctor consistency` CLI and the release-gate functional test can
67
68
  * call it without spinning up the daemon.
@@ -85,6 +86,9 @@ export async function runAllChecks({ repoRoot = REPO_ROOT } = {}) {
85
86
  const contracts = await checkContractsDrift({ repoRoot });
86
87
  collect(contracts, findings, passed, 'contracts-drift');
87
88
 
89
+ const hosts = checkHostSurfaces({ repoRoot });
90
+ collect(hosts, findings, passed, 'host-surfaces');
91
+
88
92
  return { findings, passed };
89
93
  }
90
94
 
@@ -219,8 +223,8 @@ function camelToSnake(s) {
219
223
 
220
224
  function checkRolesDrift({ repoRoot }) {
221
225
  const violations = [];
222
- const manifestsPath = join(repoRoot, 'agents', 'role-manifests.json');
223
- const registryPath = join(repoRoot, 'agents', 'registry.json');
226
+ const manifestsPath = join(repoRoot, 'specialists', 'role-manifests.json');
227
+ const registryPath = join(repoRoot, 'specialists', 'registry.json');
224
228
 
225
229
  if (!existsSync(manifestsPath) || !existsSync(registryPath)) {
226
230
  return { summary: 'role manifests or registry missing', violations: [] };
@@ -230,11 +234,12 @@ function checkRolesDrift({ repoRoot }) {
230
234
  const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
231
235
 
232
236
  const known = new Set();
233
- for (const a of registry.agents || []) {
234
- if (a?.name) { known.add(a.name); known.add(`cx-${a.name}`); }
237
+ for (const s of registry.specialists || []) {
238
+ if (s?.name) { known.add(s.name); known.add(`cx-${s.name}`); }
235
239
  }
236
- for (const p of registry.personas || []) {
237
- if (p?.name) { known.add(p.name); known.add(`cx-${p.name}`); }
240
+ if (registry.orchestrator?.name) {
241
+ known.add(registry.orchestrator.name);
242
+ known.add(`cx-${registry.orchestrator.name}`);
238
243
  }
239
244
 
240
245
  const personaKeys = Object.keys(manifests.personas || manifests || {});
@@ -257,13 +262,16 @@ function checkRolesDrift({ repoRoot }) {
257
262
 
258
263
  function checkPersonaPrompts({ repoRoot }) {
259
264
  const violations = [];
260
- const registryPath = join(repoRoot, 'agents', 'registry.json');
265
+ const registryPath = join(repoRoot, 'specialists', 'registry.json');
261
266
  if (!existsSync(registryPath)) return { summary: 'registry missing', violations: [] };
262
267
 
263
268
  const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
264
269
  let checked = 0;
265
270
 
266
- for (const list of [registry.personas || [], registry.agents || []]) {
271
+ for (const list of [
272
+ registry.orchestrator ? [registry.orchestrator] : [],
273
+ registry.specialists || [],
274
+ ]) {
267
275
  for (const entry of list) {
268
276
  const ref = entry?.promptFile;
269
277
  if (!ref) continue;
@@ -308,3 +316,45 @@ async function checkContractsDrift({ repoRoot }) {
308
316
  }
309
317
  return { summary: `contracts: ${violations.length} drift`, violations };
310
318
  }
319
+
320
+ // ── Check 6: host surfaces ─────────────────────────────────────────────────
321
+
322
+ function getVSCodeSettingsDir(homeDir) {
323
+ const platform = os.platform();
324
+ if (platform === 'darwin') return join(homeDir, 'Library', 'Application Support', 'Code', 'User');
325
+ if (platform === 'linux') return join(homeDir, '.config', 'Code', 'User');
326
+ const appData = process.env.APPDATA ?? join(homeDir, 'AppData', 'Roaming');
327
+ return join(appData, 'Code', 'User');
328
+ }
329
+
330
+ function checkHostSurfaces({ repoRoot }) {
331
+ const violations = [];
332
+ const homeDir = os.homedir();
333
+ const surfaces = [
334
+ { id: 'claude', label: 'Claude Code', check: () => existsSync(join(homeDir, '.claude', 'agents')) },
335
+ { id: 'opencode', label: 'OpenCode', check: () => existsSync(join(homeDir, '.config', 'opencode', 'opencode.json')) },
336
+ { id: 'codex', label: 'Codex', check: () => existsSync(join(homeDir, '.codex', 'agents')) },
337
+ { id: 'copilot', label: 'Copilot', check: () => existsSync(join(homeDir, '.github', 'prompts')) },
338
+ { id: 'vscode', label: 'VS Code', check: () => existsSync(join(getVSCodeSettingsDir(homeDir), 'settings.json')) },
339
+ { id: 'cursor', label: 'Cursor', check: () => existsSync(join(homeDir, '.cursor', 'mcp.json')) },
340
+ ];
341
+
342
+ const absent = surfaces.filter((s) => !s.check());
343
+ if (absent.length > 0) {
344
+ for (const s of absent) {
345
+ violations.push({
346
+ category: 'host-surfaces',
347
+ severity: 'advisory',
348
+ target: s.id,
349
+ summary: `Host surface '${s.label}' config not found — run \`construct sync\` to set up adapters`,
350
+ });
351
+ }
352
+ violations.push({
353
+ category: 'host-surfaces',
354
+ severity: 'advisory',
355
+ summary: `${absent.length}/${surfaces.length} host surfaces absent: ${absent.map((s) => s.id).join(', ')}`,
356
+ });
357
+ }
358
+
359
+ return { summary: `host-surfaces: ${surfaces.length - absent.length}/${surfaces.length} present`, violations };
360
+ }